diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4f2d0ff08558a6ed3d48eb3828f8c4f8dc5c0274 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ +.venv + +# Environment variables +.env +.env.local +.env.*.local +.env.production +.env.development + +# IDE / Editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +*.sublime-project +*.sublime-workspace + +# Jupyter Notebook +.ipynb_checkpoints + +# pytest +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.hypothesis/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Node.js / Next.js (root level) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Next.js (if built at root) +.next/ +out/ +build/ +.vercel + +# Generated assets +assets/generated/* +!assets/generated/.gitkeep + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ +*.log.* + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*~ + +# Docker +.dockerignore + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# TypeScript +*.tsbuildinfo + +# Misc +*.pem +.cache/ +.temp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..06d6760927e992d69180702965cf42b713f9c0c8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Use Python 3.11 slim image +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create output directory for generated images +RUN mkdir -p assets/generated + +# Expose port (Hugging Face Spaces uses port 7860 by default, but we'll use 8000) +EXPOSE 8000 + +# Set environment variables +ENV PYTHONUNBUFFERED=1 +ENV PORT=8000 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 2612102768c78c45190d4fbde6876837c3a0f2c0..e4ca4838aab42ae023a1fec51a92d7bd3823c5e9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,154 @@ ---- -title: Creative Breakthrough -emoji: 😻 -colorFrom: green -colorTo: blue -sdk: docker -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Ad Generator Lite + +Generate high-converting ad creatives for Home Insurance and GLP-1 niches using psychological triggers and AI-powered image generation. + +## Features + +- **Multiple Generation Modes**: + - Standard generation with randomization + - Batch generation for multiple ads + - Angle × Concept matrix system (100 angles × 100 concepts) + - Extensive generation with researcher → creative director → designer → copywriter flow + +- **Image Generation**: Supports multiple models (z-image-turbo, nano-banana, nano-banana-pro, imagen-4-ultra, recraft-v3, ideogram-v3, photon, seedream-3) + +- **Image Correction**: AI-powered image correction for spelling mistakes and visual issues + +- **Database Storage**: MongoDB integration for storing generated ads + +- **Authentication**: JWT-based authentication system + +## Setup + +### Environment Variables + +Copy `.env.example` to `.env` and fill in your API keys: + +```bash +cp .env.example .env +``` + +Required variables: +- `OPENAI_API_KEY`: Your OpenAI API key +- `REPLICATE_API_TOKEN`: Your Replicate API token +- `JWT_SECRET_KEY`: Secret key for JWT tokens (change in production!) + +Optional variables: +- `MONGODB_URL`: MongoDB connection string (for database features) +- R2 Storage credentials (for cloud image storage) + +### Running Locally + +1. Install dependencies: +```bash +pip install -r requirements.txt +``` + +2. Run the server: +```bash +uvicorn main:app --reload +``` + +3. Access the API at `http://localhost:8000` + +### API Documentation + +Once running, visit: +- API docs: `http://localhost:8000/docs` +- Alternative docs: `http://localhost:8000/redoc` + +## Deployment on Hugging Face Spaces + +This app is configured for deployment on Hugging Face Spaces using Docker. + +### Steps to Deploy + +1. **Create a new Space** on Hugging Face: + - Go to https://huggingface.co/spaces + - Click "Create new Space" + - Choose "Docker" as the SDK + - Name your space (e.g., `your-username/ad-generator-lite`) + +2. **Push your code** to the Space: + ```bash + git clone https://huggingface.co/spaces/your-username/ad-generator-lite + cd ad-generator-lite + # Copy your files here + git add . + git commit -m "Initial commit" + git push + ``` + +3. **Set Environment Variables**: + - Go to your Space settings + - Navigate to "Variables and secrets" + - Add all required environment variables from `.env.example` + - **Important**: Set `JWT_SECRET_KEY` to a secure random string + +4. **Wait for Build**: + - Hugging Face will automatically build and deploy your Docker container + - Check the "Logs" tab for build progress + +### Space Configuration + +The `huggingface.yml` file configures: +- Docker-based deployment +- Port 8000 for the FastAPI app +- Health check endpoint + +### Accessing Your Deployed App + +Once deployed, your app will be available at: +``` +https://your-username-ad-generator-lite.hf.space +``` + +## API Endpoints + +### Authentication +- `POST /auth/login` - Login and get JWT token + +### Generation +- `POST /generate` - Generate single ad (requires auth) +- `POST /generate/batch` - Generate multiple ads (requires auth) +- `POST /matrix/generate` - Generate using Angle × Concept matrix (requires auth) +- `POST /matrix/testing` - Generate testing matrix +- `POST /extensive/generate` - Extensive generation flow (requires auth) + +### Matrix System +- `GET /matrix/angles` - List all 100 angles +- `GET /matrix/concepts` - List all 100 concepts +- `GET /matrix/angle/{key}` - Get specific angle details +- `GET /matrix/concept/{key}` - Get specific concept details +- `GET /matrix/compatible/{angle_key}` - Get compatible concepts + +### Image Correction +- `POST /api/correct` - Correct image for spelling/visual issues (requires auth) + +### Database +- `GET /db/stats` - Get database statistics (requires auth) +- `GET /db/ads` - List stored ads (requires auth) +- `GET /db/ad/{ad_id}` - Get specific ad +- `DELETE /db/ad/{ad_id}` - Delete ad (requires auth) + +### Health +- `GET /health` - Health check +- `GET /` - API information + +## Supported Niches + +- `home_insurance`: Fear, urgency, savings, authority, guilt strategies +- `glp1`: Shame, transformation, FOMO, authority, simplicity strategies + +## Matrix System + +The app includes a systematic Angle × Concept matrix: +- **100 Angles**: Psychological triggers (10 categories) +- **100 Concepts**: Visual approaches (10 categories) +- **10,000 possible combinations** + +Formula: 1 Offer → 5-8 Angles → 3-5 Concepts per angle + +## License + +[Add your license here] diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..ab00d480c1c522506e965aef666020fc3b2cbe64 --- /dev/null +++ b/config.py @@ -0,0 +1,70 @@ +"""Configuration settings for the ad generator.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Optional + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # OpenAI API Key (required for copy generation) + openai_api_key: str + + # Replicate API Token (required for image generation) + replicate_api_token: str + + # Database (MongoDB) + mongodb_url: Optional[str] = None + mongodb_db_name: str = "creative_breakthrough" + + # R2 Storage (Cloudflare R2) + r2_endpoint: Optional[str] = None + r2_bucket_name: Optional[str] = None + r2_access_key: Optional[str] = None + r2_secret_key: Optional[str] = None + r2_public_domain: Optional[str] = None # Optional: Custom domain for public URLs (e.g., "cdn.example.com") + + # LLM Settings + llm_model: str = "gpt-4o-mini" # Cost-effective model + llm_temperature: float = 0.95 # High for variety + + # Vision API Settings + vision_model: str = "gpt-4o" # Vision-capable model for image analysis + + # Third Flow (Extensive) GPT Model Settings + third_flow_model: str = "gpt-4o" # Model for researcher, creative_director, designer, copywriter + # Options: "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4" + + # Image Generation Settings (same models as creative-breakthrough project) + image_model: str = "z-image-turbo" # Z-Image Turbo - fast and high quality + # Alternative models: "nano-banana", "nano-banana-pro", "imagen-4-ultra", "recraft-v3", "ideogram-v3", "photon", "seedream-3", "gpt-image-1.5" + image_width: int = 1024 + image_height: int = 1024 + + # Output Settings + output_dir: str = "assets/generated" + + # Production & Storage Settings + environment: str = "development" # "development" or "production" + save_images_locally: bool = True # Whether to save images locally + local_image_retention_hours: int = 24 # Hours to keep images locally before cleanup (only in production) + + # Auth Settings + jwt_secret_key: str = "your-secret-key-change-in-production" # Change this in production! + jwt_algorithm: str = "HS256" + jwt_expiration_hours: int = 24 + + # Debug + debug: bool = False + + +# Global settings instance +settings = Settings() + diff --git a/create_user.py b/create_user.py new file mode 100755 index 0000000000000000000000000000000000000000..64d559f5728bdc5a179bf7e213dde406379ec949 --- /dev/null +++ b/create_user.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +CLI script to create users manually. +This script is for backend-only user creation (not exposed in frontend). + +Usage: + python create_user.py + +Example: + python create_user.py admin mypassword123 +""" + +import asyncio +import sys +import os + +# Add current directory to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from services.database import db_service +from services.auth import auth_service + + +async def create_user(username: str, password: str): + """Create a new user.""" + # Connect to database + print("Connecting to database...") + connected = await db_service.connect() + if not connected: + print("❌ Failed to connect to database. Please check your MONGODB_URL configuration.") + return False + + try: + # Check if user already exists + existing_user = await db_service.get_user(username) + if existing_user: + print(f"❌ User '{username}' already exists!") + return False + + # Hash password + print(f"Creating user '{username}'...") + hashed_password = auth_service.hash_password(password) + + # Create user + user_id = await db_service.create_user(username, hashed_password) + + if user_id: + print(f"✅ User '{username}' created successfully!") + print(f" User ID: {user_id}") + print(f"\nYou can now login with:") + print(f" Username: {username}") + print(f" Password: {password}") + return True + else: + print(f"❌ Failed to create user '{username}'") + return False + + except Exception as e: + print(f"❌ Error creating user: {e}") + return False + + finally: + await db_service.disconnect() + + +async def list_users(): + """List all users.""" + print("Connecting to database...") + connected = await db_service.connect() + if not connected: + print("❌ Failed to connect to database. Please check your MONGODB_URL configuration.") + return + + try: + users = await db_service.list_users() + if not users: + print("No users found.") + else: + print(f"\nFound {len(users)} user(s):\n") + for user in users: + print(f" - {user['username']}") + print(f" Created: {user.get('created_at', 'N/A')}") + print() + + except Exception as e: + print(f"❌ Error listing users: {e}") + + finally: + await db_service.disconnect() + + +async def delete_user(username: str): + """Delete a user.""" + print("Connecting to database...") + connected = await db_service.connect() + if not connected: + print("❌ Failed to connect to database. Please check your MONGODB_URL configuration.") + return False + + try: + # Confirm deletion + confirm = input(f"Are you sure you want to delete user '{username}'? (yes/no): ") + if confirm.lower() != "yes": + print("Cancelled.") + return False + + deleted = await db_service.delete_user(username) + if deleted: + print(f"✅ User '{username}' deleted successfully!") + return True + else: + print(f"❌ User '{username}' not found or could not be deleted.") + return False + + except Exception as e: + print(f"❌ Error deleting user: {e}") + return False + + finally: + await db_service.disconnect() + + +def print_usage(): + """Print usage instructions.""" + print(""" +User Management Script + +Usage: + python create_user.py create - Create a new user + python create_user.py list - List all users + python create_user.py delete - Delete a user + +Examples: + python create_user.py create admin mypassword123 + python create_user.py list + python create_user.py delete admin + """) + + +async def main(): + """Main entry point.""" + if len(sys.argv) < 2: + print_usage() + return + + command = sys.argv[1].lower() + + if command == "create": + if len(sys.argv) != 4: + print("❌ Error: Username and password required") + print("Usage: python create_user.py create ") + return + + username = sys.argv[2] + password = sys.argv[3] + + if len(username) < 3: + print("❌ Error: Username must be at least 3 characters") + return + + if len(password) < 6: + print("❌ Error: Password must be at least 6 characters") + return + + await create_user(username, password) + + elif command == "list": + await list_users() + + elif command == "delete": + if len(sys.argv) != 3: + print("❌ Error: Username required") + print("Usage: python create_user.py delete ") + return + + username = sys.argv[2] + await delete_user(username) + + else: + print(f"❌ Unknown command: {command}") + print_usage() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e264cecaa3f252c75d9446255c7bfa67f4e2a690 --- /dev/null +++ b/data/__init__.py @@ -0,0 +1,2 @@ +# Data module - psychological triggers and patterns + diff --git a/data/angles.py b/data/angles.py new file mode 100644 index 0000000000000000000000000000000000000000..2b8ce5e3ffe11cf1c8458d13e04bca2c91be9a93 --- /dev/null +++ b/data/angles.py @@ -0,0 +1,297 @@ +""" +Affiliate Marketing Angles Framework (Lite Version) +100 angles organized into 10 categories. +Angles answer: "Why should I care?" (Psychological WHY) +""" + +from typing import Dict, List, Any, Optional +from enum import Enum +import random + + +class AngleCategory(str, Enum): + """Angle categories.""" + EMOTIONAL = "emotional" + FINANCIAL = "financial" + CONVENIENCE = "convenience" + IDENTITY = "identity" + AUTHORITY = "authority" + SOCIAL_PROOF = "social_proof" + URGENCY = "urgency" + INTELLECTUAL = "intellectual" + CURIOSITY = "curiosity" + PROBLEM_SOLUTION = "problem_solution" + + +# Complete angles framework - 100 angles (10 per category) +ANGLES = { + AngleCategory.EMOTIONAL: { + "name": "Emotional & Psychological", + "angles": [ + {"key": "fear_loss", "name": "Fear / Loss Prevention", "trigger": "Fear", "example": "Don't lose your home to disaster"}, + {"key": "anxiety_reduction", "name": "Anxiety Reduction", "trigger": "Relief", "example": "Sleep better knowing you're protected"}, + {"key": "security_safety", "name": "Security / Safety", "trigger": "Security", "example": "Your family's safety is our priority"}, + {"key": "peace_of_mind", "name": "Peace of Mind", "trigger": "Relief", "example": "Peace of mind for just $X/month"}, + {"key": "stress_relief", "name": "Stress Relief", "trigger": "Relief", "example": "End the stress of [problem]"}, + {"key": "relief_escape", "name": "Relief / Escape", "trigger": "Relief", "example": "Finally escape [problem]"}, + {"key": "confidence_boost", "name": "Confidence Boost", "trigger": "Pride", "example": "Feel confident about your future"}, + {"key": "hope_optimism", "name": "Hope / Optimism", "trigger": "Hope", "example": "A brighter future starts today"}, + {"key": "guilt_responsibility", "name": "Guilt (Family)", "trigger": "Guilt", "example": "Do it for your family"}, + {"key": "pride_self_worth", "name": "Pride / Self-worth", "trigger": "Pride", "example": "You deserve the best"}, + {"key": "emotional_connection", "name": "Emotional Connection", "trigger": "Belonging", "example": "Feel connected to what matters"}, + {"key": "nostalgia", "name": "Nostalgia", "trigger": "Emotion", "example": "Remember when things were simpler"}, + {"key": "empowerment", "name": "Empowerment", "trigger": "Pride", "example": "Take control of your future"}, + ] + }, + AngleCategory.FINANCIAL: { + "name": "Financial", + "angles": [ + {"key": "save_money", "name": "Save Money", "trigger": "Greed", "example": "Save $600/year"}, + {"key": "cut_hidden_costs", "name": "Cut Hidden Costs", "trigger": "Anger", "example": "Stop paying hidden fees"}, + {"key": "avoid_overpaying", "name": "Avoid Overpaying", "trigger": "Anger", "example": "Stop overpaying for [service]"}, + {"key": "financial_freedom", "name": "Financial Freedom", "trigger": "Desire", "example": "Achieve financial freedom"}, + {"key": "budget_control", "name": "Budget Control", "trigger": "Control", "example": "Take control of your budget"}, + {"key": "price_comparison", "name": "Price Comparison", "trigger": "Greed", "example": "Compare prices in 30 seconds"}, + {"key": "smart_spending", "name": "Smart Spending", "trigger": "Pride", "example": "Make smart financial choices"}, + {"key": "long_term_value", "name": "Long-Term Value", "trigger": "Greed", "example": "Invest in your future"}, + {"key": "roi_investment", "name": "ROI / Investment", "trigger": "Greed", "example": "Get 3x return on investment"}, + {"key": "cost_transparency", "name": "Cost Transparency", "trigger": "Trust", "example": "100% transparent pricing"}, + {"key": "hidden_fees_exposed", "name": "Hidden Fees Exposed", "trigger": "Anger", "example": "No hidden fees, ever"}, + {"key": "money_back", "name": "Money-Back Guarantee", "trigger": "Security", "example": "Get your money back if not satisfied"}, + {"key": "payment_flexibility", "name": "Payment Flexibility", "trigger": "Convenience", "example": "Pay your way, when you want"}, + ] + }, + AngleCategory.CONVENIENCE: { + "name": "Convenience & Ease", + "angles": [ + {"key": "fast_instant", "name": "Fast / Instant", "trigger": "Convenience", "example": "Get a quote in 30 seconds"}, + {"key": "simple_easy", "name": "Simple / Easy", "trigger": "Convenience", "example": "It's that simple"}, + {"key": "no_paperwork", "name": "No Paperwork", "trigger": "Convenience", "example": "No paperwork required"}, + {"key": "no_phone_calls", "name": "No Phone Calls", "trigger": "Convenience", "example": "No phone calls needed"}, + {"key": "one_click", "name": "One-Click / Few Steps", "trigger": "Convenience", "example": "Get started in 3 steps"}, + {"key": "beginner_friendly", "name": "Beginner-Friendly", "trigger": "Security", "example": "Perfect for beginners"}, + {"key": "done_for_you", "name": "Done-For-You", "trigger": "Convenience", "example": "We handle everything"}, + {"key": "hassle_free", "name": "Hassle-Free", "trigger": "Convenience", "example": "100% hassle-free"}, + {"key": "low_effort", "name": "Low Effort", "trigger": "Convenience", "example": "Minimal effort, maximum results"}, + {"key": "time_saving", "name": "Time Saving", "trigger": "Convenience", "example": "Save 10 hours per week"}, + {"key": "automated_process", "name": "Automated Process", "trigger": "Convenience", "example": "Fully automated, zero work"}, + {"key": "instant_access", "name": "Instant Access", "trigger": "Convenience", "example": "Get instant access now"}, + {"key": "no_waiting", "name": "No Waiting", "trigger": "Convenience", "example": "No waiting, start immediately"}, + ] + }, + AngleCategory.IDENTITY: { + "name": "Identity & Personalization", + "angles": [ + {"key": "age_based", "name": "Age-Based", "trigger": "Personalization", "example": "Special rates for seniors 65+"}, + {"key": "location_based", "name": "Location-Based", "trigger": "Personalization", "example": "Best rates in [location]"}, + {"key": "profession_specific", "name": "Profession-Specific", "trigger": "Personalization", "example": "Special rates for teachers"}, + {"key": "life_stage", "name": "Life Stage", "trigger": "Personalization", "example": "Perfect for new homeowners"}, + {"key": "lifestyle_match", "name": "Lifestyle Match", "trigger": "Personalization", "example": "Fits your lifestyle"}, + {"key": "people_like_you", "name": "People Like You", "trigger": "Social Proof", "example": "Join thousands like you"}, + {"key": "custom_fit", "name": "Custom Fit", "trigger": "Personalization", "example": "Customized just for you"}, + {"key": "personal_relevance", "name": "Personal Relevance", "trigger": "Personalization", "example": "Built specifically for you"}, + {"key": "niche_targeting", "name": "Niche Targeting", "trigger": "Personalization", "example": "Designed for [niche]"}, + {"key": "localized_offer", "name": "Localized Offer", "trigger": "Personalization", "example": "Best deals in [city]"}, + {"key": "behavioral_match", "name": "Behavioral Match", "trigger": "Personalization", "example": "Fits your lifestyle perfectly"}, + {"key": "preference_based", "name": "Preference-Based", "trigger": "Personalization", "example": "Tailored to your preferences"}, + {"key": "demographic_specific", "name": "Demographic-Specific", "trigger": "Personalization", "example": "Made for people like you"}, + ] + }, + AngleCategory.AUTHORITY: { + "name": "Authority & Trust", + "angles": [ + {"key": "expert_backed", "name": "Expert-Backed", "trigger": "Authority", "example": "Recommended by experts"}, + {"key": "industry_standard", "name": "Industry Standard", "trigger": "Authority", "example": "The industry standard"}, + {"key": "government_related", "name": "Government-Related", "trigger": "Authority", "example": "Government-approved program"}, + {"key": "trusted_millions", "name": "Trusted by Millions", "trigger": "Social Proof", "example": "Trusted by 2M+ customers"}, + {"key": "years_experience", "name": "Years of Experience", "trigger": "Authority", "example": "20+ years of experience"}, + {"key": "certified_verified", "name": "Certified / Verified", "trigger": "Authority", "example": "Certified and verified"}, + {"key": "brand_reputation", "name": "Brand Reputation", "trigger": "Authority", "example": "Trusted brand name"}, + {"key": "compliance", "name": "Compliance / Regulation", "trigger": "Authority", "example": "Fully compliant and regulated"}, + {"key": "awards_recognition", "name": "Awards / Recognition", "trigger": "Authority", "example": "Award-winning service"}, + {"key": "risk_free", "name": "Risk-Free", "trigger": "Security", "example": "100% risk-free guarantee"}, + {"key": "industry_leader", "name": "Industry Leader", "trigger": "Authority", "example": "The industry leader"}, + {"key": "proven_track_record", "name": "Proven Track Record", "trigger": "Authority", "example": "Proven results over decades"}, + ] + }, + AngleCategory.SOCIAL_PROOF: { + "name": "Social Proof & Validation", + "angles": [ + {"key": "testimonials", "name": "Testimonials", "trigger": "Social Proof", "example": "See what customers say"}, + {"key": "reviews_ratings", "name": "Reviews / Ratings", "trigger": "Social Proof", "example": "4.8/5 stars from 10K+ reviews"}, + {"key": "mass_adoption", "name": "Mass Adoption", "trigger": "Social Proof", "example": "Join 2M+ users"}, + {"key": "case_studies", "name": "Case Studies", "trigger": "Social Proof", "example": "See real success stories"}, + {"key": "word_of_mouth", "name": "Word of Mouth", "trigger": "Social Proof", "example": "Recommended by friends"}, + {"key": "community_trust", "name": "Community Trust", "trigger": "Social Proof", "example": "Trusted by our community"}, + {"key": "real_stories", "name": "Real Stories", "trigger": "Social Proof", "example": "Real stories from real customers"}, + {"key": "viral_popularity", "name": "Viral Popularity", "trigger": "FOMO", "example": "Going viral on [platform]"}, + {"key": "trending_now", "name": "Trending Now", "trigger": "FOMO", "example": "Trending now"}, + {"key": "most_chosen", "name": "Most Chosen", "trigger": "Social Proof", "example": "The most chosen option"}, + {"key": "peer_recommendation", "name": "Peer Recommendation", "trigger": "Social Proof", "example": "Recommended by your peers"}, + {"key": "success_rate", "name": "High Success Rate", "trigger": "Social Proof", "example": "95% success rate"}, + ] + }, + AngleCategory.URGENCY: { + "name": "Urgency & Scarcity", + "angles": [ + {"key": "limited_time", "name": "Limited Time", "trigger": "FOMO", "example": "Limited time offer - ends Friday"}, + {"key": "ending_soon", "name": "Ending Soon", "trigger": "FOMO", "example": "Offer ending in 48 hours"}, + {"key": "price_increase", "name": "Price Increase Warning", "trigger": "FOMO", "example": "Prices increasing on [date]"}, + {"key": "seasonal_change", "name": "Seasonal Change", "trigger": "FOMO", "example": "Spring special - ends soon"}, + {"key": "renewal_reminder", "name": "Policy Renewal", "trigger": "FOMO", "example": "Renew before [date] to save"}, + {"key": "countdown", "name": "Countdown", "trigger": "FOMO", "example": "Only 24 hours left"}, + {"key": "last_chance", "name": "Last Chance", "trigger": "FOMO", "example": "Last chance to save"}, + {"key": "market_shift", "name": "Market Shift", "trigger": "FOMO", "example": "Market rates changing soon"}, + {"key": "deadline_pressure", "name": "Deadline Pressure", "trigger": "FOMO", "example": "Deadline: [date]"}, + {"key": "miss_out_avoidance", "name": "Miss-Out Avoidance", "trigger": "FOMO", "example": "Don't miss this opportunity"}, + {"key": "early_bird", "name": "Early Bird Special", "trigger": "FOMO", "example": "Early bird pricing ends soon"}, + {"key": "flash_sale", "name": "Flash Sale", "trigger": "FOMO", "example": "Flash sale - 24 hours only"}, + ] + }, + AngleCategory.INTELLECTUAL: { + "name": "Intellectual / Smart Choice", + "angles": [ + {"key": "insider_knowledge", "name": "Insider Knowledge", "trigger": "Curiosity", "example": "Insider tip: [secret]"}, + {"key": "avoid_mistakes", "name": "Avoid Common Mistakes", "trigger": "Fear", "example": "Avoid these 5 common mistakes"}, + {"key": "educated_decision", "name": "Educated Decision", "trigger": "Pride", "example": "Make an educated decision"}, + {"key": "comparison_logic", "name": "Comparison Logic", "trigger": "Pride", "example": "Compare and choose wisely"}, + {"key": "transparency", "name": "Transparency", "trigger": "Trust", "example": "100% transparent pricing"}, + {"key": "informed_buyer", "name": "Informed Buyer", "trigger": "Pride", "example": "For informed buyers"}, + {"key": "data_driven", "name": "Data-Driven", "trigger": "Authority", "example": "Backed by data"}, + {"key": "rational_choice", "name": "Rational Choice", "trigger": "Pride", "example": "The rational choice"}, + {"key": "what_experts_do", "name": "What Experts Do", "trigger": "Authority", "example": "What experts do"}, + {"key": "optimization", "name": "Optimization", "trigger": "Pride", "example": "Optimize your [thing]"}, + {"key": "smart_investment", "name": "Smart Investment", "trigger": "Pride", "example": "The smart investment choice"}, + {"key": "evidence_based", "name": "Evidence-Based", "trigger": "Authority", "example": "Backed by research and data"}, + ] + }, + AngleCategory.CURIOSITY: { + "name": "Curiosity & Pattern Interrupt", + "angles": [ + {"key": "shocking_stats", "name": "Shocking Stats", "trigger": "Curiosity", "example": "Shocking stat: [number]"}, + {"key": "did_you_know", "name": "Did You Know?", "trigger": "Curiosity", "example": "Did you know [fact]?"}, + {"key": "open_loops", "name": "Open Loops", "trigger": "Curiosity", "example": "Thousands doing THIS instead"}, + {"key": "contrarian", "name": "Contrarian Claims", "trigger": "Curiosity", "example": "Why everyone is wrong about [thing]"}, + {"key": "myth_busting", "name": "Myth Busting", "trigger": "Curiosity", "example": "Myth busted: [myth]"}, + {"key": "unexpected_truth", "name": "Unexpected Truth", "trigger": "Curiosity", "example": "The truth about [thing]"}, + {"key": "hidden_secrets", "name": "Hidden Secrets", "trigger": "Curiosity", "example": "The hidden secret to [thing]"}, + {"key": "scroll_stopper", "name": "Scroll Stopper", "trigger": "Curiosity", "example": "Stop scrolling - read this"}, + {"key": "pattern_break", "name": "Pattern Break", "trigger": "Curiosity", "example": "This breaks all patterns"}, + {"key": "curiosity_gap", "name": "Curiosity Gap", "trigger": "Curiosity", "example": "What is THIS?"}, + {"key": "reveal_secret", "name": "Reveal Secret", "trigger": "Curiosity", "example": "The secret they don't want you to know"}, + {"key": "unexpected_benefit", "name": "Unexpected Benefit", "trigger": "Curiosity", "example": "The benefit nobody talks about"}, + ] + }, + AngleCategory.PROBLEM_SOLUTION: { + "name": "Problem–Solution", + "angles": [ + {"key": "pain_point", "name": "Pain Point Highlight", "trigger": "Anger", "example": "Tired of [problem]?"}, + {"key": "frustration_relief", "name": "Frustration Relief", "trigger": "Relief", "example": "End your frustration with [problem]"}, + {"key": "complexity_simplified", "name": "Complexity Simplified", "trigger": "Convenience", "example": "We make [thing] simple"}, + {"key": "confusion_clarity", "name": "Confusion → Clarity", "trigger": "Relief", "example": "From confusion to clarity"}, + {"key": "overwhelm_reduction", "name": "Overwhelm Reduction", "trigger": "Relief", "example": "Stop feeling overwhelmed"}, + {"key": "direct_fix", "name": "Direct Fix", "trigger": "Relief", "example": "The direct fix for [problem]"}, + {"key": "shortcut", "name": "Shortcut", "trigger": "Convenience", "example": "The shortcut to [goal]"}, + {"key": "better_alternative", "name": "Better Alternative", "trigger": "Desire", "example": "A better alternative to [current]"}, + {"key": "replace_old_way", "name": "Replace Old Way", "trigger": "Desire", "example": "Replace the old way"}, + {"key": "modern_solution", "name": "Modern Solution", "trigger": "Desire", "example": "The modern solution to [problem]"}, + {"key": "pain_elimination", "name": "Pain Elimination", "trigger": "Relief", "example": "Eliminate [problem] forever"}, + {"key": "simplified_complexity", "name": "Simplified Complexity", "trigger": "Convenience", "example": "We simplified the complex"}, + {"key": "one_click_solution", "name": "One-Click Solution", "trigger": "Convenience", "example": "Solve it in one click"}, + ] + }, +} + + +def get_all_angles() -> List[Dict[str, Any]]: + """Get all angles as a flat list.""" + all_angles = [] + for category, data in ANGLES.items(): + for angle in data["angles"]: + angle_copy = angle.copy() + angle_copy["category"] = data["name"] + angle_copy["category_key"] = category + all_angles.append(angle_copy) + return all_angles + + +def get_angles_by_category(category: AngleCategory) -> List[Dict[str, Any]]: + """Get angles for a specific category.""" + return ANGLES.get(category, {}).get("angles", []) + + +def get_angle_by_key(key: str) -> Optional[Dict[str, Any]]: + """Get a specific angle by key.""" + for category, data in ANGLES.items(): + for angle in data["angles"]: + if angle["key"] == key: + angle_copy = angle.copy() + angle_copy["category"] = data["name"] + angle_copy["category_key"] = category + return angle_copy + return None + + +def get_random_angles(count: int = 6, diverse: bool = True) -> List[Dict[str, Any]]: + """Get random angles, optionally ensuring diversity across categories.""" + if diverse: + # Select one from each category first + selected = [] + categories = list(ANGLES.keys()) + random.shuffle(categories) + + for category in categories[:count]: + angles = ANGLES[category]["angles"] + angle = random.choice(angles).copy() + angle["category"] = ANGLES[category]["name"] + angle["category_key"] = category + selected.append(angle) + + return selected[:count] + else: + all_angles = get_all_angles() + return random.sample(all_angles, min(count, len(all_angles))) + + +def get_angles_for_niche(niche: str) -> List[Dict[str, Any]]: + """Get angles best suited for a niche.""" + niche_lower = niche.lower() + + # Niche-specific angle recommendations + if "insurance" in niche_lower: + recommended_keys = [ + "fear_loss", "peace_of_mind", "save_money", "price_comparison", + "trusted_millions", "limited_time", "avoid_mistakes", "pain_point" + ] + elif "glp" in niche_lower or "weight" in niche_lower: + recommended_keys = [ + "confidence_boost", "pride_self_worth", "before_after_shock", + "testimonials", "trending_now", "modern_solution", "shortcut" + ] + else: + # Default mix + recommended_keys = [ + "save_money", "fast_instant", "testimonials", "limited_time", + "shocking_stats", "pain_point" + ] + + angles = [] + for key in recommended_keys: + angle = get_angle_by_key(key) + if angle: + angles.append(angle) + + return angles + + +# Top performing angles for initial testing +TOP_ANGLES = [ + "save_money", "fear_loss", "fast_instant", "expert_backed", + "testimonials", "limited_time", "age_based", "pain_point" +] + + +def get_top_angles() -> List[Dict[str, Any]]: + """Get top performing angles for initial testing.""" + return [get_angle_by_key(key) for key in TOP_ANGLES if get_angle_by_key(key)] + diff --git a/data/concepts.py b/data/concepts.py new file mode 100644 index 0000000000000000000000000000000000000000..992ecbe3fa93a3e63a9462a954617590529142db --- /dev/null +++ b/data/concepts.py @@ -0,0 +1,282 @@ +""" +Affiliate Marketing Concepts Framework (Lite Version) +100 concepts organized into 10 categories. +Concepts answer: "How do I show it visually?" (Creative HOW) +""" + +from typing import Dict, List, Any, Optional +from enum import Enum +import random + + +class ConceptCategory(str, Enum): + """Concept categories.""" + VISUAL_STRUCTURE = "visual_structure" + UGC_NATIVE = "ugc_native" + STORYTELLING = "storytelling" + COMPARISON = "comparison" + AUTHORITY = "authority" + SOCIAL_PROOF = "social_proof" + SCROLL_STOPPING = "scroll_stopping" + EDUCATIONAL = "educational" + PERSONALIZATION = "personalization" + CTA_FOCUSED = "cta_focused" + + +# Complete concepts framework - 100 concepts (10 per category) +CONCEPTS = { + ConceptCategory.VISUAL_STRUCTURE: { + "name": "Visual Structure", + "concepts": [ + {"key": "before_after", "name": "Before vs After", "structure": "Left=pain, Right=solution", "visual": "Split-screen transformation"}, + {"key": "split_screen", "name": "Split Screen", "structure": "Two halves comparison", "visual": "Vertical or horizontal split"}, + {"key": "checklist", "name": "Checklist / Tick Marks", "structure": "List with checkmarks", "visual": "Clean list, prominent checks"}, + {"key": "bold_headline", "name": "Bold Headline Image", "structure": "Headline dominates", "visual": "Large bold typography"}, + {"key": "text_first", "name": "Text-First Image", "structure": "Text primary, image secondary", "visual": "Clear typography hierarchy"}, + {"key": "minimalist", "name": "Minimalist Design", "structure": "White space, minimal elements", "visual": "Clean lines, focused"}, + {"key": "big_numbers", "name": "Big Numbers Visual", "structure": "Numbers dominate", "visual": "Huge numbers, bold type"}, + {"key": "highlight_circle", "name": "Highlight / Circle Focus", "structure": "Circle on key element", "visual": "Red circles, arrows"}, + {"key": "step_by_step", "name": "Step-by-Step Visual", "structure": "Step 1 → 2 → 3", "visual": "Clear flow, numbered"}, + {"key": "icon_based", "name": "Icon-Based Layout", "structure": "Icons represent features", "visual": "Clear icons, organized"}, + {"key": "grid_layout", "name": "Grid Layout", "structure": "Organized grid format", "visual": "Clean grid, easy to scan"}, + {"key": "timeline_visual", "name": "Timeline Visual", "structure": "Time-based progression", "visual": "Clear timeline flow"}, + {"key": "infographic_style", "name": "Infographic Style", "structure": "Information graphics", "visual": "Data visualization"}, + ] + }, + ConceptCategory.UGC_NATIVE: { + "name": "UGC & Native", + "concepts": [ + {"key": "selfie_style", "name": "Selfie-Style Image", "structure": "Phone camera angle", "visual": "Casual, authentic feel"}, + {"key": "casual_phone", "name": "Casual Phone Shot", "structure": "Mobile perspective", "visual": "Unpolished, authentic"}, + {"key": "pov", "name": "POV Perspective", "structure": "First-person view", "visual": "Immersive feel"}, + {"key": "just_found", "name": "Just Found This", "structure": "Discovery moment", "visual": "Excited, sharing"}, + {"key": "organic_feed", "name": "Organic Feed Style", "structure": "Native to platform", "visual": "Doesn't look like ad"}, + {"key": "influencer", "name": "Influencer-Style", "structure": "Polished but authentic", "visual": "Influencer aesthetic"}, + {"key": "low_production", "name": "Low-Production Authentic", "structure": "Raw, unpolished", "visual": "Authentic feel"}, + {"key": "reaction", "name": "Reaction Shot", "structure": "Emotional reaction", "visual": "Clear emotion visible"}, + {"key": "screenshot", "name": "Screenshot-Style", "structure": "Device screenshot", "visual": "Realistic screenshot"}, + {"key": "story_frame", "name": "Story Frame", "structure": "Vertical story format", "visual": "Story-style layout"}, + {"key": "behind_scenes", "name": "Behind the Scenes", "structure": "Raw, unedited look", "visual": "Authentic, unpolished"}, + {"key": "day_in_life", "name": "Day in the Life", "structure": "Daily routine snapshot", "visual": "Relatable daily moments"}, + {"key": "unboxing_style", "name": "Unboxing Style", "structure": "Product reveal moment", "visual": "Discovery excitement"}, + ] + }, + ConceptCategory.STORYTELLING: { + "name": "Storytelling", + "concepts": [ + {"key": "emotional_snapshot", "name": "Emotional Snapshot", "structure": "Single emotional moment", "visual": "Authentic expression"}, + {"key": "relatable_moment", "name": "Relatable Daily Moment", "structure": "Everyday scene", "visual": "Relatable situation"}, + {"key": "micro_story", "name": "Micro-Story Scene", "structure": "Small story moment", "visual": "Narrative feel"}, + {"key": "life_situation", "name": "Life Situation", "structure": "Real-life context", "visual": "Authentic situation"}, + {"key": "decision_moment", "name": "Decision Moment", "structure": "Decision point", "visual": "Contemplative"}, + {"key": "problem_awareness", "name": "Problem Awareness Scene", "structure": "Problem visible", "visual": "Awareness moment"}, + {"key": "turning_point", "name": "Turning Point Frame", "structure": "Transformation moment", "visual": "Change visible"}, + {"key": "relief_moment", "name": "Relief Moment", "structure": "Relief visible", "visual": "Peace visible"}, + {"key": "success_moment", "name": "Success Moment", "structure": "Success visible", "visual": "Achievement visible"}, + {"key": "future_projection", "name": "Future Projection", "structure": "Future vision", "visual": "Aspirational"}, + {"key": "journey_arc", "name": "Journey Arc", "structure": "Complete transformation story", "visual": "Full journey visible"}, + {"key": "milestone_moment", "name": "Milestone Moment", "structure": "Key achievement moment", "visual": "Celebration visible"}, + ] + }, + ConceptCategory.COMPARISON: { + "name": "Comparison & Logic", + "concepts": [ + {"key": "side_by_side_table", "name": "Side-by-Side Table", "structure": "Comparison table", "visual": "Checkmarks/crosses"}, + {"key": "old_vs_new", "name": "Old Way vs New Way", "structure": "Old vs new comparison", "visual": "Modern highlighted"}, + {"key": "choice_elimination", "name": "Choice Elimination", "structure": "Others crossed out", "visual": "Winner clear"}, + {"key": "feature_breakdown", "name": "Feature Breakdown", "structure": "Features compared", "visual": "Easy to compare"}, + {"key": "pros_cons", "name": "Pros vs Cons", "structure": "Pros on one side, cons other", "visual": "Balanced view"}, + {"key": "ranking", "name": "Ranking Visual", "structure": "Ranked list", "visual": "Top highlighted"}, + {"key": "winner_highlight", "name": "Winner Highlight", "structure": "Winner highlighted", "visual": "Others dimmed"}, + {"key": "bar_scale", "name": "Bar / Scale Visual", "structure": "Visual bars/scales", "visual": "Easy to compare"}, + {"key": "price_stack", "name": "Price Stack", "structure": "Prices stacked", "visual": "Savings visible"}, + {"key": "value_breakdown", "name": "Value Breakdown", "structure": "Value components", "visual": "Easy to understand"}, + {"key": "versus_comparison", "name": "Versus Comparison", "structure": "Head-to-head comparison", "visual": "Clear winner"}, + {"key": "feature_matrix", "name": "Feature Matrix", "structure": "Feature comparison grid", "visual": "Easy comparison"}, + ] + }, + ConceptCategory.AUTHORITY: { + "name": "Authority & Trust", + "concepts": [ + {"key": "expert_portrait", "name": "Expert Portrait", "structure": "Professional portrait", "visual": "Trustworthy appearance"}, + {"key": "badge_seal", "name": "Badge / Seal Visual", "structure": "Badges prominent", "visual": "Official look"}, + {"key": "certification", "name": "Certification Overlay", "structure": "Cert overlaid", "visual": "Official"}, + {"key": "media_mention", "name": "Media Mention Style", "structure": "Media logos/quotes", "visual": "Credible"}, + {"key": "professional_setting", "name": "Professional Setting", "structure": "Office/professional space", "visual": "Clean, organized"}, + {"key": "office_scene", "name": "Office / Desk Scene", "structure": "Office environment", "visual": "Professional"}, + {"key": "institutional", "name": "Institutional Look", "structure": "Official design", "visual": "Institutional"}, + {"key": "data_backed", "name": "Data-Backed Visual", "structure": "Charts, graphs", "visual": "Data visible"}, + {"key": "chart_graph", "name": "Chart / Graph Snapshot", "structure": "Chart dominates", "visual": "Easy to read"}, + {"key": "trust_signals", "name": "Trust Signal Stack", "structure": "Multiple trust elements", "visual": "Organized signals"}, + {"key": "credentials_display", "name": "Credentials Display", "structure": "Certifications shown", "visual": "Official credentials"}, + {"key": "partnership_logos", "name": "Partnership Logos", "structure": "Partner brands visible", "visual": "Trusted partnerships"}, + ] + }, + ConceptCategory.SOCIAL_PROOF: { + "name": "Social Proof", + "concepts": [ + {"key": "testimonial_screenshot", "name": "Testimonial Screenshot", "structure": "Screenshot format", "visual": "Authentic testimonial"}, + {"key": "review_stars", "name": "Review Stars Visual", "structure": "Stars dominate", "visual": "Large stars, rating"}, + {"key": "quote_card", "name": "Quote Card", "structure": "Quote as main visual", "visual": "Clear, readable"}, + {"key": "case_study_frame", "name": "Case Study Frame", "structure": "Case study format", "visual": "Results visible"}, + {"key": "crowd", "name": "Crowd Visual", "structure": "Many people visible", "visual": "Popular feel"}, + {"key": "others_like_you", "name": "Others Like You", "structure": "Similar people", "visual": "Relatable, authentic"}, + {"key": "real_customer", "name": "Real Customer Photo", "structure": "Real customer shown", "visual": "Authentic, relatable"}, + {"key": "comment_screenshot", "name": "Comment Screenshot", "structure": "Comments visible", "visual": "Realistic comments"}, + {"key": "ugc_collage", "name": "UGC Collage", "structure": "Multiple UGC pieces", "visual": "Collage format"}, + {"key": "community", "name": "Community Visual", "structure": "Community visible", "visual": "Belonging feel"}, + {"key": "user_spotlight", "name": "User Spotlight", "structure": "Individual user featured", "visual": "Personal success story"}, + {"key": "group_success", "name": "Group Success", "structure": "Multiple success stories", "visual": "Collective achievement"}, + ] + }, + ConceptCategory.SCROLL_STOPPING: { + "name": "Scroll-Stopping", + "concepts": [ + {"key": "shock_headline", "name": "Shock Headline", "structure": "Shocking headline dominates", "visual": "Bold, high contrast"}, + {"key": "red_warning", "name": "Big Red Warning Text", "structure": "Red warning prominent", "visual": "Large red text"}, + {"key": "unusual_contrast", "name": "Unusual Contrast", "structure": "High contrast, unusual colors", "visual": "Stands out"}, + {"key": "pattern_break", "name": "Pattern Break Design", "structure": "Different from expected", "visual": "Unexpected"}, + {"key": "unexpected_image", "name": "Unexpected Image", "structure": "Surprising visual", "visual": "Attention-grabbing"}, + {"key": "bold_claim", "name": "Bold Claim Card", "structure": "Bold claim prominent", "visual": "Large text"}, + {"key": "glitch_highlight", "name": "Glitch / Highlight", "structure": "Glitch effect", "visual": "Modern"}, + {"key": "disruptive_color", "name": "Disruptive Color Use", "structure": "Unexpected colors", "visual": "Disruptive"}, + {"key": "meme_style", "name": "Meme-Style Image", "structure": "Meme format", "visual": "Shareable"}, + {"key": "visual_tension", "name": "Visual Tension", "structure": "Tension in visual", "visual": "Engaging"}, + {"key": "bold_typography", "name": "Bold Typography Focus", "structure": "Typography dominates", "visual": "Text-first impact"}, + {"key": "motion_blur", "name": "Motion Blur Effect", "structure": "Dynamic movement feel", "visual": "Energy and action"}, + ] + }, + ConceptCategory.EDUCATIONAL: { + "name": "Educational & Explainer", + "concepts": [ + {"key": "how_it_works", "name": "How It Works", "structure": "Process explanation", "visual": "Step-by-step"}, + {"key": "three_step", "name": "3-Step Process", "structure": "Step 1→2→3", "visual": "Simple flow"}, + {"key": "flow_diagram", "name": "Flow Diagram", "structure": "Flowchart format", "visual": "Easy to follow"}, + {"key": "simple_explainer", "name": "Simple Explainer", "structure": "Simple explanation", "visual": "Easy to understand"}, + {"key": "faq", "name": "FAQ Visual", "structure": "FAQ format", "visual": "Clear Q&A"}, + {"key": "mistake_callout", "name": "Mistake Callout", "structure": "Mistakes highlighted", "visual": "Avoid them"}, + {"key": "do_dont", "name": "Do / Don't Visual", "structure": "Do vs Don't", "visual": "Easy to understand"}, + {"key": "learning_card", "name": "Learning Card", "structure": "Card with content", "visual": "Educational"}, + {"key": "visual_guide", "name": "Visual Guide", "structure": "Guide format", "visual": "Instructional"}, + {"key": "instructional", "name": "Instructional Frame", "structure": "Instruction format", "visual": "Easy to follow"}, + {"key": "tip_card", "name": "Tip Card", "structure": "Quick tip format", "visual": "Actionable advice"}, + {"key": "checklist_visual", "name": "Checklist Visual", "structure": "Checklist format", "visual": "Easy to follow"}, + ] + }, + ConceptCategory.PERSONALIZATION: { + "name": "Call-Out & Personalization", + "concepts": [ + {"key": "if_you_are", "name": "If You Are X…", "structure": "Direct callout", "visual": "Specific group"}, + {"key": "age_specific", "name": "Age-Specific Visual", "structure": "Age-specific imagery", "visual": "Relatable"}, + {"key": "location_specific", "name": "Location-Specific", "structure": "Location visible", "visual": "Local feel"}, + {"key": "role_based", "name": "Role-Based Scene", "structure": "Role-specific scene", "visual": "Relatable"}, + {"key": "lifestyle_scene", "name": "Lifestyle Match", "structure": "Lifestyle visible", "visual": "Match"}, + {"key": "identity_mirror", "name": "Identity Mirror", "structure": "Identity reflected", "visual": "Connection"}, + {"key": "targeted_headline", "name": "Targeted Headline", "structure": "Targeted headline", "visual": "Relevant"}, + {"key": "personalized_hook", "name": "Personalized Hook", "structure": "Personalized opening", "visual": "Connection"}, + {"key": "segment_callout", "name": "Segment Callout", "structure": "Segment-specific", "visual": "Targeted"}, + {"key": "direct_address", "name": "Direct Address", "structure": "Direct address format", "visual": "Personal"}, + {"key": "custom_message", "name": "Custom Message", "structure": "Personalized message", "visual": "One-on-one feel"}, + {"key": "targeted_audience", "name": "Targeted Audience Visual", "structure": "Specific audience shown", "visual": "Relatable group"}, + ] + }, + ConceptCategory.CTA_FOCUSED: { + "name": "CTA-Focused", + "concepts": [ + {"key": "button_focused", "name": "Button-Focused Image", "structure": "Button prominent", "visual": "Action-oriented"}, + {"key": "arrow_flow", "name": "Arrow-Directed Flow", "structure": "Arrows point to CTA", "visual": "Directional"}, + {"key": "highlighted_cta", "name": "Highlighted CTA Box", "structure": "CTA box stands out", "visual": "Clear"}, + {"key": "action_words", "name": "Action Words Overlay", "structure": "Action words prominent", "visual": "Urgent"}, + {"key": "click_prompt", "name": "Click Prompt Visual", "structure": "Click prompt visible", "visual": "Action-oriented"}, + {"key": "tap_here", "name": "Tap-Here Cue", "structure": "Tap here visible", "visual": "Mobile-friendly"}, + {"key": "next_step", "name": "Next Step Highlight", "structure": "Next step prominent", "visual": "Action-oriented"}, + {"key": "simple_cta", "name": "Simple CTA Card", "structure": "Simple card with CTA", "visual": "Minimal"}, + {"key": "countdown_cta", "name": "Countdown CTA", "structure": "Countdown + CTA", "visual": "Urgent"}, + {"key": "final_push", "name": "Final Push Frame", "structure": "Final push format", "visual": "Urgent, action"}, + {"key": "pulsing_cta", "name": "Pulsing CTA", "structure": "Animated attention", "visual": "Eye-catching action"}, + {"key": "multi_cta", "name": "Multiple CTAs", "structure": "Multiple action options", "visual": "Flexible engagement"}, + ] + }, +} + + +def get_all_concepts() -> List[Dict[str, Any]]: + """Get all concepts as a flat list.""" + all_concepts = [] + for category, data in CONCEPTS.items(): + for concept in data["concepts"]: + concept_copy = concept.copy() + concept_copy["category"] = data["name"] + concept_copy["category_key"] = category + all_concepts.append(concept_copy) + return all_concepts + + +def get_concepts_by_category(category: ConceptCategory) -> List[Dict[str, Any]]: + """Get concepts for a specific category.""" + return CONCEPTS.get(category, {}).get("concepts", []) + + +def get_concept_by_key(key: str) -> Optional[Dict[str, Any]]: + """Get a specific concept by key.""" + for category, data in CONCEPTS.items(): + for concept in data["concepts"]: + if concept["key"] == key: + concept_copy = concept.copy() + concept_copy["category"] = data["name"] + concept_copy["category_key"] = category + return concept_copy + return None + + +def get_random_concepts(count: int = 5, diverse: bool = True) -> List[Dict[str, Any]]: + """Get random concepts, optionally ensuring diversity across categories.""" + if diverse: + selected = [] + categories = list(CONCEPTS.keys()) + random.shuffle(categories) + + for category in categories[:count]: + concepts = CONCEPTS[category]["concepts"] + concept = random.choice(concepts).copy() + concept["category"] = CONCEPTS[category]["name"] + concept["category_key"] = category + selected.append(concept) + + return selected[:count] + else: + all_concepts = get_all_concepts() + return random.sample(all_concepts, min(count, len(all_concepts))) + + +# Top performing concepts for initial testing +TOP_CONCEPTS = [ + "before_after", "selfie_style", "problem_awareness", + "side_by_side_table", "relatable_moment" +] + + +def get_top_concepts() -> List[Dict[str, Any]]: + """Get top performing concepts for initial testing.""" + return [get_concept_by_key(key) for key in TOP_CONCEPTS if get_concept_by_key(key)] + + +def get_compatible_concepts(angle_trigger: str) -> List[Dict[str, Any]]: + """Get concepts compatible with a psychological trigger.""" + # Compatibility mapping + compatibility = { + "Fear": ["before_after", "shock_headline", "red_warning", "problem_awareness"], + "Relief": ["before_after", "relief_moment", "success_moment", "turning_point"], + "Greed": ["big_numbers", "price_stack", "value_breakdown", "side_by_side_table"], + "FOMO": ["countdown_cta", "red_warning", "crowd", "trending"], + "Social Proof": ["testimonial_screenshot", "review_stars", "real_customer", "crowd"], + "Authority": ["expert_portrait", "badge_seal", "data_backed", "certification"], + "Curiosity": ["shock_headline", "unexpected_image", "pattern_break", "bold_claim"], + "Pride": ["success_moment", "before_after", "winner_highlight"], + "Convenience": ["three_step", "how_it_works", "simple_explainer"], + "Trust": ["trust_signals", "badge_seal", "real_customer", "testimonial_screenshot"], + } + + concept_keys = compatibility.get(angle_trigger, []) + return [get_concept_by_key(key) for key in concept_keys if get_concept_by_key(key)] + diff --git a/data/containers.py b/data/containers.py new file mode 100644 index 0000000000000000000000000000000000000000..e1c7c07f6970c021d6191c326f983fbda3c3d73a --- /dev/null +++ b/data/containers.py @@ -0,0 +1,434 @@ +""" +Container Types - Visual container styles for ad creatives. +These simulate different interface styles to make ads feel native and authentic. +""" + +from typing import Dict, Any, List, Optional +import random + + +# Complete list of container types with visual guidance +CONTAINER_TYPES: Dict[str, Dict[str, Any]] = { + "imessage": { + "name": "iMessage", + "description": "iOS iMessage screenshot style", + "visual_guidance": "iOS iMessage screenshot style, blue/green message bubbles, iPhone interface, authentic conversation look", + "font_style": "San Francisco, system font", + "colors": { + "primary": "#007AFF", # iOS blue + "secondary": "#34C759", # iOS green + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["personal connection", "conversation", "informal offers"], + "authenticity_tips": [ + "Include battery %, time, signal bars", + "Use realistic conversation format", + "Keep messages short (2-4 messages)", + ], + }, + "whatsapp": { + "name": "WhatsApp", + "description": "WhatsApp chat screenshot style", + "visual_guidance": "WhatsApp chat interface, green bubbles, checkmarks, authentic conversation feel", + "font_style": "Helvetica Neue, system font", + "colors": { + "primary": "#25D366", # WhatsApp green + "secondary": "#128C7E", + "background": "#ECE5DD", + "text": "#000000", + }, + "best_for": ["personal recommendations", "peer-to-peer", "urgent messages"], + "authenticity_tips": [ + "Include double checkmarks (read receipts)", + "Add timestamps", + "Use typical WhatsApp formatting", + ], + }, + "sms": { + "name": "SMS/Text", + "description": "Standard SMS text message style", + "visual_guidance": "Android/iOS SMS interface, simple text bubbles, notification style", + "font_style": "Roboto or San Francisco", + "colors": { + "primary": "#2196F3", + "secondary": "#4CAF50", + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["urgent alerts", "personal messages", "time-sensitive offers"], + "authenticity_tips": [ + "Keep messages very short", + "Use typical SMS abbreviations", + "Include carrier/time info", + ], + }, + "bank_alert": { + "name": "Bank Alert", + "description": "Bank transaction notification style", + "visual_guidance": "Bank transaction notification style, red alert box, bank app UI, urgent notification aesthetic", + "font_style": "Arial, Helvetica, system font", + "colors": { + "primary": "#D32F2F", # Alert red + "secondary": "#1976D2", # Bank blue + "background": "#FFFFFF", + "text": "#212121", + }, + "best_for": ["financial urgency", "savings alerts", "money-related offers"], + "authenticity_tips": [ + "Use official-looking format", + "Include dollar amounts", + "Add bank-style icons", + ], + }, + "news_chyron": { + "name": "News Chyron", + "description": "Breaking news ticker style", + "visual_guidance": "Breaking news ticker style, red/white scrolling text bar, news channel aesthetic, urgent feel", + "font_style": "Impact, Arial Black, bold sans-serif", + "colors": { + "primary": "#FF0000", # Breaking news red + "secondary": "#FFFFFF", + "background": "#000000", + "text": "#FFFFFF", + }, + "best_for": ["breaking announcements", "urgent news", "time-sensitive offers"], + "authenticity_tips": [ + "Use BREAKING/ALERT prefix", + "Include news channel logo area", + "Add scrolling ticker effect", + ], + }, + "email_notification": { + "name": "Email Notification", + "description": "Email notification/preview style", + "visual_guidance": "Email notification style, email client interface, notification badge, real email app look", + "font_style": "System font, Segoe UI, Roboto", + "colors": { + "primary": "#1A73E8", # Gmail blue + "secondary": "#EA4335", # Notification badge + "background": "#FFFFFF", + "text": "#202124", + }, + "best_for": ["official communications", "professional offers", "formal announcements"], + "authenticity_tips": [ + "Include sender, subject, preview", + "Add unread badge if relevant", + "Use email timestamp format", + ], + }, + "reddit_post": { + "name": "Reddit Post", + "description": "Reddit post/comment style", + "visual_guidance": "Reddit discussion style, Reddit UI, comment thread appearance, authentic forum look", + "font_style": "Noto Sans, Arial", + "colors": { + "primary": "#FF4500", # Reddit orange + "secondary": "#0079D3", # Reddit blue + "background": "#DAE0E6", + "text": "#1A1A1B", + }, + "best_for": ["social proof", "user discussions", "authentic testimonials"], + "authenticity_tips": [ + "Include upvote counts", + "Add username (anonymous style)", + "Use subreddit reference", + ], + }, + "system_notification": { + "name": "System Notification", + "description": "iOS/Android system notification popup", + "visual_guidance": "iOS/Android system notification style - plain white or gray rounded rectangle box, NO gradients, NO emojis, NO decorative elements, simple system font, minimal text, authentic OS notification appearance", + "font_style": "San Francisco, Roboto, system font only", + "colors": { + "primary": "#000000", + "secondary": "#666666", + "background": "#F2F2F2", + "text": "#000000", + }, + "best_for": ["urgent alerts", "app notifications", "system messages"], + "authenticity_tips": [ + "NO emojis or decorative elements", + "Keep to 1-2 short lines", + "Use app icon if relevant", + ], + "avoid": ["emojis", "decorative elements", "gradients", "colorful backgrounds"], + }, + "push_notification": { + "name": "Push Notification", + "description": "Mobile app push notification style", + "visual_guidance": "Mobile push notification banner, app icon, brief text, swipe-to-view format", + "font_style": "System font", + "colors": { + "primary": "#007AFF", + "secondary": "#8E8E93", + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["app alerts", "time-sensitive messages", "quick updates"], + "authenticity_tips": [ + "Include app icon", + "Very short headline (5-7 words)", + "Add timestamp (e.g., 'now', '2m ago')", + ], + }, + "sticky_note": { + "name": "Sticky Note", + "description": "Handwritten sticky note overlay", + "visual_guidance": "Yellow sticky note overlay on image, handwritten-style text, authentic note appearance, slightly wrinkled paper texture", + "font_style": "Handwriting fonts, marker style", + "colors": { + "primary": "#FFEB3B", # Sticky note yellow + "secondary": "#FFC107", + "background": "#FFEB3B", + "text": "#000000", + }, + "best_for": ["personal reminders", "quick tips", "informal notes"], + "authenticity_tips": [ + "Slight angle/tilt", + "Handwritten font style", + "Paper texture/wrinkles", + ], + }, + "memo": { + "name": "Internal Memo", + "description": "Office memo/document style", + "visual_guidance": "Internal memo document style, typewriter font, yellow/white paper, authentic document appearance", + "font_style": "Courier, typewriter fonts", + "colors": { + "primary": "#000000", + "secondary": "#333333", + "background": "#FFFFCC", # Paper yellow + "text": "#000000", + }, + "best_for": ["official announcements", "leaked documents", "internal secrets"], + "authenticity_tips": [ + "Add CONFIDENTIAL stamp if relevant", + "Include date, to/from fields", + "Paper texture/fold marks", + ], + }, + "browser_alert": { + "name": "Browser Alert", + "description": "Browser popup/alert dialog", + "visual_guidance": "Browser dialog box, alert icons, OK/Cancel buttons, system alert aesthetic", + "font_style": "System font, Segoe UI", + "colors": { + "primary": "#0078D4", # Windows blue + "secondary": "#D83B01", # Warning orange + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["urgent warnings", "system alerts", "confirmation messages"], + "authenticity_tips": [ + "Include browser chrome", + "Add alert icon", + "Button styling", + ], + }, + "social_post": { + "name": "Social Media Post", + "description": "Facebook/Instagram post style", + "visual_guidance": "Social media feed post, user profile, likes/comments, authentic social feel", + "font_style": "Helvetica, system font", + "colors": { + "primary": "#1877F2", # Facebook blue + "secondary": "#E4405F", # Instagram pink + "background": "#FFFFFF", + "text": "#1C1E21", + }, + "best_for": ["social proof", "user content", "organic feel"], + "authenticity_tips": [ + "Include profile picture", + "Add like/comment counts", + "Use platform-specific formatting", + ], + }, + "standard": { + "name": "Standard Ad", + "description": "Clean, professional ad format", + "visual_guidance": "Clean ad layout, professional design, clear headline and CTA", + "font_style": "Clean sans-serif fonts", + "colors": { + "primary": "#2196F3", + "secondary": "#FF9800", + "background": "#FFFFFF", + "text": "#212121", + }, + "best_for": ["professional campaigns", "brand awareness", "general advertising"], + "authenticity_tips": [ + "Clear visual hierarchy", + "Prominent CTA", + "Brand-consistent design", + ], + }, + "telegram": { + "name": "Telegram", + "description": "Telegram chat message style", + "visual_guidance": "Telegram chat interface, blue message bubbles, Telegram UI, authentic messaging look", + "font_style": "Roboto, system font", + "colors": { + "primary": "#3390EC", # Telegram blue + "secondary": "#0088CC", + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["personal messages", "group chats", "informal communication"], + "authenticity_tips": [ + "Include Telegram UI elements", + "Use typical Telegram formatting", + "Add read receipts if relevant", + ], + }, + "slack": { + "name": "Slack", + "description": "Slack workspace message style", + "visual_guidance": "Slack workspace interface, channel messages, Slack UI, professional team communication", + "font_style": "Lato, Slack font", + "colors": { + "primary": "#4A154B", # Slack purple + "secondary": "#36C5F0", + "background": "#FFFFFF", + "text": "#1D1C1D", + }, + "best_for": ["team communication", "workplace announcements", "professional updates"], + "authenticity_tips": [ + "Include channel name", + "Add user avatar", + "Use Slack message formatting", + ], + }, + "instagram_story": { + "name": "Instagram Story", + "description": "Instagram story frame style", + "visual_guidance": "Instagram story format, vertical 9:16 aspect ratio, story UI elements, authentic Instagram look", + "font_style": "Instagram font, system font", + "colors": { + "primary": "#E4405F", # Instagram pink + "secondary": "#833AB4", + "background": "#000000", + "text": "#FFFFFF", + }, + "best_for": ["social media engagement", "story-style content", "mobile-first ads"], + "authenticity_tips": [ + "Vertical format (9:16)", + "Include story UI elements", + "Use Instagram-style fonts", + ], + }, + "tiktok_style": { + "name": "TikTok Style", + "description": "TikTok video frame style", + "visual_guidance": "TikTok video frame, vertical format, TikTok UI overlay, authentic TikTok appearance", + "font_style": "TikTok font, bold sans-serif", + "colors": { + "primary": "#000000", + "secondary": "#FE2C55", # TikTok red + "background": "#000000", + "text": "#FFFFFF", + }, + "best_for": ["youth engagement", "viral content", "trending topics"], + "authenticity_tips": [ + "Vertical video format", + "Bold text overlays", + "Trending style elements", + ], + }, + "linkedin_post": { + "name": "LinkedIn Post", + "description": "LinkedIn feed post style", + "visual_guidance": "LinkedIn feed post, professional network UI, LinkedIn interface, authentic professional look", + "font_style": "LinkedIn font, professional sans-serif", + "colors": { + "primary": "#0077B5", # LinkedIn blue + "secondary": "#000000", + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["professional networking", "B2B marketing", "career-focused content"], + "authenticity_tips": [ + "Include profile elements", + "Professional tone", + "LinkedIn-style formatting", + ], + }, + "app_store_listing": { + "name": "App Store Listing", + "description": "App store screenshot style", + "visual_guidance": "App store listing screenshot, app icon, ratings, screenshots, authentic app store appearance", + "font_style": "San Francisco, system font", + "colors": { + "primary": "#007AFF", # iOS blue + "secondary": "#FF9500", + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["app promotion", "mobile apps", "app features"], + "authenticity_tips": [ + "Include app icon", + "Star ratings", + "App store UI elements", + ], + }, + "email_signature": { + "name": "Email Signature", + "description": "Professional email signature style", + "visual_guidance": "Professional email signature, contact info, logo, authentic email signature appearance", + "font_style": "Arial, Helvetica, professional fonts", + "colors": { + "primary": "#000000", + "secondary": "#666666", + "background": "#FFFFFF", + "text": "#000000", + }, + "best_for": ["professional communication", "B2B outreach", "formal announcements"], + "authenticity_tips": [ + "Include contact information", + "Professional formatting", + "Company logo if relevant", + ], + }, +} + + +def get_all_containers() -> Dict[str, Dict[str, Any]]: + """Get all available container types.""" + return CONTAINER_TYPES + + +def get_container(key: str) -> Optional[Dict[str, Any]]: + """Get a specific container type by key.""" + return CONTAINER_TYPES.get(key.lower().replace(" ", "_")) + + +def get_random_container() -> Dict[str, Any]: + """Get a random container type.""" + key = random.choice(list(CONTAINER_TYPES.keys())) + return {"key": key, **CONTAINER_TYPES[key]} + + +def get_container_visual_guidance(container_key: str) -> str: + """Get visual guidance for a container type.""" + container = get_container(container_key) + if container: + return container.get("visual_guidance", "") + return f"Container type: {container_key}, authentic appearance" + + +def get_native_containers() -> List[str]: + """Get container types that look like native app interfaces.""" + native = ["imessage", "whatsapp", "sms", "system_notification", "push_notification", "email_notification"] + return native + + +def get_ugc_containers() -> List[str]: + """Get container types that look like user-generated content.""" + ugc = ["reddit_post", "social_post", "sticky_note", "memo"] + return ugc + + +def get_alert_containers() -> List[str]: + """Get container types that create urgency.""" + alerts = ["bank_alert", "news_chyron", "browser_alert", "system_notification"] + return alerts + diff --git a/data/frameworks.py b/data/frameworks.py new file mode 100644 index 0000000000000000000000000000000000000000..0de609e7fac06108466844e688838ac25ae8d922 --- /dev/null +++ b/data/frameworks.py @@ -0,0 +1,393 @@ +""" +Ad Frameworks - Different structural approaches for ad creatives. +Each framework has a unique style and is suited for different marketing goals. +""" + +from typing import Dict, Any, List, Optional +import random + + +# Complete list of 10 ad frameworks with detailed configurations +FRAMEWORKS: Dict[str, Dict[str, Any]] = { + "breaking_news": { + "name": "Breaking News", + "description": "Creates urgency through news-style presentation, feels like breaking news", + "best_for": ["announcements", "limited offers", "new discoveries", "price drops"], + "visual_style": "News channel aesthetic, red/white ticker, urgent feel", + "hook_examples": [ + "BREAKING: Home Insurance Rates Drop 40%", + "URGENT: Limited Time Savings Available Now", + "ALERT: New Insurance Savings Discovered", + "FLASH: This Week Only - Save $500", + "EXCLUSIVE: Secret Savings Method Revealed", + ], + "headline_style": "ALL CAPS with news prefix (BREAKING:, URGENT:, ALERT:)", + "tone": "Urgent, newsworthy, time-sensitive", + "psychological_triggers": ["FOMO", "Urgency", "Curiosity"], + }, + "mobile_post": { + "name": "Mobile Post", + "description": "Optimized for mobile scrolling, short and punchy", + "best_for": ["quick engagement", "thumb-stopping content", "social feeds"], + "visual_style": "Mobile-first design, clean, easy to read on small screens", + "hook_examples": [ + "Save $500/Year in 2 Minutes", + "Get Your Quote - Tap to Start", + "See Your Savings Instantly", + "One tap. Big savings.", + "30 seconds to lower rates", + ], + "headline_style": "Short, punchy, action-oriented (under 8 words)", + "tone": "Quick, casual, conversational", + "psychological_triggers": ["Convenience", "Speed", "Ease"], + }, + "before_after": { + "name": "Before/After", + "description": "Shows transformation, contrast between old and new state", + "best_for": ["transformations", "improvements", "results showcase"], + "visual_style": "Split-screen comparison, clear before/after contrast", + "hook_examples": [ + "From $200/Month to $50/Month", + "Before: High Rates. After: Big Savings", + "Transform Your Insurance Costs Today", + "See What Changed Everything", + "The Difference One Call Made", + ], + "headline_style": "Comparison format, specific numbers, transformation language", + "tone": "Results-focused, proof-based, transformation", + "psychological_triggers": ["Transformation", "Proof", "Results"], + }, + "testimonial": { + "name": "Testimonial", + "description": "Social proof through customer stories and reviews", + "best_for": ["trust building", "social proof", "credibility"], + "visual_style": "Quote cards, star ratings, real customer photos", + "hook_examples": [ + "Join 50,000+ Happy Customers", + "Rated 5 Stars by Thousands", + "See Why Customers Love Us", + '"Best decision I ever made"', + "Real people. Real savings.", + ], + "headline_style": "Quote format, numbers, social proof indicators", + "tone": "Trustworthy, relatable, authentic", + "psychological_triggers": ["Social Proof", "Trust", "Belonging"], + }, + "lifestyle": { + "name": "Lifestyle", + "description": "Aspirational imagery showing the desired lifestyle", + "best_for": ["aspiration", "emotional connection", "dream selling"], + "visual_style": "Aspirational imagery, happy people, dream scenarios", + "hook_examples": [ + "Live Life Fully Protected", + "Drive with Complete Confidence", + "Peace of Mind on Every Journey", + "The Life You Deserve", + "Freedom Starts Here", + ], + "headline_style": "Aspirational, emotional, lifestyle-focused", + "tone": "Aspirational, emotional, inspiring", + "psychological_triggers": ["Aspiration", "Desire", "Freedom"], + }, + "educational": { + "name": "Educational", + "description": "Provides value through information and tips", + "best_for": ["authority building", "value-first approach", "complex topics"], + "visual_style": "Infographic style, lists, clear information hierarchy", + "hook_examples": [ + "3 Things Your Insurance Agent Won't Tell You", + "The Hidden Costs of Cheap Insurance", + "What Every Homeowner Must Know", + "Learn the Secret to Lower Rates", + "5 Mistakes That Cost You Money", + ], + "headline_style": "Numbers, curiosity gaps, valuable information promise", + "tone": "Informative, helpful, authoritative", + "psychological_triggers": ["Curiosity", "Knowledge", "Fear of Missing Out"], + }, + "comparison": { + "name": "Comparison", + "description": "Compares to competitors or alternatives", + "best_for": ["differentiation", "competitive positioning", "value demonstration"], + "visual_style": "Side-by-side comparison, checkmarks vs X marks", + "hook_examples": [ + "Why Customers Are Switching", + "The Difference Is Clear", + "Compare and Save Hundreds", + "What Others Charge vs Our Price", + "See the Better Option", + ], + "headline_style": "Comparison language, competitive positioning", + "tone": "Confident, comparative, fact-based", + "psychological_triggers": ["Comparison", "Value", "Smart Choice"], + }, + "storytelling": { + "name": "Storytelling", + "description": "Narrative-driven content that tells a story", + "best_for": ["emotional connection", "memorable content", "brand building"], + "visual_style": "Narrative imagery, sequential scenes, story elements", + "hook_examples": [ + "When Sarah Lost Everything...", + "The Day That Changed My Life", + "I Never Thought It Would Happen to Me", + "Here's What Happened Next", + "The Story Nobody Tells You", + ], + "headline_style": "Narrative hooks, story beginnings, curiosity builders", + "tone": "Narrative, emotional, personal", + "psychological_triggers": ["Empathy", "Curiosity", "Emotion"], + }, + "problem_solution": { + "name": "Problem/Solution", + "description": "Identifies a problem and presents the solution", + "best_for": ["pain point targeting", "solution selling", "problem awareness"], + "visual_style": "Problem visualization, solution presentation, relief imagery", + "hook_examples": [ + "Tired of High Insurance Rates?", + "Finally, a Solution That Works", + "The Problem Nobody Talks About", + "End Your Insurance Frustration", + "There's a Better Way", + ], + "headline_style": "Problem questions, solution statements, relief language", + "tone": "Empathetic, problem-aware, solution-focused", + "psychological_triggers": ["Pain Relief", "Problem Awareness", "Hope"], + }, + "authority": { + "name": "Authority", + "description": "Establishes expertise and credibility", + "best_for": ["credibility building", "expert positioning", "trust establishment"], + "visual_style": "Professional imagery, credentials, expert endorsements", + "hook_examples": [ + "Expert-Recommended Insurance", + "Backed by 50 Years of Experience", + "What the Pros Know About Insurance", + "Industry-Leading Protection", + "Trusted by Professionals", + ], + "headline_style": "Authority indicators, credentials, expert language", + "tone": "Professional, authoritative, trustworthy", + "psychological_triggers": ["Authority", "Trust", "Expertise"], + }, + "scarcity": { + "name": "Scarcity", + "description": "Creates urgency through limited availability", + "best_for": ["limited offers", "exclusive deals", "time-sensitive promotions"], + "visual_style": "Countdown timers, limited stock indicators, exclusive badges", + "hook_examples": [ + "Only 50 Spots Left This Month", + "Limited Availability - Act Fast", + "Exclusive Offer for First 100", + "While Supplies Last", + "Don't Miss Your Chance", + ], + "headline_style": "Numbers, time limits, availability language", + "tone": "Urgent, exclusive, time-sensitive", + "psychological_triggers": ["FOMO", "Urgency", "Exclusivity"], + }, + "benefit_stack": { + "name": "Benefit Stack", + "description": "Lists multiple benefits in quick succession", + "best_for": ["value demonstration", "feature highlights", "quick scanning"], + "visual_style": "Bullet points, checkmarks, organized list format", + "hook_examples": [ + "Save Money, Save Time, Save Stress", + "3 Benefits in One Solution", + "Everything You Need, All in One", + "More Coverage, Lower Cost, Better Service", + "Protection + Savings + Peace of Mind", + ], + "headline_style": "Multiple benefits, parallel structure, value stacking", + "tone": "Value-focused, comprehensive, efficient", + "psychological_triggers": ["Value", "Convenience", "Completeness"], + }, + "risk_reversal": { + "name": "Risk Reversal", + "description": "Removes risk and uncertainty from the decision", + "best_for": ["overcoming objections", "building confidence", "reducing hesitation"], + "visual_style": "Guarantee badges, risk-free indicators, confidence builders", + "hook_examples": [ + "100% Risk-Free Guarantee", + "Try It Free - No Commitment", + "Cancel Anytime, No Questions", + "Money-Back Guarantee", + "No Risk, All Reward", + ], + "headline_style": "Guarantee language, risk removal, confidence builders", + "tone": "Reassuring, confident, risk-free", + "psychological_triggers": ["Security", "Trust", "Risk Reduction"], + }, + "contrarian": { + "name": "Contrarian", + "description": "Challenges conventional wisdom or expectations", + "best_for": ["differentiation", "attention-grabbing", "thought leadership"], + "visual_style": "Bold statements, unexpected visuals, pattern breaks", + "hook_examples": [ + "Why Everything You Know About Insurance Is Wrong", + "The Unpopular Truth About Rates", + "Stop Following the Crowd", + "The Counter-Intuitive Way to Save", + "What They Don't Want You to Know", + ], + "headline_style": "Contrarian statements, pattern breaks, unexpected angles", + "tone": "Bold, provocative, thought-provoking", + "psychological_triggers": ["Curiosity", "Differentiation", "Intellectual"], + }, + "case_study": { + "name": "Case Study", + "description": "Shows real results and specific outcomes", + "best_for": ["proof demonstration", "results showcase", "credibility"], + "visual_style": "Before/after data, specific numbers, real examples", + "hook_examples": [ + "How Sarah Saved $1,200 in 6 Months", + "Real Results from Real Customers", + "The Exact Steps That Worked", + "See the Numbers That Matter", + "From Problem to Solution: The Journey", + ], + "headline_style": "Specific examples, real names, concrete results", + "tone": "Proof-based, specific, results-focused", + "psychological_triggers": ["Proof", "Social Proof", "Results"], + }, + "interactive": { + "name": "Interactive", + "description": "Engages through questions, quizzes, or participation", + "best_for": ["engagement", "personalization", "interaction"], + "visual_style": "Interactive elements, questions, quiz formats", + "hook_examples": [ + "Take Our 30-Second Quiz", + "Answer 3 Questions, Get Your Rate", + "See If You Qualify in 60 Seconds", + "What's Your Insurance Personality?", + "Find Your Perfect Match", + ], + "headline_style": "Questions, interactive prompts, participation language", + "tone": "Engaging, interactive, personalized", + "psychological_triggers": ["Engagement", "Personalization", "Curiosity"], + }, +} + +# Framework examples by niche +NICHE_FRAMEWORK_EXAMPLES: Dict[str, Dict[str, List[str]]] = { + "home_insurance": { + "breaking_news": [ + "BREAKING: Home Insurance Rates Drop 40%", + "ALERT: New Homeowner Discounts Available", + "URGENT: Rate Freeze Ends Friday", + ], + "mobile_post": [ + "Protect Your Home in 3 Minutes", + "One Quote. Big Savings.", + "Tap to See Your Rate", + ], + "before_after": [ + "Before: $2,400/year. After: $1,200/year", + "Old policy vs New savings", + "What switching saved me", + ], + "testimonial": [ + '"I saved $1,200 on my first year"', + "Join 100,000+ protected homeowners", + "Rated #1 by customers like you", + ], + "problem_solution": [ + "Worried your home isn't covered?", + "Stop overpaying for insurance", + "End the coverage gaps", + ], + }, + "glp1": { + "breaking_news": [ + "NEW: FDA-Approved Weight Loss Solution", + "ALERT: Limited Appointments Available", + "EXCLUSIVE: Online Consultations Now Open", + ], + "before_after": [ + "Her 60-Day Transformation", + "What Changed in 90 Days", + "The Results Speak for Themselves", + ], + "testimonial": [ + '"I finally found what works"', + "Thousands have transformed", + "Real patients. Real results.", + ], + "lifestyle": [ + "Feel Confident Again", + "The Energy to Live Fully", + "Your New Chapter Starts Here", + ], + "authority": [ + "Doctor-Recommended Solution", + "Clinically Proven Results", + "Backed by Medical Research", + ], + "scarcity": [ + "Only 20 Appointments This Week", + "Limited Spots Available", + "Join the Waitlist Now", + ], + "risk_reversal": [ + "100% Satisfaction Guarantee", + "Try Risk-Free for 30 Days", + "No Commitment Required", + ], + "case_study": [ + "How Maria Lost 30 Pounds in 3 Months", + "Real Patient Results", + "The Transformation Journey", + ], + }, +} + + +def get_all_frameworks() -> Dict[str, Dict[str, Any]]: + """Get all available frameworks.""" + return FRAMEWORKS + + +def get_framework(key: str) -> Optional[Dict[str, Any]]: + """Get a specific framework by key.""" + return FRAMEWORKS.get(key) + + +def get_random_framework() -> Dict[str, Any]: + """Get a random framework.""" + key = random.choice(list(FRAMEWORKS.keys())) + return {"key": key, **FRAMEWORKS[key]} + + +def get_frameworks_for_niche(niche: str, count: int = 3) -> List[Dict[str, Any]]: + """Get recommended frameworks for a niche.""" + niche_lower = niche.lower().replace(" ", "_").replace("-", "_") + + # Niche-specific framework preferences + niche_preferences = { + "home_insurance": ["testimonial", "problem_solution", "authority", "before_after", "lifestyle"], + "glp1": ["before_after", "testimonial", "lifestyle", "authority", "problem_solution"], + } + + # Get preferred frameworks or use all + preferred_keys = niche_preferences.get(niche_lower, list(FRAMEWORKS.keys())) + + # Add remaining frameworks for variety + all_keys = preferred_keys + [k for k in FRAMEWORKS.keys() if k not in preferred_keys] + + # Select count frameworks with some randomization + selected = all_keys[:count] + random.shuffle(selected) + + return [{"key": k, **FRAMEWORKS[k]} for k in selected] + + +def get_framework_hook_examples(framework_key: str, niche: Optional[str] = None) -> List[str]: + """Get hook examples for a framework, optionally niche-specific.""" + if niche: + niche_key = niche.lower().replace(" ", "_").replace("-", "_") + niche_examples = NICHE_FRAMEWORK_EXAMPLES.get(niche_key, {}).get(framework_key, []) + if niche_examples: + return niche_examples + + framework = FRAMEWORKS.get(framework_key) + return framework.get("hook_examples", []) if framework else [] + diff --git a/data/glp1.py b/data/glp1.py new file mode 100644 index 0000000000000000000000000000000000000000..02642b87592c8439b0c09fe71502c1302b8a281f --- /dev/null +++ b/data/glp1.py @@ -0,0 +1,683 @@ +""" +GLP-1 (Weight Loss) - Complete Psychological Arsenal +All strategies: shame, transformation, FOMO, authority, simplicity, etc. +Updated with winning ad patterns from high-converting creative analysis. +""" + +# Strategy categories with hooks and descriptions +STRATEGIES = { + # ========================================================================== + # NEW: WINNING STRATEGIES FROM HIGH-CONVERTING AD ANALYSIS + # ========================================================================== + + "accusation_opener": { + "name": "Accusation Opener", + "description": "Direct accusation triggers immediate emotional response", + "hooks": [ + "Still Overweight?", + "Another Failed Diet?", + "Still Hiding Your Body?", + "Tired Of The Lies?", + "How Many More Diets Will Fail?", + "Still Making Excuses?", + "Wasted Money On Diets That Don't Work?", + "Why Are You Still Struggling?", + "Avoiding The Mirror Again?", + "How Long Will You Wait?", + "Still Wearing Baggy Clothes To Hide?", + "Done Lying To Yourself?", + ], + "visual_styles": [ + "person on scale looking frustrated, candid documentary", + "oversized clothes on person, accusatory framing", + "mirror reflection with accusatory text overlay", + "pile of diet books and unused gym equipment", + "frustrated person looking at reflection, vintage feel", + "before photo style, raw and honest", + ], + }, + + "curiosity_gap": { + "name": "Curiosity Gap", + "description": "Open loop with 'THIS' or 'Secret' demands click", + "hooks": [ + "Thousands Are Losing Weight After Discovering THIS", + "Doctors Are Prescribing THIS Instead Of Diets", + "What Celebrities Have Used For Years", + "The Weight Loss Secret They Tried To Hide", + "Everyone's Losing Weight Except You. Here's Why.", + "What Hollywood Knew For Years Is Now Available", + "After THIS, Dieting Becomes Obsolete", + "The ONE Thing That Actually Works", + "What Your Doctor Isn't Telling You", + "People Are Ditching Diets For THIS", + "The Method That's Changing Everything", + "Discover What Everyone's Talking About", + ], + "visual_styles": [ + "transformation collage with 'THIS' callout, documentary style", + "person revealing secret, candid testimonial", + "before/after with mysterious 'secret' overlay", + "celebrity transformation reference, tabloid aesthetic", + "dramatic reveal moment, old footage feel", + "person sharing discovery, authentic UGC style", + ], + }, + + "specific_numbers": { + "name": "Specific Numbers", + "description": "Oddly specific numbers create instant believability", + "hooks": [ + "Lost 47 lbs In 90 Days", + "Down 23 lbs In 6 Weeks", + "Dropped 4 Dress Sizes In 8 Weeks", + "Lost 67 lbs Without Exercise", + "From 247 lbs to 168 lbs", + "12 lbs Gone In The First Month", + "Lost 38 lbs Before Her Reunion", + "Dropped 52 lbs After Starting THIS", + "91 Days: Lost 41 lbs", + "From Size 18 to Size 8", + "Lost 1.2 lbs Per Day", + "Down 29 lbs In 10 Weeks", + ], + "visual_styles": [ + "scale showing specific number, close-up documentary", + "before/after with specific weight numbers visible", + "measuring tape showing inches lost", + "old jeans that are now too big, specific sizes shown", + "calendar tracking progress with numbers", + "weight loss chart with specific data points", + ], + }, + + "before_after_proof": { + "name": "Before/After Proof", + "description": "Transformation proof creates immediate desire", + "hooks": [ + "Same Person. 90 Days Apart.", + "Is This Even The Same Person?", + "The Transformation That Shocked Everyone", + "From THIS to THIS In 12 Weeks", + "Unrecognizable In 90 Days", + "Her Friends Didn't Recognize Her", + "The Picture That Went Viral", + "From Hiding To Showing Off", + "Watch The Transformation", + "Before Anyone Asks: Yes, It's The Same Person", + "Jaw-Dropping 90-Day Results", + "The Change Everyone's Talking About", + ], + "visual_styles": [ + "dramatic split-screen before/after, same outfit", + "timeline showing 30/60/90 day progress", + "same location before/after comparison", + "loose pants/old clothes proof shot", + "side-by-side full body transformation", + "progress photos documentary style", + ], + }, + + "quiz_interactive": { + "name": "Quiz/Interactive", + "description": "Quiz format drives engagement and self-selection", + "hooks": [ + "How Much Weight Could You Lose?", + "Take The 30-Second Quiz", + "What's Your Weight Loss Type?", + "See If You Qualify", + "Answer 3 Questions. See Your Results.", + "Calculate Your Potential Weight Loss", + "Find Your Starting Point", + "Check Your Eligibility Now", + "Tap Your Age To See Results", + "What's Your Goal Weight?", + "Select Your Starting Weight", + "How Much Do You Want To Lose?", + ], + "visual_styles": [ + "quiz interface with weight options, app screenshot", + "goal weight selector, clean UI", + "Notes app style checklist: 10-20 lbs, 20-40 lbs, 40+ lbs", + "interactive calculator showing potential results", + "age/weight selector buttons", + "eligibility checker interface mockup", + ], + }, + + "authority_transfer": { + "name": "Authority Transfer", + "description": "Transfer trust from medical/celebrity authority", + "hooks": [ + "FDA-Approved Weight Loss", + "Doctor-Prescribed. Clinically Proven.", + "Used By 15 Million Patients", + "What Doctors Prescribe Their Own Families", + "The Medical Breakthrough Of The Decade", + "Backed By Harvard Research", + "Endorsed By Leading Endocrinologists", + "The Prescription Celebrities Pay Thousands For", + "Clinically Proven 20%+ Weight Loss", + "What Oprah, Elon And Millions Know", + "Peer-Reviewed Science. Real Results.", + "The Treatment Doctors Trust", + ], + "visual_styles": [ + "FDA approval badge, medical document aesthetic", + "doctor in white coat, professional clinical setting", + "scientific study graphics, research paper style", + "celebrity transformation reference, tabloid feel", + "medical prescription pad aesthetic", + "clinical trial results visualization", + ], + }, + + "identity_targeting": { + "name": "Identity Targeting", + "description": "Direct demographic callout creates self-selection", + "hooks": [ + "Women Over 40: This Changes Everything", + "If You've Tried Every Diet And Failed...", + "For People Who've Struggled For Years", + "Busy Moms Are Losing Weight Without Dieting", + "Over 50 And Struggling To Lose Weight?", + "If Nothing Has Worked For You Before...", + "For Anyone Who's Given Up On Diets", + "People With 30+ lbs To Lose", + "If You've Been Overweight For 10+ Years", + "Yo-Yo Dieters: This Is Different", + "For Those Who Hate The Gym", + "If Willpower Has Never Been Enough", + ], + "visual_styles": [ + "relatable person matching demographic, candid shot", + "real woman 40-60 in transformation moment", + "busy mom in everyday setting, authentic", + "person in regular clothes, not fitness model", + "testimonial portrait, trustworthy face", + "before/after of relatable person", + ], + }, + + "insider_secret": { + "name": "Insider Secret", + "description": "Exclusivity and hidden knowledge framing", + "hooks": [ + "Hollywood's Best-Kept Secret", + "What Big Pharma Doesn't Want You To Know", + "The Industry Secret Finally Revealed", + "How Celebrities Really Lose Weight", + "The Method They Tried To Keep Hidden", + "What Doctors Know But Won't Tell You", + "The Weight Loss Trick No One Talks About", + "Finally: The Truth About Weight Loss", + "What The Diet Industry Hides", + "The Secret Behind Celebrity Transformations", + "Insider Knowledge Now Available To Everyone", + "The Method They Don't Advertise", + ], + "visual_styles": [ + "reveal/exposed documentary style", + "person sharing secret, intimate UGC feel", + "tabloid expose aesthetic, revealing truth", + "hidden document being shown", + "before/after celebrity transformation style", + "insider testimonial, candid shot", + ], + }, + + # ========================================================================== + # ORIGINAL STRATEGIES (UPDATED WITH VINTAGE VISUAL STYLES) + # ========================================================================== + + "shame_insecurity": { + "name": "Shame & Insecurity", + "description": "Trigger personal shame and body insecurity", + "hooks": [ + "Tired of hiding your body?", + "Still wearing baggy clothes?", + "How long will you keep lying to yourself?", + "The mirror doesn't lie", + "Everyone notices. They just don't say it.", + "Summer is coming...", + "When's the last time you felt confident?", + "Still avoiding photos?", + "How many more events will you skip?", + "Your body is holding you back", + "Stop making excuses", + "The scale doesn't lie. Neither does your reflection.", + "Are you embarrassed to undress?", + "How many diets have failed you?", + ], + "visual_styles": [ + "person looking in mirror, disappointed, vintage film grain", + "oversized clothes, hiding body, documentary candid", + "scale showing high number, old footage aesthetic", + "person avoiding camera, raw authentic moment", + "closet full of clothes that don't fit, nostalgic feel", + "person looking at old photos, sepia tones", + ], + }, + + "transformation_desire": { + "name": "Transformation & Desire", + "description": "Appeal to transformation and aspiration", + "hooks": [ + "Imagine fitting into your old jeans", + "Get your confidence back", + "Become the person you were meant to be", + "Your best self is waiting", + "Turn heads again", + "Feel sexy for the first time in years", + "Reclaim your body", + "The glow-up you deserve", + "Finally love what you see in the mirror", + "Your transformation starts today", + "Become unrecognizable in 90 days", + "The body you've always wanted", + "Unlock the best version of yourself", + "From invisible to irresistible", + ], + "visual_styles": [ + "dramatic before/after transformation", + "confident person in fitted clothes", + "person checking out reflection, smiling", + "beach body, confidence pose", + "person getting compliments", + "transformation timeline photos", + ], + }, + + "fomo": { + "name": "Fear of Missing Out", + "description": "Create fear of being left behind", + "hooks": [ + "Hollywood's secret is finally available", + "Celebrities have used this for years", + "Everyone is losing weight except you", + "Don't be the last to know", + "Limited supply - high demand", + "Waitlist growing daily", + "The weight loss revolution you're missing", + "While you're reading this, someone is transforming", + "Why does everyone look better except you?", + "The secret they tried to hide from you", + "TikTok's viral weight loss trend", + "The A-list solution now available to you", + "What Oprah, Elon, and millions know", + "The shortage is real. Act now.", + ], + "visual_styles": [ + "celebrity transformation reference", + "trending hashtag graphics", + "viral social media aesthetic", + "before/after celebrity style", + "exclusive access imagery", + "sold out, limited availability badges", + ], + }, + + "social_rejection_acceptance": { + "name": "Social Rejection & Acceptance", + "description": "Leverage social dynamics and dating", + "hooks": [ + "They'll finally see the REAL you", + "Stop being invisible", + "Dating becomes easier", + "People treat you differently when you're thin", + "Your ex will regret leaving", + "Make them jealous", + "Get the attention you deserve", + "Stop being overlooked", + "First impressions matter. Yours is failing.", + "They're judging you. Change the verdict.", + "Imagine being the hot one in your friend group", + "Turn rejection into attraction", + "From friend-zoned to desired", + "Become someone people want to be around", + ], + "visual_styles": [ + "confident person at social event", + "person getting attention, admired", + "dating app success aesthetic", + "group of friends, confident person", + "romantic couple, attractive partners", + "person commanding room, confident", + ], + }, + + "medical_authority": { + "name": "Medical Authority", + "description": "Leverage medical credibility and science", + "hooks": [ + "FDA-approved", + "Doctor-prescribed", + "Clinically proven", + "Used by 15 million patients", + "Backed by Harvard research", + "Your doctor won't tell you about this", + "Big Pharma's best-kept secret", + "The science is undeniable", + "Medical breakthrough of the decade", + "Doctors are prescribing this to their own families", + "Clinical trials prove 20%+ weight loss", + "The medication celebrities pay thousands for", + "Peer-reviewed. Science-backed. Life-changing.", + "What every endocrinologist knows", + ], + "visual_styles": [ + "doctor in white coat, professional", + "FDA approval badge", + "clinical study graphics", + "medical office setting", + "scientific research aesthetic", + "prescription medication style", + ], + }, + + "urgency_scarcity": { + "name": "Urgency & Scarcity", + "description": "Create time pressure and limited availability", + "hooks": [ + "Shortage alert - limited doses available", + "Insurance may stop covering soon", + "Prices increasing next month", + "Get in before the waitlist", + "Supply chain issues - act now", + "Only accepting 50 new patients this month", + "The shortage is getting worse", + "Pharmacy stock running low", + "Manufacturer limiting supply", + "Price hike coming in 30 days", + "Don't miss the enrollment window", + "Available for a limited time", + ], + "visual_styles": [ + "countdown timer, urgent", + "low stock warning", + "sold out badges", + "limited availability graphics", + "pharmacy shelves nearly empty", + "urgent notification style", + ], + }, + + "guilt": { + "name": "Guilt", + "description": "Trigger guilt about health and family", + "hooks": [ + "Your kids need a healthy parent", + "Don't miss their graduation", + "How many more years will you lose?", + "Life is passing you by", + "You deserve to feel good", + "Stop punishing yourself", + "Your family needs you around", + "What example are you setting?", + "They're worried about you", + "You're not just hurting yourself", + "Your grandkids deserve to know you", + "Every pound is a year off your life", + "You owe it to yourself", + "Stop letting yourself down", + ], + "visual_styles": [ + "parent with children, health focus", + "family moments, active lifestyle", + "grandparent playing with grandkids", + "health warning, medical imagery", + "funeral aesthetic, mortality reminder", + "empty chair at family gathering", + ], + }, + + "simplicity_laziness": { + "name": "Simplicity & Laziness", + "description": "Emphasize ease and minimal effort", + "hooks": [ + "No diet. No exercise. Just results.", + "One injection, that's it", + "Lose weight while you sleep", + "The lazy way to get thin", + "Stop torturing yourself with diets", + "Finally, something that actually works", + "No gym required", + "Eat what you want and still lose weight", + "The effortless weight loss solution", + "Works while you Netflix", + "No willpower needed", + "The anti-diet diet", + "Stop counting calories forever", + "The 10-second daily routine that melts fat", + ], + "visual_styles": [ + "person relaxing, effortless", + "simple injection graphic", + "no gym, no diet icons", + "person eating favorite foods", + "couch to confidence transformation", + "simple 1-2-3 step process", + ], + }, + + "comparison_envy": { + "name": "Comparison & Envy", + "description": "Compare to others who succeeded", + "hooks": [ + "She lost 47 lbs. What's your excuse?", + "Same age. Different body.", + "While you're reading this, someone is getting results", + "Your coworker's secret weapon", + "Why does she look 10 years younger?", + "They started where you are now", + "Same starting point. Different ending.", + "She was bigger than you. Look at her now.", + "What's her secret? (Now it's yours)", + "Your high school reunion is coming...", + "Everyone's getting results except you", + "They found the answer. Why haven't you?", + ], + "visual_styles": [ + "side by side comparison", + "success story testimonial", + "before/after same person", + "two people comparison", + "results showcase gallery", + "transformation collage", + ], + }, + + "before_after_shock": { + "name": "Before/After Shock", + "description": "Dramatic visual transformations", + "hooks": [ + "90 days. 50 lbs. Real person.", + "The same person. Unbelievable.", + "Is this even the same person?", + "Wait until you see the after...", + "Jaw-dropping transformation", + "You won't believe your eyes", + "The most incredible transformation ever", + "From XL to S in 4 months", + "Lost half her body weight", + "From plus-size to model", + "The picture that broke the internet", + "This can be you", + ], + "visual_styles": [ + "dramatic before/after split screen", + "timeline transformation 30/60/90 days", + "same outfit, different body", + "scale showing massive loss", + "measuring tape victory", + "loose pants, weight loss proof", + ], + }, + + "future_pacing": { + "name": "Future Pacing", + "description": "Help them visualize their future self", + "hooks": [ + "Picture yourself at your goal weight", + "6 months from now...", + "Your future self will thank you", + "This time next year...", + "Imagine summer with your dream body", + "What will you do when you hit your goal?", + "The vacation you've been putting off", + "The dress you've been saving", + "Finally, that beach trip", + "Your wedding, your reunion, your moment", + "Close your eyes. See yourself thin.", + "The life that's waiting for you", + ], + "visual_styles": [ + "aspirational lifestyle imagery", + "beach vacation, confident person", + "dream wedding, fitting dress", + "vision board aesthetic", + "future self visualization", + "bucket list experiences", + ], + }, + + "loss_aversion": { + "name": "Loss Aversion", + "description": "Emphasize what they're losing by not acting", + "hooks": [ + "Every day you wait is another day wasted", + "How many more years will you lose to this?", + "You're not getting younger", + "Time is running out on your metabolism", + "Each year makes it harder", + "Your 20s are gone. Don't waste your 30s too.", + "The best years of your life - spent overweight?", + "Missed opportunities don't come back", + "Your youth is slipping away", + "Obesity is stealing your life", + "The experiences you're missing", + "Life is passing you by while you stay stuck", + ], + "visual_styles": [ + "hourglass, time running out", + "calendar pages flipping", + "missed opportunities montage", + "aging comparison, health decline", + "before it's too late imagery", + "now vs never contrast", + ], + }, +} + +# All hooks flattened for random selection +ALL_HOOKS = [] +for strategy in STRATEGIES.values(): + ALL_HOOKS.extend(strategy["hooks"]) + +# All visual styles flattened +ALL_VISUAL_STYLES = [] +for strategy in STRATEGIES.values(): + ALL_VISUAL_STYLES.extend(strategy["visual_styles"]) + +# Strategy names for random selection +STRATEGY_NAMES = list(STRATEGIES.keys()) + +# Creative directions for variety - Updated with winning patterns +# Note: These are TONE/APPROACH, not structural formats (use frameworks.py for structure) +CREATIVE_DIRECTIONS = [ + "accusatory", # Still overweight? style + "curiosity-driven", # THIS/Secret style + "numbers-focused", # 47 lbs in 90 days style + "proof-transformation", # Before/after evidence (use "before_after" framework for structure) + "quiz-interactive", # How much could you lose? + "authority-medical", # FDA/Doctor style (use "authority" framework for structure) + "identity-targeted", # Women over 40... + "insider-reveal", # Hollywood secret + "urgent", + "aspirational", +] + +# Visual aesthetic styles (documentary/authentic formats for ad images) +# Note: These are aesthetic styles, not emotional moods (see data/visuals.py for emotional moods) +VISUAL_MOODS = [ + "documentary-candid", # Real people, unposed + "vintage-authentic", # Old footage feel + "transformation-proof", # Before/after evidence + "ui-screenshot", # Quiz, calculator + "medical-clinical", # Doctor/FDA aesthetic + "celebrity-tabloid", # Reveal/transformation + "raw-testimonial", # UGC feel + "warm-nostalgic", # Amber, sepia tones +] + +# Ad copy templates +COPY_TEMPLATES = [ + { + "structure": "hook_then_cta", + "format": "{hook}\n\n{supporting_text}\n\n👉 {cta}", + }, + { + "structure": "question_answer", + "format": "{question}\n\n{answer}\n\n{cta}", + }, + { + "structure": "before_after", + "format": "BEFORE: {before}\n\nAFTER: {after}\n\n{cta}", + }, + { + "structure": "stat_hook", + "format": "⚠️ {statistic}\n\n{explanation}\n\n{cta}", + }, + { + "structure": "story_hook", + "format": "{story_opening}\n\n{story_middle}\n\n{cta}", + }, + { + "structure": "testimonial", + "format": '"{testimonial}"\n\n- {name}, lost {weight}\n\n{cta}', + }, +] + +# CTAs for variety - Updated with high-converting patterns +CTAS = [ + # Discovery/Eligibility CTAs (highest conversion) + "See If You Qualify", + "Check Your Eligibility", + "Take The Quiz", + "Calculate Your Results", + "See How Much You Could Lose", + + # Action CTAs + "Start Your Transformation", + "Get Your Personalized Plan", + "Claim Your Consultation", + "Learn More", + "See Your Options", + + # Urgency CTAs + "Start Now", + "Don't Wait Another Day", + "Begin Today", + "Get Started", + + # Specific CTAs + "Get Your Prescription", + "Join Thousands Who Transformed", + "See Real Results", +] + + +def get_niche_data(): + """Return all GLP-1 data for the generator.""" + return { + "niche": "glp1", + "strategies": STRATEGIES, + "all_hooks": ALL_HOOKS, + "all_visual_styles": ALL_VISUAL_STYLES, + "strategy_names": STRATEGY_NAMES, + "creative_directions": CREATIVE_DIRECTIONS, + "visual_moods": VISUAL_MOODS, + "copy_templates": COPY_TEMPLATES, + "ctas": CTAS, + } + diff --git a/data/home_insurance.py b/data/home_insurance.py new file mode 100644 index 0000000000000000000000000000000000000000..dbbc52781df585544b7b419f910885cdc04ec4a2 --- /dev/null +++ b/data/home_insurance.py @@ -0,0 +1,741 @@ +""" +Home Insurance - Complete Psychological Arsenal +All strategies: fear, shame, urgency, greed, authority, loss aversion, etc. +Updated with winning ad patterns from high-converting creative analysis. +""" + +# ============================================================================ +# SECTION 1: PSYCHOLOGICAL STRATEGIES +# ============================================================================ + +STRATEGIES = { + # ------------------------------------------------------------------------ + # Winning Strategies (High-Converting Patterns) + # ------------------------------------------------------------------------ + + "accusation_opener": { + "name": "Accusation Opener", + "description": "Direct accusation triggers immediate loss aversion - 'OVERPAYING?' style", + "hooks": [ + "OVERPAYING?", + "Still Overpaying For Home Insurance?", + "Wasting $1,247/year On Insurance?", + "Are You Being Overcharged?", + "Paying Too Much? Most Homeowners Are.", + "Your Insurance Company Is Overcharging You", + "Stop Getting Ripped Off", + "You're Probably Paying Double", + "Why Are You Still Paying Full Price?", + "Being Overcharged Without Knowing It?", + "Throwing Money Away Every Month?", + "Your Premium Is Too High. Here's Proof.", + ], + "visual_styles": [ + "person holding fan of $100 bills, hiding face, accusatory", + "frustrated senior looking at insurance bill, red 'OVERPAYING?' text", + "money flying away from house, loss visualization", + "comparison of high vs low price with red X on high price", + "person counting money with worried expression", + "wallet being emptied, coins and bills falling out", + ], + }, + + "curiosity_gap": { + "name": "Curiosity Gap", + "description": "Open loop with 'THIS' or 'Instead' demands click - highest CTR pattern", + "hooks": [ + "Seniors Are Ditching Their Home Insurance & Doing This Instead", + "Thousands of homeowners are dropping their home insurance after THIS", + "Homeowners Over 50 Are Switching To THIS", + "What Smart Seniors Know About Home Insurance", + "The Secret Insurance Companies Don't Want You To Know", + "Everyone's Switching. Here's Why.", + "This Is Why Your Neighbors Pay Less", + "What 847 Homeowners In Your Area Just Discovered", + "The Loophole Seniors Are Using", + "They Found Something Better. Have You?", + "After Seeing THIS, You'll Never Overpay Again", + "One Simple Change Saves Thousands", + ], + "visual_styles": [ + "senior man looking at utility meter or electric box, candid documentary", + "collage with Social Security card and government building", + "person reading document with surprised expression", + "group of seniors with knowing expressions", + "person pointing at hidden information, reveal moment", + "before/after document comparison with circled numbers", + ], + }, + + "specific_price_anchor": { + "name": "Specific Price Anchor", + "description": "Oddly specific prices ($97.33 not $100) create instant believability", + "hooks": [ + "Home Insurance for as low as $43/month", + "Home Insurance with coverage as low as $43/month", + "$97.33/month For Full Coverage", + "Pay Just $47/month Instead of $200+", + "Locked In At $52/month", + "Full Protection For $1.50/day", + "Coverage From $39/month", + "$67/month Beats Your Current Rate", + "Most Seniors Qualify For $49/month", + "Switch And Pay Just $83/month", + "Rates Starting At $37.50/month", + ], + "visual_styles": [ + "giant price number $43 in teal/bold color, age buttons below", + "clean white background, price dominant, age selector: 21-40, 41-64, 65+", + "BLACK FRIDAY style with specific price and gold balloons", + "price comparison showing crossed-out high price, new low price", + "calculator interface showing specific savings number", + "receipt or bill showing exact monthly amount", + ], + }, + + "before_after_proof": { + "name": "Before/After Proof", + "description": "Specific savings numbers with visual proof creates social proof", + "hooks": [ + "WAS: $1,701 → NOW: $583", + "How I Dropped My Premium By $1,118/year", + "Paid $2,400. Now Pay $876.", + "From $189/month To $67/month", + "Cut My Bill In Half. Here's How.", + "Saved $1,247 In 5 Minutes", + "Before: $2,100/year After: $720/year", + "My Neighbor Showed Me How To Save $800", + "Real Savings: $1,456 Less Per Year", + "Went From Overpaying To Saving Big", + "The 5-Minute Switch That Saved Me $1,100", + ], + "visual_styles": [ + "real woman 50-60 holding document with circled numbers, testimonial", + "before/after price bar: red 'WAS' vs green 'NOW'", + "split screen: old bill vs new bill with circles", + "person giving thumbs up with savings numbers visible", + "calculator or phone showing savings calculation", + "happy couple reviewing lower insurance bill together", + ], + }, + + "quiz_interactive": { + "name": "Quiz/Interactive", + "description": "Quiz format drives engagement and self-selection", + "hooks": [ + "What Year Was Your House Built?", + "Tap Your Age To Calculate Your New Payment", + "Answer 3 Questions. See Your Rate.", + "How Old Is Your Home?", + "Check Your Eligibility In 60 Seconds", + "Take The 30-Second Quiz", + "Select Your Age Bracket", + "What's Your Home Worth?", + "Find Your New Rate - 2 Questions", + "See If You Qualify", + "Quick Quiz: How Much Can You Save?", + "Select Your State To See Rates", + ], + "visual_styles": [ + "iPhone Notes app dark mode with checkboxes: Before 1970, 1970-1999, etc.", + "age selector buttons: 21-40 (yellow), 41-64 (blue), 65+ (red)", + "quiz interface with multiple choice options", + "clean UI with 'Tap below to see your rate'", + "interactive calculator mockup", + "state selection dropdown or map interface", + ], + }, + + "authority_transfer": { + "name": "Authority Transfer", + "description": "Transfer trust from government/institutions - highest trust pattern", + "hooks": [ + "State Farm Brings Welfare!", + "Sponsored by the US Government and State Farm", + "Government Program For Senior Homeowners", + "New State Program Cuts Insurance Costs", + "Federal Assistance For Homeowners Over 50", + "Official: Seniors Qualify For Reduced Rates", + "State-Approved Savings Program", + "Government-Backed Insurance Savings", + "Medicare-Age Homeowners: New Benefit Available", + "Social Security Recipients: Check Eligibility", + "Official Notice: Rate Reduction Program", + "State Insurance Commission Announces Savings", + ], + "visual_styles": [ + "government eagle seal, official document aesthetic", + "Social Security card with government building background", + "official letterhead style with seal and formal typography", + "state capitol building with official banner", + "government form aesthetic with checkboxes", + "presidential seal or state seal visible", + ], + }, + + "identity_targeting": { + "name": "Identity Targeting", + "description": "Direct demographic callout creates instant self-selection", + "hooks": [ + "Seniors Won't Have To Pay More Than $49 A Month", + "Homeowners Over 50: Check Your Eligibility", + "Senior homeowners over the age of 50...", + "If You're 50+ And Own A Home, Read This", + "Attention: Homeowners Born Before 1975", + "For Homeowners 55 And Older", + "65+ Homeowners: New Rate Available", + "Baby Boomers: Insurance Relief Is Here", + "Retired Homeowners: Special Program", + "If You Own A Home And You're Over 50...", + "Senior Citizen Home Insurance Rates", + "Homeowners Turning 65 This Year", + ], + "visual_styles": [ + "four senior faces in portrait style, dignified, relatable", + "real senior couple in front of their home", + "senior looking at camera, trustworthy expression", + "multiple seniors of different ethnicities, inclusive", + "senior holding document, testimonial style", + "elderly hands holding house keys or insurance papers", + ], + }, + + "insider_secret": { + "name": "Insider Secret", + "description": "Exclusivity and hidden knowledge framing", + "hooks": [ + "The Easiest Way To Cut Home Insurance Bills", + "What Insurance Companies Don't Want You To Know", + "The Loophole That Saves Thousands", + "Former Agent Reveals Industry Secret", + "The Trick Your Insurance Company Hides", + "Why Insiders Pay 40% Less", + "The One Thing That Cuts Your Premium In Half", + "What They Don't Tell You About Home Insurance", + "Insurance Industry Insider Speaks Out", + "The Secret Smart Homeowners Use", + "Hidden Discount Most People Miss", + "The Backdoor To Lower Rates", + ], + "visual_styles": [ + "person whispering or revealing secret, documentary candid", + "document being unveiled or revealed", + "insider/whistleblower aesthetic, anonymous feel", + "magnifying glass over insurance document", + "hidden text being exposed or highlighted", + "person looking over shoulder, sharing secret", + ], + }, + + # ------------------------------------------------------------------------ + # Core Psychological Strategies + # ------------------------------------------------------------------------ + + "fear_based": { + "name": "Fear-Based", + "description": "Trigger fear of loss, disaster, and worst-case scenarios", + "hooks": [ + "Your home could be gone tomorrow", + "One spark. Everything lost.", + "93% of homeowners are UNDERINSURED", + "Will your family be homeless?", + "Storm season is HERE - are you ready?", + "Your neighbor's house burned down last week", + "What if you can't afford to rebuild?", + "Bankruptcy from one disaster", + "Fire doesn't wait. Neither should you.", + "8 minutes. That's how fast you can lose everything.", + "The average house fire costs $287,000", + "Are you gambling with your family's safety?", + ], + "visual_styles": [ + "burning house at night with vintage film grain, documentary footage", + "flooded living room with old VHS quality, damage visible", + "storm damage to roof, aged documentary photography", + "worried family looking at damaged home, candid shot", + "fire truck responding, old news footage aesthetic", + "tornado approaching, vintage weather broadcast style", + ], + }, + + "urgency_scarcity": { + "name": "Urgency & Scarcity", + "description": "Create time pressure and limited availability", + "hooks": [ + "Rates increasing in 48 hours", + "Last chance for 2024 pricing", + "Only 23 spots left at this rate", + "Offer expires midnight", + "Insurers are DROPPING coverage in your area", + "Lock in before it's too late", + "Price hike coming January 1st", + "Limited-time discount ending soon", + "Your quote expires in 24 hours", + "Enrollment window closing", + "Act now or pay 30% more next month", + "Final warning: rates going up", + ], + "visual_styles": [ + "countdown timer graphic, urgent red colors", + "calendar with deadline circled", + "clock showing almost midnight", + "red URGENT stamp on documents", + "limited time banner, expiring offer", + ], + }, + + "social_proof_fomo": { + "name": "Social Proof & FOMO", + "description": "Show others are doing it, create fear of missing out", + "hooks": [ + "847 homeowners in your area switched THIS WEEK", + "Your neighbors are protected. Are you?", + "Join 2.3 million smart homeowners", + "Why is everyone switching?", + "The #1 choice for homeowners in 2024", + "9 out of 10 homeowners recommend this", + "Everyone on your street has coverage. Except you?", + "Don't be the last one unprotected", + "Over 500,000 claims paid this year", + "Rated #1 by Consumer Reports", + "The insurance your neighbors trust", + "Smart homeowners are making the switch", + ], + "visual_styles": [ + "happy neighborhood, protected homes", + "map showing covered homes in area", + "crowd of satisfied customers", + "testimonial collage of happy families", + "5-star rating badges, trust indicators", + ], + }, + + "guilt_shame": { + "name": "Guilt & Shame", + "description": "Trigger guilt about family responsibility", + "hooks": [ + "Can you look your family in the eye without protection?", + "Your kids are counting on you", + "Don't let them down", + "Responsible homeowners don't gamble with their family's future", + "What will you tell your kids when there's nothing left?", + "A real parent protects their family", + "Your spouse trusts you to keep them safe", + "Failure to protect is a choice", + "They're depending on you. Don't fail them.", + "Would your family forgive you?", + "Every night without coverage is a risk to your family", + "What kind of homeowner are you?", + ], + "visual_styles": [ + "parent looking worried at sleeping children", + "family photo with protective imagery", + "father looking at burned home, regret", + "mother holding child, concerned expression", + "empty picture frame, lost memories", + ], + }, + + "greed_savings": { + "name": "Greed & Savings", + "description": "Appeal to desire to save money and get more", + "hooks": [ + "You're overpaying by $1,247/year", + "Stop throwing money away", + "Get $500 back instantly", + "Why pay more for less?", + "Save up to 40% on your premium", + "Free quote reveals your savings", + "Most homeowners can save $800+", + "You're leaving money on the table", + "Switch and save in 5 minutes", + "Same coverage. Half the price.", + "Get more coverage for less money", + "Stop wasting money on overpriced insurance", + ], + "visual_styles": [ + "stack of cash, money savings", + "piggy bank overflowing with coins", + "comparison chart showing savings", + "happy couple reviewing lower bills", + "calculator showing big savings number", + "wallet with money, financial freedom", + ], + }, + + "authority_trust": { + "name": "Authority & Trust", + "description": "Leverage expert credibility and insider knowledge", + "hooks": [ + "Former agent reveals the truth", + "Industry insider secret exposed", + "A+ rated, 50 years trusted", + "Backed by Warren Buffett", + "Exposed: The coverage gap trap", + "Insurance agent confessions", + "The dirty secret of cheap policies", + "What your agent isn't telling you", + "BBB accredited with zero complaints", + "Trusted by Fortune 500 companies", + "Licensed in all 50 states", + ], + "visual_styles": [ + "professional insurance agent, trustworthy", + "A+ rating badge, gold seal", + "official documents, certificates", + "expert in business attire", + "trust badges, security icons", + "newspaper headline style, exposed", + ], + }, + + "loss_aversion": { + "name": "Loss Aversion", + "description": "Emphasize what they stand to lose", + "hooks": [ + "Everything you've worked for - GONE in 8 minutes", + "Average fire destroys $287,000 in belongings", + "You can't get back what's already ash", + "Your memories. Your savings. Your future. Gone.", + "One lawsuit could take everything", + "Imagine losing it all tomorrow", + "The average flood destroys 20 years of memories", + "Your life's work, wiped out in an hour", + "What would you save if you only had 2 minutes?", + "Some things can never be replaced", + "Everything you own could be gone by morning", + "Your equity, your memories, your peace of mind", + ], + "visual_styles": [ + "before/after disaster comparison", + "pile of ash where home used to be", + "family photo partially burned", + "empty lot where house stood", + "destroyed personal belongings", + "wallet with nothing inside", + ], + }, + + "anchoring": { + "name": "Anchoring", + "description": "Compare high value to low cost", + "hooks": [ + "Coverage worth $500,000 for just $47/month", + "Compared to losing everything, $1.50/day is nothing", + "Full protection for less than your Netflix subscription", + "Your home is worth $400K. Protection is $39/month.", + "Rebuild cost: $350,000. Coverage cost: $52/month.", + "Insurance: $40/month. Disaster recovery: $500,000.", + "Skip one coffee a day. Protect everything you own.", + "The cost of not having insurance: everything", + "$1.25/day protects $500,000 in assets", + "Cheaper than your daily coffee habit", + ], + "visual_styles": [ + "scale comparing cost vs value", + "coffee cup vs house comparison", + "small price tag vs big house", + "simple math equation graphic", + "price comparison infographic", + ], + }, + + "simplicity": { + "name": "Simplicity & Ease", + "description": "Emphasize how easy it is to get covered", + "hooks": [ + "Get covered in 3 minutes", + "One click. Full protection.", + "No paperwork. No hassle.", + "Quote in 60 seconds", + "The easiest insurance you'll ever buy", + "Set it and forget it protection", + "Online in minutes, protected for years", + "Skip the agent. Save time and money.", + "Apply from your couch", + "Instant quote, instant coverage", + "The lazy homeowner's insurance solution", + "Why is getting insurance still this hard? (It isn't anymore)", + ], + "visual_styles": [ + "person on phone, relaxed", + "simple 3-step process graphic", + "checkmark, done, complete icons", + "happy person on couch with laptop", + "clean, minimal interface screenshot", + ], + }, + + "comparison_envy": { + "name": "Comparison & Envy", + "description": "Compare to others who are better protected", + "hooks": [ + "Your neighbor pays less and gets more coverage", + "Why are smart homeowners switching?", + "They're protected. Why aren't you?", + "Same house. Same street. Half the premium.", + "What do they know that you don't?", + "The Jones family just saved $800. Your turn.", + "Your colleague's home is protected. Is yours?", + "Everyone's switching. What are you waiting for?", + "Don't be the only unprotected house on the block", + "Your neighbor's claim was covered. Would yours be?", + ], + "visual_styles": [ + "two houses side by side, one protected", + "neighbor comparison graphic", + "protected house vs exposed house", + "happy neighbor vs worried neighbor", + "community map showing coverage", + ], + }, + + "transformation": { + "name": "Transformation & Peace", + "description": "Show the transformation from worry to peace", + "hooks": [ + "From worried to worry-free in 5 minutes", + "Sleep soundly knowing you're covered", + "Finally, peace of mind for your family", + "Stop worrying. Start living.", + "Imagine never worrying about disasters again", + "The weight off your shoulders", + "From stressed to blessed", + "Live your life. We'll protect your home.", + "Worry-free homeownership starts here", + "Breathe easy. You're protected.", + ], + "visual_styles": [ + "relaxed family in protected home", + "person sleeping peacefully", + "before/after: worried vs happy homeowner", + "sunny day, secure home", + "family enjoying life, not worrying", + ], + }, +} + +# ============================================================================ +# SECTION 2: HIGH-CONVERTING VISUAL LIBRARY +# ============================================================================ + +PROTECTION_SAFETY_VISUALS = [ + "family inside home, warm lights on, night outside", + "house surrounded by subtle glowing shield", + "parent locking the front door while kids inside", + "calm home while storm clouds gather in distance", + "hands holding a small house icon", + "roof + checkmark overlay", +] + +DISASTER_FEAR_VISUALS = [ + "half image: safe home / damaged neighborhood", + "flood water stopping at doorstep", + "fire smoke behind intact house", + "fallen tree near but not on the house", + "cracked wall close-up", + "burnt house blurred in background, intact one sharp", +] + +FAMILY_EMOTIONAL_VISUALS = [ + "parents hugging kids inside living room", + "child doing homework at dining table", + "family movie night at home", + "newborn in nursery", + "elderly parents sitting peacefully at home", +] + +FIRST_TIME_HOMEBUYER_VISUALS = [ + "couple holding house keys", + "empty living room with boxes", + "first night sleeping on mattress on floor", + "SOLD sign outside house", + "smiling couple + nervous body language", +] + +ASSET_INVESTMENT_VISUALS = [ + "beautiful home at sunset", + "clean driveway + car parked", + "home with subtle price tag icon", + "blueprint / house plan overlay", + "before/after renovation shots", +] + +PROBLEM_RISK_VISUALS = [ + "leaking ceiling", + "broken window", + "short-circuit sparks (safe depiction)", + "burst pipe under sink", + "mold on wall", + "roof damage after storm", +] + +RELIEF_VISUALS = [ + "sunlight after rain over house", + "rainbow behind neighborhood", + "family relaxing on couch", + "home with covered tag", + "coffee mug + window rain outside", +] + +MORTGAGE_BANK_VISUALS = [ + "official-looking documents", + "laptop with insurance form open", + "house + bank icon", + "EMI letter on table", + "calculator + paperwork", +] + +COMPARISON_CHOICE_VISUALS = [ + "covered vs uncovered home split", + "cheap insurance vs proper coverage", + "umbrella over house vs rain", + "shield vs lightning bolt", +] + +MINIMAL_SYMBOLIC_VISUALS = [ + "simple house icon + shield", + "line-art house with lock", + "home inside heart shape", + "roof outline with checkmark", + "key + house silhouette", +] + +LIFESTYLE_VISUALS = [ + "quiet suburban morning", + "weekend BBQ in backyard", + "dog running in yard", + "home decorated for festivals", + "neighborhood aerial shot", +] + +TEXT_FIRST_VISUALS = [ + "text overlay: 'This home is insured. Is yours?'", + "text overlay: 'Most homeowners are underinsured.'", + "text overlay: 'Hope is not a plan.'", + "text overlay: 'One storm can change everything.'", +] + +SEASONAL_VISUALS = [ + "monsoon rain (flood angle)", + "summer heat (fire risk)", + "winter storms", + "festival decorations (emotional peak)", +] + +HIGH_CONVERTING_VISUAL_LIBRARY = ( + PROTECTION_SAFETY_VISUALS + + DISASTER_FEAR_VISUALS + + FAMILY_EMOTIONAL_VISUALS + + FIRST_TIME_HOMEBUYER_VISUALS + + ASSET_INVESTMENT_VISUALS + + PROBLEM_RISK_VISUALS + + RELIEF_VISUALS + + MORTGAGE_BANK_VISUALS + + COMPARISON_CHOICE_VISUALS + + MINIMAL_SYMBOLIC_VISUALS + + LIFESTYLE_VISUALS + + TEXT_FIRST_VISUALS + + SEASONAL_VISUALS +) + +# ============================================================================ +# SECTION 3: CREATIVE ELEMENTS +# ============================================================================ + +CREATIVE_DIRECTIONS = [ + "accusatory", # Direct accusation style + "curiosity-driven", # Open loop/secret style + "price-focused", # Price anchor emphasis + "proof-based", # Evidence/testimonial style (note: use testimonial framework for structure) + "quiz-interactive", # Interactive/engagement style + "authority-backed", # Authority/trust transfer + "identity-targeted", # Demographic callout + "insider-reveal", # Exclusive/hidden knowledge + "urgent", # Time-sensitive urgency + # Note: "testimonial" removed - use "testimonial" framework instead for structure +] + +# Visual aesthetic styles (documentary/authentic formats for ad images) +# Note: These are aesthetic styles, not emotional moods (see data/visuals.py for emotional moods) +VISUAL_MOODS = [ + "documentary-candid", # Documentary photography style + "vintage-authentic", # Vintage/retro aesthetic + "proof-testimonial", # Testimonial/evidence style + "ui-screenshot", # Native app interface style + "official-institutional", # Official/document style + "warm-nostalgic", # Warm, nostalgic tones + "raw-unpolished", # Raw, unpolished UGC feel + "news-expose", # News/editorial style +] + +COPY_TEMPLATES = [ + { + "structure": "hook_then_cta", + "format": "{hook}\n\n{supporting_text}\n\n👉 {cta}", + }, + { + "structure": "question_answer", + "format": "{question}\n\n{answer}\n\n{cta}", + }, + { + "structure": "stat_hook", + "format": "⚠️ {statistic}\n\n{explanation}\n\n{cta}", + }, + { + "structure": "story_hook", + "format": "{story_opening}\n\n{story_middle}\n\n{cta}", + }, +] + +CTAS = [ + "Check Your Eligibility", + "See If You Qualify", + "Check Eligibility Now", + "Tap To See Your Rate", + "Calculate Your Savings", + "Get Your Free Quote", + "See Your New Rate", + "Find Out How Much You Can Save", + "Click To See Your Savings", + "Get Protected Now", + "Start Saving Today", + "Don't Miss This", + "Claim Your Rate", + "Seniors: Check Your Rate", + "See Senior Rates", + "50+: Get Your Quote", +] + +# ============================================================================ +# SECTION 4: AGGREGATED DATA +# ============================================================================ + +STRATEGY_NAMES = list(STRATEGIES.keys()) + +ALL_HOOKS = [] +for strategy in STRATEGIES.values(): + ALL_HOOKS.extend(strategy["hooks"]) + +ALL_VISUAL_STYLES = [] +for strategy in STRATEGIES.values(): + ALL_VISUAL_STYLES.extend(strategy["visual_styles"]) +ALL_VISUAL_STYLES.extend(HIGH_CONVERTING_VISUAL_LIBRARY) + +# ============================================================================ +# SECTION 5: DATA EXPORT +# ============================================================================ + +def get_niche_data(): + """Return all home insurance data for the generator.""" + return { + "niche": "home_insurance", + "strategies": STRATEGIES, + "all_hooks": ALL_HOOKS, + "all_visual_styles": ALL_VISUAL_STYLES, + "strategy_names": STRATEGY_NAMES, + "creative_directions": CREATIVE_DIRECTIONS, + "visual_moods": VISUAL_MOODS, + "copy_templates": COPY_TEMPLATES, + "ctas": CTAS, + } diff --git a/data/hooks.py b/data/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..2bab3db1a9eadcfef208adcded8c10e46602808c --- /dev/null +++ b/data/hooks.py @@ -0,0 +1,156 @@ +""" +Hook Styles and Power Words - Language elements for compelling ad copy. +""" + +from typing import Dict, Any, List, Optional +import random + +HOOK_STYLES: Dict[str, Dict[str, Any]] = { + "question": { + "name": "Question Hook", + "description": "Starts with a question to engage curiosity", + "examples": ["What if you could save $500/year?", "Are you overpaying for insurance?", "Tired of high premiums?"], + "trigger": "Curiosity", + }, + "statement": { + "name": "Statement Hook", + "description": "Bold declaration that demands attention", + "examples": ["The secret to affordable insurance", "Most people don't know this trick", "The truth about insurance rates"], + "trigger": "Curiosity", + }, + "number": { + "name": "Number Hook", + "description": "Uses specific numbers for credibility", + "examples": ["3 ways to cut insurance costs", "Save $847 in 5 minutes", "97% of users see results"], + "trigger": "Logic", + }, + "transformation": { + "name": "Transformation Hook", + "description": "Shows before/after change", + "examples": ["From $200/month to $50/month", "Before: worried. After: protected.", "The change that saved everything"], + "trigger": "Transformation", + }, + "urgency": { + "name": "Urgency Hook", + "description": "Creates time pressure", + "examples": ["Limited time: Save 40% today", "Offer expires Friday", "Last chance for this rate"], + "trigger": "FOMO", + }, + "notification": { + "name": "Notification-Style Hook", + "description": "Mimics system notifications", + "examples": ["ALERT: Rates dropping now", "New message: Your savings are waiting", "Notice: Rate change detected"], + "trigger": "Urgency", + }, + "curiosity_gap": { + "name": "Curiosity Gap Hook", + "description": "Creates information gap that needs closing", + "examples": ["The reason your rates are so high", "What they don't want you to know", "The hidden trick insurance companies hate"], + "trigger": "Curiosity", + }, + "pattern_interrupt": { + "name": "Pattern Interrupt Hook", + "description": "Breaks expected patterns to grab attention", + "examples": ["Stop saving money (hear me out)", "I was wrong about insurance", "The worst advice that actually works"], + "trigger": "Surprise", + }, + "social_proof": { + "name": "Social Proof Hook", + "description": "Leverages others' actions or opinions", + "examples": ["Join 50,000+ satisfied customers", "Why thousands are switching", "Rated 5 stars by 10,000+ users"], + "trigger": "Social Proof", + }, + "benefit": { + "name": "Benefit-First Hook", + "description": "Leads with the primary benefit", + "examples": ["Save $500/year on home insurance", "Get coverage in 5 minutes", "Lower rates, better coverage"], + "trigger": "Greed", + }, + "shocking_revelation": { + "name": "Shocking Revelation Hook", + "description": "Reveals surprising or shocking information", + "examples": ["The insurance trick that saves you $1,000", "What insurance companies don't want you to know", "The hidden cost of cheap insurance"], + "trigger": "Curiosity", + }, + "direct_command": { + "name": "Direct Command Hook", + "description": "Tells the reader exactly what to do", + "examples": ["Stop overpaying today", "Get your quote now", "Compare rates in 60 seconds"], + "trigger": "Action", + }, + "statistic_driven": { + "name": "Statistic-Driven Hook", + "description": "Uses compelling statistics to grab attention", + "examples": ["9 out of 10 people overpay", "Save an average of $847 per year", "97% see results in 30 days"], + "trigger": "Logic", + }, + "story_opener": { + "name": "Story Opener Hook", + "description": "Begins a narrative that draws the reader in", + "examples": ["Last month, Sarah discovered something that changed everything", "Three years ago, I made a mistake that cost me thousands", "The day I found out I was overpaying..."], + "trigger": "Curiosity", + }, + "contrarian_statement": { + "name": "Contrarian Statement Hook", + "description": "Challenges conventional wisdom", + "examples": ["Everything you know about insurance is wrong", "The worst advice that actually works", "Why the popular choice is the wrong choice"], + "trigger": "Curiosity", + }, + "empathy_first": { + "name": "Empathy-First Hook", + "description": "Shows understanding of the reader's situation", + "examples": ["Tired of confusing insurance policies?", "Frustrated with rising rates?", "Wish insurance was simpler?"], + "trigger": "Empathy", + }, + "exclusive_access": { + "name": "Exclusive Access Hook", + "description": "Offers special or limited access", + "examples": ["Exclusive offer for new customers only", "VIP access to special rates", "Invitation-only pricing"], + "trigger": "Exclusivity", + }, +} + +POWER_WORDS: Dict[str, List[str]] = { + "urgency": ["Now", "Today", "Hurry", "Instant", "Fast", "Quick", "Limited", "Deadline", "Expires", "Final", "Last chance", "Act now"], + "exclusivity": ["Exclusive", "Secret", "Hidden", "Private", "VIP", "Insider", "Confidential", "Elite", "Special", "Rare"], + "savings": ["Free", "Save", "Discount", "Deal", "Bargain", "Value", "Affordable", "Bonus", "Extra", "Reward", "Cashback"], + "trust": ["Guaranteed", "Proven", "Certified", "Verified", "Official", "Trusted", "Reliable", "Secure", "Safe", "Protected"], + "transformation": ["Transform", "Change", "Unlock", "Discover", "Reveal", "Breakthrough", "Revolutionary", "New", "Improved", "Ultimate"], + "emotion": ["Love", "Happy", "Joy", "Peace", "Confident", "Proud", "Relieved", "Excited", "Amazing", "Incredible"], + "fear": ["Warning", "Danger", "Risk", "Threat", "Avoid", "Mistake", "Problem", "Crisis", "Emergency", "Alert"], + "curiosity": ["Discover", "Learn", "Find out", "See why", "Uncover", "Secret", "Mystery", "Surprising", "Shocking", "Revealed"], + "social_proof": ["Popular", "Trending", "Best-selling", "Top-rated", "Award-winning", "Recommended", "Thousands", "Millions", "Join"], + "action": ["Get", "Start", "Try", "Claim", "Grab", "Take", "Join", "Sign up", "Subscribe", "Download", "Order", "Apply"], +} + +CTA_TEMPLATES: Dict[str, List[str]] = { + "action": ["Get Started", "Get Your Quote", "Start Saving", "Claim Your Discount", "See Your Rate", "Get Protected"], + "urgency": ["Get It Now", "Claim Today", "Don't Miss Out", "Act Now", "Limited Time", "Last Chance"], + "curiosity": ["Learn More", "See How", "Find Out", "Discover More", "See If You Qualify", "Check Eligibility"], + "value": ["Save Now", "Get Free Quote", "Compare & Save", "See Your Savings", "Unlock Savings", "Get Best Rate"], + "low_commitment": ["Try Free", "No Obligation", "See for Yourself", "Take a Look", "Explore Options", "Check It Out"], +} + +def get_all_hook_styles() -> Dict[str, Dict[str, Any]]: + return HOOK_STYLES + +def get_hook_style(key: str) -> Optional[Dict[str, Any]]: + return HOOK_STYLES.get(key) + +def get_random_hook_style() -> Dict[str, Any]: + key = random.choice(list(HOOK_STYLES.keys())) + return {"key": key, **HOOK_STYLES[key]} + +def get_power_words(category: Optional[str] = None, count: int = 5) -> List[str]: + if category and category in POWER_WORDS: + words = POWER_WORDS[category] + else: + words = [w for cat_words in POWER_WORDS.values() for w in cat_words] + return random.sample(words, min(count, len(words))) + +def get_random_cta(style: Optional[str] = None) -> str: + if style and style in CTA_TEMPLATES: + ctas = CTA_TEMPLATES[style] + else: + ctas = [c for style_ctas in CTA_TEMPLATES.values() for c in style_ctas] + return random.choice(ctas) diff --git a/data/triggers.py b/data/triggers.py new file mode 100644 index 0000000000000000000000000000000000000000..abaf3d0b80fe020a594195cb94f54e880613ef75 --- /dev/null +++ b/data/triggers.py @@ -0,0 +1,141 @@ +""" +Psychological Triggers - Core emotional drivers that make ads convert. +""" + +from typing import Dict, Any, List, Optional +import random + +PSYCHOLOGICAL_TRIGGERS: Dict[str, Dict[str, Any]] = { + "fear": { + "name": "Fear / Loss Aversion", + "description": "Fear of losing what you have or missing out on protection", + "copy_angles": ["Don't risk losing your home", "Protect what matters most", "Before disaster strikes"], + "visual_cues": ["warning signs", "protection imagery", "security elements"], + "color_palette": "warning", + "best_for": ["insurance", "security", "protection services"], + }, + "greed": { + "name": "Greed / Savings", + "description": "Desire to save money, get more value, maximize gain", + "copy_angles": ["Save $500+ per year", "Get more for less", "Maximize your savings"], + "visual_cues": ["money imagery", "savings charts", "discount badges"], + "color_palette": "savings", + "best_for": ["financial products", "deals", "discounts"], + }, + "fomo": { + "name": "FOMO (Fear of Missing Out)", + "description": "Anxiety about missing a limited opportunity", + "copy_angles": ["Limited time offer", "Only 50 spots left", "Offer expires soon"], + "visual_cues": ["countdown timers", "urgency badges", "scarcity indicators"], + "color_palette": "urgency", + "best_for": ["sales", "launches", "limited offers"], + }, + "authority": { + "name": "Authority / Expertise", + "description": "Trust in experts, credentials, and established institutions", + "copy_angles": ["Doctor recommended", "Expert approved", "Industry leading"], + "visual_cues": ["credentials", "certifications", "professional imagery"], + "color_palette": "trust", + "best_for": ["health", "professional services", "premium products"], + }, + "social_proof": { + "name": "Social Proof", + "description": "Following what others are doing, trust in numbers", + "copy_angles": ["Join 50,000+ customers", "5-star rated", "Most popular choice"], + "visual_cues": ["star ratings", "customer counts", "testimonials"], + "color_palette": "trust", + "best_for": ["consumer products", "services", "subscriptions"], + }, + "curiosity": { + "name": "Curiosity", + "description": "Desire to learn, discover, and fill knowledge gaps", + "copy_angles": ["The secret they don't want you to know", "Discover how", "What they won't tell you"], + "visual_cues": ["mystery elements", "reveal moments", "hidden information"], + "color_palette": "premium", + "best_for": ["educational content", "reveals", "discoveries"], + }, + "belonging": { + "name": "Belonging / Community", + "description": "Desire to be part of a group, tribe, or community", + "copy_angles": ["Join the community", "Be part of something", "Welcome to the family"], + "visual_cues": ["group imagery", "community symbols", "togetherness"], + "color_palette": "calm", + "best_for": ["memberships", "communities", "brands"], + }, + "transformation": { + "name": "Transformation", + "description": "Desire for change, improvement, and becoming better", + "copy_angles": ["Transform your life", "Become the best version", "Change everything"], + "visual_cues": ["before/after", "progression imagery", "improvement indicators"], + "color_palette": "energy", + "best_for": ["fitness", "personal development", "lifestyle"], + }, + "relief": { + "name": "Relief / Pain Relief", + "description": "Escaping pain, stress, or uncomfortable situations", + "copy_angles": ["End the stress", "Finally get relief", "Stop the pain"], + "visual_cues": ["relaxation imagery", "relief moments", "calm scenes"], + "color_palette": "calm", + "best_for": ["health", "stress relief", "solutions"], + }, + "pride": { + "name": "Pride / Self-Worth", + "description": "Feeling good about oneself, achievements, and status", + "copy_angles": ["You deserve the best", "Treat yourself", "You've earned it"], + "visual_cues": ["success imagery", "achievement symbols", "premium elements"], + "color_palette": "premium", + "best_for": ["luxury", "premium products", "self-care"], + }, + "urgency": { + "name": "Urgency / Time Pressure", + "description": "Need to act quickly before time runs out", + "copy_angles": ["Act now", "Today only", "Time is running out"], + "visual_cues": ["clocks", "timers", "urgent badges"], + "color_palette": "urgency", + "best_for": ["sales", "deadlines", "limited offers"], + }, + "exclusivity": { + "name": "Exclusivity", + "description": "Desire for special access, VIP treatment, insider status", + "copy_angles": ["Exclusive offer", "VIP access", "By invitation only"], + "visual_cues": ["VIP badges", "exclusive tags", "premium styling"], + "color_palette": "premium", + "best_for": ["premium products", "memberships", "exclusive deals"], + }, +} + +# Trigger combinations that work well together +TRIGGER_COMBINATIONS: List[Dict[str, Any]] = [ + {"primary": "fear", "secondary": "urgency", "name": "Fear + Urgency", "description": "Creates immediate action through fear of loss"}, + {"primary": "greed", "secondary": "fomo", "name": "Savings + FOMO", "description": "Limited-time savings opportunity"}, + {"primary": "social_proof", "secondary": "authority", "name": "Proof + Authority", "description": "Expert-backed with customer validation"}, + {"primary": "transformation", "secondary": "curiosity", "name": "Transform + Curiosity", "description": "Discover the secret to transformation"}, + {"primary": "relief", "secondary": "social_proof", "name": "Relief + Proof", "description": "Join thousands who found relief"}, + {"primary": "exclusivity", "secondary": "urgency", "name": "Exclusive + Urgent", "description": "Limited VIP opportunity"}, +] + +def get_all_triggers() -> Dict[str, Dict[str, Any]]: + return PSYCHOLOGICAL_TRIGGERS + +def get_trigger(key: str) -> Optional[Dict[str, Any]]: + return PSYCHOLOGICAL_TRIGGERS.get(key) + +def get_random_trigger() -> Dict[str, Any]: + key = random.choice(list(PSYCHOLOGICAL_TRIGGERS.keys())) + return {"key": key, **PSYCHOLOGICAL_TRIGGERS[key]} + +def get_trigger_combination() -> Dict[str, Any]: + return random.choice(TRIGGER_COMBINATIONS) + +def get_triggers_for_niche(niche: str) -> List[Dict[str, Any]]: + niche_lower = niche.lower().replace(" ", "_").replace("-", "_") + niche_triggers = { + "home_insurance": ["fear", "greed", "social_proof", "authority", "relief"], + "glp1": ["transformation", "pride", "social_proof", "authority", "relief"], + } + trigger_keys = niche_triggers.get(niche_lower, list(PSYCHOLOGICAL_TRIGGERS.keys())[:5]) + return [{"key": k, **PSYCHOLOGICAL_TRIGGERS[k]} for k in trigger_keys if k in PSYCHOLOGICAL_TRIGGERS] + +def get_copy_angles_for_trigger(trigger_key: str) -> List[str]: + trigger = get_trigger(trigger_key) + return trigger.get("copy_angles", []) if trigger else [] diff --git a/data/visuals.py b/data/visuals.py new file mode 100644 index 0000000000000000000000000000000000000000..8f142ee6b6b39752b12ea1ac201ef80a900ea8c6 --- /dev/null +++ b/data/visuals.py @@ -0,0 +1,126 @@ +""" +Visual Elements - Colors, typography, styles, and image generation guidance. +""" + +from typing import Dict, Any, List, Optional +import random + +COLOR_PALETTES: Dict[str, Dict[str, Any]] = { + "urgency": {"primary": "#FF0000", "secondary": "#FF4500", "accent": "#FFD700", "background": "#000000", "text": "#FFFFFF"}, + "trust": {"primary": "#1976D2", "secondary": "#0D47A1", "accent": "#4CAF50", "background": "#FFFFFF", "text": "#212121"}, + "savings": {"primary": "#4CAF50", "secondary": "#2E7D32", "accent": "#FFD700", "background": "#FFFFFF", "text": "#212121"}, + "energy": {"primary": "#FF9800", "secondary": "#F57C00", "accent": "#FFEB3B", "background": "#FFFFFF", "text": "#212121"}, + "premium": {"primary": "#212121", "secondary": "#424242", "accent": "#FFD700", "background": "#FFFFFF", "text": "#212121"}, + "calm": {"primary": "#00BCD4", "secondary": "#0097A7", "accent": "#B2EBF2", "background": "#FFFFFF", "text": "#212121"}, + "health": {"primary": "#4CAF50", "secondary": "#81C784", "accent": "#2196F3", "background": "#E8F5E9", "text": "#212121"}, + "warning": {"primary": "#F44336", "secondary": "#D32F2F", "accent": "#FFEB3B", "background": "#FFF3E0", "text": "#212121"}, +} + +TYPOGRAPHY_STYLES: Dict[str, Dict[str, Any]] = { + "system": {"name": "System/Native", "fonts": ["Arial", "Helvetica", "San Francisco", "Roboto"], "best_for": ["notifications", "system alerts"]}, + "bold_impact": {"name": "Bold Impact", "fonts": ["Impact", "Arial Black", "Bebas Neue", "Anton"], "best_for": ["headlines", "breaking news"]}, + "modern_clean": {"name": "Modern Clean", "fonts": ["Montserrat", "Lato", "Open Sans", "Poppins"], "best_for": ["professional ads", "lifestyle"]}, + "handwritten": {"name": "Handwritten", "fonts": ["Indie Flower", "Dancing Script", "Pacifico", "Caveat"], "best_for": ["testimonials", "personal notes"]}, + "typewriter": {"name": "Typewriter", "fonts": ["Courier", "Courier New", "American Typewriter"], "best_for": ["memos", "documents"]}, + "newspaper": {"name": "Newspaper", "fonts": ["Times New Roman", "Georgia", "Playfair Display"], "best_for": ["news style", "editorial"]}, +} + +VISUAL_STYLES: List[Dict[str, Any]] = [ + {"key": "ugc_authentic", "name": "UGC Authentic", "prompt_guidance": "Casual phone camera snapshot, authentic feel, slightly imperfect, natural lighting"}, + {"key": "clean_minimal", "name": "Clean Minimal", "prompt_guidance": "Clean minimal design, white space, simple composition, modern aesthetic"}, + {"key": "bold_graphic", "name": "Bold Graphic", "prompt_guidance": "Bold graphic design, high contrast, strong colors, impactful visual"}, + {"key": "lifestyle_aspirational", "name": "Lifestyle Aspirational", "prompt_guidance": "Aspirational lifestyle photography, happy people, warm lighting"}, + {"key": "documentary_real", "name": "Documentary Real", "prompt_guidance": "Documentary style, candid shots, real moments, natural light"}, + {"key": "screenshot_native", "name": "Screenshot Native", "prompt_guidance": "Mobile app screenshot, native UI elements, authentic interface"}, + {"key": "news_editorial", "name": "News Editorial", "prompt_guidance": "News publication style, editorial photography, professional"}, + {"key": "retro_nostalgic", "name": "Retro Nostalgic", "prompt_guidance": "Retro aesthetic, vintage colors, nostalgic feel"}, + {"key": "dark_dramatic", "name": "Dark Dramatic", "prompt_guidance": "Dark dramatic lighting, moody atmosphere, cinematic"}, + {"key": "bright_optimistic", "name": "Bright Optimistic", "prompt_guidance": "Bright cheerful photography, optimistic feel, positive energy"}, + {"key": "corporate_professional", "name": "Corporate Professional", "prompt_guidance": "Corporate photography, business setting, professional attire, clean office environment"}, + {"key": "street_style", "name": "Street Style", "prompt_guidance": "Urban street photography, candid moments, city backdrop, authentic urban feel"}, + {"key": "studio_clean", "name": "Studio Clean", "prompt_guidance": "Studio photography, controlled lighting, clean background, product-focused"}, + {"key": "cinematic_epic", "name": "Cinematic Epic", "prompt_guidance": "Cinematic wide shots, epic scale, dramatic composition, film-like quality"}, + {"key": "handheld_casual", "name": "Handheld Casual", "prompt_guidance": "Handheld camera feel, slight motion blur, casual framing, everyday moments"}, +] + +CAMERA_ANGLES: List[str] = [ + "eye level shot", "slightly low angle (empowering)", "slightly high angle (overview)", "close-up portrait", + "medium shot", "wide establishing shot", "over-the-shoulder shot", "POV first-person perspective", "candid angle", + "dutch angle (tilted)", "bird's eye view", "worm's eye view", "extreme close-up", "two-shot (two people)", + "group shot", "profile shot", "three-quarter angle", "top-down view", "oblique angle", +] + +LIGHTING_STYLES: List[str] = [ + "natural daylight", "golden hour warm light", "soft diffused light", "harsh direct sunlight", + "indoor ambient lighting", "dramatic side lighting", "backlit silhouette", "overcast soft light", + "rim lighting", "softbox lighting", "window light", "neon lighting", "candlelight warm", + "studio lighting", "natural window light", "dramatic chiaroscuro", "soft ambient glow", +] + +COMPOSITIONS: List[str] = [ + "rule of thirds", "centered subject", "leading lines to subject", "frame within frame", + "negative space emphasis", "symmetrical balance", "asymmetrical dynamic", "diagonal composition", + "golden ratio", "triangular composition", "circular composition", "layered depth", + "foreground/background separation", "depth of field focus", "repetitive patterns", "contrasting elements", +] + +VISUAL_MOODS: List[str] = [ + "urgent and alarming", "calm and reassuring", "exciting and energetic", "trustworthy and professional", + "warm and friendly", "bold and confident", "subtle and sophisticated", "raw and authentic", + "hopeful and optimistic", "serious and authoritative", "playful and fun", "mysterious and intriguing", + "inspiring and motivational", "comfortable and cozy", "dynamic and action-packed", "peaceful and serene", +] + +NEGATIVE_PROMPTS: List[str] = [ + "no watermarks", "no logos", "no brand marks", "no stock photo aesthetic", "no overly polished professional photography", + "no perfect studio lighting", "no corporate photography style", "no artificial perfection", "no text errors", + "no distorted faces", "no extra limbs", "no AI artifacts", "no blurry main subjects", "no over-processed HDR look", +] + +NICHE_VISUAL_GUIDANCE: Dict[str, Dict[str, Any]] = { + "home_insurance": { + "subjects": ["family in front of home", "house exterior", "homeowner looking confident", "couple reviewing papers"], + "props": ["insurance documents", "house keys", "tablet showing coverage", "family photos"], + "avoid": ["disasters", "fire or floods", "stressed expressions", "dark settings"], + "color_preference": "trust", + }, + "glp1": { + "subjects": ["confident person smiling", "active lifestyle scenes", "healthy meal preparation", "doctor consultation"], + "props": ["fitness equipment", "healthy food", "comfortable clothing"], + "avoid": ["before/after weight comparisons", "measuring tapes", "scales prominently", "needle close-ups"], + "color_preference": "health", + }, +} + +def get_color_palette(trigger: str) -> Dict[str, str]: + return COLOR_PALETTES.get(trigger, COLOR_PALETTES["trust"]) + +def get_random_visual_style() -> Dict[str, Any]: + return random.choice(VISUAL_STYLES) + +def get_random_camera_angle() -> str: + return random.choice(CAMERA_ANGLES) + +def get_random_lighting() -> str: + return random.choice(LIGHTING_STYLES) + +def get_random_composition() -> str: + return random.choice(COMPOSITIONS) + +def get_random_mood() -> str: + return random.choice(VISUAL_MOODS) + +def get_niche_visual_guidance(niche: str) -> Optional[Dict[str, Any]]: + return NICHE_VISUAL_GUIDANCE.get(niche.lower().replace(" ", "_").replace("-", "_")) + +def build_negative_prompt() -> str: + return ", ".join(NEGATIVE_PROMPTS) + +def get_random_visual_elements() -> Dict[str, Any]: + return { + "style": get_random_visual_style(), + "camera_angle": get_random_camera_angle(), + "lighting": get_random_lighting(), + "composition": get_random_composition(), + "mood": get_random_mood(), + } diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5ef6a520780202a1d6addd833d800ccb1ecac0bb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5833b0995e5dba49e30400320ce74d0c0480d660 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,144 @@ +# Ad Generator Lite - Frontend + +Modern Next.js dashboard for generating and managing ad creatives for Home Insurance and GLP-1 niches. + +## Features + +- **Single Ad Generation**: Generate individual ads with randomized strategies +- **Batch Generation**: Create multiple ads at once for testing +- **Matrix System**: Generate ads using specific angle × concept combinations +- **Gallery**: Browse, filter, and manage all generated ads +- **Testing Matrix Builder**: Create systematic testing matrices +- **Export**: Download images, export JSON/CSV, copy ad copy + +## Tech Stack + +- **Next.js 16** with App Router +- **TypeScript** for type safety +- **Tailwind CSS** for styling +- **Zustand** for state management +- **React Hook Form** + **Zod** for form validation +- **Axios** for API calls +- **React Hot Toast** for notifications + +## Getting Started + +### Prerequisites + +- Node.js 18+ and npm +- Backend API running on `http://localhost:8000` (or configure `NEXT_PUBLIC_API_URL`) + +### Installation + +```bash +# Install dependencies +npm install + +# Run development server +npm run dev +``` + +The app will be available at `http://localhost:3000` + +### Environment Variables + +Create a `.env.local` file: + +```env +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +## Project Structure + +``` +frontend/ +├── app/ # Next.js pages (App Router) +│ ├── page.tsx # Dashboard +│ ├── generate/ # Generation pages +│ ├── gallery/ # Gallery pages +│ └── matrix/ # Matrix system pages +├── components/ # React components +│ ├── ui/ # Base UI components +│ ├── generation/ # Generation components +│ ├── gallery/ # Gallery components +│ └── matrix/ # Matrix components +├── lib/ # Utilities +│ ├── api/ # API client +│ ├── hooks/ # Custom hooks +│ └── utils/ # Utility functions +├── store/ # Zustand stores +├── types/ # TypeScript types +└── styles/ # Global styles +``` + +## Available Scripts + +- `npm run dev` - Start development server +- `npm run build` - Build for production +- `npm run start` - Start production server +- `npm run lint` - Run ESLint + +## API Integration + +The frontend integrates with the FastAPI backend. All API endpoints are defined in `lib/api/endpoints.ts` and use the axios client in `lib/api/client.ts`. + +## Features Overview + +### Dashboard +- Quick stats (total ads, by niche, system status) +- Recent ads preview +- Quick action buttons + +### Generation +- **Single**: Generate one ad with configurable image count +- **Batch**: Generate multiple ads (1-20) with 1-3 images each +- **Matrix**: Select specific angle and concept combinations + +### Gallery +- Grid view of all ads +- Filter by niche, method, search +- Pagination +- Bulk actions (select, delete) +- Ad detail view with full copy and metadata + +### Matrix System +- Browse all 100 angles and 100 concepts +- View compatibility between angles and concepts +- Generate testing matrices for systematic optimization +- Export matrices as JSON/CSV + +## Development + +### Adding New Components + +1. Create component in appropriate `components/` subdirectory +2. Use TypeScript for type safety +3. Follow existing patterns for styling (Tailwind CSS) +4. Use UI components from `components/ui/` + +### State Management + +Use Zustand stores in `store/`: +- `generationStore` - Current generation state +- `galleryStore` - Gallery filters, pagination, selection +- `matrixStore` - Matrix angles, concepts, selections + +### API Calls + +All API calls should use functions from `lib/api/endpoints.ts` which provide: +- Type safety +- Error handling +- Automatic toast notifications + +## Building for Production + +```bash +npm run build +npm start +``` + +The production build will be optimized and ready for deployment. + +## License + +Same as the main project. diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/gallery/[id]/page.tsx b/frontend/app/gallery/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..744b8fbc4caf01da85ae5395d2fcdca22c111376 --- /dev/null +++ b/frontend/app/gallery/[id]/page.tsx @@ -0,0 +1,512 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { getAd, deleteAd, listAds } from "@/lib/api/endpoints"; +import { formatDate, formatNiche, getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters"; +import { downloadImage, copyToClipboard, exportAsJSON } from "@/lib/utils/export"; +import { toast } from "react-hot-toast"; +import { ArrowLeft, ArrowRight, Download, Copy, Trash2, FileJson, Wand2 } from "lucide-react"; +import type { AdCreativeDB, ImageCorrectResponse } from "@/types/api"; +import { CorrectionModal } from "@/components/generation/CorrectionModal"; + +export default function AdDetailPage() { + const params = useParams(); + const router = useRouter(); + const adId = params.id as string; + + const [ad, setAd] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [imageSrc, setImageSrc] = useState(null); + const [imageError, setImageError] = useState(false); + const [allAds, setAllAds] = useState([]); + const [currentIndex, setCurrentIndex] = useState(-1); + const [showCorrectionModal, setShowCorrectionModal] = useState(false); + + useEffect(() => { + loadAd(); + loadAllAds(); + }, [adId]); + + useEffect(() => { + if (ad) { + const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename); + setImageSrc(primary || fallback); + setImageError(false); + const index = allAds.findIndex((a) => a.id === ad.id); + setCurrentIndex(index); + } else { + setImageSrc(null); + setImageError(false); + } + }, [ad, allAds]); + + const navigateToPrevious = useCallback(() => { + if (currentIndex > 0 && allAds.length > 0) { + const previousAd = allAds[currentIndex - 1]; + router.push(`/gallery/${previousAd.id}`); + } + }, [currentIndex, allAds, router]); + + const navigateToNext = useCallback(() => { + if (currentIndex >= 0 && currentIndex < allAds.length - 1) { + const nextAd = allAds[currentIndex + 1]; + router.push(`/gallery/${nextAd.id}`); + } + }, [currentIndex, allAds, router]); + + const hasPrevious = currentIndex > 0; + const hasNext = currentIndex >= 0 && currentIndex < allAds.length - 1; + + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + if (e.key === "ArrowLeft" && hasPrevious) { + e.preventDefault(); + navigateToPrevious(); + } else if (e.key === "ArrowRight" && hasNext) { + e.preventDefault(); + navigateToNext(); + } + }; + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [hasPrevious, hasNext, navigateToPrevious, navigateToNext]); + + const loadAd = async () => { + setIsLoading(true); + try { + const data = await getAd(adId); + setAd(data); + } catch (error: any) { + toast.error("Failed to load ad"); + router.push("/gallery"); + } finally { + setIsLoading(false); + } + }; + + const loadAllAds = async () => { + try { + const response = await listAds({ limit: 1000, offset: 0 }); + setAllAds(response.ads); + } catch (error: any) { + console.error("Failed to load ads list:", error); + } + }; + + const handleDelete = async () => { + if (!confirm("Are you sure you want to delete this ad?")) return; + try { + await deleteAd(adId); + toast.success("Ad deleted"); + router.push("/gallery"); + } catch (error: any) { + toast.error("Failed to delete ad"); + } + }; + + const handleDownloadImage = async () => { + if (!ad?.image_url && !ad?.image_filename) { + toast.error("No image available"); + return; + } + try { + const imageUrl = getImageUrl(ad.image_url, ad.image_filename); + if (imageUrl) { + await downloadImage(imageUrl, ad.image_filename || `ad-${ad.id}.png`); + toast.success("Image downloaded"); + } + } catch (error) { + toast.error("Failed to download image"); + } + }; + + const handleCopyText = async (text: string, label: string) => { + try { + await copyToClipboard(text); + toast.success(`${label} copied`); + } catch (error) { + toast.error("Failed to copy"); + } + }; + + const handleExportJSON = () => { + if (!ad) return; + exportAsJSON(ad, `ad-${ad.id}.json`); + toast.success("JSON exported"); + }; + + const handleImageError = () => { + if (!ad) return; + const { fallback } = getImageUrlFallback(ad.image_url, ad.image_filename); + if (!imageError && fallback && imageSrc) { + setImageSrc(fallback); + setImageError(true); + } else { + setImageSrc(null); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!ad) { + return ( +
+
+

Ad not found

+ + + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + + + {allAds.length > 0 && currentIndex >= 0 && ( +
+
+ + {hasPrevious && ( + + Previous (←) + + )} +
+ + {currentIndex + 1} / {allAds.length} + +
+ + {hasNext && ( + + Next (→) + + )} +
+
+ )} + +
+
+ + + Correct Image + +
+
+ + + Download Image + +
+
+ + + Export JSON + +
+
+ + + Delete Ad + +
+
+
+
+ + {/* Main Content */} +
+
+ {/* Left - Image */} +
+ {imageSrc ? ( +
+ {ad.headline} +
+ ) : ( +
+
+ + + +

No image

+
+
+ )} + + {/* Metadata */} +
+

Details

+
+
+

Niche

+

{formatNiche(ad.niche)}

+
+ {ad.generation_method && ( +
+

Method

+

{ad.generation_method}

+
+ )} + {ad.angle_name && ( +
+

Angle

+

{ad.angle_name}

+
+ )} + {ad.concept_name && ( +
+

Concept

+

{ad.concept_name}

+
+ )} + {ad.image_model && ( +
+

Image Model

+

{ad.image_model}

+
+ )} + {ad.created_at && ( +
+

Created

+

{formatDate(ad.created_at)}

+
+ )} +
+
+
+ + {/* Right - Ad Copy */} +
+ {/* Headline */} +
+
+
+ {ad.title && ( +

{ad.title}

+ )} +

+ {ad.headline} +

+
+
+ + + Copy Headline + +
+
+
+ + {/* Primary Text */} + {ad.primary_text && ( +
+
+

Primary Text

+
+ + + Copy Text + +
+
+

{ad.primary_text}

+
+ )} + + {/* Description */} + {ad.description && ( +
+
+

Description

+
+ + + Copy Description + +
+
+

{ad.description}

+
+ )} + + {/* Body Story */} + {ad.body_story && ( +
+
+

Body Story

+
+ + + Copy Story + +
+
+

{ad.body_story}

+
+ )} + + {/* CTA */} + {ad.cta && ( +
+
+

Call to Action

+
+ + + Copy CTA + +
+
+

{ad.cta}

+
+ )} + + {/* Psychological Angle */} + {ad.psychological_angle && ( +
+
+

🧠 Psychological Angle

+
+ + + Copy Angle + +
+
+

{ad.psychological_angle}

+ {ad.why_it_works && ( +
+

💡 Why It Works

+

{ad.why_it_works}

+
+ )} +
+ )} +
+
+
+ + {/* Correction Modal */} + setShowCorrectionModal(false)} + adId={adId} + ad={ad} + onSuccess={(result: ImageCorrectResponse) => { + // Update the displayed image if correction was successful + if (result.corrected_image?.image_url) { + setImageSrc(result.corrected_image.image_url); + } + // If a new ad was created, optionally navigate to it or reload the gallery + if (result.corrected_image?.ad_id) { + toast.success("Corrected image saved to gallery!"); + // Optionally: router.push(`/gallery/${result.corrected_image.ad_id}`); + } + }} + /> +
+ ); +} diff --git a/frontend/app/gallery/page.tsx b/frontend/app/gallery/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..738c65d005f03d36cfdbb3dfd737d295b6c14bc0 --- /dev/null +++ b/frontend/app/gallery/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { GalleryGrid } from "@/components/gallery/GalleryGrid"; +import { FilterBar } from "@/components/gallery/FilterBar"; +import { Button } from "@/components/ui/Button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { listAds, deleteAd } from "@/lib/api/endpoints"; +import { useGalleryStore } from "@/store/galleryStore"; +import { toast } from "react-hot-toast"; +import { Download, Trash2, CheckSquare, Square } from "lucide-react"; +import type { AdFilters } from "@/types"; + +export default function GalleryPage() { + const { + ads, + total, + limit, + offset, + filters, + selectedAds, + isLoading, + setAds, + setFilters, + setOffset, + toggleAdSelection, + clearSelection, + selectAll, + setIsLoading, + removeAd, + } = useGalleryStore(); + + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + + const loadAds = useCallback(async () => { + setIsLoading(true); + try { + // Map generation_method filter values to backend values + let generationMethod: string | null | undefined = filters.generation_method; + if (generationMethod === "original") { + generationMethod = "standard"; // Backend uses "standard" for original method + } + // "angle_concept_matrix" and "extensive" map directly + + const response = await listAds({ + niche: filters.niche || undefined, + generation_method: generationMethod || undefined, + limit, + offset, + }); + + // Filter client-side for search only (text search is better done client-side) + let filteredAds = response.ads; + + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + filteredAds = filteredAds.filter( + (ad) => + ad.headline?.toLowerCase().includes(searchLower) || + ad.title?.toLowerCase().includes(searchLower) || + ad.primary_text?.toLowerCase().includes(searchLower) || + ad.description?.toLowerCase().includes(searchLower) + ); + } + + // Use the total from backend (it's already filtered by niche and generation_method) + // For search, we show the filtered count but this is only for current page + // In a production app, you'd want server-side search for accurate totals + const total = filters.search ? filteredAds.length : response.total; + setAds(filteredAds, total); + } catch (error: any) { + toast.error("Failed to load ads"); + console.error(error); + } finally { + setIsLoading(false); + } + }, [filters, limit, offset, setAds, setIsLoading]); + + useEffect(() => { + loadAds(); + }, [loadAds]); + + const handleBulkDelete = async () => { + if (selectedAds.length === 0) return; + + if (!confirm(`Are you sure you want to delete ${selectedAds.length} ad(s)?`)) { + return; + } + + try { + await Promise.all(selectedAds.map((id) => deleteAd(id))); + selectedAds.forEach((id) => removeAd(id)); + toast.success(`Deleted ${selectedAds.length} ad(s)`); + clearSelection(); + loadAds(); + } catch (error: any) { + toast.error("Failed to delete some ads"); + } + }; + + const handlePageChange = (newOffset: number) => { + setOffset(newOffset); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + return ( +
+ {/* Hero Section */} +
+
+
+
+

+ Gallery +

+

+ {total} {total === 1 ? "ad" : "ads"} total +

+
+
+
+ +
+
+
+
+ {selectedAds.length > 0 && ( + <> + + + + )} + {selectedAds.length === 0 && ads.length > 0 && ( + + )} +
+
+
+ + + +
+ +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} +
+
+ ); +} diff --git a/frontend/app/generate/batch/page.tsx b/frontend/app/generate/batch/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..948d5dacde0ee5339b273000ff0c4f1b0e6a147c --- /dev/null +++ b/frontend/app/generate/batch/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import React, { useState } from "react"; +import { BatchForm } from "@/components/generation/BatchForm"; +import { ProgressBar } from "@/components/ui/ProgressBar"; +import { Card, CardContent } from "@/components/ui/Card"; +import { generateBatch } from "@/lib/api/endpoints"; +import { toast } from "react-hot-toast"; +import { AdPreview } from "@/components/generation/AdPreview"; +import { Sparkles } from "lucide-react"; +import type { Niche, GenerateResponse } from "@/types/api"; + +export default function BatchGeneratePage() { + const [results, setResults] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [progress, setProgress] = useState(0); + const [currentIndex, setCurrentIndex] = useState(0); + + const handleGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => { + setResults([]); + setIsGenerating(true); + setProgress(0); + setCurrentIndex(0); + + // Estimate time per ad (roughly 30-60 seconds per ad) + const estimatedTimePerAd = 45; // seconds + const totalEstimatedTime = data.count * estimatedTimePerAd; + let elapsedTime = 0; + const progressInterval = 500; // Update every 500ms + + // Start progress simulation + const progressIntervalId = setInterval(() => { + elapsedTime += progressInterval / 1000; // Convert to seconds + // Calculate progress: start at 5%, reach 90% by estimated time + const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85); + setProgress(progress); + }, progressInterval); + + try { + const result = await generateBatch(data); + clearInterval(progressIntervalId); + setResults(result.ads); + setProgress(100); + toast.success(`Successfully generated ${result.count} ads!`); + } catch (error: any) { + clearInterval(progressIntervalId); + setProgress(0); + toast.error(error.message || "Failed to generate batch"); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+ {/* Hero Section */} +
+
+
+
+

+ Batch + Generation +

+

+ Generate multiple ads at once for testing +

+
+
+
+ +
+
+ {/* Left Column - Form */} +
+ + + {isGenerating && ( + + +
+
+
+
+
+
+ +
+
+
+

Generating Batch Ads

+

Creating multiple ad variations...

+
+
+
+ +
+
+
+ )} +
+ + {/* Right Column - Results */} +
+ {results.length > 0 && ( +
+
+

+ Generated Ads ({results.length}) +

+
+ {results.map((_, index) => ( + + ))} +
+
+ + {results[currentIndex] && ( + + )} +
+ )} + + {!isGenerating && results.length === 0 && ( + +
+ +
+

Fill out the form and click "Generate Batch" to create multiple ads

+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/app/generate/matrix/page.tsx b/frontend/app/generate/matrix/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..08932e66e88e234b54f69bc23cba746f48990d03 --- /dev/null +++ b/frontend/app/generate/matrix/page.tsx @@ -0,0 +1,198 @@ +"use client"; + +import React, { useState } from "react"; +import { AngleSelector } from "@/components/matrix/AngleSelector"; +import { ConceptSelector } from "@/components/matrix/ConceptSelector"; +import { GenerationForm } from "@/components/generation/GenerationForm"; +import { GenerationProgressComponent } from "@/components/generation/GenerationProgress"; +import { AdPreview } from "@/components/generation/AdPreview"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { generateMatrixAd } from "@/lib/api/endpoints"; +import { Sparkles } from "lucide-react"; +import { useGenerationStore } from "@/store/generationStore"; +import { useMatrixStore } from "@/store/matrixStore"; +import { toast } from "react-hot-toast"; +import { IMAGE_MODELS } from "@/lib/constants/models"; +import type { Niche, MatrixGenerateResponse, AngleInfo, ConceptInfo } from "@/types/api"; +import type { GenerationProgress } from "@/types"; + +export default function MatrixGeneratePage() { + const { + currentGeneration, + progress, + isGenerating, + setCurrentGeneration, + setProgress, + setIsGenerating, + setError, + reset, + } = useGenerationStore(); + + const { selectedAngle, selectedConcept, setSelectedAngle, setSelectedConcept } = useMatrixStore(); + + const [niche, setNiche] = useState("home_insurance"); + const [numImages, setNumImages] = useState(1); + const [imageModel, setImageModel] = useState(null); + + const handleGenerate = async () => { + if (!selectedAngle || !selectedConcept) { + toast.error("Please select both an angle and a concept"); + return; + } + + reset(); + setIsGenerating(true); + setProgress({ + step: "copy", + progress: 10, + message: "Generating ad with selected angle and concept...", + }); + + try { + const result = await generateMatrixAd({ + niche, + angle_key: selectedAngle.key, + concept_key: selectedConcept.key, + num_images: numImages, + image_model: imageModel, + }); + + setCurrentGeneration(result); + setProgress({ + step: "complete", + progress: 100, + message: "Ad generated successfully!", + }); + + toast.success("Ad generated successfully!"); + } catch (error: any) { + setError(error.message || "Failed to generate ad"); + setProgress({ + step: "error", + progress: 0, + message: error.message || "An error occurred", + }); + toast.error(error.message || "Failed to generate ad"); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+ {/* Hero Section */} +
+
+
+
+

+ Matrix + Generation +

+

+ Generate ads using specific angle × concept combinations +

+
+
+
+ +
+
+ {/* Left Column - Selection */} +
+ + + Configuration + + +
+ + +
+ +
+ + +
+ +
+ + setNumImages(Number(e.target.value))} + /> +
+ 1 + 5 +
+
+
+
+ + + + + + + + {isGenerating && ( + + )} +
+ + {/* Right Column - Preview */} +
+ {currentGeneration ? ( + + ) : ( +
+

Select an angle and concept, then click "Generate Ad"

+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/app/generate/page.tsx b/frontend/app/generate/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37e9e24863d6cde15726056ed103b6aa0aefc370 --- /dev/null +++ b/frontend/app/generate/page.tsx @@ -0,0 +1,563 @@ +"use client"; + +import React, { useState } from "react"; +import { GenerationForm } from "@/components/generation/GenerationForm"; +import { BatchForm } from "@/components/generation/BatchForm"; +import { ExtensiveForm } from "@/components/generation/ExtensiveForm"; +import { GenerationProgressComponent } from "@/components/generation/GenerationProgress"; +import { AdPreview } from "@/components/generation/AdPreview"; +import { AngleSelector } from "@/components/matrix/AngleSelector"; +import { ConceptSelector } from "@/components/matrix/ConceptSelector"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { ProgressBar } from "@/components/ui/ProgressBar"; +import { generateAd, generateMatrixAd, generateBatch, generateExtensiveAd } from "@/lib/api/endpoints"; +import { useGenerationStore } from "@/store/generationStore"; +import { useMatrixStore } from "@/store/matrixStore"; +import { toast } from "react-hot-toast"; +import { Sparkles, Zap, Layers, Package, Workflow } from "lucide-react"; +import { IMAGE_MODELS } from "@/lib/constants/models"; +import type { Niche, GenerateResponse } from "@/types/api"; + +type GenerationMode = "standard" | "matrix" | "batch" | "extensive"; + +export default function GeneratePage() { + const [mode, setMode] = useState("standard"); + const [niche, setNiche] = useState("home_insurance"); + const [numImages, setNumImages] = useState(1); + const [imageModel, setImageModel] = useState(null); + const [batchResults, setBatchResults] = useState([]); + const [currentBatchIndex, setCurrentBatchIndex] = useState(0); + const [batchProgress, setBatchProgress] = useState(0); + + const { + currentGeneration, + progress, + isGenerating, + setCurrentGeneration, + setProgress, + setIsGenerating, + setError, + reset, + } = useGenerationStore(); + + const { selectedAngle, selectedConcept, setSelectedAngle, setSelectedConcept } = useMatrixStore(); + + const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null }) => { + reset(); + setIsGenerating(true); + setProgress({ + step: "copy", + progress: 10, + message: "Generating ad copy...", + }); + + try { + // Simulate progress updates + let currentProgress = 20; + const progressInterval = setInterval(() => { + currentProgress = Math.min(90, currentProgress + 5); + setProgress({ + step: currentProgress < 50 ? "copy" : "image", + progress: currentProgress, + message: currentProgress < 50 ? "Generating ad copy..." : "Generating images...", + }); + }, 1000); + + // Generate ad + const result = await generateAd(data); + + clearInterval(progressInterval); + + setProgress({ + step: "saving", + progress: 90, + message: "Saving to database...", + }); + + setCurrentGeneration(result); + setProgress({ + step: "complete", + progress: 100, + message: "Ad generated successfully!", + }); + + toast.success("Ad generated successfully!"); + } catch (error: any) { + setError(error.message || "Failed to generate ad"); + setProgress({ + step: "error", + progress: 0, + message: error.message || "An error occurred", + }); + toast.error(error.message || "Failed to generate ad"); + } finally { + setIsGenerating(false); + } + }; + + const handleMatrixGenerate = async () => { + if (!selectedAngle || !selectedConcept) { + toast.error("Please select both an angle and a concept"); + return; + } + + reset(); + setIsGenerating(true); + setProgress({ + step: "copy", + progress: 10, + message: "Generating ad with selected angle and concept...", + }); + + try { + const result = await generateMatrixAd({ + niche, + angle_key: selectedAngle.key, + concept_key: selectedConcept.key, + num_images: numImages, + image_model: imageModel, + }); + + setCurrentGeneration(result); + setProgress({ + step: "complete", + progress: 100, + message: "Ad generated successfully!", + }); + + toast.success("Ad generated successfully!"); + } catch (error: any) { + setError(error.message || "Failed to generate ad"); + setProgress({ + step: "error", + progress: 0, + message: error.message || "An error occurred", + }); + toast.error(error.message || "Failed to generate ad"); + } finally { + setIsGenerating(false); + } + }; + + const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => { + setBatchResults([]); + setIsGenerating(true); + setBatchProgress(0); + setCurrentBatchIndex(0); + + // Estimate time per ad (roughly 30-60 seconds per ad) + const estimatedTimePerAd = 45; // seconds + const totalEstimatedTime = data.count * estimatedTimePerAd; + let elapsedTime = 0; + const progressInterval = 500; // Update every 500ms + + // Start progress simulation + const progressIntervalId = setInterval(() => { + elapsedTime += progressInterval / 1000; // Convert to seconds + // Calculate progress: start at 5%, reach 90% by estimated time + const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85); + setBatchProgress(progress); + }, progressInterval); + + try { + const result = await generateBatch(data); + clearInterval(progressIntervalId); + setBatchResults(result.ads); + setBatchProgress(100); + toast.success(`Successfully generated ${result.count} ads!`); + } catch (error: any) { + clearInterval(progressIntervalId); + setBatchProgress(0); + toast.error(error.message || "Failed to generate batch"); + } finally { + setIsGenerating(false); + } + }; + + const handleExtensiveGenerate = async (data: { + niche: Niche; + target_audience: string; + offer: string; + num_images: number; + num_strategies: number; + image_model?: string | null; + }) => { + reset(); + setIsGenerating(true); + setProgress({ + step: "copy", + progress: 10, + message: "Researching psychology triggers, angles, and concepts...", + }); + + try { + // Simulate progress updates for extensive + let currentProgress = 20; + const progressSteps = [ + { step: "copy" as const, progress: 20, message: "Researching psychology triggers..." }, + { step: "copy" as const, progress: 35, message: "Retrieving marketing knowledge..." }, + { step: "copy" as const, progress: 50, message: "Creating creative strategies..." }, + { step: "copy" as const, progress: 70, message: "Generating image prompts and copy..." }, + { step: "image" as const, progress: 85, message: "Generating images..." }, + ]; + let stepIndex = 0; + + const progressInterval = setInterval(() => { + if (stepIndex < progressSteps.length) { + setProgress(progressSteps[stepIndex]); + stepIndex++; + } else { + currentProgress = Math.min(95, currentProgress + 2); + setProgress({ + step: "image", + progress: currentProgress, + message: "Generating images...", + }); + } + }, 2000); + + // Generate ad using extensive + const result = await generateExtensiveAd(data); + + clearInterval(progressInterval); + + setProgress({ + step: "saving", + progress: 90, + message: "Saving to database...", + }); + + setCurrentGeneration(result); + setProgress({ + step: "complete", + progress: 100, + message: "Ad generated successfully!", + }); + + toast.success("Ad generated successfully using Extensive!"); + } catch (error: any) { + setError(error.message || "Failed to generate ad"); + setProgress({ + step: "error", + progress: 0, + message: error.message || "An error occurred", + }); + toast.error(error.message || "Failed to generate ad"); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+ {/* Hero Section */} +
+
+
+
+

+ Generate + Ad +

+

+ Create high-converting ad creatives using standard, matrix, batch, or Extensive generation +

+ + {/* Mode Toggle */} +
+ + + + +
+
+
+
+ +
+
+ {/* Left Column - Form and Configuration */} +
+ {mode === "standard" ? ( + <> +
+ +
+ + {isGenerating && ( +
+ +
+ )} + + ) : mode === "matrix" ? ( + <> + + + Configuration + + +
+ + +
+ +
+ + +
+ +
+ + setNumImages(Number(e.target.value))} + /> +
+ 1 + 5 +
+

+ Each variation will have a unique image and slight copy variations +

+
+
+
+ + + + + + + + {isGenerating && ( + + )} + + ) : mode === "batch" ? ( + <> +
+ +
+ + {isGenerating && ( + + +
+
+
+
+
+
+ +
+
+
+

Generating Batch Ads

+

Creating multiple ad variations...

+
+
+
+ +
+
+
+ )} + + ) : ( + <> +
+ +
+ + {isGenerating && ( +
+ +
+ )} + + )} +
+ + {/* Right Column - Preview */} +
+ {mode === "batch" && batchResults.length > 0 ? ( +
+
+
+
+

+ Generated Ads +

+

+ {batchResults.length} {batchResults.length === 1 ? "ad" : "ads"} created +

+
+
+ {batchResults.map((_, index) => ( + + ))} +
+
+
+ + {batchResults[currentBatchIndex] && ( +
+ +
+ )} +
+ ) : currentGeneration ? ( +
+ +
+ ) : ( + +
+ +
+

+ {mode === "standard" + ? "Ready to Generate" + : mode === "matrix" + ? "Select Your Combination" + : mode === "batch" + ? "Batch Generation Ready" + : "Extensive Generation Ready" + } +

+

+ {mode === "standard" + ? "Fill out the form and click 'Generate Ad' to create your ad creative" + : mode === "matrix" + ? "Select an angle and concept, then click 'Generate Ad' to create your ad creative" + : mode === "batch" + ? "Fill out the form and click 'Generate Batch' to create multiple ads at once" + : "Fill out the form with target audience and offer, then click 'Generate with Extensive'" + } +

+
+
+

+ {mode === "standard" + ? "Uses randomized strategies for variety" + : mode === "matrix" + ? "Uses specific angle × concept combinations for targeted testing" + : mode === "batch" + ? "Generate 1-20 ads at once for comprehensive testing and variety" + : "Researcher → Creative Director → Designer → Copywriter flow with knowledge retrieval" + } +

+
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..fb96736064a5d1db2a8fd72c3c4e0896c28eef53 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,244 @@ +@import "tailwindcss"; + +:root { + /* Color System - Vibrant Gradients */ + --color-primary-start: #3b82f6; /* blue-500 */ + --color-primary-end: #06b6d4; /* cyan-500 */ + --color-secondary-start: #fb923c; /* orange-400 */ + --color-secondary-end: #ec4899; /* pink-500 */ + --color-success: #10b981; + --color-warning: #f59e0b; + --color-error: #ef4444; + + /* Background */ + --background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + --foreground: #111827; + + /* Glassmorphism */ + --glass-bg: rgba(255, 255, 255, 0.7); + --glass-border: rgba(255, 255, 255, 0.18); + --glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3); + + /* Spacing */ + --spacing-xs: 0.25rem; /* 4px */ + --spacing-sm: 0.5rem; /* 8px */ + --spacing-md: 1rem; /* 16px */ + --spacing-lg: 1.5rem; /* 24px */ + --spacing-xl: 2rem; /* 32px */ + --spacing-2xl: 3rem; /* 48px */ + --spacing-3xl: 4rem; /* 64px */ + + /* Border Radius */ + --radius-sm: 0.5rem; /* 8px */ + --radius-md: 0.75rem; /* 12px */ + --radius-lg: 1rem; /* 16px */ + --radius-xl: 1.5rem; /* 24px */ + + /* Transitions */ + --transition-fast: 150ms ease-out; + --transition-base: 250ms ease-out; + --transition-slow: 350ms ease-out; +} + +body { + background: var(--background); + background-attachment: fixed; + color: var(--foreground); + font-family: var(--font-inter), system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +/* Gradient Background Animation */ +@keyframes gradient-shift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +.gradient-bg { + background: linear-gradient(-45deg, #3b82f6, #06b6d4, #fb923c, #ec4899); + background-size: 400% 400%; + animation: gradient-shift 15s ease infinite; +} + +/* Glassmorphism Effect */ +.glass { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); +} + +/* Smooth Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 20px rgba(59, 130, 246, 0.3); + } + 50% { + box-shadow: 0 0 30px rgba(59, 130, 246, 0.5); + } +} + +.animate-fade-in { + animation: fadeIn 0.5s ease-out; +} + +.animate-slide-in { + animation: slideIn 0.4s ease-out; +} + +.animate-scale-in { + animation: scaleIn 0.3s ease-out; +} + +.animate-pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; +} + +/* Hover Effects */ +.hover-lift { + transition: transform var(--transition-base), box-shadow var(--transition-base); +} + +.hover-lift:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); +} + +.hover-glow { + transition: box-shadow var(--transition-base); +} + +.hover-glow:hover { + box-shadow: var(--shadow-glow); +} + +/* Gradient Text */ +.gradient-text { + background: linear-gradient(135deg, var(--color-primary-start), var(--color-primary-end)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.gradient-text-secondary { + background: linear-gradient(135deg, var(--color-secondary-start), var(--color-secondary-end)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 10px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, var(--color-primary-start), var(--color-primary-end)); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, var(--color-primary-end), var(--color-primary-start)); +} + +/* Selection */ +::selection { + background: rgba(59, 130, 246, 0.3); + color: inherit; +} + +/* Shimmer animation for skeleton loaders */ +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.animate-shimmer { + animation: shimmer 2s linear infinite; +} + +/* Grid pattern background */ +.bg-grid-pattern { + background-image: + linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px), + linear-gradient(to bottom, rgba(0,0,0,0.1) 1px, transparent 1px); + background-size: 20px 20px; +} + +/* Slow spin animation for icons */ +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin-slow { + animation: spin-slow 3s linear infinite; +} + +/* Enhanced gradient shift for progress bar */ +@keyframes gradient-shift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5fcbc68facfa83d8ef69e0db4011114a3474a88 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { Toaster } from "react-hot-toast"; +import { ConditionalHeader } from "@/components/layout/ConditionalHeader"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); + +export const metadata: Metadata = { + title: "Creative Breakthrough", + description: "Generate high-converting ad creatives for Home Insurance and GLP-1 niches", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
+ {children} +
+ + + + ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d84fa60c65cd74887172f11593f94540b3479824 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Input } from "@/components/ui/Input"; +import { Button } from "@/components/ui/Button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { useAuthStore } from "@/store/authStore"; +import { login } from "@/lib/api/endpoints"; +import { Lock, User, Rocket, AlertCircle } from "lucide-react"; +import { toast } from "react-hot-toast"; + +const loginSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), +}); + +type LoginFormData = z.infer; + +export default function LoginPage() { + const router = useRouter(); + const { isAuthenticated, login: setAuth } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated) { + router.push("/"); + } + }, [isAuthenticated, router]); + + const onSubmit = async (data: LoginFormData) => { + setIsLoading(true); + try { + const response = await login(data.username, data.password); + setAuth(response.token, response.username); + toast.success("Login successful!"); + router.push("/"); + } catch (error: any) { + const errorMessage = + error.response?.data?.detail || error.message || "Login failed. Please check your credentials."; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Logo and Title */} +
+
+
+ +
+
+
+

+ Creative + Breakthrough +

+

Sign in to your account

+
+ + {/* Login Card */} + + + Login + + +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+

Note:

+

Credentials are managed manually. Please contact your administrator for access.

+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/app/matrix/angles/page.tsx b/frontend/app/matrix/angles/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e3dda1fb74182d6e8a7dd3fe891df6d9ad5535aa --- /dev/null +++ b/frontend/app/matrix/angles/page.tsx @@ -0,0 +1,134 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Input } from "@/components/ui/Input"; +import { Select } from "@/components/ui/Select"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { getAllAngles } from "@/lib/api/endpoints"; +import type { AnglesResponse } from "@/types/api"; + +export default function AnglesPage() { + const [angles, setAngles] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(""); + + useEffect(() => { + loadAngles(); + }, []); + + const loadAngles = async () => { + try { + const data = await getAllAngles(); + setAngles(data); + } catch (error) { + console.error("Failed to load angles:", error); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!angles) { + return ( +
+
+

Failed to load angles

+
+
+ ); + } + + // Filter angles + let filteredAngles: Array<{ category: string; angle: any }> = []; + + Object.entries(angles.categories).forEach(([catKey, catData]) => { + if (selectedCategory && catKey !== selectedCategory) return; + + catData.angles.forEach((angle) => { + if ( + !searchTerm || + angle.name.toLowerCase().includes(searchTerm.toLowerCase()) || + angle.trigger.toLowerCase().includes(searchTerm.toLowerCase()) || + angle.key.toLowerCase().includes(searchTerm.toLowerCase()) + ) { + filteredAngles.push({ category: catData.name, angle }); + } + }); + }); + + const categories = Object.entries(angles.categories).map(([key, data]) => ({ + value: key, + label: `${data.name} (${data.angle_count})`, + })); + + return ( +
+ {/* Hero Section */} +
+
+
+
+

+ Angles +

+

+ Browse all {angles.total_angles} available angles +

+
+
+
+ +
+
+ setSearchTerm(e.target.value)} + /> + setSearchTerm(e.target.value)} + /> + handleFilterChange("search", e.target.value)} + /> + + handleFilterChange("generation_method", e.target.value || null)} + /> +
+
+ + ); +}; diff --git a/frontend/components/gallery/GalleryGrid.tsx b/frontend/components/gallery/GalleryGrid.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7f457d12a3ec2ea65796b0cb12320b06fcdabed1 --- /dev/null +++ b/frontend/components/gallery/GalleryGrid.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React from "react"; +import { AdCard } from "./AdCard"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import type { AdCreativeDB } from "@/types/api"; + +interface GalleryGridProps { + ads: AdCreativeDB[]; + selectedAds: string[]; + onAdSelect: (adId: string) => void; + isLoading?: boolean; +} + +export const GalleryGrid: React.FC = ({ + ads, + selectedAds, + onAdSelect, + isLoading = false, +}) => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (ads.length === 0) { + return ( +
+

No ads found

+

Try adjusting your filters or generate a new ad

+
+ ); + } + + return ( +
+ {ads.map((ad) => ( + + ))} +
+ ); +}; diff --git a/frontend/components/generation/AdPreview.tsx b/frontend/components/generation/AdPreview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..39221e09c310eef135673c6f0eab07fd0ddb786f --- /dev/null +++ b/frontend/components/generation/AdPreview.tsx @@ -0,0 +1,357 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/Button"; +import { Download, Copy } from "lucide-react"; +import { downloadImage, copyToClipboard } from "@/lib/utils/export"; +import { getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters"; +import { toast } from "react-hot-toast"; +import type { GenerateResponse, MatrixGenerateResponse } from "@/types/api"; + +interface AdPreviewProps { + ad: GenerateResponse | MatrixGenerateResponse; +} + +export const AdPreview: React.FC = ({ ad }) => { + const [imageErrors, setImageErrors] = useState>({}); + + const handleDownloadImage = async (imageUrl: string | null | undefined, filename: string | null | undefined) => { + if (!imageUrl && !filename) { + toast.error("No image URL available"); + return; + } + + try { + const url = getImageUrl(imageUrl, filename); + if (url) { + await downloadImage(url, filename || `ad-${ad.id}.png`); + toast.success("Image downloaded"); + } + } catch (error) { + toast.error("Failed to download image"); + } + }; + + const handleCopyText = async (text: string, label: string) => { + try { + await copyToClipboard(text); + toast.success(`${label} copied`); + } catch (error) { + toast.error("Failed to copy"); + } + }; + + const handleImageError = (index: number, image: { image_url?: string | null; filename?: string | null }) => { + if (!imageErrors[index]) { + const { fallback } = getImageUrlFallback(image.image_url, image.filename); + if (fallback) { + setImageErrors((prev) => ({ ...prev, [index]: true })); + } + } + }; + + return ( +
+ {/* Images */} + {ad.images && ad.images.length > 0 && ( +
+ {ad.images.length === 1 ? ( + // Single image - full width with better styling +
+ {(() => { + const image = ad.images[0]; + const { primary, fallback } = getImageUrlFallback(image.image_url, image.filename); + const imageUrl = imageErrors[0] ? fallback : (primary || fallback); + + return imageUrl ? ( +
+ {ad.headline handleImageError(0, image)} + /> +
+
+ + + Download Image + +
+
+
+ ) : ( +
+
+ + + +

No image

+
+
+ ); + })()} + {ad.images[0].error && ( +
+

Error: {ad.images[0].error}

+
+ )} +
+ ) : ( + // Multiple images - grid layout +
+ {ad.images.map((image, index) => { + const { primary, fallback } = getImageUrlFallback(image.image_url, image.filename); + const imageUrl = imageErrors[index] ? fallback : (primary || fallback); + + return ( +
+ {imageUrl ? ( +
+ {`Ad handleImageError(index, image)} + /> +
+
+ + + Download + +
+
+
+ ) : ( +
+
+ + + +

No image

+
+
+ )} + {image.error && ( +
+

Error: {image.error}

+
+ )} +
+ ); + })} +
+ )} +
+ )} + + {/* Ad Copy Section */} +
+ {/* Left Column - Main Copy */} +
+ {/* Title */} + {ad.title && ( +
+
+

Title

+
+ + + Copy Title + +
+
+

{ad.title}

+
+ )} + + {/* Headline */} +
+
+

Headline

+
+ + + Copy Headline + +
+
+

+ {ad.headline} +

+
+ + {/* Primary Text */} + {ad.primary_text && ( +
+
+

Primary Text

+
+ + + Copy Text + +
+
+

{ad.primary_text}

+
+ )} + + {/* Description */} + {ad.description && ( +
+
+

Description

+
+ + + Copy Description + +
+
+

{ad.description}

+
+ )} +
+ + {/* Right Column - Additional Info */} +
+ {/* Body Story */} + {ad.body_story && ( +
+
+

Body Story

+
+ + + Copy Story + +
+
+

{ad.body_story}

+
+ )} + + {/* CTA */} + {ad.cta && ( +
+
+

Call to Action

+
+ + + Copy CTA + +
+
+

{ad.cta}

+
+ )} + + {/* Psychological Angle */} +
+
+

🧠 Psychological Angle

+
+ + + Copy Angle + +
+
+

{ad.psychological_angle}

+ {ad.why_it_works && ( +
+

💡 Why It Works

+

{ad.why_it_works}

+
+ )} +
+ + {/* Matrix Details */} + {"matrix" in ad && ad.matrix && ( +
+

Matrix Details

+
+
+

Angle

+

{ad.matrix.angle.name}

+

{ad.matrix.angle.trigger}

+
+
+

Concept

+

{ad.matrix.concept.name}

+

{ad.matrix.concept.structure}

+
+
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/components/generation/BatchForm.tsx b/frontend/components/generation/BatchForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9c58f58ab611de91181af66ade4b7c86e36380c2 --- /dev/null +++ b/frontend/components/generation/BatchForm.tsx @@ -0,0 +1,139 @@ +"use client"; + +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { generateBatchSchema } from "@/lib/utils/validators"; +import { Input } from "@/components/ui/Input"; +import { Select } from "@/components/ui/Select"; +import { Button } from "@/components/ui/Button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card"; +import { IMAGE_MODELS } from "@/lib/constants/models"; +import type { Niche } from "@/types/api"; + +interface BatchFormProps { + onSubmit: (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => Promise; + isLoading: boolean; +} + +export const BatchForm: React.FC = ({ + onSubmit, + isLoading, +}) => { + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm({ + resolver: zodResolver(generateBatchSchema), + defaultValues: { + niche: "home_insurance" as Niche, + count: 5, + images_per_ad: 1, + image_model: null, + }, + }); + + const count = watch("count"); + const imagesPerAd = watch("images_per_ad"); + + return ( + + + Batch Generation + + Generate multiple ads at once for testing and variety + + + +
+ ({ value: model.value, label: model.label }))} + error={errors.image_model?.message} + {...register("image_model")} + /> + +
+ + +
+ 1 + 20 +
+ {errors.count && ( +

+ {errors.count.message} +

+ )} +
+ +
+ + +
+ 1 + 3 +
+

+ Each variation will have a unique image and slight copy variations +

+ {errors.images_per_ad && ( +

+ {errors.images_per_ad.message} +

+ )} +
+ +
+

+ Estimated: {count} ads × {imagesPerAd} variation(s) = {count * imagesPerAd} total variations +

+

+ This may take several minutes to complete +

+
+ + +
+
+
+ ); +}; diff --git a/frontend/components/generation/CorrectionModal.tsx b/frontend/components/generation/CorrectionModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da65ede5d27c8d164931bed1000f28bc99ad1f2f --- /dev/null +++ b/frontend/components/generation/CorrectionModal.tsx @@ -0,0 +1,352 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { X, Wand2, Image as ImageIcon, CheckCircle2, AlertCircle, Loader2, Sparkles } from "lucide-react"; +import { correctImage } from "@/lib/api/endpoints"; +import type { ImageCorrectResponse, AdCreativeDB } from "@/types/api"; +import { ProgressBar } from "@/components/ui/ProgressBar"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { Card, CardContent } from "@/components/ui/Card"; + +interface CorrectionModalProps { + isOpen: boolean; + onClose: () => void; + adId: string; + ad?: AdCreativeDB | null; + onSuccess?: (result: ImageCorrectResponse) => void; +} + +type CorrectionStep = "idle" | "input" | "analyzing" | "correcting" | "regenerating" | "complete" | "error"; + +export const CorrectionModal: React.FC = ({ + isOpen, + onClose, + adId, + ad, + onSuccess, +}) => { + const [step, setStep] = useState("idle"); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [userInstructions, setUserInstructions] = useState(""); + const [useAutoAnalyze, setUseAutoAnalyze] = useState(false); + + useEffect(() => { + if (isOpen) { + setStep("input"); + setProgress(0); + setResult(null); + setError(null); + setUserInstructions(""); + setUseAutoAnalyze(false); + } else { + // Reset state when modal closes + setStep("idle"); + setProgress(0); + setResult(null); + setError(null); + setUserInstructions(""); + setUseAutoAnalyze(false); + } + }, [isOpen]); + + const handleCorrection = async () => { + if (!userInstructions && !useAutoAnalyze) { + setError("Please specify what you want to correct or enable auto-analysis"); + return; + } + + setStep("analyzing"); + setProgress(0); + setError(null); + setResult(null); + + try { + // Simulate progress updates + const progressInterval = setInterval(() => { + setProgress((prev) => { + if (prev < 90) { + return prev + 5; + } + return prev; + }); + }, 500); + + setStep("analyzing"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + setStep("correcting"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + setStep("regenerating"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Actually perform the correction + const response = await correctImage({ + image_id: adId, + user_instructions: userInstructions || undefined, + auto_analyze: useAutoAnalyze, + }); + + clearInterval(progressInterval); + setProgress(100); + + if (response.status === "success") { + setStep("complete"); + setResult(response); + onSuccess?.(response); + } else { + setStep("error"); + setError(response.error || "Correction failed"); + } + } catch (err: any) { + setStep("error"); + setError(err.response?.data?.detail || err.message || "Failed to correct image"); + setProgress(0); + } + }; + + const getStepLabel = () => { + switch (step) { + case "input": + return "Specify Corrections"; + case "analyzing": + return "Analyzing image..."; + case "correcting": + return "Generating corrections..."; + case "regenerating": + return "Regenerating with nano-banana-pro..."; + case "complete": + return "Correction complete!"; + case "error": + return "Error occurred"; + default: + return "Starting correction..."; + } + }; + + const getStepIcon = () => { + switch (step) { + case "complete": + return ; + case "error": + return ; + default: + return ; + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

Correct Image

+

+ {step === "input" + ? "Specify what you want to correct" + : "Analyzing and correcting your ad creative"} +

+
+
+ +
+
+ + {/* Content */} +
+ {/* Input Step */} + {step === "input" && ( +
+ + +
+
+ + setUserInstructions(e.target.value)} + className="w-full" + /> +

+ Be specific about what you want to change. Only the specified changes will be made. +

+
+ +
+ setUseAutoAnalyze(e.target.checked)} + className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> + +
+ + {useAutoAnalyze && ( +
+

Auto-Analysis Mode

+

AI will analyze the image for spelling mistakes and visual issues, then suggest corrections.

+
+ )} + + {userInstructions && ( +
+

Custom Correction Mode

+

Only the changes you specified will be made. The rest of the image will be preserved.

+
+ )} +
+
+
+
+ )} + + {/* Progress Section */} + {step !== "input" && step !== "complete" && step !== "error" && ( +
+
+ {getStepIcon()} +
+

{getStepLabel()}

+ +
+
+
+ )} + + {/* Error State */} + {step === "error" && ( +
+
+ +
+

Correction Failed

+

{error}

+
+
+
+ )} + + {/* Success State */} + {step === "complete" && result && ( +
+
+
+ +
+

Correction Complete!

+

Your image has been corrected successfully

+
+
+
+ + {result.corrected_image?.image_url && ( +
+

Corrected Image

+ Corrected +
+ )} + + {result.corrections && ( +
+ {result.corrections.spelling_corrections.length > 0 && ( +
+

Spelling Corrections

+
+ {result.corrections.spelling_corrections.map((correction, idx) => ( +
+ + {correction.detected} + {" "} + →{" "} + + {correction.corrected} + +
+ ))} +
+
+ )} + + {result.corrections.visual_corrections.length > 0 && ( +
+

Visual Improvements

+
+ {result.corrections.visual_corrections.map((correction, idx) => ( +
+

{correction.issue}

+

{correction.suggestion}

+
+ ))} +
+
+ )} +
+ )} +
+ )} +
+ + {/* Footer */} +
+
+ {step === "input" && ( + + )} + {step === "error" && ( + + )} + +
+
+
+
+ ); +}; diff --git a/frontend/components/generation/ExtensiveForm.tsx b/frontend/components/generation/ExtensiveForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..057961c83af4bbe08a24c37f24c9485aa9446fce --- /dev/null +++ b/frontend/components/generation/ExtensiveForm.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card"; +import { Select } from "@/components/ui/Select"; +import { IMAGE_MODELS } from "@/lib/constants/models"; +import type { Niche } from "@/types/api"; + +const extensiveSchema = z.object({ + niche: z.enum(["home_insurance", "glp1"]), + target_audience: z.string().min(1, "Target audience is required"), + offer: z.string().min(1, "Offer is required"), + num_images: z.number().min(1).max(3), + num_strategies: z.number().min(1).max(10), + image_model: z.string().nullable().optional(), +}); + +type ExtensiveFormData = z.infer; + +interface ExtensiveFormProps { + onSubmit: (data: { + niche: Niche; + target_audience: string; + offer: string; + num_images: number; + num_strategies: number; + image_model?: string | null; + }) => Promise; + isLoading: boolean; +} + +export const ExtensiveForm: React.FC = ({ + onSubmit, + isLoading, +}) => { + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm({ + resolver: zodResolver(extensiveSchema), + defaultValues: { + niche: "home_insurance" as Niche, + target_audience: "", + offer: "", + num_images: 1, + num_strategies: 5, + image_model: null, + }, + }); + + const numImages = watch("num_images"); + const numStrategies = watch("num_strategies"); + + return ( + + + Extensive Generation + + Researcher → Creative Director → Designer → Copywriter flow + + + +
+ + {errors.target_audience && ( +

{errors.target_audience.message}

+ )} +
+ +
+ + + {errors.offer && ( +

{errors.offer.message}

+ )} +
+ + +
+ 1 + 3 +
+ + +
+ + +
+ 1 + 10 +
+

+ More strategies = more variety, but longer generation time +

+
+ + + + + + ); +}; diff --git a/frontend/components/generation/GenerationForm.tsx b/frontend/components/generation/GenerationForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7dbce24394a21d60e09121594cdd4d3c24c5fcc2 --- /dev/null +++ b/frontend/components/generation/GenerationForm.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { generateAdSchema } from "@/lib/utils/validators"; +import { Input } from "@/components/ui/Input"; +import { Select } from "@/components/ui/Select"; +import { Button } from "@/components/ui/Button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card"; +import { IMAGE_MODELS } from "@/lib/constants/models"; +import type { Niche } from "@/types/api"; + +interface GenerationFormProps { + onSubmit: (data: { niche: Niche; num_images: number; image_model?: string | null }) => Promise; + isLoading: boolean; +} + +export const GenerationForm: React.FC = ({ + onSubmit, + isLoading, +}) => { + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm({ + resolver: zodResolver(generateAdSchema), + defaultValues: { + niche: "home_insurance" as Niche, + num_images: 1, + image_model: null, + }, + }); + + const numImages = watch("num_images"); + + return ( + + + Generate Ad + + Create a new ad creative using randomized strategies + + + +
+ ({ value: model.value, label: model.label }))} + error={errors.image_model?.message} + {...register("image_model")} + /> + +
+ + +
+ 1 + 10 +
+

+ Each variation will have a unique image and slight copy variations +

+ {errors.num_images && ( +

+ {errors.num_images.message} +

+ )} +
+ + +
+
+
+ ); +}; diff --git a/frontend/components/generation/GenerationProgress.tsx b/frontend/components/generation/GenerationProgress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d7cb826365523e24673fb362e70ae68c1a21e0f7 --- /dev/null +++ b/frontend/components/generation/GenerationProgress.tsx @@ -0,0 +1,217 @@ +"use client"; + +import React from "react"; +import { Card, CardContent } from "@/components/ui/Card"; +import { ProgressBar } from "@/components/ui/ProgressBar"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { + Sparkles, + Image as ImageIcon, + Database, + CheckCircle2, + AlertCircle, + Wand2, + Zap +} from "lucide-react"; +import type { GenerationProgress } from "@/types"; + +interface GenerationProgressProps { + progress: GenerationProgress; +} + +const STEPS = [ + { key: "copy", label: "Crafting Copy", icon: Sparkles, color: "from-blue-500 to-cyan-500", messages: [ + "Brainstorming compelling headlines...", + "Writing persuasive ad copy...", + "Polishing the perfect message...", + "Adding psychological triggers...", + ]}, + { key: "image", label: "Generating Images", icon: ImageIcon, color: "from-cyan-500 to-pink-500", messages: [ + "Creating stunning visuals...", + "Bringing your vision to life...", + "Rendering high-quality images...", + "Adding creative flair...", + ]}, + { key: "saving", label: "Saving", icon: Database, color: "from-pink-500 to-purple-500", messages: [ + "Storing your creative...", + "Securing your masterpiece...", + "Almost done...", + ]}, +] as const; + +export const GenerationProgressComponent: React.FC = ({ + progress, +}) => { + const stepProgress = { + idle: 0, + copy: 33, + image: 66, + saving: 90, + complete: 100, + error: 0, + }; + + const currentProgress = progress.progress || stepProgress[progress.step]; + const currentStepIndex = STEPS.findIndex(s => s.key === progress.step); + const isComplete = progress.step === "complete"; + const isError = progress.step === "error"; + + // Get random message for current step + const getStepMessage = () => { + if (progress.message) return progress.message; + const step = STEPS.find(s => s.key === progress.step); + if (step && step.messages.length > 0) { + const randomIndex = Math.floor(Math.random() * step.messages.length); + return step.messages[randomIndex]; + } + return "Processing..."; + }; + + return ( + + +
+ {/* Header with animated icon */} +
+
+ {isComplete ? ( +
+
+ +
+ ) : isError ? ( + + ) : ( +
+
+
+ +
+
+ )} +
+

+ {isComplete ? "Generation Complete!" : isError ? "Generation Failed" : "Creating Your Ad"} +

+

+ {isComplete ? "Your ad is ready!" : isError ? "Something went wrong" : getStepMessage()} +

+
+
+ {progress.estimatedTimeRemaining && !isComplete && !isError && ( +
+
+ + ~{Math.ceil(progress.estimatedTimeRemaining)}s +
+

remaining

+
+ )} +
+ + {/* Step Indicators */} + {!isComplete && !isError && ( +
+ {/* Progress line */} +
+
+
+ + {STEPS.map((step, index) => { + const StepIcon = step.icon; + const isActive = progress.step === step.key; + const isCompleted = currentStepIndex > index; + const isUpcoming = currentStepIndex < index; + + return ( +
+
+ {isActive ? ( +
+ +
+ ) : isCompleted ? ( + + ) : ( + + )} + {isActive && ( +
+ )} +
+

+ {step.label} +

+
+ ); + })} +
+ )} + + {/* Progress Bar */} +
+
+ Overall Progress + + {Math.round(currentProgress)}% + +
+ +
+ + {/* Success State */} + {isComplete && ( +
+
+ +
+

+ Ad generated successfully! +

+

+ Your creative is ready to use +

+
+
+
+ )} + + {/* Error State */} + {isError && ( +
+
+ +
+

+ Generation failed +

+

+ {progress.message || "An error occurred. Please try again."} +

+
+
+
+ )} +
+ + + ); +}; diff --git a/frontend/components/layout/ConditionalHeader.tsx b/frontend/components/layout/ConditionalHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7fd05ddffedc918a9da7e2ec0e9f338707b79d94 --- /dev/null +++ b/frontend/components/layout/ConditionalHeader.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import { usePathname } from "next/navigation"; +import { Header } from "./Header"; + +export const ConditionalHeader: React.FC = () => { + const pathname = usePathname(); + const isLoginPage = pathname === "/login"; + + if (isLoginPage) { + return null; + } + + return
; +}; diff --git a/frontend/components/layout/Header.tsx b/frontend/components/layout/Header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9840b46868d32349bee776b46a0a61091881c53a --- /dev/null +++ b/frontend/components/layout/Header.tsx @@ -0,0 +1,100 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { Home, Rocket, Grid, Layers, LogOut, User } from "lucide-react"; +import { useAuthStore } from "@/store/authStore"; +import { Button } from "@/components/ui/Button"; + +export const Header: React.FC = () => { + const pathname = usePathname(); + const router = useRouter(); + const { isAuthenticated, user, logout } = useAuthStore(); + + const navItems = [ + { href: "/", label: "Dashboard", icon: Home }, + { href: "/generate", label: "Generate", icon: Rocket }, + { href: "/gallery", label: "Gallery", icon: Grid }, + { href: "/matrix", label: "Matrix", icon: Layers }, + ]; + + const handleLogout = () => { + logout(); + router.push("/login"); + }; + + return ( +
+
+
+
+ +
+ +
+
+ + Creative Breakthrough + + +
+ +
+ + + {isAuthenticated ? ( +
+
+ + {user?.username} +
+ +
+ ) : ( + pathname !== "/login" && ( + + + + ) + )} +
+
+
+
+ ); +}; diff --git a/frontend/components/matrix/AngleSelector.tsx b/frontend/components/matrix/AngleSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e127222eb0b59c5382a186946f875bdd70592be5 --- /dev/null +++ b/frontend/components/matrix/AngleSelector.tsx @@ -0,0 +1,133 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Input } from "@/components/ui/Input"; +import { Select } from "@/components/ui/Select"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { getAllAngles } from "@/lib/api/endpoints"; +import { useMatrixStore } from "@/store/matrixStore"; +import type { AngleInfo, AnglesResponse } from "@/types/api"; + +interface AngleSelectorProps { + onSelect?: (angle: AngleInfo) => void; + selectedAngle?: AngleInfo | null; +} + +export const AngleSelector: React.FC = ({ + onSelect, + selectedAngle, +}) => { + const { angles, isLoading, setAngles, setIsLoading } = useMatrixStore(); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(""); + + useEffect(() => { + if (!angles) { + loadAngles(); + } + }, []); + + const loadAngles = async () => { + setIsLoading(true); + try { + const data = await getAllAngles(); + setAngles(data); + } catch (error) { + console.error("Failed to load angles:", error); + } finally { + setIsLoading(false); + } + }; + + if (isLoading && !angles) { + return ( + + + + + + ); + } + + if (!angles) { + return ( + + +

Failed to load angles

+
+
+ ); + } + + // Filter angles + let filteredAngles: Array<{ category: string; angle: any }> = []; + + Object.entries(angles.categories).forEach(([catKey, catData]) => { + if (selectedCategory && catKey !== selectedCategory) return; + + catData.angles.forEach((angle) => { + if ( + !searchTerm || + angle.name.toLowerCase().includes(searchTerm.toLowerCase()) || + angle.trigger.toLowerCase().includes(searchTerm.toLowerCase()) || + angle.key.toLowerCase().includes(searchTerm.toLowerCase()) + ) { + filteredAngles.push({ category: catData.name, angle }); + } + }); + }); + + const categories = Object.entries(angles.categories).map(([key, data]) => ({ + value: key, + label: data.name, + })); + + return ( + + + Select Angle + + +
+ setSearchTerm(e.target.value)} + /> + setShowCompatibleOnly(e.target.checked)} + className="rounded" + /> + Show compatible concepts only + +
+ )} + + +
+ setSearchTerm(e.target.value)} + /> + setNiche(e.target.value as Niche)} + /> + + setAngleCount(Number(e.target.value))} + /> +
+ 1 + 10 +
+
+ +
+ + setConceptCount(Number(e.target.value))} + /> +
+ 1 + 10 +
+
+ +
+

+ Total Combinations: {angleCount} × {conceptCount} = {angleCount * conceptCount} +

+
+ + +
+
+ + {matrix && ( + <> + + +
+
+ Matrix Summary + + {matrix.summary.total_combinations} combinations ready for testing + +
+
+ + +
+
+
+ +
+
+

Total Combinations

+

{matrix.summary.total_combinations}

+
+
+

Unique Angles

+

{matrix.summary.unique_angles}

+
+
+

Unique Concepts

+

{matrix.summary.unique_concepts}

+
+
+

Avg Compatibility

+

+ {(matrix.summary.average_compatibility * 100).toFixed(0)}% +

+
+
+ + {/* Generate Ads Section */} +
+
+
+

Generate Ads from Matrix

+

+ Select combinations to generate ads, or generate all +

+
+
+ +
+ + setNumVariations(Number(e.target.value))} + /> +
+ 1 + 3 +
+
+ +
+ setFocused(true)} + onBlur={() => setFocused(false)} + {...props} + /> + {(focused || hasValue) && !error && ( +
+ )} +
+ {error && ( +

{error}

+ )} +
+ ); +}; diff --git a/frontend/components/ui/LoadingSkeleton.tsx b/frontend/components/ui/LoadingSkeleton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9951399600672299835fbdfba3eb6556535fa9fc --- /dev/null +++ b/frontend/components/ui/LoadingSkeleton.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { cn } from "../../lib/utils/cn"; + +interface LoadingSkeletonProps { + className?: string; + variant?: "text" | "circular" | "rectangular"; +} + +export const LoadingSkeleton: React.FC = ({ + className, + variant = "rectangular", +}) => { + const baseStyles = "animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-shimmer"; + + const variants = { + text: "h-4 rounded", + circular: "rounded-full aspect-square", + rectangular: "rounded-xl", + }; + + return ( +
+ ); +}; + +// Add shimmer animation to globals.css +const shimmerKeyframes = ` +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} +`; diff --git a/frontend/components/ui/LoadingSpinner.tsx b/frontend/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3657e0f8dcae09e02025cdf527cb03f3c97ed161 --- /dev/null +++ b/frontend/components/ui/LoadingSpinner.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { cn } from "../../lib/utils/cn"; + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg"; + className?: string; +} + +export const LoadingSpinner: React.FC = ({ + size = "md", + className, +}) => { + const sizes = { + sm: "h-4 w-4", + md: "h-8 w-8", + lg: "h-12 w-12", + }; + + return ( +
+ {/* Outer glow */} +
+ + {/* Main spinner */} + + + + + + + + + + + +
+ ); +}; diff --git a/frontend/components/ui/ProgressBar.tsx b/frontend/components/ui/ProgressBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b7751ba867861693d5daf252f590d7af45c56fdd --- /dev/null +++ b/frontend/components/ui/ProgressBar.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { cn } from "../../lib/utils/cn"; + +interface ProgressBarProps { + progress: number; // 0-100 + label?: string; + showPercentage?: boolean; + className?: string; +} + +export const ProgressBar: React.FC = ({ + progress, + label, + showPercentage = true, + className, +}) => { + const clampedProgress = Math.min(100, Math.max(0, progress)); + + return ( +
+ {label && ( +
+ {label} + {showPercentage && ( + + {Math.round(clampedProgress)}% + + )} +
+ )} +
+
0 && clampedProgress < 100 ? "gradient-shift 3s ease infinite" : "none" + }} + > + {/* Animated shimmer effect */} +
+ + {/* Glowing effect at the end */} + {clampedProgress > 0 && ( +
+ )} +
+ + {/* Pulse effect on the progress bar */} + {clampedProgress > 0 && clampedProgress < 100 && ( +
+ )} +
+
+ ); +}; diff --git a/frontend/components/ui/Select.tsx b/frontend/components/ui/Select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5c077a94b2472abb04c042d1d31c391011b82b2 --- /dev/null +++ b/frontend/components/ui/Select.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import { cn } from "../../lib/utils/cn"; + +interface SelectProps extends React.SelectHTMLAttributes { + label?: string; + error?: string; + options: Array<{ value: string; label: string }>; +} + +export const Select: React.FC = ({ + label, + error, + options, + className, + ...props +}) => { + const [focused, setFocused] = useState(false); + const hasValue = props.value !== undefined && props.value !== ""; + + return ( +
+ {label && ( + + )} +
+ + {/* Custom arrow */} +
+ + + +
+ {(focused || hasValue) && !error && ( +
+ )} +
+ {error && ( +

{error}

+ )} +
+ ); +}; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..05e726d1b4201bc8c7716d2b058279676582e8c0 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..416e6e9831f3664b69fb7dfdae619d1929e974a0 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,40 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ + // Ensure module resolution works correctly + experimental: { + // This helps with module resolution in monorepos + }, + + // Image optimization + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**.r2.cloudflarestorage.com', + }, + { + protocol: 'https', + hostname: 'replicate.delivery', + }, + { + protocol: 'http', + hostname: 'localhost', + port: '8000', + pathname: '/images/**', + }, + ], + formats: ['image/avif', 'image/webp'], + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + }, + + // Compression + compress: true, + + // Performance + poweredByHeader: false, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..89cc70a2744faf974fb8684543e081f36dce148f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6817 @@ +{ + "name": "frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "axios": "^1.13.2", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "framer-motion": "^12.25.0", + "lucide-react": "^0.562.0", + "next": "16.1.1", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-hook-form": "^7.70.0", + "react-hot-toast": "^2.6.0", + "tailwind-merge": "^3.4.0", + "zod": "^4.3.5", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.1", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", + "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz", + "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", + "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", + "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", + "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", + "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", + "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", + "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", + "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", + "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", + "integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.52.0", + "@typescript-eslint/type-utils": "8.52.0", + "@typescript-eslint/utils": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.52.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", + "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.52.0", + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz", + "integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.52.0", + "@typescript-eslint/types": "^8.52.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz", + "integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz", + "integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz", + "integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0", + "@typescript-eslint/utils": "8.52.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz", + "integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz", + "integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.52.0", + "@typescript-eslint/tsconfig-utils": "8.52.0", + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/visitor-keys": "8.52.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz", + "integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.52.0", + "@typescript-eslint/types": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz", + "integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.52.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz", + "integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.1.1", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/framer-motion": { + "version": "12.25.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.25.0.tgz", + "integrity": "sha512-mlWqd0rApIjeyhTCSNCqPYsUAEhkcUukZxH3ke6KbstBRPcxhEpuIjmiUQvB+1E9xkEm5SpNHBgHCapH/QHTWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.24.11", + "motion-utils": "^12.24.10", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/motion-dom": { + "version": "12.24.11", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz", + "integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.24.10" + } + }, + "node_modules/motion-utils": { + "version": "12.24.10", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz", + "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", + "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.1", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.1", + "@next/swc-darwin-x64": "16.1.1", + "@next/swc-linux-arm64-gnu": "16.1.1", + "@next/swc-linux-arm64-musl": "16.1.1", + "@next/swc-linux-x64-gnu": "16.1.1", + "@next/swc-linux-x64-musl": "16.1.1", + "@next/swc-win32-arm64-msvc": "16.1.1", + "@next/swc-win32-x64-msvc": "16.1.1", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-hook-form": { + "version": "7.70.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", + "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.52.0.tgz", + "integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.52.0", + "@typescript-eslint/parser": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0", + "@typescript-eslint/utils": "8.52.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..466fca9e04568a2bb3511bf706e5bf00276c0192 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "axios": "^1.13.2", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "framer-motion": "^12.25.0", + "lucide-react": "^0.562.0", + "next": "16.1.1", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-hook-form": "^7.70.0", + "react-hot-toast": "^2.6.0", + "tailwind-merge": "^3.4.0", + "zod": "^4.3.5", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.1", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..61e36849cf7cfa9f1f71b4a3964a4953e3e243d3 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862 --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000000000000000000000000000000000000..b2b2a44f6ebc70c450043c05a002e7a93ba5d651 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/store/authStore.ts b/frontend/store/authStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a0eee0a4476c8ba5912fd12477992e6d512ae7a --- /dev/null +++ b/frontend/store/authStore.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface AuthState { + isAuthenticated: boolean; + token: string | null; + user: { username: string } | null; + login: (token: string, username: string) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + isAuthenticated: false, + token: null, + user: null, + login: (token: string, username: string) => { + set({ + isAuthenticated: true, + token, + user: { username }, + }); + }, + logout: () => { + set({ + isAuthenticated: false, + token: null, + user: null, + }); + }, + }), + { + name: "auth-storage", + } + ) +); diff --git a/frontend/store/galleryStore.ts b/frontend/store/galleryStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a7b591e7bd409380fd20bbaddf0eb9405d83f26 --- /dev/null +++ b/frontend/store/galleryStore.ts @@ -0,0 +1,76 @@ +import { create } from "zustand"; +import type { AdCreativeDB, AdFilters, AdSortOptions } from "../types"; + +interface GalleryState { + ads: AdCreativeDB[]; + total: number; + limit: number; + offset: number; + filters: AdFilters; + sortOptions: AdSortOptions; + selectedAds: string[]; + isLoading: boolean; + error: string | null; + + setAds: (ads: AdCreativeDB[], total: number) => void; + setLimit: (limit: number) => void; + setOffset: (offset: number) => void; + setFilters: (filters: Partial) => void; + setSortOptions: (sort: AdSortOptions) => void; + toggleAdSelection: (adId: string) => void; + clearSelection: () => void; + selectAll: () => void; + setIsLoading: (isLoading: boolean) => void; + setError: (error: string | null) => void; + removeAd: (adId: string) => void; +} + +export const useGalleryStore = create((set, get) => ({ + ads: [], + total: 0, + limit: 50, + offset: 0, + filters: {}, + sortOptions: { + field: "created_at", + direction: "desc", + }, + selectedAds: [], + isLoading: false, + error: null, + + setAds: (ads, total) => set({ ads, total }), + + setLimit: (limit) => set({ limit, offset: 0 }), + + setOffset: (offset) => set({ offset }), + + setFilters: (filters) => set((state) => ({ + filters: { ...state.filters, ...filters }, + offset: 0, // Reset to first page when filters change + })), + + setSortOptions: (sort) => set({ sortOptions: sort }), + + toggleAdSelection: (adId) => set((state) => ({ + selectedAds: state.selectedAds.includes(adId) + ? state.selectedAds.filter((id) => id !== adId) + : [...state.selectedAds, adId], + })), + + clearSelection: () => set({ selectedAds: [] }), + + selectAll: () => set((state) => ({ + selectedAds: state.ads.map((ad) => ad.id), + })), + + setIsLoading: (isLoading) => set({ isLoading }), + + setError: (error) => set({ error }), + + removeAd: (adId) => set((state) => ({ + ads: state.ads.filter((ad) => ad.id !== adId), + total: state.total - 1, + selectedAds: state.selectedAds.filter((id) => id !== adId), + })), +})); diff --git a/frontend/store/generationStore.ts b/frontend/store/generationStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfe94eaee7935b9483b269015c6f6b38090ea4d4 --- /dev/null +++ b/frontend/store/generationStore.ts @@ -0,0 +1,39 @@ +import { create } from "zustand"; +import type { GenerateResponse, MatrixGenerateResponse, GenerationProgress } from "../types"; + +interface GenerationState { + currentGeneration: GenerateResponse | MatrixGenerateResponse | null; + progress: GenerationProgress; + isGenerating: boolean; + error: string | null; + + setCurrentGeneration: (ad: GenerateResponse | MatrixGenerateResponse | null) => void; + setProgress: (progress: GenerationProgress) => void; + setIsGenerating: (isGenerating: boolean) => void; + setError: (error: string | null) => void; + reset: () => void; +} + +const initialState = { + currentGeneration: null as GenerateResponse | MatrixGenerateResponse | null, + progress: { + step: "idle" as const, + progress: 0, + }, + isGenerating: false, + error: null as string | null, +}; + +export const useGenerationStore = create((set) => ({ + ...initialState, + + setCurrentGeneration: (ad) => set({ currentGeneration: ad }), + + setProgress: (progress) => set({ progress }), + + setIsGenerating: (isGenerating) => set({ isGenerating }), + + setError: (error) => set({ error }), + + reset: () => set(initialState), +})); diff --git a/frontend/store/matrixStore.ts b/frontend/store/matrixStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfce1305c84c83a885033a801e6ce4440ab52b18 --- /dev/null +++ b/frontend/store/matrixStore.ts @@ -0,0 +1,66 @@ +import { create } from "zustand"; +import type { AngleInfo, ConceptInfo, AnglesResponse, ConceptsResponse } from "../types/api"; +import type { MatrixFilters } from "../types/matrix"; + +interface MatrixState { + angles: AnglesResponse | null; + concepts: ConceptsResponse | null; + selectedAngle: AngleInfo | null; + selectedConcept: ConceptInfo | null; + compatibleConcepts: ConceptInfo[]; + angleFilters: MatrixFilters; + conceptFilters: MatrixFilters; + isLoading: boolean; + error: string | null; + + setAngles: (angles: AnglesResponse) => void; + setConcepts: (concepts: ConceptsResponse) => void; + setSelectedAngle: (angle: AngleInfo | null) => void; + setSelectedConcept: (concept: ConceptInfo | null) => void; + setCompatibleConcepts: (concepts: ConceptInfo[]) => void; + setAngleFilters: (filters: Partial) => void; + setConceptFilters: (filters: Partial) => void; + setIsLoading: (isLoading: boolean) => void; + setError: (error: string | null) => void; + reset: () => void; +} + +const initialState = { + angles: null, + concepts: null, + selectedAngle: null, + selectedConcept: null, + compatibleConcepts: [], + angleFilters: {}, + conceptFilters: {}, + isLoading: false, + error: null, +}; + +export const useMatrixStore = create((set) => ({ + ...initialState, + + setAngles: (angles) => set({ angles }), + + setConcepts: (concepts) => set({ concepts }), + + setSelectedAngle: (angle) => set({ selectedAngle: angle }), + + setSelectedConcept: (concept) => set({ selectedConcept: concept }), + + setCompatibleConcepts: (concepts) => set({ compatibleConcepts: concepts }), + + setAngleFilters: (filters) => set((state) => ({ + angleFilters: { ...state.angleFilters, ...filters }, + })), + + setConceptFilters: (filters) => set((state) => ({ + conceptFilters: { ...state.conceptFilters, ...filters }, + })), + + setIsLoading: (isLoading) => set({ isLoading }), + + setError: (error) => set({ error }), + + reset: () => set(initialState), +})); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..3a13f90a773b0facb675bf5b1a8239c8f33d36f5 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/frontend/types/ad.ts b/frontend/types/ad.ts new file mode 100644 index 0000000000000000000000000000000000000000..4515ae84f9d0782e03242386be602682bb396e1c --- /dev/null +++ b/frontend/types/ad.ts @@ -0,0 +1,34 @@ +// Ad-related types for frontend use + +import type { GenerateResponse, MatrixGenerateResponse, AdCreativeDB } from "./api"; + +export type AdDisplay = GenerateResponse | MatrixGenerateResponse | AdCreativeDB; + +export interface GenerationProgress { + step: "idle" | "copy" | "image" | "saving" | "complete" | "error"; + progress: number; // 0-100 + message?: string; + estimatedTimeRemaining?: number; // seconds +} + +export interface AdFilters { + niche?: "home_insurance" | "glp1" | null; + generation_method?: "original" | "angle_concept_matrix" | "extensive" | null; + date_from?: string | null; + date_to?: string | null; + angle_key?: string | null; + concept_key?: string | null; + search?: string | null; +} + +export interface AdSortOptions { + field: "created_at" | "niche" | "generation_method" | "headline"; + direction: "asc" | "desc"; +} + +export interface GenerationProgress { + step: "idle" | "copy" | "image" | "saving" | "complete" | "error"; + progress: number; // 0-100 + message?: string; + estimatedTimeRemaining?: number; // seconds +} diff --git a/frontend/types/api.ts b/frontend/types/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..30611db959726c8baf645a299862f239618cfedb --- /dev/null +++ b/frontend/types/api.ts @@ -0,0 +1,260 @@ +// API Response Types based on FastAPI backend + +export type Niche = "home_insurance" | "glp1"; + +export interface ImageResult { + filename?: string | null; + filepath?: string | null; + image_url?: string | null; + model_used?: string | null; + seed?: number | null; + error?: string | null; +} + +export interface AdMetadata { + strategies_used: string[]; + creative_direction: string; + visual_mood: string; + framework?: string | null; + camera_angle?: string | null; + lighting?: string | null; + composition?: string | null; + hooks_inspiration: string[]; + visual_styles: string[]; +} + +export interface GenerateResponse { + id: string; + niche: string; + created_at: string; + title: string; + headline: string; + primary_text: string; + description: string; + body_story: string; + cta: string; + psychological_angle: string; + why_it_works?: string | null; + images: ImageResult[]; + metadata: AdMetadata; +} + +export interface BatchResponse { + count: number; + ads: GenerateResponse[]; +} + +export interface AngleInfo { + key: string; + name: string; + trigger: string; + category: string; +} + +export interface ConceptInfo { + key: string; + name: string; + structure: string; + visual: string; + category: string; +} + +export interface MatrixResult { + angle: AngleInfo; + concept: ConceptInfo; +} + +export interface MatrixMetadata { + generation_method: string; +} + +export interface MatrixGenerateResponse { + id: string; + niche: string; + created_at: string; + title: string; + headline: string; + primary_text: string; + description: string; + body_story: string; + cta: string; + psychological_angle: string; + why_it_works?: string | null; + images: ImageResult[]; + matrix: MatrixResult; + metadata: MatrixMetadata; +} + +export interface CombinationInfo { + combination_id: string; + angle: AngleInfo; + concept: ConceptInfo; + compatibility_score: number; + prompt_guidance: string; +} + +export interface MatrixSummary { + total_combinations: number; + unique_angles: number; + unique_concepts: number; + average_compatibility: number; + angles_used: string[]; + concepts_used: string[]; +} + +export interface TestingMatrixResponse { + niche: string; + strategy: string; + summary: MatrixSummary; + combinations: CombinationInfo[]; +} + +export interface AngleCategory { + name: string; + angle_count: number; + angles: Array<{ + key: string; + name: string; + trigger: string; + example: string; + }>; +} + +export interface ConceptCategory { + name: string; + concept_count: number; + concepts: Array<{ + key: string; + name: string; + structure: string; + visual: string; + }>; +} + +export interface AnglesResponse { + total_angles: number; + categories: Record; +} + +export interface ConceptsResponse { + total_concepts: number; + categories: Record; +} + +export interface CompatibleConceptsResponse { + angle: { + key: string; + name: string; + trigger: string; + }; + compatible_concepts: Array<{ + key: string; + name: string; + structure: string; + }>; +} + +export interface DbStatsResponse { + connected: boolean; + total_ads?: number | null; + by_niche?: Record | null; + by_method?: Record | null; + error?: string | null; +} + +export interface AdCreativeDB { + id: string; + niche: string; + title?: string | null; + headline: string; + primary_text?: string | null; + description?: string | null; + body_story?: string | null; + cta?: string | null; + psychological_angle?: string | null; + why_it_works?: string | null; + image_url?: string | null; + image_filename?: string | null; + image_model?: string | null; + image_seed?: number | null; + angle_key?: string | null; + angle_name?: string | null; + concept_key?: string | null; + concept_name?: string | null; + generation_method?: string | null; + created_at?: string | null; +} + +export interface AdsListResponse { + total: number; + limit: number; + offset: number; + ads: AdCreativeDB[]; +} + +export interface HealthResponse { + status: string; +} + +export interface ApiRootResponse { + name: string; + version: string; + description: string; + endpoints: Record; + supported_niches: string[]; + matrix_system: { + total_angles: number; + total_concepts: number; + possible_combinations: number; + formula: string; + }; +} + +// Image Correction Types +export interface SpellingCorrection { + detected: string; + corrected: string; + context?: string | null; +} + +export interface VisualCorrection { + issue: string; + suggestion: string; + priority?: string | null; +} + +export interface CorrectionData { + spelling_corrections: SpellingCorrection[]; + visual_corrections: VisualCorrection[]; + corrected_prompt: string; +} + +export interface CorrectedImageResult { + filename?: string | null; + filepath?: string | null; + image_url?: string | null; + r2_url?: string | null; + model_used?: string | null; + corrected_prompt?: string | null; + ad_id?: string | null; +} + +export interface ImageCorrectResponse { + status: string; + analysis?: string | null; + corrections?: CorrectionData | null; + corrected_image?: CorrectedImageResult | null; + error?: string | null; +} + +// Auth Types +export interface LoginRequest { + username: string; + password: string; +} + +export interface LoginResponse { + token: string; + username: string; + message?: string; +} diff --git a/frontend/types/index.ts b/frontend/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..df6effc7086d15a375794f974e7f6fb4658d2736 --- /dev/null +++ b/frontend/types/index.ts @@ -0,0 +1,4 @@ +// Re-export all types for convenience +export * from "./api"; +export * from "./ad"; +export * from "./matrix"; diff --git a/frontend/types/matrix.ts b/frontend/types/matrix.ts new file mode 100644 index 0000000000000000000000000000000000000000..f34dfe561134cde8063e9a17068b0fcc2f802288 --- /dev/null +++ b/frontend/types/matrix.ts @@ -0,0 +1,28 @@ +// Matrix system types + +import type { AngleInfo, ConceptInfo, CombinationInfo } from "./api"; + +export interface MatrixFilters { + category?: string | null; + search?: string | null; + trigger?: string | null; +} + +export interface SelectedMatrix { + angle?: AngleInfo | null; + concept?: ConceptInfo | null; + compatibilityScore?: number; +} + +export interface MatrixFilters { + category?: string | null; + search?: string | null; + trigger?: string | null; +} + +export interface TestingMatrixConfig { + niche: "home_insurance" | "glp1"; + strategy: "balanced" | "top_performers" | "diverse"; + angle_count: number; + concept_count: number; +} diff --git a/huggingface.yml b/huggingface.yml new file mode 100644 index 0000000000000000000000000000000000000000..e048e6aaed1737fd2d4b29e08af6c3d6de4dbbd9 --- /dev/null +++ b/huggingface.yml @@ -0,0 +1,16 @@ +# Hugging Face Space configuration +# This file configures the Space for Docker-based deployment + +# SDK type +sdk: docker + +# App port (Hugging Face Spaces will map this) +# The Dockerfile exposes port 8000, and HF Spaces will handle routing +port: 8000 + +# Health check path +healthcheck_path: /health + +# Title and description for the Space +title: Ad Generator Lite +sdk_version: "latest" diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..614b29a8449c2d083b5fd77f9463d2a688683a36 --- /dev/null +++ b/main.py @@ -0,0 +1,1192 @@ +""" +Ad Generator Lite - FastAPI Application +Simplified winning ads generator for Home Insurance and GLP-1 niches +Saves all ads to Neon PostgreSQL database with image URLs +""" + +from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException, Request, Response, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from typing import Optional, List, Literal, Any, Dict +import os +import asyncio +from starlette.middleware.gzip import GZipMiddleware + +from services.generator import ad_generator +from services.matrix import matrix_service +from services.database import db_service +from services.correction import correction_service +from services.image import image_service +from services.auth import auth_service +from services.auth_dependency import get_current_user +from services.image_cleanup import cleanup_service +from config import settings + + +async def cleanup_task(): + """Background task to periodically clean up old images.""" + while True: + try: + # Run cleanup every hour + await asyncio.sleep(3600) # 1 hour + + # Only run cleanup in production if local storage is enabled + if settings.environment.lower() == "production" and settings.save_images_locally: + print("Running scheduled image cleanup...") + stats = cleanup_service.cleanup_old_images(dry_run=False) + print(f"Cleanup completed: {stats['deleted']} images deleted, {stats['total_size_mb']} MB freed") + except Exception as e: + print(f"Error in cleanup task: {e}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown events.""" + # Startup: Connect to database + print("Starting Ad Generator Lite...") + await db_service.connect() + + # Start background cleanup task if in production with local storage enabled + cleanup_task_handle = None + if settings.environment.lower() == "production" and settings.save_images_locally: + print(f"Starting image cleanup task (retention: {settings.local_image_retention_hours} hours)") + cleanup_task_handle = asyncio.create_task(cleanup_task()) + + yield + + # Shutdown: Cancel cleanup task and disconnect from database + print("Shutting down...") + if cleanup_task_handle: + cleanup_task_handle.cancel() + try: + await cleanup_task_handle + except asyncio.CancelledError: + pass + await db_service.disconnect() + + +# Create FastAPI app with lifespan for database connection +app = FastAPI( + title="Ad Generator Lite", + description="Generate high-converting ad creatives for Home Insurance and GLP-1 niches using psychological triggers. All ads are saved to Neon database.", + version="2.0.0", + lifespan=lifespan, +) + +# Compression middleware (gzip responses) +app.add_middleware(GZipMiddleware, minimum_size=1000) + +# CORS middleware +# Allow localhost for development and Hugging Face Spaces for deployment +cors_origins = [ + "http://localhost:3000", + "http://127.0.0.1:3000", +] + +# Add custom origins from environment variable +if os.getenv("CORS_ORIGINS"): + cors_origins.extend([origin.strip() for origin in os.getenv("CORS_ORIGINS").split(",")]) + +# For Hugging Face Spaces, use regex to match any .hf.space domain +# Note: If deploying to HF Spaces, add your Space URL to CORS_ORIGINS env var +# Example: CORS_ORIGINS=https://your-username-ad-generator-lite.hf.space + +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_origin_regex=r"https://.*\.hf\.space", # Match any Hugging Face Space + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Add cache headers for static files +@app.middleware("http") +async def add_cache_headers(request: Request, call_next): + response = await call_next(request) + if request.url.path.startswith("/images/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + +# Serve generated images +os.makedirs(settings.output_dir, exist_ok=True) +app.mount("/images", StaticFiles(directory=settings.output_dir), name="images") + + +# Request/Response schemas +class GenerateRequest(BaseModel): + """Request schema for ad generation.""" + niche: Literal["home_insurance", "glp1"] = Field( + description="Target niche: home_insurance or glp1" + ) + num_images: int = Field( + default=1, + ge=1, + le=10, + description="Number of images to generate (1-10)" + ) + image_model: Optional[str] = Field( + default=None, + description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')" + ) + + +class GenerateBatchRequest(BaseModel): + """Request schema for batch ad generation.""" + niche: Literal["home_insurance", "glp1"] = Field( + description="Target niche: home_insurance or glp1" + ) + count: int = Field( + default=5, + ge=1, + le=20, + description="Number of ads to generate (1-20)" + ) + images_per_ad: int = Field( + default=1, + ge=1, + le=3, + description="Images per ad (1-3)" + ) + image_model: Optional[str] = Field( + default=None, + description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')" + ) + + +class ImageResult(BaseModel): + """Image result schema.""" + filename: Optional[str] = None + filepath: Optional[str] = None + image_url: Optional[str] = Field(default=None, description="Direct URL to the image (Replicate hosted)") + model_used: Optional[str] = None + seed: Optional[int] = None + error: Optional[str] = None + + +class AdMetadata(BaseModel): + """Metadata about the generation.""" + strategies_used: List[str] + creative_direction: str + visual_mood: str + framework: Optional[str] = None + camera_angle: Optional[str] = None + lighting: Optional[str] = None + composition: Optional[str] = None + hooks_inspiration: List[str] + visual_styles: List[str] + + +class GenerateResponse(BaseModel): + """Response schema for ad generation.""" + id: str + niche: str + created_at: str + title: str = Field(description="Short punchy ad title (3-5 words)") + headline: str + primary_text: str + description: str + body_story: str = Field(description="Compelling 4-6 sentence story that hooks emotionally") + cta: str + psychological_angle: str + why_it_works: Optional[str] = None + images: List[ImageResult] + metadata: AdMetadata + + +class BatchResponse(BaseModel): + """Response schema for batch generation.""" + count: int + ads: List[GenerateResponse] + + +# Matrix-based schemas +class MatrixGenerateRequest(BaseModel): + """Request for angle × concept matrix generation.""" + niche: Literal["home_insurance", "glp1"] = Field( + description="Target niche" + ) + angle_key: Optional[str] = Field( + default=None, + description="Specific angle key (random if not provided)" + ) + concept_key: Optional[str] = Field( + default=None, + description="Specific concept key (random if not provided)" + ) + num_images: int = Field( + default=1, + ge=1, + le=5, + description="Number of images to generate" + ) + image_model: Optional[str] = Field( + default=None, + description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')" + ) + + +class MatrixBatchRequest(BaseModel): + """Request for batch matrix generation.""" + niche: Literal["home_insurance", "glp1"] = Field( + description="Target niche" + ) + angle_count: int = Field( + default=6, + ge=1, + le=10, + description="Number of angles to test" + ) + concept_count: int = Field( + default=5, + ge=1, + le=10, + description="Number of concepts per angle" + ) + strategy: Literal["balanced", "top_performers", "diverse"] = Field( + default="balanced", + description="Selection strategy" + ) + + +class AngleInfo(BaseModel): + """Angle information.""" + key: str + name: str + trigger: str + category: str + + +class ConceptInfo(BaseModel): + """Concept information.""" + key: str + name: str + structure: str + visual: str + category: str + + +class MatrixMetadata(BaseModel): + """Matrix generation metadata.""" + generation_method: str = "angle_concept_matrix" + + +class MatrixResult(BaseModel): + """Result from matrix-based generation.""" + angle: AngleInfo + concept: ConceptInfo + + +class MatrixGenerateResponse(BaseModel): + """Response for matrix-based ad generation.""" + id: str + niche: str + created_at: str + title: str = Field(description="Short punchy ad title (3-5 words)") + headline: str + primary_text: str + description: str + body_story: str = Field(description="Compelling 4-6 sentence story that hooks emotionally") + cta: str + psychological_angle: str + why_it_works: Optional[str] = None + images: List[ImageResult] + matrix: MatrixResult + metadata: MatrixMetadata + + +class CombinationInfo(BaseModel): + """Info about a single angle × concept combination.""" + combination_id: str + angle: AngleInfo + concept: ConceptInfo + compatibility_score: float + prompt_guidance: str + + +class MatrixSummary(BaseModel): + """Summary of a testing matrix.""" + total_combinations: int + unique_angles: int + unique_concepts: int + average_compatibility: float + angles_used: List[str] + concepts_used: List[str] + + +class TestingMatrixResponse(BaseModel): + """Response for testing matrix generation.""" + niche: str + strategy: str + summary: MatrixSummary + combinations: List[CombinationInfo] + + +# Endpoints +@app.get("/") +async def root(): + """Root endpoint with API info.""" + return { + "name": "Ad Generator Lite", + "version": "2.0.0", + "description": "Generate high-converting ads using Angle × Concept matrix system", + "endpoints": { + "POST /generate": "Generate single ad (original mode)", + "POST /generate/batch": "Generate multiple ads (original mode)", + "POST /matrix/generate": "Generate ad using Angle × Concept matrix", + "POST /matrix/testing": "Generate testing matrix (30 combinations)", + "GET /matrix/angles": "List all 100 angles", + "GET /matrix/concepts": "List all 100 concepts", + "GET /matrix/angle/{key}": "Get specific angle details", + "GET /matrix/concept/{key}": "Get specific concept details", + "GET /matrix/compatible/{angle_key}": "Get compatible concepts for angle", + "POST /extensive/generate": "Generate ad using extensive (researcher → creative director → designer → copywriter)", + "POST /api/correct": "Correct image for spelling mistakes and visual issues (requires image_id)", + "GET /health": "Health check", + }, + "supported_niches": ["home_insurance", "glp1"], + "matrix_system": { + "total_angles": 100, + "total_concepts": 100, + "possible_combinations": 10000, + "formula": "1 Offer → 5-8 Angles → 3-5 Concepts per angle", + }, + } + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return {"status": "healthy"} + + +# ============================================================================= +# AUTHENTICATION ENDPOINTS +# ============================================================================= + +class LoginRequest(BaseModel): + """Login request schema.""" + username: str = Field(description="Username") + password: str = Field(description="Password") + + +class LoginResponse(BaseModel): + """Login response schema.""" + token: str + username: str + message: str = "Login successful" + + +@app.post("/auth/login", response_model=LoginResponse) +async def login(request: LoginRequest): + """ + Authenticate a user and return a JWT token. + + Credentials must be created manually using the create_user.py script. + """ + # Get user from database + user = await db_service.get_user(request.username) + if not user: + raise HTTPException( + status_code=401, + detail="Invalid username or password" + ) + + # Verify password + hashed_password = user.get("hashed_password") + if not hashed_password: + raise HTTPException( + status_code=500, + detail="User data corrupted" + ) + + if not auth_service.verify_password(request.password, hashed_password): + raise HTTPException( + status_code=401, + detail="Invalid username or password" + ) + + # Create access token + token = auth_service.create_access_token(request.username) + + return { + "token": token, + "username": request.username, + "message": "Login successful" + } + + +@app.post("/generate", response_model=GenerateResponse) +async def generate( + request: GenerateRequest, + username: str = Depends(get_current_user) +): + """ + Generate a single ad creative. + + Requires authentication. Users can only see their own generated ads. + + Uses maximum randomization to ensure different results every time: + - Random psychological strategies (2-3 combined) + - Random hooks and angles + - Random visual styles and moods + - Random seed for image generation + + Supported niches: + - home_insurance: Fear, urgency, savings, authority, guilt strategies + - glp1: Shame, transformation, FOMO, authority, simplicity strategies + """ + try: + result = await ad_generator.generate_ad( + niche=request.niche, + num_images=request.num_images, + image_model=request.image_model, + username=username, # Pass current user + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/generate/batch", response_model=BatchResponse) +async def generate_batch( + request: GenerateBatchRequest, + username: str = Depends(get_current_user) +): + """ + Generate multiple ad creatives in batch. + + Requires authentication. Users can only see their own generated ads. + + Each ad will be unique due to randomization: + - Different strategy combinations + - Different hooks and angles + - Different visual styles + - Different random seeds + """ + try: + results = await ad_generator.generate_batch( + niche=request.niche, + count=request.count, + images_per_ad=request.images_per_ad, + image_model=request.image_model, + username=username, # Pass current user + ) + return { + "count": len(results), + "ads": results, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/image/{filename}") +async def get_image(filename: str): + """Get a generated image by filename.""" + filepath = os.path.join(settings.output_dir, filename) + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="Image not found") + return FileResponse(filepath) + + +# ============================================================================= +# IMAGE CORRECTION ENDPOINTS +# ============================================================================= + +class ImageCorrectRequest(BaseModel): + """Request schema for image correction.""" + image_id: str = Field( + description="ID of existing ad creative in database" + ) + user_instructions: Optional[str] = Field( + default=None, + description="User-specified instructions for what to correct (e.g., 'Fix spelling in the headline', 'Adjust colors', 'Change text to X')" + ) + auto_analyze: bool = Field( + default=False, + description="Whether to automatically analyze the image for issues (if user_instructions not provided)" + ) + + +class SpellingCorrection(BaseModel): + """Spelling correction entry.""" + detected: str + corrected: str + context: Optional[str] = None + + +class VisualCorrection(BaseModel): + """Visual correction entry.""" + issue: str + suggestion: str + priority: Optional[str] = None + + +class CorrectionData(BaseModel): + """Correction data structure.""" + spelling_corrections: List[SpellingCorrection] + visual_corrections: List[VisualCorrection] + corrected_prompt: str + + +class CorrectedImageResult(BaseModel): + """Corrected image result.""" + filename: Optional[str] = None + filepath: Optional[str] = None + image_url: Optional[str] = None + r2_url: Optional[str] = None + model_used: Optional[str] = None + corrected_prompt: Optional[str] = None + + +class ImageCorrectResponse(BaseModel): + """Response schema for image correction.""" + status: str + analysis: Optional[str] = None + corrections: Optional[CorrectionData] = None + corrected_image: Optional[CorrectedImageResult] = None + error: Optional[str] = None + + +@app.post("/api/correct", response_model=ImageCorrectResponse) +async def correct_image( + request: ImageCorrectRequest, + username: str = Depends(get_current_user) +): + """ + Correct an image by analyzing it for spelling mistakes and visual issues, + then regenerating a corrected version using nano-banana-pro model. + + Requires authentication. Users can only correct their own ads. + + The service will automatically fetch the image and metadata from the database + using the provided image_id, then: + 1. Analyze the image using GPT-4 Vision for text and visual issues + 2. Generate a structured correction JSON with spelling and visual fixes + 3. Regenerate the image using nano-banana-pro model with corrected prompt and original image + 4. Return the corrected image along with analysis and corrections + """ + try: + # Fetch ad from database to get image and metadata (only if it belongs to current user) + ad = await db_service.get_ad_creative(request.image_id, username=username) + if not ad: + raise HTTPException(status_code=404, detail=f"Ad creative with ID {request.image_id} not found or access denied") + + # Get image URL from ad (prefer R2 URL, fallback to image_url) + image_url = ad.get("r2_url") or ad.get("image_url") + if not image_url: + raise HTTPException( + status_code=404, + detail="Image URL not found for this ad creative" + ) + + # Load image bytes for analysis (needed for vision API) + image_bytes = await image_service.load_image( + image_id=request.image_id, + image_url=None, + image_bytes=None, + filepath=None, + ) + + if not image_bytes: + raise HTTPException( + status_code=404, + detail="Image not found for analysis" + ) + + # Get original prompt from ad metadata if available + original_prompt = ad.get("image_prompt") or None + + # Perform correction + result = await correction_service.correct_image( + image_bytes=image_bytes, + image_url=image_url, # Pass URL for image-to-image generation + original_prompt=original_prompt, + width=1024, + height=1024, + niche=ad.get("niche"), + user_instructions=request.user_instructions, + auto_analyze=request.auto_analyze, + ) + + # Format response + response_data = { + "status": result["status"], + "analysis": result.get("analysis"), + "corrections": None, + "corrected_image": None, + "error": result.get("error"), + } + + # Format corrections if available + if result.get("corrections"): + corrections = result["corrections"] + response_data["corrections"] = { + "spelling_corrections": corrections.get("spelling_corrections", []), + "visual_corrections": corrections.get("visual_corrections", []), + "corrected_prompt": corrections.get("corrected_prompt", ""), + } + + # Format corrected image if available + if result.get("corrected_image"): + corrected_img = result["corrected_image"] + response_data["corrected_image"] = { + "filename": corrected_img.get("filename"), + "filepath": corrected_img.get("filepath"), + "image_url": corrected_img.get("image_url"), + "r2_url": corrected_img.get("r2_url"), + "model_used": corrected_img.get("model_used"), + "corrected_prompt": corrected_img.get("corrected_prompt"), + } + + # Save corrected image to database as a new ad creative + if result.get("status") == "success" and result.get("_db_metadata"): + db_metadata = result["_db_metadata"] + try: + # Save as a new ad creative with same copy but corrected image + corrected_ad_id = await db_service.save_ad_creative( + niche=ad.get("niche", ""), + title=ad.get("title", ""), + headline=ad.get("headline", ""), + primary_text=ad.get("primary_text", ""), + description=ad.get("description", ""), + body_story=ad.get("body_story", ""), + cta=ad.get("cta", ""), + psychological_angle=ad.get("psychological_angle", ""), + why_it_works=ad.get("why_it_works", ""), + username=username, # Pass current user + image_url=db_metadata.get("image_url"), + image_filename=db_metadata.get("filename"), + image_model=db_metadata.get("model_used"), + image_prompt=db_metadata.get("corrected_prompt"), + angle_key=ad.get("angle_key"), + angle_name=ad.get("angle_name"), + angle_trigger=ad.get("angle_trigger"), + angle_category=ad.get("angle_category"), + concept_key=ad.get("concept_key"), + concept_name=ad.get("concept_name"), + concept_structure=ad.get("concept_structure"), + concept_visual=ad.get("concept_visual"), + concept_category=ad.get("concept_category"), + generation_method="correction", + metadata={ + "original_ad_id": request.image_id, + "corrections": result.get("corrections"), + "is_corrected": True, + }, + ) + if corrected_ad_id: + # Add the new ad ID to the response + if "corrected_image" not in response_data: + response_data["corrected_image"] = {} + response_data["corrected_image"]["ad_id"] = corrected_ad_id + print(f"Corrected image saved to database with ID: {corrected_ad_id}") + except Exception as e: + print(f"Warning: Failed to save corrected image to database: {e}") + # Don't fail the request if database save fails + + return response_data + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + + + +@app.get("/strategies/{niche}") +async def get_strategies(niche: Literal["home_insurance", "glp1"]): + """ + Get available psychological strategies for a niche. + + Useful for understanding what strategies will be used. + """ + from data import home_insurance, glp1 + + if niche == "home_insurance": + data = home_insurance.get_niche_data() + else: + data = glp1.get_niche_data() + + strategies = {} + for name, strategy in data["strategies"].items(): + strategies[name] = { + "name": strategy["name"], + "description": strategy["description"], + "hook_count": len(strategy["hooks"]), + "sample_hooks": strategy["hooks"][:3], + } + + return { + "niche": niche, + "total_strategies": len(strategies), + "total_hooks": len(data["all_hooks"]), + "strategies": strategies, + } + + +# ============================================================================= +# ANGLE × CONCEPT MATRIX ENDPOINTS +# ============================================================================= + +@app.post("/matrix/generate", response_model=MatrixGenerateResponse) +async def generate_with_matrix( + request: MatrixGenerateRequest, + username: str = Depends(get_current_user) +): + """ + Generate ad using the Angle × Concept matrix approach. + + Requires authentication. Users can only see their own generated ads. + + This provides systematic ad generation with explicit control over: + - ANGLE: The psychological WHY (100 angles in 10 categories) + - CONCEPT: The visual HOW (100 concepts in 10 categories) + + If angle_key and concept_key are not provided, a compatible + combination will be selected automatically based on the niche. + """ + try: + result = await ad_generator.generate_ad_with_matrix( + niche=request.niche, + angle_key=request.angle_key, + concept_key=request.concept_key, + num_images=request.num_images, + image_model=request.image_model, + username=username, # Pass current user + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/matrix/testing", response_model=TestingMatrixResponse) +async def generate_testing_matrix(request: MatrixBatchRequest): + """ + Generate a testing matrix for systematic ad testing. + + Implements the scaling formula: + - 1 Offer → 5-8 Angles → 3-5 Concepts per angle + - Default: 6 angles × 5 concepts = 30 combinations + + Strategies: + - balanced: Mix of top performers and diverse selection + - top_performers: Focus on proven winning angles/concepts + - diverse: Maximum variety across categories + + Returns combinations WITHOUT generating images (for planning). + """ + try: + combinations = matrix_service.generate_testing_matrix( + niche=request.niche, + angle_count=request.angle_count, + concept_count=request.concept_count, + strategy=request.strategy, + ) + + summary = matrix_service.get_matrix_summary(combinations) + + return { + "niche": request.niche, + "strategy": request.strategy, + "summary": summary, + "combinations": combinations, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/matrix/angles") +async def list_angles(): + """ + List all available angles (100 total, 10 categories). + + Angles answer: "Why should I care?" - the psychological WHY. + """ + from data.angles import ANGLES, get_all_angles, AngleCategory + + categories = {} + for cat_key, cat_data in ANGLES.items(): + categories[cat_key.value] = { + "name": cat_data["name"], + "angle_count": len(cat_data["angles"]), + "angles": [ + { + "key": a["key"], + "name": a["name"], + "trigger": a["trigger"], + "example": a["example"], + } + for a in cat_data["angles"] + ], + } + + return { + "total_angles": len(get_all_angles()), + "categories": categories, + } + + +@app.get("/matrix/concepts") +async def list_concepts(): + """ + List all available concepts (100 total, 10 categories). + + Concepts answer: "How do I show it?" - the visual HOW. + """ + from data.concepts import CONCEPTS, get_all_concepts + + categories = {} + for cat_key, cat_data in CONCEPTS.items(): + categories[cat_key.value] = { + "name": cat_data["name"], + "concept_count": len(cat_data["concepts"]), + "concepts": [ + { + "key": c["key"], + "name": c["name"], + "structure": c["structure"], + "visual": c["visual"], + } + for c in cat_data["concepts"] + ], + } + + return { + "total_concepts": len(get_all_concepts()), + "categories": categories, + } + + +@app.get("/matrix/angle/{angle_key}") +async def get_angle(angle_key: str): + """Get details for a specific angle by key.""" + from data.angles import get_angle_by_key + + angle = get_angle_by_key(angle_key) + if not angle: + raise HTTPException(status_code=404, detail=f"Angle '{angle_key}' not found") + + return angle + + +@app.get("/matrix/concept/{concept_key}") +async def get_concept(concept_key: str): + """Get details for a specific concept by key.""" + from data.concepts import get_concept_by_key + + concept = get_concept_by_key(concept_key) + if not concept: + raise HTTPException(status_code=404, detail=f"Concept '{concept_key}' not found") + + return concept + + +@app.get("/matrix/compatible/{angle_key}") +async def get_compatible_concepts(angle_key: str): + """ + Get concepts compatible with a specific angle. + + Compatibility is based on psychological trigger matching. + """ + from data.angles import get_angle_by_key + from data.concepts import get_compatible_concepts as get_compatible + + angle = get_angle_by_key(angle_key) + if not angle: + raise HTTPException(status_code=404, detail=f"Angle '{angle_key}' not found") + + compatible = get_compatible(angle.get("trigger", "")) + + return { + "angle": { + "key": angle["key"], + "name": angle["name"], + "trigger": angle["trigger"], + }, + "compatible_concepts": [ + { + "key": c["key"], + "name": c["name"], + "structure": c["structure"], + } + for c in compatible + ], + } + + +# ============================================================================= +# EXTENSIVE ENDPOINTS +# ============================================================================= + +class ExtensiveGenerateRequest(BaseModel): + """Request for extensive generation.""" + niche: Literal["home_insurance", "glp1"] = Field( + description="Target niche" + ) + target_audience: str = Field( + description="Target audience description (e.g., 'US people over 50+ age')" + ) + offer: str = Field( + description="Offer to run (e.g., 'Don't overpay your insurance')" + ) + num_images: int = Field( + default=1, + ge=1, + le=3, + description="Number of images to generate per strategy (1-3)" + ) + image_model: Optional[str] = Field( + default=None, + description="Image generation model to use" + ) + num_strategies: int = Field( + default=5, + ge=1, + le=10, + description="Number of creative strategies to generate (1-10)" + ) + + +@app.post("/extensive/generate", response_model=GenerateResponse) +async def generate_extensive( + request: ExtensiveGenerateRequest, + username: str = Depends(get_current_user) +): + """ + Generate ad using extensive: researcher → creative director → designer → copywriter. + + Requires authentication. Users can only see their own generated ads. + + This flow: + 1. Researches psychology triggers, angles, and concepts + 2. Retrieves marketing book knowledge and old ads data + 3. Creates creative strategies + 4. Generates image prompts and ad copy in parallel + 5. Generates images for each strategy + + Returns the first generated ad from the strategies. + """ + try: + result = await ad_generator.generate_ad_extensive( + niche=request.niche, + target_audience=request.target_audience, + offer=request.offer, + num_images=request.num_images, + image_model=request.image_model, + num_strategies=request.num_strategies, + username=username, # Pass current user + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# DATABASE ENDPOINTS +# ============================================================================= + +class AdCreativeDB(BaseModel): + """Ad creative from database.""" + id: str + niche: str + title: Optional[str] = None + headline: str + primary_text: Optional[str] = None + description: Optional[str] = None + body_story: Optional[str] = None + cta: Optional[str] = None + psychological_angle: Optional[str] = None + why_it_works: Optional[str] = None + image_url: Optional[str] = None + image_filename: Optional[str] = None + image_model: Optional[str] = None + image_seed: Optional[int] = None + angle_key: Optional[str] = None + angle_name: Optional[str] = None + concept_key: Optional[str] = None + concept_name: Optional[str] = None + generation_method: Optional[str] = None + created_at: Optional[str] = None + + +class DbStatsResponse(BaseModel): + """Database statistics response.""" + connected: bool + total_ads: Optional[int] = None + by_niche: Optional[Dict[str, int]] = None + by_method: Optional[Dict[str, int]] = None + error: Optional[str] = None + + +@app.get("/db/stats", response_model=DbStatsResponse) +async def get_database_stats(username: str = Depends(get_current_user)): + """ + Get statistics about stored ad creatives for the current user. + + Requires authentication. Shows only the current user's ads. + + Shows total ads, breakdown by niche, and breakdown by generation method. + """ + stats = await db_service.get_stats(username=username) + return stats + + +@app.get("/db/ads") +async def list_stored_ads( + niche: Optional[str] = None, + generation_method: Optional[str] = None, + limit: int = 50, + offset: int = 0, + username: str = Depends(get_current_user), +): + """ + List ad creatives stored in the database for the current user. + + Requires authentication. Users can only see their own ads. + + - Filter by niche (optional) + - Filter by generation_method (optional) + - Paginate with limit and offset + - Returns ads with direct image URLs + """ + ads, total = await db_service.list_ad_creatives( + username=username, + niche=niche, + generation_method=generation_method, + limit=limit, + offset=offset, + ) + + return { + "total": total, + "limit": limit, + "offset": offset, + "ads": [ + { + "id": str(ad.get("id", "")), + "niche": ad.get("niche", ""), + "title": ad.get("title"), + "headline": ad.get("headline", ""), + "primary_text": ad.get("primary_text"), + "description": ad.get("description"), + "body_story": ad.get("body_story"), + "cta": ad.get("cta", ""), + "psychological_angle": ad.get("psychological_angle", ""), + "image_url": ad.get("image_url"), + "image_filename": ad.get("image_filename"), + "image_model": ad.get("image_model"), + "angle_key": ad.get("angle_key"), + "concept_key": ad.get("concept_key"), + "generation_method": ad.get("generation_method", "standard"), + "created_at": ad.get("created_at"), + } + for ad in ads + ], + } + + +@app.get("/db/ad/{ad_id}") +async def get_stored_ad(ad_id: str): + """ + Get a specific ad creative by ID. + + Returns full ad data including image URL that can be used directly in documents. + """ + ad = await db_service.get_ad_creative(ad_id) + + if not ad: + raise HTTPException(status_code=404, detail=f"Ad '{ad_id}' not found") + + return { + "id": str(ad.get("id", "")), + "niche": ad.get("niche", ""), + "title": ad.get("title"), + "headline": ad.get("headline", ""), + "primary_text": ad.get("primary_text"), + "description": ad.get("description"), + "body_story": ad.get("body_story"), + "cta": ad.get("cta", ""), + "psychological_angle": ad.get("psychological_angle", ""), + "why_it_works": ad.get("why_it_works"), + "image_url": ad.get("image_url"), + "image_filename": ad.get("image_filename"), + "image_model": ad.get("image_model"), + "image_seed": ad.get("image_seed"), + "angle_key": ad.get("angle_key"), + "angle_name": ad.get("angle_name"), + "angle_trigger": ad.get("angle_trigger"), + "angle_category": ad.get("angle_category"), + "concept_key": ad.get("concept_key"), + "concept_name": ad.get("concept_name"), + "concept_structure": ad.get("concept_structure"), + "concept_visual": ad.get("concept_visual"), + "concept_category": ad.get("concept_category"), + "generation_method": ad.get("generation_method", "standard"), + "metadata": ad.get("metadata"), + "created_at": ad.get("created_at"), + "updated_at": ad.get("updated_at"), + } + + +@app.delete("/db/ad/{ad_id}") +async def delete_stored_ad(ad_id: str, username: str = Depends(get_current_user)): + """ + Delete an ad creative from the database. + + Requires authentication. Users can only delete their own ads. + """ + success = await db_service.delete_ad_creative(ad_id, username=username) + + if not success: + raise HTTPException(status_code=404, detail=f"Ad '{ad_id}' not found or could not be deleted") + + return {"success": True, "deleted_id": ad_id} + + +@app.get("/admin/storage/stats") +async def get_storage_stats(username: str = Depends(get_current_user)): + """ + Get storage statistics for locally saved images. + + Requires authentication. + """ + stats = cleanup_service.get_storage_stats() + return { + "stats": stats, + "retention_hours": settings.local_image_retention_hours, + "environment": settings.environment, + "save_images_locally": settings.save_images_locally, + } + + +@app.post("/admin/storage/cleanup") +async def trigger_cleanup( + dry_run: bool = False, + username: str = Depends(get_current_user) +): + """ + Manually trigger image cleanup. + + Args: + dry_run: If True, only report what would be deleted without actually deleting + + Requires authentication. + """ + result = cleanup_service.cleanup_old_images(dry_run=dry_run) + return { + "success": True, + "dry_run": dry_run, + "result": result, + } + + +# Run with: uvicorn main:app --reload +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..11fffcd7f08664b0de5f02a2904449a19191d119 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +openai>=1.12.0 +httpx>=0.26.0 +python-dotenv>=1.0.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +aiofiles>=23.0.0 +Pillow>=10.0.0 +replicate>=0.25.0 +python-docx>=1.1.0 +motor +boto3>=1.34.0 +bcrypt>=4.0.0 +python-jose[cryptography]>=3.3.0 \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e66bce08ef320803b149c6e8d3dc825e96062145 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,2 @@ +# Services module + diff --git a/services/auth.py b/services/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..7d3f4468c879124be8cc6f9f80b635e21e610ece --- /dev/null +++ b/services/auth.py @@ -0,0 +1,69 @@ +""" +Authentication Service +Handles password hashing, JWT token generation, and verification +""" + +import bcrypt +from datetime import datetime, timedelta +from jose import JWTError, jwt +from typing import Optional +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config import settings + + +class AuthService: + """Service for authentication operations.""" + + @staticmethod + def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed.decode('utf-8') + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash.""" + return bcrypt.checkpw( + plain_password.encode('utf-8'), + hashed_password.encode('utf-8') + ) + + @staticmethod + def create_access_token(username: str) -> str: + """Create a JWT access token.""" + expire = datetime.utcnow() + timedelta(hours=settings.jwt_expiration_hours) + to_encode = { + "sub": username, + "exp": expire, + } + encoded_jwt = jwt.encode( + to_encode, + settings.jwt_secret_key, + algorithm=settings.jwt_algorithm + ) + return encoded_jwt + + @staticmethod + def verify_token(token: str) -> Optional[str]: + """Verify a JWT token and return the username.""" + try: + payload = jwt.decode( + token, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm] + ) + username: str = payload.get("sub") + if username is None: + return None + return username + except JWTError: + return None + + +# Global auth service instance +auth_service = AuthService() diff --git a/services/auth_dependency.py b/services/auth_dependency.py new file mode 100644 index 0000000000000000000000000000000000000000..d557500739d674fec2d57b008b620e0691ea4020 --- /dev/null +++ b/services/auth_dependency.py @@ -0,0 +1,52 @@ +""" +Authentication Dependency for FastAPI +Provides dependency to get current authenticated user from JWT token +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from services.auth import auth_service + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> str: + """ + Dependency to get the current authenticated user from JWT token. + + Raises HTTPException if token is invalid or missing. + """ + token = credentials.credentials + username = auth_service.verify_token(token) + + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return username + + +async def get_current_user_optional( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)) +) -> Optional[str]: + """ + Optional dependency to get current user if token is provided. + Returns None if no token or invalid token. + """ + if credentials is None: + return None + + token = credentials.credentials + username = auth_service.verify_token(token) + return username diff --git a/services/correction.py b/services/correction.py new file mode 100644 index 0000000000000000000000000000000000000000..0f3ed818d8615e315a2ca2eb45103274b312d43d --- /dev/null +++ b/services/correction.py @@ -0,0 +1,468 @@ +""" +Image Correction Service +Analyzes images for spelling mistakes and visual issues, then regenerates corrected versions. +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import json +import uuid +from datetime import datetime +from typing import Dict, Any, Optional, Tuple + +from config import settings +from services.llm import llm_service +from services.image import image_service + +# Optional database import +try: + from services.database import db_service +except ImportError: + db_service = None + +# Optional R2 storage import +try: + from services.r2_storage import get_r2_storage + r2_storage_available = True +except ImportError: + r2_storage_available = False + + +class CorrectionService: + """Service for analyzing and correcting generated ad images.""" + + def __init__(self): + """Initialize the correction service.""" + self.output_dir = settings.output_dir + os.makedirs(self.output_dir, exist_ok=True) + + def _should_save_locally(self) -> bool: + """ + Determine if images should be saved locally based on environment settings. + + Returns: + True if images should be saved locally, False otherwise + """ + # In production, only save locally if explicitly enabled + if settings.environment.lower() == "production": + return settings.save_images_locally + # In development, always save locally + return True + + def _save_image_locally(self, image_bytes: bytes, filename: str) -> Optional[str]: + """ + Conditionally save image locally based on environment settings. + + Args: + image_bytes: Image data to save + filename: Filename for the image + + Returns: + Filepath if saved, None otherwise + """ + if not self._should_save_locally(): + return None + + try: + filepath = os.path.join(self.output_dir, filename) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "wb") as f: + f.write(image_bytes) + return filepath + except Exception as e: + print(f"Warning: Failed to save image locally: {e}") + return None + + async def analyze_image( + self, + image_bytes: bytes, + original_prompt: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Analyze an image for spelling mistakes and visual issues using vision API. + + Args: + image_bytes: Image file bytes to analyze + original_prompt: Optional original generation prompt for context + + Returns: + Analysis results dictionary + """ + system_prompt = """You are an expert image quality analyst specializing in ad creatives. +Your task is to carefully analyze images for: +1. Spelling mistakes in any visible text +2. Visual composition issues (layout, positioning, balance) +3. Color and contrast problems +4. Lighting issues +5. Overall quality and professionalism + +Be thorough and specific in your analysis.""" + + analysis_prompt = """Please analyze this ad creative image in detail. + +Focus on: +1. TEXT & SPELLING: Extract all visible text and check for spelling mistakes, typos, or grammatical errors. List each error with the detected text and what it should be. + +2. VISUAL ISSUES: Analyze the visual composition, including: + - Layout and positioning of elements + - Color choices and contrast + - Lighting and shadows + - Overall balance and composition + - Any visual elements that look unprofessional or could be improved + +3. QUALITY ASSESSMENT: Provide an overall quality score (1-10) and identify the most critical issues that need correction. + +Be specific and actionable in your feedback.""" + + if original_prompt: + analysis_prompt += f"\n\nOriginal generation prompt: {original_prompt}" + + try: + analysis_text = await llm_service.analyze_image_with_vision( + image_bytes=image_bytes, + analysis_prompt=analysis_prompt, + system_prompt=system_prompt, + ) + + return { + "analysis": analysis_text, + "status": "success", + } + except Exception as e: + return { + "analysis": None, + "status": "error", + "error": str(e), + } + + async def generate_correction_json( + self, + analysis: str, + original_prompt: Optional[str] = None, + user_instructions: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Generate structured JSON correction prompt from analysis. + + Args: + analysis: Vision API analysis text + original_prompt: Optional original generation prompt + + Returns: + Correction JSON dictionary + """ + system_prompt = """You are an expert prompt engineer specializing in image generation. +Your task is to create a corrected image generation prompt based on analysis feedback. +Generate a structured JSON response with spelling corrections and visual improvements.""" + + if user_instructions: + # User-specified corrections - focus only on what user wants + correction_prompt = f"""The user wants to make a SPECIFIC correction to an existing image using image-to-image generation. + +User's correction request: {user_instructions} + +Original image prompt (for reference only): {original_prompt or "Not provided"} + +Create a JSON response with this exact structure: +{{ + "spelling_corrections": [ + {{ + "detected": "incorrect text found in image", + "corrected": "corrected text", + "context": "where it appears in the image" + }} + ], + "visual_corrections": [ + {{ + "issue": "description of visual issue", + "suggestion": "specific improvement suggestion", + "priority": "high|medium|low" + }} + ], + "corrected_prompt": "MINIMAL prompt that ONLY specifies the exact change requested" +}} + +CRITICAL INSTRUCTIONS FOR corrected_prompt: +- The corrected_prompt must be MINIMAL and FOCUSED - only mention the specific change +- DO NOT describe the entire image or recreate it +- DO NOT change anything except what the user specified +- For text changes: Use format like "Change text 'OLD' to 'NEW'" or "Replace 'X' with 'Y'" +- For visual changes: Use format like "Make colors brighter" or "Adjust lighting to be softer" +- Keep it under 20 words if possible +- The image-to-image model will preserve everything else automatically + +Examples: +- User: "Change 'Save 50%' to 'Save 60%'" + → corrected_prompt: "Change text 'Save 50%' to 'Save 60%'" + +- User: "Fix spelling: 'insurrance' should be 'insurance'" + → corrected_prompt: "Change text 'insurrance' to 'insurance'" + +- User: "Make the background brighter" + → corrected_prompt: "Make background brighter" + +- User: "Change headline to 'Get Started Today'" + → corrected_prompt: "Change headline text to 'Get Started Today'" + +Respond with valid JSON only, no markdown formatting.""" + else: + # Auto-analysis - full correction + correction_prompt = f"""Based on this image analysis, generate a structured correction plan: + +{analysis} + +Create a JSON response with this exact structure: +{{ + "spelling_corrections": [ + {{ + "detected": "incorrect text found in image", + "corrected": "corrected text", + "context": "where it appears in the image" + }} + ], + "visual_corrections": [ + {{ + "issue": "description of visual issue", + "suggestion": "specific improvement suggestion", + "priority": "high|medium|low" + }} + ], + "corrected_prompt": "Complete corrected image generation prompt that addresses all issues" +}} + +Important guidelines: +- The corrected_prompt should be a complete, ready-to-use image generation prompt +- Include all necessary visual details to fix the issues +- Maintain the original creative intent while fixing problems +- Be specific about text corrections, colors, composition, lighting, etc. +- If no original_prompt is provided, infer the creative intent from the analysis + +Respond with valid JSON only, no markdown formatting.""" + + if original_prompt and not user_instructions: + correction_prompt += f"\n\nOriginal prompt for reference:\n{original_prompt}" + + try: + correction_json = await llm_service.generate_json( + prompt=correction_prompt, + system_prompt=system_prompt, + temperature=0.3, # Lower temperature for more consistent corrections + ) + + # Validate structure + if not isinstance(correction_json, dict): + raise ValueError("Correction response is not a dictionary") + + # Ensure all required fields exist + if "corrected_prompt" not in correction_json: + raise ValueError("Missing 'corrected_prompt' in correction JSON") + + if "spelling_corrections" not in correction_json: + correction_json["spelling_corrections"] = [] + + if "visual_corrections" not in correction_json: + correction_json["visual_corrections"] = [] + + return { + "corrections": correction_json, + "status": "success", + } + except Exception as e: + return { + "corrections": None, + "status": "error", + "error": str(e), + } + + async def regenerate_image( + self, + corrected_prompt: str, + original_image_url: str, + width: int = 1024, + height: int = 1024, + user_instructions: Optional[str] = None, + ) -> Tuple[Optional[bytes], Optional[str], Optional[str]]: + """ + Regenerate image using nano-banana-pro model with corrected prompt and original image URL. + Uses minimal changes when user provides specific instructions. + + Args: + corrected_prompt: Corrected image generation prompt + original_image_url: Original image URL for image-to-image generation + width: Image width + height: Image height + user_instructions: Optional user instructions to determine strength + + Returns: + Tuple of (image_bytes, model_used, image_url) + """ + try: + # If user provided specific instructions, use a more focused prompt + # and let the model preserve more of the original + if user_instructions: + # For user-specified corrections, make the prompt more minimal + # The prompt should focus only on the change requested + focused_prompt = corrected_prompt + else: + # For auto-analysis, use the full corrected prompt + focused_prompt = corrected_prompt + + image_bytes, model_used, image_url = await image_service.generate( + prompt=focused_prompt, + model_key="nano-banana-pro", # Always use nano-banana-pro for corrections + width=width, + height=height, + image_url=original_image_url, # Pass original image URL for image-to-image + ) + + return image_bytes, model_used, image_url + except Exception as e: + print(f"Failed to regenerate image: {e}") + return None, None, None + + async def correct_image( + self, + image_bytes: bytes, + image_url: str, + original_prompt: Optional[str] = None, + width: int = 1024, + height: int = 1024, + niche: Optional[str] = None, + user_instructions: Optional[str] = None, + auto_analyze: bool = False, + ) -> Dict[str, Any]: + """ + Complete correction workflow: analyze, generate corrections, and regenerate. + + Args: + image_bytes: Original image bytes to correct (for analysis) + image_url: Original image URL (for image-to-image generation) + original_prompt: Optional original generation prompt + width: Image width for regeneration + height: Image height for regeneration + niche: Optional niche name for filename generation + + Returns: + Complete correction result dictionary + """ + result = { + "status": "pending", + "analysis": None, + "corrections": None, + "corrected_image": None, + "error": None, + } + + # Step 1: Analyze image (only if auto_analyze is True or no user instructions) + analysis_text = None + if user_instructions: + # Use user instructions directly + print(f"Using user-specified corrections: {user_instructions}") + analysis_text = f"User requested corrections: {user_instructions}" + elif auto_analyze: + print("Analyzing image for issues...") + analysis_result = await self.analyze_image( + image_bytes=image_bytes, + original_prompt=original_prompt, + ) + + if analysis_result["status"] != "success": + result["status"] = "error" + result["error"] = analysis_result.get("error", "Image analysis failed") + return result + + analysis_text = analysis_result["analysis"] + result["analysis"] = analysis_text + else: + # No analysis or user instructions - error + result["status"] = "error" + result["error"] = "Either user_instructions or auto_analyze must be provided" + return result + + # Step 2: Generate correction JSON + print("Generating correction plan...") + correction_result = await self.generate_correction_json( + analysis=analysis_text, + original_prompt=original_prompt, + user_instructions=user_instructions, + ) + + if correction_result["status"] != "success": + result["status"] = "error" + result["error"] = correction_result.get("error", "Correction generation failed") + return result + + result["corrections"] = correction_result["corrections"] + corrected_prompt = correction_result["corrections"]["corrected_prompt"] + + # Step 3: Regenerate image with original image URL for image-to-image + print("Regenerating image with corrections...") + image_bytes_new, model_used, image_url_new = await self.regenerate_image( + corrected_prompt=corrected_prompt, + original_image_url=image_url, # Pass original image URL for correction + width=width, + height=height, + user_instructions=user_instructions, + ) + + if not image_bytes_new: + result["status"] = "error" + result["error"] = "Image regeneration failed" + return result + + # Step 4: Save corrected image + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = uuid.uuid4().hex[:8] + niche_prefix = niche or "corrected" + filename = f"{niche_prefix}_corrected_{timestamp}_{unique_id}.png" + filepath = os.path.join("assets", "generated", filename) + + # Upload to R2 if available + r2_url = None + if r2_storage_available: + try: + r2_storage = get_r2_storage() + if r2_storage: + r2_url = r2_storage.upload_image( + image_bytes=image_bytes_new, + filename=filename, + niche=niche, + ) + print(f"Corrected image uploaded to R2: {r2_url}") + except Exception as e: + print(f"Warning: Failed to upload to R2: {e}. Saving locally as backup.") + + # Save locally conditionally (based on environment settings) + filepath = self._save_image_locally(image_bytes_new, filename) + + # Use R2 URL if available, otherwise use Replicate URL + final_image_url = r2_url or image_url_new + + result["corrected_image"] = { + "filename": filename, + "filepath": filepath, + "image_url": final_image_url, + "r2_url": r2_url, + "model_used": model_used, + "corrected_prompt": corrected_prompt, + } + result["status"] = "success" + + # Store metadata for database saving + result["_db_metadata"] = { + "filename": filename, + "image_url": final_image_url, + "r2_url": r2_url, + "model_used": model_used, + "corrected_prompt": corrected_prompt, + } + + return result + + +# Global instance +correction_service = CorrectionService() diff --git a/services/database.py b/services/database.py new file mode 100644 index 0000000000000000000000000000000000000000..0fadad7a16da0a40852106647088e1e37ba7e077 --- /dev/null +++ b/services/database.py @@ -0,0 +1,388 @@ +""" +Database Service for Ad Generator Lite +Handles MongoDB connection and CRUD operations +""" + +from motor.motor_asyncio import AsyncIOMotorClient +from typing import Optional, Dict, Any, List +from datetime import datetime +from bson import ObjectId +import json + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config import settings + + +class DatabaseService: + """Async MongoDB database service for storing ad creatives.""" + + def __init__(self): + self.client: Optional[AsyncIOMotorClient] = None + self.db = None + self.collection = None + self.mongodb_url = settings.mongodb_url + self.db_name = settings.mongodb_db_name + + async def connect(self): + """Create connection to MongoDB.""" + if not self.mongodb_url: + print("Warning: MONGODB_URL not configured. Database features disabled.") + return False + + try: + self.client = AsyncIOMotorClient(self.mongodb_url) + # Test connection + await self.client.admin.command('ping') + self.db = self.client[self.db_name] + self.collection = self.db["ad_creatives"] + + # Create indexes for better query performance + await self.collection.create_index("niche") + await self.collection.create_index("created_at") + await self.collection.create_index([("niche", 1), ("created_at", -1)]) + await self.collection.create_index("generation_method") + await self.collection.create_index("username") # User-specific index + await self.collection.create_index([("username", 1), ("created_at", -1)]) # User + date index + + print(f"✓ Connected to MongoDB database: {self.db_name}") + return True + except Exception as e: + print(f"✗ Database connection failed: {e}") + return False + + async def disconnect(self): + """Close database connection.""" + if self.client: + self.client.close() + print("Database connection closed") + + async def save_ad_creative( + self, + niche: str, + title: str, + headline: str, + primary_text: str, + description: str, + body_story: str, + cta: str, + psychological_angle: str, + why_it_works: str, + username: str, # Required: username of the user creating the ad + image_url: Optional[str] = None, + image_filename: Optional[str] = None, + image_model: Optional[str] = None, + image_seed: Optional[int] = None, + image_prompt: Optional[str] = None, + angle_key: Optional[str] = None, + angle_name: Optional[str] = None, + angle_trigger: Optional[str] = None, + angle_category: Optional[str] = None, + concept_key: Optional[str] = None, + concept_name: Optional[str] = None, + concept_structure: Optional[str] = None, + concept_visual: Optional[str] = None, + concept_category: Optional[str] = None, + generation_method: str = "standard", + metadata: Optional[Dict[str, Any]] = None, + ) -> Optional[str]: + """ + Save an ad creative to the database. + + Returns: + The ID of the saved record, or None if save failed. + """ + if self.collection is None: + print("Database not connected - skipping save") + return None + + try: + # Prepare document + doc = { + "niche": niche, + "title": title, + "headline": headline, + "primary_text": primary_text, + "description": description, + "body_story": body_story, + "cta": cta, + "psychological_angle": psychological_angle, + "why_it_works": why_it_works, + "username": username, # Store username for filtering + "image_url": image_url, + "image_filename": image_filename, + "image_model": image_model, + "image_seed": image_seed, + "image_prompt": image_prompt, + "angle_key": angle_key, + "angle_name": angle_name, + "angle_trigger": angle_trigger, + "angle_category": angle_category, + "concept_key": concept_key, + "concept_name": concept_name, + "concept_structure": concept_structure, + "concept_visual": concept_visual, + "concept_category": concept_category, + "generation_method": generation_method, + "metadata": metadata, + "created_at": datetime.utcnow(), + } + + # Remove None values to keep documents clean + doc = {k: v for k, v in doc.items() if v is not None} + + result = await self.collection.insert_one(doc) + return str(result.inserted_id) + except Exception as e: + print(f"Failed to save ad creative: {e}") + return None + + async def get_ad_creative(self, ad_id: str, username: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Get a single ad creative by ID. + If username is provided, only returns the ad if it belongs to that user. + """ + if self.collection is None: + return None + + try: + # Try to convert to ObjectId if it's a valid ObjectId string + try: + object_id = ObjectId(ad_id) + except: + # If not a valid ObjectId, try as string + object_id = ad_id + + query = {"_id": object_id} + if username: + query["username"] = username + + doc = await self.collection.find_one(query) + if doc: + # Convert ObjectId to string for JSON serialization + doc["id"] = str(doc["_id"]) + del doc["_id"] + # Convert datetime to ISO format string + if "created_at" in doc and isinstance(doc["created_at"], datetime): + doc["created_at"] = doc["created_at"].isoformat() + # Handle updated_at if it exists + if "updated_at" in doc and isinstance(doc["updated_at"], datetime): + doc["updated_at"] = doc["updated_at"].isoformat() + return doc + return None + except Exception as e: + print(f"Failed to get ad creative: {e}") + return None + + async def list_ad_creatives( + self, + username: str, # Required: filter by username + niche: Optional[str] = None, + generation_method: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[List[Dict[str, Any]], int]: + """ + List ad creatives for a specific user with optional filtering. + Returns (results, total_count). + """ + if self.collection is None: + return [], 0 + + try: + query = {"username": username} # Always filter by username + if niche: + query["niche"] = niche + if generation_method: + query["generation_method"] = generation_method + + # Get total count before pagination + total_count = await self.collection.count_documents(query) + + cursor = self.collection.find(query).sort("created_at", -1).skip(offset).limit(limit) + docs = await cursor.to_list(length=limit) + + # Convert documents to dict format + results = [] + for doc in docs: + doc["id"] = str(doc["_id"]) + del doc["_id"] + # Convert datetime to ISO format string + if "created_at" in doc and isinstance(doc["created_at"], datetime): + doc["created_at"] = doc["created_at"].isoformat() + # Handle updated_at if it exists + if "updated_at" in doc and isinstance(doc["updated_at"], datetime): + doc["updated_at"] = doc["updated_at"].isoformat() + results.append(doc) + + return results, total_count + except Exception as e: + print(f"Failed to list ad creatives: {e}") + return [], 0 + + async def delete_ad_creative(self, ad_id: str, username: Optional[str] = None) -> bool: + """ + Delete an ad creative by ID. + If username is provided, only deletes if the ad belongs to that user. + """ + if self.collection is None: + return False + + try: + # Try to convert to ObjectId if it's a valid ObjectId string + try: + object_id = ObjectId(ad_id) + except: + # If not a valid ObjectId, try as string + object_id = ad_id + + query = {"_id": object_id} + if username: + query["username"] = username + + result = await self.collection.delete_one(query) + return result.deleted_count > 0 + except Exception as e: + print(f"Failed to delete ad creative: {e}") + return False + + async def get_stats(self, username: Optional[str] = None) -> Dict[str, Any]: + """ + Get statistics about stored ad creatives. + If username is provided, only returns stats for that user's ads. + """ + if self.collection is None: + return {"connected": False} + + try: + # Build base query + base_query = {} + if username: + base_query["username"] = username + + total = await self.collection.count_documents(base_query) + + # Get counts by niche + pipeline_niche = [ + {"$match": base_query}, + {"$group": {"_id": "$niche", "count": {"$sum": 1}}} + ] + by_niche_cursor = self.collection.aggregate(pipeline_niche) + by_niche_list = await by_niche_cursor.to_list(length=100) + by_niche = {item["_id"]: item["count"] for item in by_niche_list if item["_id"]} + + # Get counts by generation method + pipeline_method = [ + {"$match": base_query}, + {"$group": {"_id": "$generation_method", "count": {"$sum": 1}}} + ] + by_method_cursor = self.collection.aggregate(pipeline_method) + by_method_list = await by_method_cursor.to_list(length=100) + by_method = {item["_id"]: item["count"] for item in by_method_list if item["_id"]} + + return { + "connected": True, + "total_ads": total, + "by_niche": by_niche, + "by_method": by_method, + } + except Exception as e: + print(f"Failed to get stats: {e}") + return {"connected": True, "error": str(e)} + + # ============================================================================= + # USER MANAGEMENT METHODS + # ============================================================================= + + async def create_user(self, username: str, hashed_password: str) -> Optional[str]: + """Create a new user in the database.""" + if self.db is None: + print("Database not connected - cannot create user") + return None + + try: + users_collection = self.db["users"] + + # Check if user already exists + existing = await users_collection.find_one({"username": username}) + if existing: + print(f"User '{username}' already exists") + return None + + # Create user document + user_doc = { + "username": username, + "hashed_password": hashed_password, + "created_at": datetime.utcnow(), + } + + result = await users_collection.insert_one(user_doc) + print(f"✓ User '{username}' created successfully") + return str(result.inserted_id) + except Exception as e: + print(f"Failed to create user: {e}") + return None + + async def get_user(self, username: str) -> Optional[Dict[str, Any]]: + """Get a user by username.""" + if self.db is None: + return None + + try: + users_collection = self.db["users"] + user = await users_collection.find_one({"username": username}) + if user: + # Convert ObjectId to string + user["id"] = str(user["_id"]) + del user["_id"] + # Convert datetime to ISO format + if "created_at" in user and isinstance(user["created_at"], datetime): + user["created_at"] = user["created_at"].isoformat() + return user + return None + except Exception as e: + print(f"Failed to get user: {e}") + return None + + async def list_users(self) -> List[Dict[str, Any]]: + """List all users (without passwords).""" + if self.db is None: + return [] + + try: + users_collection = self.db["users"] + cursor = users_collection.find({}, {"hashed_password": 0}).sort("created_at", -1) + users = await cursor.to_list(length=1000) + + # Convert documents + results = [] + for user in users: + user["id"] = str(user["_id"]) + del user["_id"] + if "created_at" in user and isinstance(user["created_at"], datetime): + user["created_at"] = user["created_at"].isoformat() + results.append(user) + + return results + except Exception as e: + print(f"Failed to list users: {e}") + return [] + + async def delete_user(self, username: str) -> bool: + """Delete a user by username.""" + if self.db is None: + return False + + try: + users_collection = self.db["users"] + result = await users_collection.delete_one({"username": username}) + return result.deleted_count > 0 + except Exception as e: + print(f"Failed to delete user: {e}") + return False + + +# Global database service instance +db_service = DatabaseService() diff --git a/services/db_optimize.py b/services/db_optimize.py new file mode 100644 index 0000000000000000000000000000000000000000..cbf75d65ee0c9d740215918623b8b9a75fc87887 --- /dev/null +++ b/services/db_optimize.py @@ -0,0 +1,72 @@ +""" +Database optimization script - creates indexes for better query performance. +MongoDB indexes are automatically created on connection, but this script +can be used to verify or recreate them. +""" + +import asyncio +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from services.database import db_service + + +async def create_indexes(): + """Create database indexes for better query performance.""" + + print("Creating MongoDB indexes for optimization...") + print("=" * 60) + + await db_service.connect() + + if not db_service.collection: + print("✗ Database not connected") + return + + indexes = [ + # Index on niche for filtering + ("niche", "niche"), + + # Index on created_at for sorting (most common query) + ("created_at", "created_at"), + + # Composite index for niche + created_at (common query pattern) + ([("niche", 1), ("created_at", -1)], "niche + created_at"), + + # Index on generation_method for filtering + ("generation_method", "generation_method"), + ] + + success_count = 0 + fail_count = 0 + + for index_spec, index_name in indexes: + try: + if isinstance(index_spec, list): + # Composite index + await db_service.collection.create_index(index_spec, name=f"idx_{index_name.replace(' ', '_')}") + else: + # Single field index + await db_service.collection.create_index(index_spec, name=f"idx_{index_spec}") + print(f"✓ Created index: {index_name}") + success_count += 1 + except Exception as e: + # Index might already exist + if "already exists" in str(e).lower() or "duplicate" in str(e).lower(): + print(f"✓ Index already exists: {index_name}") + success_count += 1 + else: + print(f"✗ Failed to create index {index_name}: {e}") + fail_count += 1 + + print() + print("=" * 60) + print(f"Summary: {success_count} indexes created/verified, {fail_count} failed") + + await db_service.disconnect() + + +if __name__ == "__main__": + asyncio.run(create_indexes()) diff --git a/services/generator.py b/services/generator.py new file mode 100644 index 0000000000000000000000000000000000000000..c048eb008a08eb2a0f61e6f5c41b5626e2ae2e27 --- /dev/null +++ b/services/generator.py @@ -0,0 +1,2310 @@ +""" +Main Ad Generator Service +Combines LLM + Image generation with maximum randomization for variety +Uses professional prompting techniques from creative-breakthrough project +Saves ad creatives to Neon database with image URLs +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import random +import uuid +import json +from datetime import datetime +from typing import Dict, Any, List, Optional + +from config import settings +from services.llm import llm_service +from services.image import image_service + +# Optional database import (for when asyncpg is available) +try: + from services.database import db_service +except ImportError: + # Database service not available (asyncpg not installed) + db_service = None + print("Note: Database service not available (asyncpg not installed). Ads will be generated but not saved to database.") + +# Optional R2 storage import +try: + from services.r2_storage import get_r2_storage + r2_storage_available = True +except ImportError: + r2_storage_available = False + print("Note: R2 storage not available. Images will only be saved locally.") + +# Optional extensive import +try: + from services.third_flow import third_flow_service + third_flow_available = True +except ImportError: + third_flow_available = False + print("Note: Extensive service not available.") + +from data import home_insurance, glp1 +from services.matrix import matrix_service +from data.frameworks import get_frameworks_for_niche, get_framework_hook_examples, get_all_frameworks +from data.containers import ( + get_random_container, get_container_visual_guidance, get_native_containers, + get_ugc_containers, get_alert_containers, get_all_containers +) +from data.hooks import get_random_hook_style, get_power_words, get_random_cta as get_hook_cta +from data.triggers import get_random_trigger, get_trigger_combination, get_triggers_for_niche +from data.visuals import ( + get_random_visual_style, get_random_camera_angle, get_random_lighting, + get_random_composition, get_random_mood, get_color_palette, get_niche_visual_guidance +) + + +# Niche data loaders +NICHE_DATA = { + "home_insurance": home_insurance.get_niche_data, + "glp1": glp1.get_niche_data, +} + +# Note: Frameworks are now loaded from data/frameworks.py +# This provides comprehensive framework data with hooks, visual styles, and niche-specific recommendations + +# ============================================================================= +# WINNING AD FORMATS (from high-converting creative analysis) +# ============================================================================= + +# Format types that bypass ad-blindness +WINNING_AD_FORMATS = [ + { + "name": "accusation_opener", + "description": "Direct accusation that triggers loss aversion immediately", + "headline_pattern": "[ACCUSATION]?", + "examples": ["OVERPAYING?", "Still Underinsured?", "Wasting Money?"], + "visual_style": "person with money/bills, documentary candid shot", + }, + { + "name": "curiosity_gap", + "description": "Open loop that demands click to close", + "headline_pattern": "[Group] are [action] and doing THIS instead", + "examples": [ + "Seniors Are Ditching Their Home Insurance & Doing This Instead", + "Thousands of homeowners are dropping their home insurance after THIS", + ], + "visual_style": "candid documentary photo, real person, everyday setting", + }, + { + "name": "specific_price_anchor", + "description": "Oddly specific price creates believability", + "headline_pattern": "[Product] for as low as $XX.XX/month", + "examples": ["Home Insurance for as low as $97.33/month", "$43/month"], + "visual_style": "clean typography focus, price dominant, age selector buttons", + }, + { + "name": "before_after_proof", + "description": "Social proof with specific numbers", + "headline_pattern": "WAS: $X,XXX → NOW: $XXX", + "examples": ["WAS: $1,701 → NOW: $583"], + "visual_style": "real person holding document with circled numbers", + }, + { + "name": "quiz_interactive", + "description": "Interactive quiz format that drives engagement", + "headline_pattern": "[Personal Question]?", + "examples": ["What Year Was Your House Built?", "Tap your age to calculate"], + "visual_style": "notes app screenshot, dark mode UI, checkbox options", + }, + { + "name": "authority_transfer", + "description": "Transfer trust from government/institution", + "headline_pattern": "[Authority] + [Benefit] for [Group]", + "examples": ["State Farm Brings Welfare!", "Sponsored by the US government"], + "visual_style": "government seal, official document aesthetic, institutional", + }, + { + "name": "identity_targeting", + "description": "Direct demographic callout for self-selection", + "headline_pattern": "[Demographic] Won't Have To Pay More Than $XX", + "examples": ["Seniors Won't Have To Pay More Than $49 A Month"], + "visual_style": "real seniors, portrait photography, relatable faces", + }, + { + "name": "insider_secret", + "description": "Exclusivity and hidden knowledge framing", + "headline_pattern": "The [Adjective] Way To [Benefit]", + "examples": ["The Easiest Way To Cut Home Insurance Bills"], + "visual_style": "person revealing information, document proof, testimonial style", + }, +] + +# Age brackets for identity targeting (proven high-CTR pattern) +AGE_BRACKETS = [ + {"label": "21-40", "color": "yellow/gold button"}, + {"label": "41-64", "color": "blue button"}, + {"label": "65+", "color": "red button"}, + {"label": "50-60 years", "color": "gray box"}, + {"label": "60-70 years", "color": "gray box"}, + {"label": "70+ years old", "color": "gray box"}, +] + +# Note: Prices are no longer hardcoded. The AI will decide contextually +# whether to include prices and what specific amounts to use based on the ad format and strategy. + +# Note: Containers are now loaded from data/containers.py +# This provides comprehensive container data with visual guidance, colors, fonts, and authenticity tips +# Native containers (iMessage, WhatsApp, SMS, etc.) are preferred for ad-blindness bypass + +# Note: CAMERA_ANGLES, LIGHTING_STYLES, and COMPOSITIONS are now imported from data/visuals.py +# This eliminates duplication and ensures consistency across the codebase + +# Old footage / vintage film styles +VINTAGE_FILM_STYLES = [ + "grainy 8mm home movie footage from the 1970s, warm faded colors, light leaks", + "old VHS tape recording with tracking lines, fuzzy edges, dated look", + "vintage 35mm film photograph, visible grain, slightly overexposed highlights", + "retro Super 8 film aesthetic, soft focus, amber tint, nostalgic", + "aged polaroid photograph style, faded colors, white border, worn edges", + "1960s Kodachrome film look, saturated but faded reds and yellows", + "old TV broadcast footage, scan lines, slight color bleeding", + "vintage sepia-toned photograph, crackled texture, antique feel", + "worn 16mm documentary footage, high grain, muted earth tones", + "degraded archival footage look, dust particles, scratches, light decay", +] + +# Film damage effects for authenticity +FILM_DAMAGE_EFFECTS = [ + "film scratches, dust specks, light leaks in corners", + "vignette darkening at edges, slight color shift", + "horizontal scan lines, minor static noise", + "subtle frame jitter effect, worn sprocket marks", + "chemical staining, uneven development marks", + "faded edges with light burn, aged patina", + "dust particles floating, hair gate scratches", + "color bleeding at high contrast edges, emulsion damage", +] + + +class AdGenerator: + """ + Generates high-converting ad creatives using psychological triggers. + Uses maximum randomization to ensure different results every time. + Implements professional prompting techniques. + """ + + def __init__(self): + """Initialize the generator.""" + self.output_dir = settings.output_dir + os.makedirs(self.output_dir, exist_ok=True) + + def _should_save_locally(self) -> bool: + """ + Determine if images should be saved locally based on environment settings. + + Returns: + True if images should be saved locally, False otherwise + """ + # In production, only save locally if explicitly enabled + if settings.environment.lower() == "production": + return settings.save_images_locally + # In development, always save locally + return True + + def _save_image_locally(self, image_bytes: bytes, filename: str) -> Optional[str]: + """ + Conditionally save image locally based on environment settings. + + Args: + image_bytes: Image data to save + filename: Filename for the image + + Returns: + Filepath if saved, None otherwise + """ + if not self._should_save_locally(): + return None + + try: + filepath = os.path.join(self.output_dir, filename) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "wb") as f: + f.write(image_bytes) + return filepath + except Exception as e: + print(f"Warning: Failed to save image locally: {e}") + return None + + def _get_niche_data(self, niche: str) -> Dict[str, Any]: + """Load data for a specific niche.""" + if niche not in NICHE_DATA: + raise ValueError(f"Unsupported niche: {niche}. Supported: {list(NICHE_DATA.keys())}") + return NICHE_DATA[niche]() + + def _get_framework_strategy_compatibility(self, framework_key: str, strategy_name: str) -> float: + """ + Calculate compatibility score between framework and strategy. + Returns score 0.0-1.0, higher = better match. + """ + # Framework-Strategy compatibility matrix + compatibility_map = { + "breaking_news": { + "accusation_opener": 0.9, + "curiosity_gap": 0.95, + "price_focused": 0.8, + "proof_based": 0.7, + "authority_backed": 0.85, + "urgent": 0.95, + }, + "testimonial": { + "proof_based": 0.95, + "authority_backed": 0.8, + "social_proof": 0.9, + }, + "before_after": { + "proof_based": 0.95, + "transformation": 0.9, + "price_focused": 0.8, + }, + "problem_solution": { + "accusation_opener": 0.85, + "problem_awareness": 0.95, + "solution_focused": 0.9, + }, + "authority": { + "authority_backed": 0.95, + "expert_recommended": 0.9, + "proof_based": 0.8, + }, + "lifestyle": { + "aspirational": 0.9, + "identity_targeted": 0.85, + "emotional": 0.8, + }, + "comparison": { + "price_focused": 0.9, + "proof_based": 0.8, + "comparison_logic": 0.95, + }, + "storytelling": { + "emotional": 0.9, + "relatable": 0.85, + "transformation": 0.8, + }, + "mobile_post": { + "convenience": 0.9, + "quick_action": 0.85, + "simple": 0.9, + }, + "educational": { + "authority_backed": 0.8, + "curiosity_gap": 0.85, + "informative": 0.9, + }, + } + + # Normalize strategy name (remove spaces, lowercase) + strategy_key = strategy_name.lower().replace(" ", "_").replace("-", "_") + + # Check direct match + if framework_key in compatibility_map: + if strategy_key in compatibility_map[framework_key]: + return compatibility_map[framework_key][strategy_key] + + # Default compatibility (still usable, just not optimal) + return 0.6 + + def _select_compatible_strategies(self, niche_data: Dict, framework_key: str, count: int = 2) -> List[Dict]: + """ + Select strategies that are compatible with the chosen framework. + Prioritizes high-compatibility matches but ensures variety. + """ + all_strategies = list(niche_data.get("strategies", {}).values()) + + if not all_strategies: + return [] + + # Score all strategies + scored_strategies = [ + (strategy, self._get_framework_strategy_compatibility(framework_key, strategy["name"])) + for strategy in all_strategies + ] + + # Sort by compatibility (highest first) + scored_strategies.sort(key=lambda x: x[1], reverse=True) + + # Select mix: 70% top compatible, 30% random for variety + top_count = max(1, int(count * 0.7)) + selected = [] + + # Add top compatible + for strategy, score in scored_strategies[:top_count]: + selected.append(strategy) + + # Add random for variety (if we need more) + remaining = count - len(selected) + if remaining > 0: + remaining_strategies = [s for s, _ in scored_strategies[top_count:]] + if remaining_strategies: + selected.extend(random.sample(remaining_strategies, min(remaining, len(remaining_strategies)))) + + return selected[:count] + + def _random_strategies(self, niche_data: Dict, count: int = 2) -> List[Dict]: + """Randomly select strategies for maximum variety.""" + strategy_names = random.sample(niche_data["strategy_names"], min(count, len(niche_data["strategy_names"]))) + return [niche_data["strategies"][name] for name in strategy_names] + + def _random_hooks(self, strategies: List[Dict], count: int = 3) -> List[str]: + """Randomly select hooks from the chosen strategies.""" + all_hooks = [] + for strategy in strategies: + all_hooks.extend(strategy["hooks"]) + return random.sample(all_hooks, min(count, len(all_hooks))) + + def _get_visual_library_for_niche(self, niche: str) -> Dict[str, List[str]]: + """ + Get visual library categories for a niche. + Returns dict mapping category names to visual descriptions. + """ + if niche == "home_insurance": + from data import home_insurance + return { + "protection_safety": home_insurance.PROTECTION_SAFETY_VISUALS, + "disaster_fear": home_insurance.DISASTER_FEAR_VISUALS, + "family_emotional": home_insurance.FAMILY_EMOTIONAL_VISUALS, + "first_time_homebuyer": home_insurance.FIRST_TIME_HOMEBUYER_VISUALS, + "asset_investment": home_insurance.ASSET_INVESTMENT_VISUALS, + "problem_risk": home_insurance.PROBLEM_RISK_VISUALS, + "relief": home_insurance.RELIEF_VISUALS, + "mortgage_bank": home_insurance.MORTGAGE_BANK_VISUALS, + "comparison_choice": home_insurance.COMPARISON_CHOICE_VISUALS, + "minimal_symbolic": home_insurance.MINIMAL_SYMBOLIC_VISUALS, + "lifestyle": home_insurance.LIFESTYLE_VISUALS, + "text_first": home_insurance.TEXT_FIRST_VISUALS, + "seasonal": home_insurance.SEASONAL_VISUALS, + } + elif niche == "glp1": + # Add GLP-1 visual library if available + return {} + return {} + + def _select_visuals_from_library(self, niche: str, strategy_name: str, count: int = 2) -> List[str]: + """ + Select visuals from the comprehensive visual library based on strategy. + This ensures we use the full visual library, not just strategy-specific visuals. + """ + visual_library = self._get_visual_library_for_niche(niche) + + if not visual_library: + # Fallback to strategy visuals + return [] + + # Map strategies to visual categories + strategy_to_category = { + "accusation_opener": ["problem_risk", "disaster_fear"], + "curiosity_gap": ["text_first", "minimal_symbolic"], + "price_focused": ["comparison_choice", "mortgage_bank"], + "proof_based": ["relief", "protection_safety"], + "authority_backed": ["mortgage_bank", "protection_safety"], + "identity_targeted": ["first_time_homebuyer", "lifestyle"], + "family_emotional": ["family_emotional", "protection_safety"], + "fear_based": ["disaster_fear", "problem_risk"], + "relief": ["relief", "protection_safety"], + } + + # Normalize strategy name + strategy_key = strategy_name.lower().replace(" ", "_").replace("-", "_") + + # Get relevant categories + categories = strategy_to_category.get(strategy_key, list(visual_library.keys())) + + # Select visuals from relevant categories + selected_visuals = [] + for category in categories[:2]: # Use top 2 categories + if category in visual_library: + visuals = visual_library[category] + if visuals: + selected_visuals.extend(random.sample(visuals, min(1, len(visuals)))) + + # If we need more, add random from any category + if len(selected_visuals) < count: + all_visuals = [] + for visuals in visual_library.values(): + all_visuals.extend(visuals) + remaining = count - len(selected_visuals) + if all_visuals: + selected_visuals.extend(random.sample(all_visuals, min(remaining, len(all_visuals)))) + + return selected_visuals[:count] + + def _random_visual_styles(self, strategies: List[Dict], count: int = 2, niche: str = "", use_library: bool = True) -> List[str]: + """ + Select visual scene descriptions from strategies and/or visual library. + + Note: These are SCENE DESCRIPTIONS (what to show), not aesthetic styles. + Aesthetic styles come from data/visuals.py VISUAL_STYLES. + + Args: + strategies: List of strategy dicts + count: Number of visuals to select + niche: Niche name for visual library access + use_library: Whether to use comprehensive visual library (improvement) + """ + all_styles = [] + + # Get strategy-specific visuals + for strategy in strategies: + all_styles.extend(strategy.get("visual_styles", [])) + + # Add visual library visuals (improvement) + if use_library and niche: + for strategy in strategies: + library_visuals = self._select_visuals_from_library(niche, strategy.get("name", ""), count=1) + all_styles.extend(library_visuals) + + # Remove duplicates while preserving order + seen = set() + unique_styles = [] + for style in all_styles: + if style not in seen: + seen.add(style) + unique_styles.append(style) + + return random.sample(unique_styles, min(count, len(unique_styles))) if unique_styles else [] + + def _get_niche_specific_guidance(self, niche: str) -> str: + """Get niche-specific guidance for the prompt.""" + if niche == "home_insurance": + return """ +NICHE-SPECIFIC REQUIREMENTS (HOME INSURANCE): +- Focus on REALISTIC scenarios homeowners can identify with +- Show real situations: storm damage, fire, flood, theft, protection +- Use authentic emotions: fear of loss, relief of protection, family safety +- Target pain points: high premiums, disaster fears, coverage gaps, savings desire +- Messaging must feel URGENT but not fear-mongering +- Visual concepts: real homes, real disasters, real families, real protection +- AVOID: fantasy elements, castles, fortresses, unrealistic scenarios +- AVOID: generic stock photo families, overly polished imagery +""" + elif niche == "glp1": + return """ +NICHE-SPECIFIC REQUIREMENTS (GLP-1 / WEIGHT LOSS): +- Focus on TRANSFORMATION and emotional journey +- Use VARIETY in visual concepts: quiz/interactive interfaces, medical/doctor settings, scale/measurement moments, mirror reflections, lifestyle/confidence scenes, testimonial portraits, celebrity references, before/after (only when strategy specifically calls for it), or other diverse visual types +- Use authentic emotions: shame, hope, confidence, transformation, urgency, curiosity +- Target pain points: failed diets, body image, social acceptance, health fears +- Messaging must feel ASPIRATIONAL with urgency +- Visual concepts should vary: quiz screens, medical authority, scale moments, lifestyle changes, confidence moments, testimonial style, or transformation proof (when appropriate) +- AVOID: defaulting to before/after for every image - use diverse visual approaches +- AVOID: unrealistic body standards, extreme before/after manipulation +- AVOID: medical claims without proper framing, shame-based imagery only +- Include elements of: medical authority, social proof, simplicity, variety +""" + return "" + + def _generate_specific_price(self, niche: str) -> str: + """ + Generate price guidance for the AI. + The AI will decide whether to include prices and what amounts to use. + This method now returns guidance text rather than a hardcoded price. + """ + if niche == "home_insurance": + return "Consider using oddly specific prices (e.g., $97.33 instead of $100) if the ad format calls for it. Typical range: $37-$147/month. Only include if it enhances believability and fits the ad format." + elif niche == "glp1": + return "Consider using specific prices if relevant to the ad format. Typical range: $197-$497. Only include if it enhances the message and fits the ad strategy." + return "Use contextually appropriate prices if the ad format requires them. Make them oddly specific (not rounded) for believability." + + def _generate_niche_numbers(self, niche: str) -> Dict[str, str]: + """Generate niche-specific numbers for authenticity.""" + if niche == "home_insurance": + # Insurance savings numbers + before = random.randint(1200, 2400) + savings_pct = random.uniform(0.55, 0.75) + after = int(before * (1 - savings_pct)) + return { + "type": "savings", + "before": f"${before:,}/year", + "after": f"${after}/year", + "difference": f"${before - after:,}", + "metric": "savings per year", + } + elif niche == "glp1": + # Weight loss numbers + before_weight = random.randint(180, 280) + lbs_lost = random.randint(25, 65) + after_weight = before_weight - lbs_lost + days = random.choice([60, 90, 120]) + sizes_dropped = random.randint(2, 5) + return { + "type": "weight_loss", + "before": f"{before_weight} lbs", + "after": f"{after_weight} lbs", + "difference": f"{lbs_lost} lbs", + "days": f"{days} days", + "sizes": f"{sizes_dropped} dress sizes", + "metric": "pounds lost", + } + return {} + + def _select_ad_format(self) -> Dict[str, Any]: + """Randomly select a winning ad format.""" + return random.choice(WINNING_AD_FORMATS) + + def _get_framework_container_compatibility(self, framework_key: str) -> List[str]: + """ + Get container types that are most compatible with a framework. + Returns list of container keys ordered by compatibility. + """ + compatibility_map = { + "breaking_news": ["news_chyron", "system_notification", "push_notification", "browser_alert"], + "testimonial": ["reddit_post", "social_post", "imessage", "whatsapp"], + "before_after": ["social_post", "reddit_post", "sticky_note", "memo"], + "problem_solution": ["imessage", "whatsapp", "sms", "email_notification"], + "authority": ["email_notification", "memo", "browser_alert", "system_notification"], + "lifestyle": ["social_post", "reddit_post", "imessage", "whatsapp"], + "comparison": ["memo", "browser_alert", "email_notification", "standard"], + "storytelling": ["social_post", "reddit_post", "imessage", "whatsapp"], + "mobile_post": ["imessage", "whatsapp", "sms", "push_notification"], + "educational": ["email_notification", "memo", "browser_alert", "standard"], + } + + # Get compatible containers for this framework + compatible = compatibility_map.get(framework_key, []) + + # Add all containers as fallback options + all_containers = list(get_all_containers().keys()) + + # Return compatible first, then others + return compatible + [c for c in all_containers if c not in compatible] + + def _select_container(self, prefer_native: bool = True, strategy: str = "balanced", framework_key: Optional[str] = None) -> Dict[str, Any]: + """ + Select a container type for native-looking ad format. + Now includes framework-aware selection for better compatibility. + + Args: + prefer_native: Prefer native containers (iMessage, WhatsApp, etc.) + strategy: 'native', 'ugc', 'alert', 'balanced', 'all', or 'framework_aware' + framework_key: Framework key for compatibility-based selection + """ + from data.containers import get_container + + all_containers = get_all_containers() + container_keys = list(all_containers.keys()) + + # Framework-aware selection (new improvement) + if strategy == "framework_aware" and framework_key: + compatible_containers = self._get_framework_container_compatibility(framework_key) + # 70% chance to use top compatible, 30% for variety + if random.random() < 0.7 and compatible_containers: + container_keys = compatible_containers[:4] # Top 4 compatible + else: + container_keys = compatible_containers # All compatible options + + elif strategy == "native": + container_keys = get_native_containers() + elif strategy == "ugc": + container_keys = get_ugc_containers() + elif strategy == "alert": + container_keys = get_alert_containers() + elif strategy == "balanced": + # 50% native, 25% UGC, 25% alert/other + rand = random.random() + if rand < 0.5: + container_keys = get_native_containers() + elif rand < 0.75: + container_keys = get_ugc_containers() + else: + container_keys = get_alert_containers() + # else "all" - use all containers + + # Select random container from chosen set + container_key = random.choice(container_keys) if container_keys else random.choice(list(all_containers.keys())) + container = get_container(container_key) + + return container if container else get_random_container() + + def _build_copy_prompt( + self, + niche: str, + niche_data: Dict, + strategies: List[Dict], + hooks: List[str], + creative_direction: str, + framework: str, + framework_data: Dict[str, Any], + framework_hooks: List[str], + trigger_data: Dict[str, Any] = None, + trigger_combination: Dict[str, Any] = None, + power_words: List[str] = None, + ) -> str: + """ + Build professional LLM prompt for ad copy generation. + Uses winning ad patterns from high-converting creatives analysis. + """ + strategy_names = [s["name"] for s in strategies] + strategy_descriptions = [f"- {s['name']}: {s['description']}" for s in strategies] + cta = random.choice(niche_data["ctas"]) + niche_guidance = self._get_niche_specific_guidance(niche) + + # Select winning format and container (native-looking format) + # Use framework-aware container selection (improvement) + container_strategy = "framework_aware" if random.random() < 0.6 else random.choice(["native", "ugc", "alert", "balanced"]) + ad_format = self._select_ad_format() + container = self._select_container(prefer_native=True, strategy=container_strategy, framework_key=framework_data.get("key")) + price_guidance = self._generate_specific_price(niche) + niche_numbers = self._generate_niche_numbers(niche) + age_bracket = random.choice(AGE_BRACKETS) + + # Build numbers section - AI decides whether to include prices/numbers + if niche == "glp1": + numbers_section = f"""=== NUMBERS GUIDANCE (WEIGHT LOSS) === +You may include specific numbers if they enhance the ad's believability and fit the format: +- Starting Weight: {niche_numbers['before']} +- Current Weight: {niche_numbers['after']} +- Total Lost: {niche_numbers['difference']} +- Timeframe: {niche_numbers['days']} +- Sizes Dropped: {niche_numbers['sizes']} +- Target Age Bracket: {age_bracket['label']} + +DECISION: You decide whether to include these numbers based on: +- The ad format (some formats work better with numbers, others without) +- The psychological strategy (some strategies benefit from specificity, others from emotional appeal) +- The overall message flow + +If including numbers: Use them naturally and make them oddly specific (e.g., "47 lbs" not "50 lbs") for believability. +If NOT including numbers: Focus on emotional transformation, lifestyle benefits, and outcomes without specific metrics.""" + else: + numbers_section = f"""=== NUMBERS GUIDANCE (HOME INSURANCE) === +You may include specific prices/numbers if they enhance the ad's believability and fit the format: +- Price Guidance: {price_guidance} +- Before Price: {niche_numbers['before']} +- After Price: {niche_numbers['after']} +- Total Saved: {niche_numbers['difference']}/year +- Target Age Bracket: {age_bracket['label']} + +DECISION: You decide whether to include prices/numbers based on: +- The ad format (e.g., "specific_price_anchor" format benefits from prices, "curiosity_gap" may not) +- The psychological strategy (some strategies need specificity, others work better emotionally) +- The overall message flow and what feels most authentic + +If including prices: Use oddly specific amounts (e.g., "$97.33/month" not "$100/month") for maximum believability. +If NOT including prices: Focus on emotional benefits, problem-solution framing, curiosity gaps, and trust without specific dollar amounts.""" + + # Headline formulas (updated to show both with and without numbers) + if niche == "glp1": + headline_formulas = """=== PROVEN WINNING HEADLINE FORMULAS (WEIGHT LOSS) === + +WITH NUMBERS (use if numbers section provided): +1. THE TRANSFORMATION: Specific weight loss results + - "Lost 47 lbs In 90 Days" + - "Down 4 Dress Sizes In 8 Weeks" + - "From 247 lbs to 168 lbs" + +WITHOUT NUMBERS (use if no numbers section): +1. THE ACCUSATION: Direct accusation about weight struggle + - "Still Overweight?" + - "Another Failed Diet?" + - "Tired Of Hiding Your Body?" + +2. THE CURIOSITY GAP: Open loop about weight loss secret + - "Thousands Are Losing Weight After THIS" + - "Doctors Are Prescribing THIS Instead Of Diets" + - "What Hollywood Has Used For Years" + +3. THE BEFORE/AFTER: Dramatic transformation proof + - "Same Person. 90 Days Apart." + - "Is This Even The Same Person?" + - "The Transformation That Shocked Everyone" + +4. THE IDENTITY CALLOUT: Target demographics + - "Women Over 40: This Changes Everything" + - "If You've Tried Every Diet And Failed..." + - "For People Who've Struggled For Years" + +5. THE MEDICAL AUTHORITY: Doctor/FDA credibility + - "FDA-Approved Weight Loss" + - "Doctor-Prescribed. Clinically Proven." + - "What Doctors Prescribe Their Own Families\"""" + else: + headline_formulas = """=== PROVEN WINNING HEADLINE FORMULAS (HOME INSURANCE) === + +WITH NUMBERS (use if numbers section provided): +1. THE SPECIFIC PRICE ANCHOR: Oddly specific = believable + - "Home Insurance for as low as $97.33/month" + - "Seniors Won't Have To Pay More Than $49 A Month" + +2. THE BEFORE/AFTER PROOF: Savings with evidence + - "WAS: $1,701 → NOW: $583" + - "The Easiest Way To Cut Home Insurance Bills" + +WITHOUT NUMBERS (use if no numbers section): +1. THE ACCUSATION: Direct accusation about overpaying + - "OVERPAYING?" + - "Still Underinsured?" + - "Wasting Money On Insurance?" + +2. THE CURIOSITY GAP: Open loop that demands click + - "Seniors Are Ditching Home Insurance & Doing This Instead" + - "Thousands of homeowners are dropping insurance after THIS" + - "Why Are Homeowners Switching?" + +3. THE IDENTITY CALLOUT: Target demographics + - "Homeowners Over 50: Check Your Eligibility" + - "Senior homeowners over the age of 50..." + +4. THE AUTHORITY TRANSFER: Government/institutional trust + - "State Farm Brings Welfare!" + - "Sponsored by the US Government" + +5. THE EMOTIONAL BENEFIT: Focus on outcomes + - "Protect What Matters Most" + - "Finally, Peace of Mind" + - "Sleep Better Knowing You're Covered\"""" + + prompt = f"""You are an elite direct-response copywriter who has reverse-engineered hundreds of 7-8 figure Facebook ad campaigns. You understand the psychology of scroll-stopping creatives that bypass ad-blindness and trigger immediate emotional response. + +=== CONTEXT === +NICHE: {niche.replace("_", " ").title()} +ADVERTISING FRAMEWORK: {framework} +FRAMEWORK DESCRIPTION: {framework_data.get('description', '')} +FRAMEWORK TONE: {framework_data.get('tone', '')} +FRAMEWORK VISUAL STYLE: {framework_data.get('visual_style', '')} +FRAMEWORK HOOK EXAMPLES: {', '.join(framework_hooks[:3]) if framework_hooks else 'N/A'} +CREATIVE DIRECTION: {creative_direction} +CALL-TO-ACTION: {cta} + +=== WINNING AD FORMAT TO USE === +FORMAT: {ad_format['name']} +DESCRIPTION: {ad_format['description']} +PATTERN: {ad_format['headline_pattern']} +EXAMPLES: {', '.join(ad_format['examples'])} +VISUAL STYLE: {ad_format['visual_style']} + +=== CONTAINER FORMAT (Native-Looking Ad) === +CONTAINER TYPE: {container['name']} +DESCRIPTION: {container.get('description', '')} +VISUAL GUIDANCE: {get_container_visual_guidance(container.get('key', ''))} +FONT STYLE: {container.get('font_style', '')} +COLORS: {', '.join(f'{k}: {v}' for k, v in container.get('colors', {}).items())} +AUTHENTICITY TIPS: {', '.join(container.get('authenticity_tips', [])[:3])} + +{numbers_section} + +=== PSYCHOLOGICAL STRATEGIES TO DEPLOY === +{chr(10).join(strategy_descriptions)} + +=== PSYCHOLOGICAL TRIGGERS === +PRIMARY TRIGGER: {trigger_data.get('name', 'N/A') if trigger_data else 'N/A'} +DESCRIPTION: {trigger_data.get('description', '') if trigger_data else ''} +COPY ANGLES: {', '.join(trigger_data.get('copy_angles', [])[:3]) if trigger_data else 'N/A'} + +TRIGGER COMBINATION: {trigger_combination.get('name', 'N/A') if trigger_combination else 'N/A'} +COMBINATION DESCRIPTION: {trigger_combination.get('description', '') if trigger_combination else ''} + +=== POWER WORDS TO USE === +Incorporate these power words naturally: {', '.join(power_words) if power_words else 'N/A'} + +=== HOOK INSPIRATION (create your own powerful variation) === +{chr(10).join(f'- "{hook}"' for hook in hooks)} +FRAMEWORK HOOK EXAMPLES: {', '.join(framework_hooks[:5]) if framework_hooks else 'N/A'} + +{niche_guidance} + +{headline_formulas} +=== YOUR MISSION === +Create a SCROLL-STOPPING Facebook ad for {niche.replace("_", " ").upper()} using the format "{ad_format['name']}" that looks like organic content, not advertising. + +=== OUTPUT REQUIREMENTS === + +1. HEADLINE (The "Arrest") + - Follow the {ad_format['name']} pattern + - MAXIMUM 10 words + - Must create INSTANT pattern interrupt + - You decide whether to include specific numbers/prices based on the ad format and what will be most effective. If including prices, make them oddly specific (e.g., "$97.33" not "$100") for believability. + - Include demographic targeting where appropriate + - NO generic phrases - be SPECIFIC and EMOTIONAL + +2. PRIMARY TEXT (The "Agitation") + - 2-3 punchy sentences that AMPLIFY the emotional hook + - You decide whether to include specific numbers/prices based on the ad format and strategy. If including, make them oddly specific for believability. + - Reference the demographic appropriately + - Create urgency + - Make them FEEL the pain or desire + +3. DESCRIPTION (The "Payoff") + - ONE powerful sentence (max 10 words) + - You decide whether to include specific metrics/numbers based on what enhances the message. If including, use oddly specific amounts for believability. + - Create action urgency + +4. IMAGE BRIEF (CRITICAL - must match {container['name']} container style) + - Describe the scene for the {container['name']} container format ONLY + - Visual guidance: {get_container_visual_guidance(container.get('key', ''))} + - The image should look like ORGANIC CONTENT, not an ad + - Include: setting, subjects, props, mood + - Follow container authenticity tips: {', '.join(container.get('authenticity_tips', [])[:2])} + - CRITICAL: Use ONLY {container['name']} format - DO NOT mix with other container types (no WhatsApp + memo, no iMessage + document) + - {"If chat container: Include 2-4 readable, coherent messages related to home insurance. Use the headline or a variation as one message." if container.get('key') in ['imessage', 'whatsapp', 'sms', 'reddit_post', 'social_post'] else ""} + - {"If document container: Include readable, properly formatted text related to home insurance." if container.get('key') in ['memo', 'email_notification', 'browser_alert'] else ""} + - FOR GLP-1: Use VARIETY - show different visual types: quiz interfaces, doctor/medical settings, person on scale, mirror reflections, lifestyle moments, confidence scenes, testimonial portraits, celebrity references, or before/after (only when strategy calls for it) use diverse visual concepts. + - FOR HOME INSURANCE: Show person with document, savings proof, home setting + +=== PSYCHOLOGICAL PRINCIPLES === +- Loss Aversion: Make them feel what they're losing/missing +- Specificity = Believability: Specific numbers beat round numbers +- Identity Targeting: Direct demographic callouts create self-selection +- Curiosity Gap: "THIS" and "Instead" demand click to close loop +- Social Proof: "Thousands are doing X" triggers herd behavior +- Native Disguise: Content that doesn't look like an ad bypasses filters + +=== CRITICAL RULES === +1. MATCH THE NICHE: {niche.replace("_", " ").upper()} content only! +2. Use SPECIFIC numbers from the numbers section above +3. ALWAYS create curiosity gap with "THIS", "Instead", "After", "Secret" +4. NEVER look like an ad - look like NEWS, PROOF, or UGC +5. Use ACCUSATION framing for maximum impact +6. The image MUST match the {container['name']} container style + +=== OUTPUT FORMAT (JSON) === +{{ + "title": "Short, punchy ad title (3-5 words max) - think of it as the campaign name", + "headline": "Your pattern-matching headline using {ad_format['name']} format", + "primary_text": "Your 2-3 sentence emotional amplification with specific numbers", + "description": "Your one powerful sentence", + "body_story": "A compelling 4-6 sentence STORY that hooks the reader emotionally. Start with a relatable situation or pain point. Build tension. Show the transformation. End with hope and a soft CTA. Write in first or second person for intimacy.", + "image_brief": "Detailed description matching {container['name']} container style - organic content feel", + "cta": "{cta}", + "ad_format_used": "{ad_format['name']}", + "container_used": "{container['name']}", + "container_key": "{container.get('key', '')}", + "psychological_angle": "Primary psychological trigger being used", + "why_it_works": "Brief explanation of the psychological mechanism" +}} + +Generate the ad copy now for {niche.replace("_", " ").upper()}. Make it look like ORGANIC CONTENT that triggers IMMEDIATE emotional response.""" + + return prompt + + def _build_image_prompt( + self, + niche: str, + ad_copy: Dict[str, Any], + visual_styles: List[str], + visual_mood: str, + camera_angle: str, + lighting: str, + composition: str, + visual_style_data: Optional[Dict[str, Any]] = None, + niche_visual_guidance_data: Optional[Dict[str, Any]] = None, + ) -> str: + """ + Build professional image generation prompt. + Uses detailed specifications, style guidance, and negative prompts. + Creates AUTHENTIC, ORGANIC CONTENT aesthetic. + Text (if included) should be part of the natural scene, NOT an overlay. + """ + image_brief = ad_copy.get("image_brief", "") + headline = ad_copy.get("headline", "") + psychological_angle = ad_copy.get("psychological_angle", "") + container_key = ad_copy.get("container_key", "") + container_name = ad_copy.get("container_used", "Standard Ad") + ad_format = ad_copy.get("ad_format_used", "curiosity_gap") + price_anchor = ad_copy.get("price_anchor", "$97") + + # Get container data if key is available + if container_key: + from data.containers import get_container + container = get_container(container_key) + else: + container = {"name": container_name, "visual_guidance": "Standard ad format"} + + # Select visual style (use visuals.py data if available, otherwise strategy visuals) + if visual_style_data and isinstance(visual_style_data, dict): + visual_style = visual_style_data.get("prompt_guidance", "") + visual_style_name = visual_style_data.get("name", "") + else: + visual_style = random.choice(visual_styles) if visual_styles else "" + visual_style_name = "" + + # Randomly decide which elements to include (for variety) + include_vintage_effects = random.random() < 0.7 # 70% chance + include_text_overlay = random.random() < 0.8 # 80% chance (headline on image) + include_container_format = random.random() < 0.4 # 40% chance (many images should be clean without container) + + # Select vintage film style and damage effects (only if including vintage) + vintage_style = random.choice(VINTAGE_FILM_STYLES) if include_vintage_effects else None + film_damage = random.choice(FILM_DAMAGE_EFFECTS) if include_vintage_effects else None + + # Get color palette from visuals.py based on trigger/mood + color_palette = get_color_palette(visual_mood.lower().replace(" ", "_").replace("-", "_")) + + # Text styling options for variety - natural text in scene (NO overlays/banners) + text_positions = [ + "naturally integrated into the scene", + "as part of a document or sign in the image", + "on a surface within the scene (wall, paper, etc.)", + "as natural text element in the environment", + "integrated into the scene naturally", + ] + text_position = random.choice(text_positions) + + text_colors = [ + "natural text color that fits the scene", + "text that appears naturally in the environment", + "text that looks like it belongs in the scene", + "authentic text appearance, not overlaid", + "text as part of the natural scene elements", + "realistic text that fits the environment", + ] + text_color = random.choice(text_colors) + + # Niche-specific image guidance (use visuals.py if available) + if niche_visual_guidance_data and isinstance(niche_visual_guidance_data, dict): + niche_image_guidance = f""" +NICHE REQUIREMENTS ({niche.replace("_", " ").title()}): +SUBJECTS: {', '.join(niche_visual_guidance_data.get('subjects', []))} +PROPS: {', '.join(niche_visual_guidance_data.get('props', []))} +AVOID: {', '.join(niche_visual_guidance_data.get('avoid', []))} +COLOR PREFERENCE: {niche_visual_guidance_data.get('color_preference', 'balanced')} +""" + elif niche == "home_insurance": + niche_image_guidance = """ +NICHE REQUIREMENTS: +- Show REAL American suburban homes (single-family, realistic architecture) +- Include authentic elements: lawns, driveways, neighborhoods +- People should look like real homeowners (diverse, relatable, 30-60 age range) +- Disaster scenes should be realistic but not gratuitous +- Protection/safety imagery should feel reassuring, not corporate +- AVOID: mansions, castles, fantasy homes, unrealistic scenarios +""" + elif niche == "glp1": + niche_image_guidance = """ +NICHE REQUIREMENTS (GLP-1): +- Use VARIETY in visual types +- Visual options include: quiz/interactive interfaces, doctor/medical settings, person on scale, mirror reflections, lifestyle/confidence scenes, testimonial portraits, celebrity references, measurement moments, or before/after (only when the strategy specifically requires it) +- Show REAL people in various moments (not just transformation) +- Body types should be relatable (not fitness models) +- Include authentic lifestyle elements: real clothes, real settings +- Medical elements should look professional and trustworthy +- Confidence/transformation moments should feel genuine +- AVOID: defaulting to before/after for every image - prioritize visual variety +- AVOID: extreme body manipulation, unrealistic transformations, shame-inducing imagery +- ENCOURAGE: diverse visual concepts that match the strategy (quiz for quiz strategy, medical for authority strategy, lifestyle for aspiration, etc.) +""" + else: + niche_image_guidance = "" + + # Container-specific visual guidance (from containers.py) + # CRITICAL: Use ONLY ONE container type, ensure readable text + container_visual_guidance = get_container_visual_guidance(container_key) if container_key else container.get("visual_guidance", "") + + # Determine if this is a chat/message container that needs readable text + is_chat_container = container_key in ["imessage", "whatsapp", "sms", "reddit_post", "social_post"] + is_document_container = container_key in ["memo", "email_notification", "browser_alert"] + + container_guidance_section = f""" +=== CONTAINER FORMAT REQUIREMENTS === +CRITICAL: Use ONLY the {container.get('name', 'Standard Ad')} container format. DO NOT mix multiple container types. + +Container Type: {container.get('name', 'Standard Ad')} +Visual Guidance: {container_visual_guidance} + +REQUIREMENTS: +1. USE ONLY THIS CONTAINER TYPE - NO other containers (no mixing WhatsApp + memo, no mixing iMessage + document) +2. NO decorative borders, frames, or boxes around the container +3. NO banners, badges, or logos +4. NO overlay boxes or rectangular overlays +5. Focus on authentic, natural appearance of the {container.get('name', 'Standard Ad')} format only + +{"=== TEXT REQUIREMENTS FOR CHAT CONTAINERS ===" if is_chat_container else ""} +{"CRITICAL: All text in chat bubbles MUST be:" if is_chat_container else ""} +{"- READABLE and COHERENT (not gibberish, not placeholder text)" if is_chat_container else ""} +{"- Realistic conversation text related to home insurance" if is_chat_container else ""} +{"- Proper spelling and grammar" if is_chat_container else ""} +{"- Natural message flow (2-4 messages max)" if is_chat_container else ""} +{"- Use the headline or a variation as one of the messages" if is_chat_container else ""} +{"- NO placeholder text like 'lorem ipsum' or random characters" if is_chat_container else ""} + +{"=== TEXT REQUIREMENTS FOR DOCUMENT CONTAINERS ===" if is_document_container else ""} +{"CRITICAL: All text in documents MUST be:" if is_document_container else ""} +{"- READABLE and COHERENT" if is_document_container else ""} +{"- Related to home insurance topic" if is_document_container else ""} +{"- Proper formatting (title, body text, etc.)" if is_document_container else ""} +{"- NO gibberish or placeholder text" if is_document_container else ""} +""" + + # Build flexible prompt based on what to include + vintage_section = "" + if include_vintage_effects and vintage_style and film_damage: + vintage_section = f""" +=== VINTAGE FILM AESTHETIC (OPTIONAL - apply if it fits the style) === + - {vintage_style} + - Film damage: {film_damage} + - Warm, faded colors + - Visible grain throughout +""" + + container_section = "" + if include_container_format: + # Only 40% of images will use container format + # CRITICAL: Use ONLY the specified container, ensure readable text + container_section = f""" +{container_guidance_section} + +CRITICAL REMINDERS: +- Use ONLY {container.get('name', 'Standard Ad')} format - NO mixing with other containers +- If using chat container: All text MUST be readable, coherent, and related to home insurance +- If using document container: All text MUST be readable and properly formatted +- NO gibberish, placeholder text, or random characters +- NO decorative borders, frames, or boxes +""" + else: + # 60% of images will be clean, natural images without container format + container_section = """ +=== STYLE GUIDANCE (NO CONTAINER FORMAT) === +- Natural, authentic image - NO container format + - Must NOT look like a polished advertisement +- Should feel like authentic, organic content +- Real, unpolished, natural appearance +- NO decorative borders, banners, overlays, or boxes +- NO native app interfaces, screenshots, or container styles +- Just a clean, natural photograph or scene +""" + + text_overlay_section = "" + if include_text_overlay: + text_overlay_section = f""" +=== HEADLINE TEXT (OPTIONAL - natural text in scene, NOT overlay) === +IMPORTANT: If including text, it should be part of the natural scene, NOT an overlay. + +Headline to include naturally: "{headline}" + +Text requirements (natural integration only): +- Text should appear as part of the scene (on documents, signs, surfaces, etc.) +- Position: {text_position} +- Style: {text_color} +- Ensure readability +- Spell every word correctly +- CRITICAL: Text must be part of the scene, NOT an overlay on top +- NO decorative borders, boxes, or frames around text +- NO banners, badges, or logos +- NO overlay boxes or rectangular overlays +- Text should look like it naturally belongs in the scene +""" + else: + text_overlay_section = """ +=== NO TEXT === +Do NOT include any text on this image. Focus on the visual scene only. +NO text overlays, decorative elements, borders, banners, or overlays. +""" + + prompt = f"""Create a Facebook advertisement image that looks like AUTHENTIC, ORGANIC CONTENT. + +{vintage_section} +{container_section} +{text_overlay_section} + +=== VISUAL SCENE === +{image_brief} + +IMPORTANT: Do NOT display numbers, prices, dollar amounts, or savings figures in the image unless they naturally appear as part of the scene (like on a document someone is holding, or a sign in the background). Focus on the visual scene and people, not numerical information. Numbers should be in the ad copy, not the image. + +=== VISUAL SPECIFICATIONS === +STYLE: {visual_style} - rendered in vintage documentary aesthetic +MOOD: {visual_mood} - nostalgic, authentic, trustworthy +CAMERA: {camera_angle} - documentary/candid feel +LIGHTING: {lighting} - natural, not studio-polished +COMPOSITION: {composition} + +{niche_image_guidance} + +=== OPTIONAL STYLING ELEMENTS === +Apply these effects IF they fit the natural aesthetic (not required): +- Film grain (if vintage style) +- Faded colors (if appropriate) +- Natural lighting variations +- Authentic imperfections (if they occur naturally) + +=== AUTHENTICITY REQUIREMENTS === +PEOPLE (if present): +- Real people, NOT models +- Age appropriate for target demographic (40-70) +- Everyday clothes (not styled) +- Natural expressions (not posed smiles) +- Relatable, trustworthy appearance + +SETTINGS (if present): +- Real locations (homes, yards, offices) +- Lived-in, not staged +- Everyday items and props +- Natural clutter/imperfection + +DOCUMENTS (if present): +- Real-looking bills, statements, cards +- Visible numbers and text +- Red circles around key information +- Slightly crumpled or worn + +=== NEGATIVE PROMPTS (AVOID) === +- NO clean, modern, HD digital look +- NO perfect studio lighting +- NO stock photo aesthetics +- NO obviously AI-generated faces +- NO polished advertising look +- NO missing or garbled text +- NO brand watermarks +- NO distorted anatomy +- NO decorative borders, frames, or boxes around the image +- NO banners, badges, or logos in corners (like "BREAKING", "TRUSTED", etc.) +- NO overlay boxes or rectangular overlays with text +- NO decorative elements that frame the image +- NO news-style chyrons or tickers +- NO graphic design elements that look like they were added on top +- NO numbers, prices, dollar amounts, or savings figures displayed prominently +- NO text overlays with numerical information +- Focus on the natural scene only, no added presentation elements or numbers +- NO mixing multiple container types (e.g., NO WhatsApp + memo, NO iMessage + document) +- NO gibberish, placeholder text, or random characters in chat bubbles or documents +- NO "lorem ipsum", placeholder text, or meaningless character strings +- If using a container format, use ONLY that one container type - NO mixing + +=== OUTPUT === +Create a scroll-stopping image that feels authentic and organic. {f'The headline "{headline}" should be included if text overlay is enabled.' if include_text_overlay else 'Focus on the visual scene without text overlay.'} The image should feel like real content - NOT like a designed advertisement. Use only the elements that fit naturally - don't force every element into every image. + +CRITICAL REQUIREMENTS: +- NO decorative borders, frames, or boxes +- NO banners, badges, or logos in corners +- NO overlay boxes or rectangular overlays +- NO news-style chyrons or tickers +- NO graphic design elements added on top +- Focus on the natural, authentic scene only +- If text is included, it should be part of the natural scene, not in a decorative box""" + + # Refine and clean the prompt before sending + refined_prompt = self._refine_image_prompt(prompt) + return refined_prompt + + def _refine_image_prompt(self, prompt: str) -> str: + """ + Refine and clean the image prompt to ensure logical, coherent image generation. + Removes contradictions, confusing instructions, and ensures the prompt makes sense. + """ + import re + + # Remove meta-instructions that confuse the model + prompt = re.sub(r'\(for model, not to display\)', '', prompt, flags=re.IGNORECASE) + prompt = re.sub(r'\(apply these, don\'t display\)', '', prompt, flags=re.IGNORECASE) + prompt = re.sub(r'IMPORTANT: Display ONLY', 'Display', prompt, flags=re.IGNORECASE) + prompt = re.sub(r'IMPORTANT: If including', 'If including', prompt, flags=re.IGNORECASE) + prompt = re.sub(r'IMPORTANT: Use this', 'Use this', prompt, flags=re.IGNORECASE) + prompt = re.sub(r'IMPORTANT: Follow this', 'Follow this', prompt, flags=re.IGNORECASE) + + # Remove lines that are clearly instructions for developers, not the model + lines = prompt.split('\n') + cleaned_lines = [] + skip_until_next_section = False + + for line in lines: + line_lower = line.lower() + + # Skip developer instructions + if any(phrase in line_lower for phrase in [ + 'do not display these instructions', + 'not to be displayed', + 'for debugging', + 'metadata', + ]): + if not line.strip().startswith('==='): + continue + + # Keep section headers + if line.strip().startswith('==='): + skip_until_next_section = False + cleaned_lines.append(line) + continue + + # Skip placeholder or empty content + if any(phrase in line_lower for phrase in ['n/a', 'not provided', 'see above', 'refer to']): + if not line.strip().startswith('-'): + continue + + # Simplify overly emphatic instructions + if line.strip().startswith('CRITICAL:'): + line = line.replace('CRITICAL:', 'Note:') + elif line.strip().startswith('IMPORTANT:'): + line = line.replace('IMPORTANT:', 'Note:') + + # Remove redundant "NO" statements that are already covered + if line.strip().startswith('- NO') and 'decorative' in line_lower: + # Keep first occurrence, skip duplicates + if 'decorative' in '\n'.join(cleaned_lines).lower(): + # Check if we already have this prohibition + continue + + cleaned_lines.append(line) + + prompt = '\n'.join(cleaned_lines) + + # Remove excessive blank lines + prompt = re.sub(r'\n{3,}', '\n\n', prompt) + + # Ensure logical coherence - check for contradictions + prompt_lower = prompt.lower() + + # If we say "no text" but have headline section, clarify + if 'no text overlay' in prompt_lower or 'no text' in prompt_lower: + # Remove or clarify headline text section + sections = prompt.split('===') + filtered_sections = [] + for section in sections: + section_lower = section.lower() + # Keep "no text" instructions + if 'no text' in section_lower and 'overlay' in section_lower: + filtered_sections.append(section) + # Skip headline text section if we said no text + elif 'headline text' in section_lower and 'no text' in prompt_lower: + # Replace with clarification + filtered_sections.append('=== NO TEXT ===\nDo NOT include any text in this image.') + else: + filtered_sections.append(section) + prompt = '==='.join(filtered_sections) + + # Ensure visual scene is clear and logical + if 'visual scene' in prompt_lower: + # Make sure scene description is coherent + if 'psychological angle' in prompt_lower: + # These are fine - they provide context + pass + + # Remove confusing conditional text that might create illogical images + # Example: "if text overlay is enabled" - simplify + prompt = re.sub( + r'if text overlay is enabled', + 'if text is included', + prompt, + flags=re.IGNORECASE + ) + prompt = re.sub( + r'if including text', + 'if text is included', + prompt, + flags=re.IGNORECASE + ) + + # Ensure the prompt ends with a clear, logical instruction + if not prompt.strip().endswith('.'): + prompt += "\n\nCreate a natural, authentic, and logically coherent image. All elements should fit together naturally and make visual sense." + + # Final cleanup: remove any remaining confusing meta-text + prompt = prompt.replace('(OPTIONAL - ', '(') + prompt = prompt.replace('(for model, not to display)', '') + prompt = prompt.replace('(apply these, don\'t display)', '') + + # Ensure no empty sections + sections = prompt.split('===') + final_sections = [] + for section in sections: + section = section.strip() + if section and len(section) > 10: # Only keep substantial sections + final_sections.append(section) + prompt = '==='.join(final_sections) + + # Final validation: ensure the prompt makes logical sense + # Check that we're not asking for impossible combinations + if 'no text' in prompt_lower and 'headline' in prompt_lower: + # Remove headline references if we explicitly said no text + prompt = re.sub( + r'===.*HEADLINE.*===[\s\S]*?(?===|$)', + '', + prompt, + flags=re.IGNORECASE | re.MULTILINE + ) + + return prompt.strip() + + async def generate_ad( + self, + niche: str, + num_images: int = 1, + image_model: Optional[str] = None, + username: Optional[str] = None, # Username of the user generating the ad + ) -> Dict[str, Any]: + """ + Generate a complete ad creative with copy and image. + + Uses maximum randomization to ensure different results every time: + - Random strategy selection (2-3 from pool) + - Random hook selection + - Random visual style + - Random creative direction + - Random visual mood + - Random camera angle, lighting, composition + - Random seed for image generation + + Args: + niche: Target niche (home_insurance or glp1) + num_images: Number of images to generate + + Returns: + Dict with ad copy, image path, and metadata + """ + # Load niche data + niche_data = self._get_niche_data(niche) + + # Get framework first (needed for compatibility scoring) + all_frameworks = get_all_frameworks() + framework_keys = list(all_frameworks.keys()) + + # 70% chance to use niche-preferred, 30% chance for any framework (ensures all are used) + if random.random() < 0.7: + framework_data = get_frameworks_for_niche(niche, count=1)[0] + else: + # Use any framework for maximum variety + framework_key = random.choice(framework_keys) + framework_data = {"key": framework_key, **all_frameworks[framework_key]} + + framework = framework_data["name"] + framework_key = framework_data["key"] + + # IMPROVEMENT: Select compatible strategies based on framework + num_strategies = random.randint(2, 3) + strategies = self._select_compatible_strategies(niche_data, framework_key, count=num_strategies) + hooks = self._random_hooks(strategies, count=3) + + # IMPROVEMENT: Use visual library in addition to strategy visuals + visual_styles = self._random_visual_styles(strategies, count=2, niche=niche, use_library=True) + creative_direction = random.choice(niche_data["creative_directions"]) + visual_mood = random.choice(niche_data["visual_moods"]) + + # Framework already selected above for compatibility scoring + framework_hooks = get_framework_hook_examples(framework_key, niche) + + # Use visual elements from visuals.py (instead of hardcoded) + visual_style_data = get_random_visual_style() + camera_angle = get_random_camera_angle() + lighting = get_random_lighting() + composition = get_random_composition() + + # Get psychological trigger for additional guidance + trigger_data = get_random_trigger() + trigger_combination = get_trigger_combination() + + # Get power words for copy enhancement + power_words = get_power_words(category=None, count=5) + + # Get niche visual guidance + niche_visual_guidance_data = get_niche_visual_guidance(niche) + + # Generate ad copy via LLM with professional prompt + copy_prompt = self._build_copy_prompt( + niche=niche, + niche_data=niche_data, + strategies=strategies, + hooks=hooks, + creative_direction=creative_direction, + framework=framework, + framework_data=framework_data, + framework_hooks=framework_hooks, + trigger_data=trigger_data, + trigger_combination=trigger_combination, + power_words=power_words, + ) + + ad_copy = await llm_service.generate_json( + prompt=copy_prompt, + temperature=0.95, # High for variety + ) + + # Generate image(s) with professional prompt + generated_images = [] + + for i in range(num_images): + # Build image prompt with all parameters + image_prompt = self._build_image_prompt( + niche=niche, + ad_copy=ad_copy, + visual_styles=visual_styles, + visual_mood=visual_mood, + camera_angle=camera_angle, + lighting=lighting, + composition=composition, + visual_style_data=visual_style_data, + niche_visual_guidance_data=niche_visual_guidance_data, + ) + + # Store the refined prompt for database saving (this is the final prompt sent to image service) + refined_image_prompt = image_prompt + + # Generate with random seed + seed = random.randint(1, 2147483647) + + try: + image_bytes, model_used, image_url = await image_service.generate( + prompt=image_prompt, + width=settings.image_width, + height=settings.image_height, + seed=seed, + model_key=image_model, + ) + + # Generate filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid.uuid4())[:8] + filename = f"{niche}_{timestamp}_{unique_id}.png" + + # Upload to R2 if available, otherwise save locally + r2_url = None + if r2_storage_available: + try: + r2_storage = get_r2_storage() + if r2_storage: + r2_url = r2_storage.upload_image( + image_bytes=image_bytes, + filename=filename, + niche=niche, + ) + print(f"Image uploaded to R2: {r2_url}") + except Exception as e: + print(f"Warning: Failed to upload to R2: {e}. Saving locally as backup.") + + # Generate filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid.uuid4())[:8] + filename = f"{niche}_{timestamp}_{unique_id}.png" + + # Upload to R2 if available, otherwise save locally + r2_url = None + if r2_storage_available: + try: + r2_storage = get_r2_storage() + if r2_storage: + r2_url = r2_storage.upload_image( + image_bytes=image_bytes, + filename=filename, + niche=niche, + ) + print(f"Image uploaded to R2: {r2_url}") + except Exception as e: + print(f"Warning: Failed to upload to R2: {e}. Saving locally as backup.") + + # Save image locally conditionally (based on environment settings) + filepath = self._save_image_locally(image_bytes, filename) + + # Use R2 URL if available, otherwise use Replicate URL, fallback to local + final_image_url = r2_url or image_url + + generated_images.append({ + "filename": filename, + "filepath": filepath, + "image_url": final_image_url, # R2 URL (preferred) or Replicate URL + "r2_url": r2_url, # R2 URL if uploaded + "model_used": model_used, + "seed": seed, + "image_prompt": refined_image_prompt, # Store the final prompt + }) + + except Exception as e: + generated_images.append({ + "error": str(e), + "seed": seed, + "image_prompt": refined_image_prompt if 'refined_image_prompt' in locals() else None, + }) + + # Generate unique ID + ad_id = str(uuid.uuid4()) + + # Build metadata + metadata = { + "strategies_used": [s["name"] for s in strategies], + "creative_direction": creative_direction, + "visual_mood": visual_mood, + "framework": framework, + "camera_angle": camera_angle, + "lighting": lighting, + "composition": composition, + "hooks_inspiration": hooks, + "visual_styles": visual_styles, + } + + # Save to database (for first image) + # Ensure database connection is initialized + if db_service and db_service.collection is None and settings.mongodb_url: + try: + await db_service.connect() + except Exception as e: + print(f"Warning: Could not connect to database: {e}") + + first_image = generated_images[0] if generated_images else {} + if db_service and db_service.collection is not None and not first_image.get("error") and username: + try: + db_id = await db_service.save_ad_creative( + niche=niche, + title=ad_copy.get("title", ""), + headline=ad_copy.get("headline", ""), + primary_text=ad_copy.get("primary_text", ""), + description=ad_copy.get("description", ""), + body_story=ad_copy.get("body_story", ""), + cta=ad_copy.get("cta", ""), + psychological_angle=ad_copy.get("psychological_angle", ""), + why_it_works=ad_copy.get("why_it_works", ""), + username=username, # Pass username + image_url=first_image.get("image_url"), + image_filename=first_image.get("filename"), + image_model=first_image.get("model_used"), + image_seed=first_image.get("seed"), + image_prompt=first_image.get("image_prompt"), # Save the final refined prompt + generation_method="standard", + metadata=metadata, + ) + if db_id: + ad_id = db_id + print(f"✓ Saved ad creative to database: {ad_id}") + except Exception as e: + print(f"Warning: Failed to save to database: {e}") + + # Build response + result = { + "id": ad_id, + "niche": niche, + "created_at": datetime.now().isoformat(), + + # Ad copy + "title": ad_copy.get("title", ""), + "headline": ad_copy.get("headline", ""), + "primary_text": ad_copy.get("primary_text", ""), + "description": ad_copy.get("description", ""), + "body_story": ad_copy.get("body_story", ""), + "cta": ad_copy.get("cta", ""), + "psychological_angle": ad_copy.get("psychological_angle", ""), + "why_it_works": ad_copy.get("why_it_works", ""), + + # Image(s) with URLs + "images": generated_images, + + # Metadata for debugging/learning + "metadata": metadata, + } + + return result + + async def generate_ad_with_matrix( + self, + niche: str, + angle_key: Optional[str] = None, + concept_key: Optional[str] = None, + num_images: int = 1, + image_model: Optional[str] = None, + username: Optional[str] = None, # Username of the user generating the ad + ) -> Dict[str, Any]: + """ + Generate ad using angle × concept matrix approach. + + This provides more systematic ad generation with explicit + control over psychological angle and visual concept. + + Args: + niche: Target niche + angle_key: Specific angle key (optional, random if not provided) + concept_key: Specific concept key (optional, random if not provided) + num_images: Number of images to generate + + Returns: + Complete ad creative with angle and concept metadata + """ + # Get or generate angle × concept combination + if angle_key and concept_key: + from data.angles import get_angle_by_key + from data.concepts import get_concept_by_key + + angle = get_angle_by_key(angle_key) + concept = get_concept_by_key(concept_key) + + if not angle or not concept: + raise ValueError(f"Invalid angle_key or concept_key") + + combination = { + "angle": angle, + "concept": concept, + "prompt_guidance": f""" +ANGLE: {angle['name']} +- Psychological trigger: {angle['trigger']} +- Example: "{angle['example']}" + +CONCEPT: {concept['name']} +- Structure: {concept['structure']} +- Visual: {concept['visual']} +""", + } + else: + combination = matrix_service.generate_single_combination(niche) + + angle = combination["angle"] + concept = combination["concept"] + + # Get niche data + niche_data = NICHE_DATA.get(niche, home_insurance.get_niche_data)() + + # Build specialized prompt using angle + concept + ad_copy_prompt = self._build_matrix_ad_prompt( + niche=niche, + angle=angle, + concept=concept, + niche_data=niche_data, + ) + + # Generate ad copy + response_format = { + "type": "json_schema", + "json_schema": { + "name": "ad_copy", + "schema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "headline": {"type": "string"}, + "primary_text": {"type": "string"}, + "description": {"type": "string"}, + "body_story": {"type": "string"}, + "image_brief": {"type": "string"}, + "cta": {"type": "string"}, + "psychological_angle": {"type": "string"}, + "why_it_works": {"type": "string"}, + }, + "required": ["title", "headline", "primary_text", "description", "body_story", "image_brief", "cta"], + }, + }, + } + + ad_copy_response = await llm_service.generate( + prompt=ad_copy_prompt, + temperature=0.8, + response_format=response_format, + ) + + import json + ad_copy = json.loads(ad_copy_response) + + # Build image prompt using concept's visual guidance + image_prompt = self._build_matrix_image_prompt( + niche=niche, + angle=angle, + concept=concept, + ad_copy=ad_copy, + ) + + # Store the refined prompt for database saving (this is the final prompt sent to image service) + refined_image_prompt = image_prompt + + # Generate images + images = [] + for i in range(num_images): + seed = random.randint(1, 2**31 - 1) + + try: + image_bytes, model_used, image_url = await image_service.generate( + prompt=image_prompt, + model_key=image_model or settings.image_model, + width=1024, + height=1024, + seed=seed, + ) + + # Generate filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = uuid.uuid4().hex[:8] + filename = f"{niche}_{timestamp}_{unique_id}.png" + + # Upload to R2 if available + r2_url = None + if r2_storage_available: + try: + r2_storage = get_r2_storage() + if r2_storage: + r2_url = r2_storage.upload_image( + image_bytes=image_bytes, + filename=filename, + niche=niche, + ) + print(f"Image uploaded to R2: {r2_url}") + except Exception as e: + print(f"Warning: Failed to upload to R2: {e}. Saving locally as backup.") + + # Save image locally conditionally (based on environment settings) + filepath = self._save_image_locally(image_bytes, filename) + + # Use R2 URL if available, otherwise use Replicate URL + final_image_url = r2_url or image_url + + images.append({ + "filename": filename, + "filepath": filepath, + "image_url": final_image_url, # R2 URL (preferred) or Replicate URL + "r2_url": r2_url, # R2 URL if uploaded + "model_used": model_used, + "seed": seed, + "image_prompt": refined_image_prompt, # Store the final prompt + "error": None, + }) + except Exception as e: + images.append({ + "filename": None, + "filepath": None, + "image_url": None, + "model_used": settings.image_model, + "seed": seed, + "image_prompt": refined_image_prompt if 'refined_image_prompt' in locals() else None, + "error": str(e), + }) + + # Generate unique ID + ad_id = str(uuid.uuid4()) + + # Save to database (for first image) + # Ensure database connection is initialized + if db_service and db_service.collection is None and settings.mongodb_url: + try: + await db_service.connect() + except Exception as e: + print(f"Warning: Could not connect to database: {e}") + + first_image = images[0] if images else {} + if db_service and db_service.collection is not None and not first_image.get("error") and username: + try: + db_id = await db_service.save_ad_creative( + niche=niche, + title=ad_copy.get("title", ""), + headline=ad_copy.get("headline", ""), + primary_text=ad_copy.get("primary_text", ""), + description=ad_copy.get("description", ""), + body_story=ad_copy.get("body_story", ""), + cta=ad_copy.get("cta", ""), + psychological_angle=ad_copy.get("psychological_angle", angle.get("name", "")), + why_it_works=ad_copy.get("why_it_works", ""), + username=username, # Pass username + image_url=first_image.get("image_url"), + image_filename=first_image.get("filename"), + image_model=first_image.get("model_used"), + image_seed=first_image.get("seed"), + image_prompt=first_image.get("image_prompt"), # Save the final refined prompt + angle_key=angle.get("key"), + angle_name=angle.get("name"), + angle_trigger=angle.get("trigger"), + angle_category=angle.get("category"), + concept_key=concept.get("key"), + concept_name=concept.get("name"), + concept_structure=concept.get("structure"), + concept_visual=concept.get("visual"), + concept_category=concept.get("category"), + generation_method="angle_concept_matrix", + metadata={"generation_method": "angle_concept_matrix"}, + ) + if db_id: + ad_id = db_id + print(f"✓ Saved matrix ad creative to database: {ad_id}") + except Exception as e: + print(f"Warning: Failed to save to database: {e}") + + return { + "id": ad_id, + "niche": niche, + "created_at": datetime.now().isoformat(), + "title": ad_copy.get("title", ""), + "headline": ad_copy.get("headline", ""), + "primary_text": ad_copy.get("primary_text", ""), + "description": ad_copy.get("description", ""), + "body_story": ad_copy.get("body_story", ""), + "cta": ad_copy.get("cta", ""), + "psychological_angle": ad_copy.get("psychological_angle", angle.get("name", "")), + "why_it_works": ad_copy.get("why_it_works", ""), + "images": images, + "matrix": { + "angle": { + "key": angle.get("key"), + "name": angle.get("name"), + "trigger": angle.get("trigger"), + "category": angle.get("category"), + }, + "concept": { + "key": concept.get("key"), + "name": concept.get("name"), + "structure": concept.get("structure"), + "visual": concept.get("visual"), + "category": concept.get("category"), + }, + }, + "metadata": { + "generation_method": "angle_concept_matrix", + }, + } + + async def generate_ad_extensive( + self, + niche: str, + target_audience: str, + offer: str, + num_images: int = 1, + image_model: Optional[str] = None, + num_strategies: int = 5, + username: Optional[str] = None, # Username of the user generating the ad + ) -> Dict[str, Any]: + """ + Generate ad using extensive: researcher → creative director → designer → copywriter. + + Args: + niche: Target niche (home_insurance or glp1) + target_audience: Target audience description + offer: Offer to run + num_images: Number of images to generate per strategy + image_model: Image generation model to use + num_strategies: Number of creative strategies to generate + + Returns: + Dict with ad copy, images, and metadata + """ + if not third_flow_available: + raise ValueError("Extensive service not available") + + # Map niche names + niche_map = { + "home_insurance": "Home Insurance", + "glp1": "GLP-1", + } + niche_display = niche_map.get(niche, niche.title()) + + # Step 1: Researcher + print("🔍 Step 1: Researching psychology triggers, angles, and concepts...") + researcher_output = third_flow_service.researcher( + target_audience=target_audience, + offer=offer, + niche=niche_display + ) + + if not researcher_output: + raise ValueError("Researcher returned no results") + + # Step 2: Retrieve knowledge (in parallel) + print("📚 Step 2: Retrieving marketing knowledge...") + from concurrent.futures import ThreadPoolExecutor + + with ThreadPoolExecutor(max_workers=2) as executor: + book_future = executor.submit( + third_flow_service.retrieve_search, + target_audience, offer, niche_display + ) + ads_future = executor.submit( + third_flow_service.retrieve_ads, + target_audience, offer, niche_display + ) + + book_knowledge = book_future.result() + ads_knowledge = ads_future.result() + + # Step 3: Creative Director + print("🎨 Step 3: Creating creative strategies...") + creative_strategies = third_flow_service.creative_director( + researcher_output=researcher_output, + book_knowledge=book_knowledge, + ads_knowledge=ads_knowledge, + target_audience=target_audience, + offer=offer, + niche=niche_display, + n=num_strategies + ) + + if not creative_strategies: + raise ValueError("Creative director returned no strategies") + + # Step 4: Process strategies in parallel (designer + copywriter) + print(f"⚡ Step 4: Processing {len(creative_strategies)} strategies in parallel...") + from concurrent.futures import ThreadPoolExecutor as TPE + + with TPE(max_workers=8) as executor: + strategy_results = list(executor.map( + third_flow_service.process_strategy, + creative_strategies + )) + + # Step 5: Generate images for each strategy + print(f"🖼️ Step 5: Generating images...") + all_results = [] + + for idx, (prompt, title, body, description) in enumerate(strategy_results): + if not prompt: + print(f"Warning: Strategy {idx + 1} has no prompt, skipping...") + continue + + # Generate images for this strategy + generated_images = [] + for img_idx in range(num_images): + try: + # Refine prompt + refined_prompt = self._refine_image_prompt(prompt) + + # Generate image + image_bytes, model_used, image_url = await image_service.generate( + prompt=refined_prompt, + seed=random.randint(1, 2147483647), + model_key=image_model, + ) + + if not image_bytes: + print(f"Warning: Failed to generate image {img_idx + 1} for strategy {idx + 1}") + # Add error entry instead of silently skipping + generated_images.append({ + "error": "Image generation returned no image data", + "seed": random.randint(1, 2147483647), + "image_prompt": refined_prompt, + }) + continue + + # Generate filename + filename = f"{niche}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}.png" + + # Upload to R2 if available + r2_url = None + if r2_storage_available: + try: + r2_storage = get_r2_storage() + if r2_storage: + r2_url = r2_storage.upload_image( + image_bytes, filename=filename, niche=niche + ) + print(f"Image uploaded to R2: {r2_url}") + except Exception as r2_e: + print(f"Warning: Failed to upload image to R2: {r2_e}") + + # Save image locally conditionally (based on environment settings) + filepath = self._save_image_locally(image_bytes, filename) + + # Use R2 URL if available, otherwise use Replicate URL + final_image_url = r2_url or image_url + + # Always add image entry (frontend can use filename for local images) + # If we have image_bytes, we saved it locally, so filename is available + generated_images.append({ + "filename": filename, + "filepath": filepath, + "image_url": final_image_url, # May be None if R2 and Replicate both failed, but filename will work + "r2_url": r2_url, + "model_used": model_used, + "seed": random.randint(1, 2147483647), + "image_prompt": refined_prompt, + }) + except Exception as e: + print(f"Error generating image {img_idx + 1} for strategy {idx + 1}: {e}") + # Add error entry instead of silently skipping (like standard flow) + generated_images.append({ + "error": str(e), + "seed": random.randint(1, 2147483647), + "image_prompt": refined_prompt if 'refined_prompt' in locals() else None, + }) + + if not generated_images: + print(f"Warning: No images generated for strategy {idx + 1}, skipping...") + continue + + # Get first image for database + first_image = generated_images[0] + + # Build ad copy + strategy = creative_strategies[idx] + headline = title or strategy.titleIdeas or "Check this out" + primary_text = body or strategy.bodyIdeas or "" + description_text = description or strategy.captionIdeas or "" + cta = strategy.cta or "Learn More" + + # Save to database + ad_id = str(uuid.uuid4()) + db_id = None + + if db_service and username: + try: + db_id = await db_service.save_ad_creative( + niche=niche, + title=title or "", + headline=headline, + primary_text=primary_text, + description=description_text, + body_story=primary_text, # Use primary_text as body_story + cta=cta, + psychological_angle=strategy.phsychologyTrigger or "", + why_it_works=f"Angle: {strategy.angle}, Concept: {strategy.concept}", + username=username, # Pass username + image_url=first_image.get("image_url"), + image_filename=first_image.get("filename"), + image_model=first_image.get("model_used"), + image_prompt=first_image.get("image_prompt"), + generation_method="extensive", + metadata={ + "generation_method": "extensive", + "target_audience": target_audience, + "offer": offer, + "strategy_index": idx, + "psychology_trigger": strategy.phsychologyTrigger, + "angle": strategy.angle, + "concept": strategy.concept, + "visual_direction": strategy.visualDirection, + }, + ) + if db_id: + ad_id = db_id + print(f"✓ Saved extensive ad creative to database: {ad_id}") + except Exception as e: + print(f"Warning: Failed to save to database: {e}") + + all_results.append({ + "id": ad_id, + "niche": niche, + "created_at": datetime.now().isoformat(), + "title": title or "", + "headline": headline, + "primary_text": primary_text, + "description": description_text, + "body_story": primary_text, + "cta": cta, + "psychological_angle": strategy.phsychologyTrigger or "", + "why_it_works": f"Angle: {strategy.angle}, Concept: {strategy.concept}", + "images": generated_images, + "metadata": { + "strategies_used": [strategy.phsychologyTrigger] if strategy.phsychologyTrigger else ["extensive"], + "creative_direction": f"Extensive: {strategy.angle} × {strategy.concept}", + "visual_mood": strategy.visualDirection.split(".")[0] if strategy.visualDirection else "authentic", + "framework": None, + "camera_angle": None, + "lighting": None, + "composition": None, + "hooks_inspiration": [strategy.titleIdeas] if strategy.titleIdeas else [], + "visual_styles": [strategy.concept] if strategy.concept else [], + }, + }) + + # Return first result (or all if needed) + if all_results: + return all_results[0] # Return first strategy result + else: + raise ValueError("No ads generated from extensive") + + def _build_matrix_ad_prompt( + self, + niche: str, + angle: Dict[str, Any], + concept: Dict[str, Any], + niche_data: Dict[str, Any], + ) -> str: + """Build ad copy prompt using angle + concept framework.""" + + cta = random.choice(niche_data.get("ctas", ["Learn More"])) + + # AI will decide whether to include numbers based on ad format and strategy + # Always provide guidance, AI decides usage + + # Get niche-specific numbers guidance (AI decides if/when to use) + if True: # Always provide guidance + if niche == "glp1": + numbers = self._generate_niche_numbers(niche) + numbers_section = f"""SPECIFIC NUMBERS TO USE: +- Weight Lost: {numbers['difference']} +- Timeframe: {numbers['days']} +- Starting: {numbers['before']}, Current: {numbers['after']}""" + else: + numbers = self._generate_niche_numbers(niche) + price_guidance = self._generate_specific_price(niche) + numbers_section = f"""NUMBERS GUIDANCE (you decide if/when to use): +- Price Guidance: {price_guidance} +- Saved: {numbers['difference']}/year +- Before: {numbers['before']}, After: {numbers['after']} + +DECISION: Include prices/numbers only if they enhance believability and fit the ad format/strategy. +Use oddly specific amounts (e.g., "$97.33" not "$100") when including prices.""" + # Note: AI decides whether to use the numbers based on format and strategy + + return f"""You are an elite direct-response copywriter creating a Facebook ad. + +=== ANGLE × CONCEPT FRAMEWORK === + +ANGLE: {angle.get('name')} +- Psychological Trigger: {angle.get('trigger')} +- Example Hook: "{angle.get('example')}" +- This angle answers WHY they should care + +CONCEPT: {concept.get('name')} +- Visual Structure: {concept.get('structure')} +- Visual Guidance: {concept.get('visual')} +- This concept defines HOW to show it visually + +=== CONTEXT === +NICHE: {niche.replace("_", " ").title()} +CTA: {cta} + +{numbers_section} + +=== YOUR MISSION === +Create a scroll-stopping ad that: +1. Uses the "{angle.get('name')}" angle to trigger {angle.get('trigger', 'emotion')} +2. The image should follow the "{concept.get('name')}" visual concept + +=== OUTPUT (JSON) === +{{ + "title": "Short punchy title (3-5 words) - the campaign/ad name", + "headline": "10 words max, triggers {angle.get('trigger')}. You decide whether to include specific numbers based on what enhances the message.", + "primary_text": "2-3 emotional sentences. You decide whether to include specific numbers based on what enhances believability and fits the strategy.", + "description": "One powerful sentence, 10 words max", + "body_story": "A compelling 4-6 sentence STORY. Start with relatable pain/situation. Build tension. Show transformation. End with hope. Write in first/second person.", + "image_brief": "Detailed scene description following '{concept.get('name')}' concept: {concept.get('structure')}", + "cta": "{cta}", + "psychological_angle": "{angle.get('name')}", + "why_it_works": "Brief explanation of the psychological mechanism" +}} + +Generate the ad now. Be bold, be specific, trigger {angle.get('trigger')}.""" + + def _build_matrix_image_prompt( + self, + niche: str, + angle: Dict[str, Any], + concept: Dict[str, Any], + ad_copy: Dict[str, Any], + ) -> str: + """Build image prompt using concept's visual guidance.""" + + headline = ad_copy.get("headline", "") + image_brief = ad_copy.get("image_brief", "") + + # Text styling based on concept + text_styles = [ + "naturally integrated into the scene", + "as part of a document or sign in the image", + "on a surface within the scene", + "as natural text element in the environment", + ] + text_style = random.choice(text_styles) + + # Get niche-specific guidance + if niche == "home_insurance": + niche_guidance = """ +NICHE: Home Insurance +- Show real American homes, suburban settings +- People should be diverse, relatable homeowners (30-60) +- Disaster scenes should be realistic but not gratuitous""" + else: + niche_guidance = """ +NICHE: GLP-1 / Weight Loss +- Show real transformation moments +- People should be relatable, not fitness models +- Confidence and lifestyle improvement focus""" + + prompt = f"""Create a Facebook ad image with natural, authentic content. + +=== HEADLINE TEXT (if included, should be part of natural scene) === +"{headline}" + +TEXT REQUIREMENTS (natural integration, NOT overlay): +- Text should appear as part of the scene (on documents, signs, surfaces) +- Position: {text_style} +- Must be READABLE +- Spell every word correctly +- CRITICAL: NO overlay boxes, banners, or decorative elements +- Text should look like it naturally belongs in the scene + +=== VISUAL CONCEPT: {concept.get('name')} === +Structure: {concept.get('structure')} +Visual Guidance: {concept.get('visual')} + +=== SCENE FROM BRIEF === +{image_brief} + +=== PSYCHOLOGICAL ANGLE: {angle.get('name')} === +This image should trigger: {angle.get('trigger')} + +{niche_guidance} + +=== LAYOUT === +- Text zone (bottom 25%): "{headline}" +- Visual zone (top 75%): Scene following {concept.get('name')} concept +- Text must have good contrast + +=== AVOID === +- Missing or misspelled text +- Text that is too small to read +- Generic stock photo look +- Watermarks, logos + +Create a scroll-stopping ad image with "{headline}" prominently displayed.""" + + # Refine and clean the prompt before sending + refined_prompt = self._refine_image_prompt(prompt) + return refined_prompt + + async def generate_batch( + self, + niche: str, + count: int = 5, + images_per_ad: int = 1, + image_model: Optional[str] = None, + username: Optional[str] = None, # Username of the user generating the ads + ) -> List[Dict[str, Any]]: + """ + Generate multiple ad creatives. + Uses variety: 50% standard generation, 50% matrix generation. + + Args: + niche: Target niche + count: Number of ads to generate + images_per_ad: Images per ad + + Returns: + List of ad results (all normalized to GenerateResponse format) + """ + results = [] + + for i in range(count): + try: + # Use variety: 50% standard, 50% matrix (ensures all resources used) + use_matrix = random.random() < 0.5 # 50% chance to use matrix + + if use_matrix: + # Use angle × concept matrix approach + result = await self.generate_ad_with_matrix( + niche=niche, + num_images=images_per_ad, + image_model=image_model, + username=username, # Pass username + ) + # Normalize matrix result to standard format for batch response + # Extract matrix info and convert metadata + matrix_info = result.get("matrix", {}) + angle = matrix_info.get("angle", {}) + concept = matrix_info.get("concept", {}) + + # Convert to standard AdMetadata format + result["metadata"] = { + "strategies_used": [angle.get("trigger", "emotional_trigger")], + "creative_direction": f"Angle: {angle.get('name', '')}, Concept: {concept.get('name', '')}", + "visual_mood": concept.get("visual", "").split(".")[0] if concept.get("visual") else "authentic", + "framework": None, + "camera_angle": None, + "lighting": None, + "composition": None, + "hooks_inspiration": [angle.get("example", "")] if angle.get("example") else [], + "visual_styles": [concept.get("structure", "")] if concept.get("structure") else [], + } + # Remove matrix field as it's not in GenerateResponse + result.pop("matrix", None) + else: + # Use standard framework-based approach + result = await self.generate_ad( + niche=niche, + num_images=images_per_ad, + image_model=image_model, + username=username, # Pass username + ) + results.append(result) + except Exception as e: + results.append({ + "error": str(e), + "index": i, + }) + + return results + + +# Global instance +ad_generator = AdGenerator() diff --git a/services/image.py b/services/image.py new file mode 100644 index 0000000000000000000000000000000000000000..7ac6190494174d76b20b1b5ffd6f5bcd4800f731 --- /dev/null +++ b/services/image.py @@ -0,0 +1,393 @@ +""" +Image generation service supporting both Replicate and OpenAI APIs. +Supports multiple image generation models with automatic fallback. +""" + +import os +import sys +import time +import random +import base64 +from typing import Optional, Tuple, Dict, Any + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import replicate +import httpx +from openai import OpenAI +from config import settings + + +# Model registry - same as original creative-breakthrough project +MODEL_REGISTRY: Dict[str, Dict[str, Any]] = { + "nano-banana": { + "id": "google/nano-banana", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, + "nano-banana-pro": { + "id": "google/nano-banana-pro", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, + "imagen-4": { + "id": "google/imagen-4", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, + "imagen-4-ultra": { + "id": "google/imagen-4-ultra", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, + "z-image-turbo": { + "id": "prunaai/z-image-turbo", + "param_name": "height", + "uses_dimensions": True, + }, + "seedream-3": { + "id": "bytedance/seedream-3", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, + "recraft-v3": { + "id": "recraft-ai/recraft-v3", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, + "photon": { + "id": "luma/photon", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, + "ideogram-v3": { + "id": "ideogram-ai/ideogram-v3-quality", + "param_name": "aspect_ratio", + "uses_dimensions": False, + }, +} + +# Default model fallback chain (same as original project) +DEFAULT_FALLBACK_MODELS = ["nano-banana", "imagen-4", "z-image-turbo"] + +RETRY_ATTEMPTS = 3 +REQUEST_TIMEOUT = 60 + + +def convert_dimensions_to_aspect_ratio(width: int, height: int) -> str: + """Convert width/height to aspect ratio string.""" + if width == height: + return "1:1" + elif width > height: + ratio = width / height + if abs(ratio - 16/9) < 0.1: + return "16:9" + elif abs(ratio - 4/3) < 0.1: + return "4:3" + elif abs(ratio - 3/2) < 0.1: + return "3:2" + else: + return "16:9" + else: + ratio = height / width + if abs(ratio - 16/9) < 0.1: + return "9:16" + elif abs(ratio - 4/3) < 0.1: + return "3:4" + elif abs(ratio - 3/2) < 0.1: + return "2:3" + else: + return "9:16" + + +class ImageService: + """Image generation service supporting Replicate and OpenAI APIs.""" + + def __init__(self): + """Initialize image generation clients.""" + self.api_token = settings.replicate_api_token + if not self.api_token: + raise ValueError("REPLICATE_API_TOKEN not configured") + + self.client = replicate.Client(api_token=self.api_token) + self.default_model = settings.image_model + + # Initialize OpenAI client for gpt-image-1.5 support + self.openai_client = None + if hasattr(settings, 'openai_api_key') and settings.openai_api_key: + self.openai_client = OpenAI(api_key=settings.openai_api_key) + + def _fetch_image(self, url: str) -> Optional[bytes]: + """Fetch image from URL with retry logic.""" + for attempt in range(RETRY_ATTEMPTS): + try: + response = httpx.get( + url, + timeout=REQUEST_TIMEOUT, + headers={ + "Cache-Control": "no-cache", + "User-Agent": "AdGeneratorLite/1.0", + }, + ) + response.raise_for_status() + return response.content + except Exception as e: + if attempt == RETRY_ATTEMPTS - 1: + print(f"Failed to fetch image from {url}: {e}") + return None + time.sleep(1) + return None + + async def load_image( + self, + image_id: Optional[str] = None, + image_url: Optional[str] = None, + image_bytes: Optional[bytes] = None, + filepath: Optional[str] = None, + ) -> Optional[bytes]: + """ + Load image from various sources (database ID, URL, bytes, or filepath). + + Args: + image_id: Database ID of ad creative (will fetch from database) + image_url: Direct URL to image + image_bytes: Raw image bytes + filepath: Local file path + + Returns: + Image bytes or None if failed + """ + # Priority: bytes > filepath > URL > database ID + + if image_bytes: + return image_bytes + + if filepath: + try: + with open(filepath, "rb") as f: + return f.read() + except Exception as e: + print(f"Failed to load image from filepath {filepath}: {e}") + return None + + if image_url: + return self._fetch_image(image_url) + + if image_id: + # Try to fetch from database + try: + from services.database import db_service + ad = await db_service.get_ad_creative(image_id) + if ad: + # Try image_url first + if ad.get("image_url"): + return self._fetch_image(ad["image_url"]) + # Try local file + if ad.get("image_filename"): + filepath = os.path.join(settings.output_dir, ad["image_filename"]) + if os.path.exists(filepath): + with open(filepath, "rb") as f: + return f.read() + except Exception as e: + print(f"Failed to load image from database ID {image_id}: {e}") + return None + + return None + + def _extract_image_from_output(self, output) -> Tuple[Optional[bytes], Optional[str]]: + """ + Extract image bytes and URL from Replicate output. + + Returns: + Tuple of (image_bytes, image_url) + """ + try: + # Handle file-like object + if hasattr(output, 'read'): + url = getattr(output, 'url', None) + return output.read(), url + + # Handle URL attribute + if hasattr(output, 'url'): + url = output.url + return self._fetch_image(url), url + + # Handle list of outputs + if isinstance(output, list) and len(output) > 0: + first = output[0] + url = getattr(first, "url", str(first)) + return self._fetch_image(url), url + + # Handle string URL + if isinstance(output, str): + return self._fetch_image(output), output + + print(f"Unknown output type: {type(output)}") + return None, None + + except Exception as e: + print(f"Error extracting image from output: {e}") + return None, None + + async def generate( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + seed: Optional[int] = None, + model_key: Optional[str] = None, + image_url: Optional[str] = None, + ) -> Tuple[bytes, str, Optional[str]]: + """ + Generate an image using Replicate API (official library). + + Args: + prompt: Image generation prompt + width: Image width + height: Image height + seed: Random seed for uniqueness (if None, generates random) + model_key: Which model to use (default from config) + image_url: Optional image URL for image-to-image generation + + Returns: + Tuple of (image_bytes, model_used, image_url) + """ + # Use random seed if not provided (ensures unique images) + if seed is None: + seed = random.randint(1, 2147483647) + + # Get models to try (fallback chain) + model_key = model_key or self.default_model + + # Check if using OpenAI image generation API (gpt-image-1.5) + if model_key == "gpt-image-1.5": + if not self.openai_client: + raise ValueError("OpenAI API key not configured for gpt-image-1.5") + + try: + print("Generating image with gpt-image-1.5") + size_str = f"{width}x{height}" + + result = self.openai_client.images.generate( + model="gpt-image-1.5", + prompt=prompt, + quality="auto", + background="auto", + moderation="auto", + size=size_str, + output_format="jpeg", + output_compression=90, + ) + + if result.data and len(result.data) > 0: + image_base64 = result.data[0].b64_json + if image_base64: + image_bytes = base64.b64decode(image_base64) + print("Successfully generated image with gpt-image-1.5") + return image_bytes, "gpt-image-1.5", None + + raise Exception("No image data returned from OpenAI API") + + except Exception as e: + print(f"OpenAI image generation failed: {e}") + print("Falling back to Replicate models...") + model_key = None # Will use default fallback chain + + # Build fallback chain for Replicate models + if model_key and model_key in MODEL_REGISTRY: + models_to_try = [model_key] + [m for m in DEFAULT_FALLBACK_MODELS if m != model_key] + else: + models_to_try = DEFAULT_FALLBACK_MODELS + + last_error = None + + for current_model in models_to_try: + cfg = MODEL_REGISTRY.get(current_model) + if not cfg: + continue + + # Build input parameters + input_data = {"prompt": prompt} + + # Add image URL for image-to-image if provided (for nano-banana-pro corrections) + # Google Nano Banana expects image_input as an array + if image_url and current_model == "nano-banana-pro": + input_data["image_input"] = [image_url] + + # Add seed if supported + input_data["seed"] = seed + + # Some models use width/height, others use aspect_ratio + if cfg.get("uses_dimensions"): + input_data["width"] = width + input_data["height"] = height + else: + aspect_ratio = convert_dimensions_to_aspect_ratio(width, height) + input_data[cfg["param_name"]] = aspect_ratio + + # Retry logic + for attempt in range(RETRY_ATTEMPTS): + try: + print(f"Generating image with {current_model} (attempt {attempt + 1})") + + # Use official Replicate client + output = self.client.run(cfg["id"], input=input_data) + + # Extract image bytes and URL + image_bytes, image_url = self._extract_image_from_output(output) + + if image_bytes: + print(f"Successfully generated image with {current_model}") + return image_bytes, current_model, image_url + + except Exception as e: + last_error = e + if attempt < RETRY_ATTEMPTS - 1: + print(f"Attempt {attempt + 1} failed: {e}, retrying...") + time.sleep(2 ** attempt) # Exponential backoff + continue + + # Model failed, try next in fallback chain + print(f"Model {current_model} failed, trying next...") + + # All models failed + raise Exception(f"All image generation models failed. Last error: {last_error}") + + async def generate_with_retry( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + max_retries: int = 2, + ) -> Tuple[bytes, str, Optional[str]]: + """ + Generate image with automatic retry on failure. + Uses different random seed each attempt for variety. + + Returns: + Tuple of (image_bytes, model_used, image_url) + """ + last_error = None + + for attempt in range(max_retries + 1): + try: + seed = random.randint(1, 2147483647) + return await self.generate( + prompt=prompt, + width=width, + height=height, + seed=seed, + ) + except Exception as e: + last_error = e + if attempt < max_retries: + import asyncio + await asyncio.sleep(2) + continue + + raise last_error + + +# Global instance +image_service = ImageService() diff --git a/services/image_cleanup.py b/services/image_cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..125d419352e08ca38181adfbedfc6bdf23e0505d --- /dev/null +++ b/services/image_cleanup.py @@ -0,0 +1,178 @@ +""" +Image cleanup service for removing old temporary images. +In production, images are saved temporarily and cleaned up after a retention period. +""" + +import os +import sys +import time +from pathlib import Path +from typing import List, Optional +from datetime import datetime, timedelta + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config import settings + + +class ImageCleanupService: + """Service for cleaning up old temporary images.""" + + def __init__(self): + """Initialize cleanup service.""" + self.output_dir = Path(settings.output_dir) + self.retention_hours = settings.local_image_retention_hours + + def get_image_age_hours(self, filepath: Path) -> Optional[float]: + """ + Get the age of an image file in hours. + + Args: + filepath: Path to the image file + + Returns: + Age in hours, or None if file doesn't exist or can't determine age + """ + try: + if not filepath.exists(): + return None + + # Get file modification time + mtime = filepath.stat().st_mtime + age_seconds = time.time() - mtime + age_hours = age_seconds / 3600 + + return age_hours + except Exception as e: + print(f"Error getting age for {filepath}: {e}") + return None + + def cleanup_old_images(self, dry_run: bool = False) -> dict: + """ + Clean up images older than the retention period. + + Args: + dry_run: If True, only report what would be deleted without actually deleting + + Returns: + Dictionary with cleanup statistics + """ + if not self.output_dir.exists(): + return { + "deleted": 0, + "failed": 0, + "skipped": 0, + "total_size_mb": 0.0, + "errors": [] + } + + deleted = 0 + failed = 0 + skipped = 0 + total_size_mb = 0.0 + errors = [] + + # Find all image files in the output directory + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} + + for filepath in self.output_dir.rglob('*'): + if not filepath.is_file(): + continue + + # Check if it's an image file + if filepath.suffix.lower() not in image_extensions: + continue + + # Get file age + age_hours = self.get_image_age_hours(filepath) + + if age_hours is None: + skipped += 1 + continue + + # Check if file is older than retention period + if age_hours > self.retention_hours: + try: + # Get file size before deletion + file_size_mb = filepath.stat().st_size / (1024 * 1024) + total_size_mb += file_size_mb + + if not dry_run: + filepath.unlink() + print(f"Deleted old image: {filepath} (age: {age_hours:.2f} hours)") + + deleted += 1 + except Exception as e: + failed += 1 + error_msg = f"Failed to delete {filepath}: {e}" + errors.append(error_msg) + print(error_msg) + + result = { + "deleted": deleted, + "failed": failed, + "skipped": skipped, + "total_size_mb": round(total_size_mb, 2), + "errors": errors + } + + if dry_run: + print(f"[DRY RUN] Would delete {deleted} images ({total_size_mb:.2f} MB)") + else: + print(f"Cleanup complete: Deleted {deleted} images, freed {total_size_mb:.2f} MB") + + return result + + def get_storage_stats(self) -> dict: + """ + Get statistics about image storage. + + Returns: + Dictionary with storage statistics + """ + if not self.output_dir.exists(): + return { + "total_files": 0, + "total_size_mb": 0.0, + "oldest_file_hours": None, + "newest_file_hours": None, + } + + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} + total_files = 0 + total_size = 0 + oldest_hours = None + newest_hours = None + + for filepath in self.output_dir.rglob('*'): + if not filepath.is_file(): + continue + + if filepath.suffix.lower() not in image_extensions: + continue + + total_files += 1 + try: + file_size = filepath.stat().st_size + total_size += file_size + + age_hours = self.get_image_age_hours(filepath) + if age_hours is not None: + if oldest_hours is None or age_hours > oldest_hours: + oldest_hours = age_hours + if newest_hours is None or age_hours < newest_hours: + newest_hours = age_hours + except Exception: + pass + + return { + "total_files": total_files, + "total_size_mb": round(total_size / (1024 * 1024), 2), + "oldest_file_hours": round(oldest_hours, 2) if oldest_hours is not None else None, + "newest_file_hours": round(newest_hours, 2) if newest_hours is not None else None, + } + + +# Global instance +cleanup_service = ImageCleanupService() diff --git a/services/llm.py b/services/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..3681bc4335bf41f4dba9b77cf12a82f812cb1e56 --- /dev/null +++ b/services/llm.py @@ -0,0 +1,168 @@ +"""Minimal OpenAI LLM service for ad copy generation.""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from typing import Optional, Dict, Any, List, Union +from openai import AsyncOpenAI +import json +import base64 +from config import settings + + +class LLMService: + """Simple OpenAI wrapper for generating ad copy.""" + + def __init__(self): + """Initialize OpenAI client.""" + self.client = AsyncOpenAI(api_key=settings.openai_api_key) + self.model = settings.llm_model + self.temperature = settings.llm_temperature + self.vision_model = getattr(settings, 'vision_model', 'gpt-4o') + + async def generate( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: Optional[float] = None, + response_format: Optional[Dict[str, Any]] = None, + ) -> str: + """ + Generate text using OpenAI. + + Args: + prompt: User prompt + system_prompt: System prompt for context + temperature: Override default temperature (0.95 for variety) + response_format: JSON schema for structured output + + Returns: + Generated text + """ + messages = [] + + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + messages.append({"role": "user", "content": prompt}) + + kwargs = { + "model": self.model, + "messages": messages, + "temperature": temperature or self.temperature, + } + + # Use gpt-4o for JSON schema (required) + if response_format: + kwargs["model"] = "gpt-4o" + kwargs["response_format"] = response_format + + response = await self.client.chat.completions.create(**kwargs) + content = response.choices[0].message.content + + if content is None: + raise ValueError("OpenAI returned empty response") + + return content + + async def generate_json( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: Optional[float] = None, + ) -> Dict[str, Any]: + """ + Generate JSON output using OpenAI. + + Args: + prompt: User prompt (should request JSON output) + system_prompt: System prompt for context + temperature: Override default temperature + + Returns: + Parsed JSON dictionary + """ + # Add JSON instruction to prompt + json_prompt = f"{prompt}\n\nRespond with valid JSON only." + + response = await self.generate( + prompt=json_prompt, + system_prompt=system_prompt, + temperature=temperature, + ) + + # Parse JSON from response + try: + # Try to extract JSON from response + response = response.strip() + if response.startswith("```json"): + response = response[7:] + if response.startswith("```"): + response = response[3:] + if response.endswith("```"): + response = response[:-3] + + return json.loads(response.strip()) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse JSON response: {e}\nResponse: {response}") + + async def analyze_image_with_vision( + self, + image_bytes: bytes, + analysis_prompt: str, + system_prompt: Optional[str] = None, + ) -> str: + """ + Analyze an image using GPT-4 Vision API. + + Args: + image_bytes: Image file bytes + analysis_prompt: Prompt describing what to analyze + system_prompt: Optional system prompt for context + + Returns: + Analysis text from vision model + """ + # Convert image bytes to base64 + image_base64 = base64.b64encode(image_bytes).decode('utf-8') + image_data_url = f"data:image/png;base64,{image_base64}" + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + messages.append({ + "role": "user", + "content": [ + { + "type": "text", + "text": analysis_prompt + }, + { + "type": "image_url", + "image_url": { + "url": image_data_url + } + } + ] + }) + + response = await self.client.chat.completions.create( + model=self.vision_model, + messages=messages, + temperature=0.3, # Lower temperature for more consistent analysis + ) + + content = response.choices[0].message.content + if content is None: + raise ValueError("Vision API returned empty response") + + return content + + +# Global instance +llm_service = LLMService() + diff --git a/services/matrix.py b/services/matrix.py new file mode 100644 index 0000000000000000000000000000000000000000..b93e14f6de883ed606dc9c0a97ca52b904a00f34 --- /dev/null +++ b/services/matrix.py @@ -0,0 +1,325 @@ +""" +Angle × Concept Matrix Service + +Implements the scaling formula: +1 Offer → 5-8 Angles → 3-5 Concepts per angle → Kill fast, scale hard + +This creates systematic ad testing by generating all possible +angle × concept combinations with compatibility scoring. +""" + +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from typing import Dict, List, Any, Optional +import random +from data.angles import ( + get_all_angles, + get_random_angles, + get_angles_for_niche, + get_top_angles, + get_angle_by_key, + AngleCategory, +) +from data.concepts import ( + get_all_concepts, + get_random_concepts, + get_top_concepts, + get_concept_by_key, + get_compatible_concepts, + ConceptCategory, +) + + +class AngleConceptMatrix: + """ + Service for generating angle × concept combinations. + + Implements the scaling formula: + - Initial testing: 6 angles × 5 concepts = 30 ad variations + - Scale winners: 3 winning angles × 5-10 new concepts + """ + + def __init__(self): + """Initialize with all angles and concepts.""" + self.all_angles = get_all_angles() + self.all_concepts = get_all_concepts() + + def generate_testing_matrix( + self, + niche: Optional[str] = None, + angle_count: int = 6, + concept_count: int = 5, + strategy: str = "balanced" + ) -> List[Dict[str, Any]]: + """ + Generate initial testing matrix. + + Default: 6 angles × 5 concepts = 30 combinations + + Args: + niche: Target niche for filtering + angle_count: Number of angles to test + concept_count: Number of concepts per angle + strategy: Selection strategy (balanced, top_performers, diverse) + + Returns: + List of angle × concept combinations + """ + # Select angles + if strategy == "top_performers": + angles = get_top_angles()[:angle_count] + elif strategy == "diverse": + angles = get_random_angles(angle_count, diverse=True) + elif niche: + angles = get_angles_for_niche(niche)[:angle_count] + if len(angles) < angle_count: + # Supplement with diverse angles + extra = get_random_angles(angle_count - len(angles), diverse=True) + angles.extend(extra) + else: + # Balanced: mix of top performers and diverse + top = get_top_angles()[:angle_count // 2] + diverse = get_random_angles(angle_count - len(top), diverse=True) + angles = top + diverse + + # Select concepts + if strategy == "top_performers": + concepts = get_top_concepts() + if len(concepts) < concept_count: + concepts.extend(get_random_concepts(concept_count - len(concepts))) + else: + concepts = get_random_concepts(concept_count, diverse=True) + + # Generate combinations + combinations = [] + for angle in angles[:angle_count]: + for concept in concepts[:concept_count]: + combo = self._create_combination(angle, concept) + combinations.append(combo) + + return combinations + + def generate_scaling_matrix( + self, + winning_angle_keys: List[str], + concept_count: int = 5 + ) -> List[Dict[str, Any]]: + """ + Generate scaling matrix for winning angles. + + After initial testing, scale the winning angles with new concepts. + + Args: + winning_angle_keys: List of winning angle keys + concept_count: Number of new concepts per angle + + Returns: + List of angle × concept combinations for scaling + """ + combinations = [] + + for angle_key in winning_angle_keys: + angle = get_angle_by_key(angle_key) + if not angle: + continue + + # Get compatible concepts based on psychological trigger + trigger = angle.get("trigger", "") + compatible = get_compatible_concepts(trigger) + + # If not enough compatible, add diverse ones + if len(compatible) < concept_count: + extra = get_random_concepts(concept_count - len(compatible), diverse=True) + compatible.extend(extra) + + # Create combinations + for concept in compatible[:concept_count]: + combo = self._create_combination(angle, concept) + combinations.append(combo) + + return combinations + + def generate_single_combination( + self, + niche: Optional[str] = None + ) -> Dict[str, Any]: + """ + Generate a single random angle × concept combination. + + Good for generating one-off ads with variety. + """ + # Get random angle (niche-aware if provided) + if niche: + angles = get_angles_for_niche(niche) + angle = random.choice(angles) if angles else random.choice(self.all_angles) + else: + angle = random.choice(self.all_angles) + + # Get compatible concept based on trigger + trigger = angle.get("trigger", "") + compatible = get_compatible_concepts(trigger) + + if compatible: + concept = random.choice(compatible) + else: + concept = random.choice(self.all_concepts) + + return self._create_combination(angle, concept) + + def generate_all_permutations( + self, + angle_keys: Optional[List[str]] = None, + concept_keys: Optional[List[str]] = None, + max_combinations: int = 100 + ) -> List[Dict[str, Any]]: + """ + Generate all possible permutations. + + 100 angles × 100 concepts = 10,000 possible combinations. + Limited by max_combinations for performance. + """ + # Get angles + if angle_keys: + angles = [get_angle_by_key(k) for k in angle_keys if get_angle_by_key(k)] + else: + angles = self.all_angles + + # Get concepts + if concept_keys: + concepts = [get_concept_by_key(k) for k in concept_keys if get_concept_by_key(k)] + else: + concepts = self.all_concepts + + # Generate combinations + combinations = [] + for angle in angles: + for concept in concepts: + if len(combinations) >= max_combinations: + break + combo = self._create_combination(angle, concept) + combinations.append(combo) + if len(combinations) >= max_combinations: + break + + return combinations + + def _create_combination( + self, + angle: Dict[str, Any], + concept: Dict[str, Any] + ) -> Dict[str, Any]: + """Create an angle × concept combination with metadata.""" + compatibility = self._calculate_compatibility(angle, concept) + + return { + "combination_id": f"{angle.get('key')}_{concept.get('key')}", + "angle": { + "key": angle.get("key"), + "name": angle.get("name"), + "trigger": angle.get("trigger"), + "example": angle.get("example"), + "category": angle.get("category"), + }, + "concept": { + "key": concept.get("key"), + "name": concept.get("name"), + "structure": concept.get("structure"), + "visual": concept.get("visual"), + "category": concept.get("category"), + }, + "compatibility_score": compatibility, + "prompt_guidance": self._build_prompt_guidance(angle, concept), + } + + def _calculate_compatibility( + self, + angle: Dict[str, Any], + concept: Dict[str, Any] + ) -> float: + """ + Calculate compatibility score between angle and concept. + + Higher score = better match. + """ + score = 0.5 # Base score + + # Check trigger-concept compatibility + trigger = angle.get("trigger", "") + compatible_concepts = get_compatible_concepts(trigger) + if any(c.get("key") == concept.get("key") for c in compatible_concepts): + score += 0.3 + + # Check category compatibility + angle_cat = angle.get("category_key") + concept_cat = concept.get("category_key") + + # Good pairs + good_pairs = [ + (AngleCategory.FINANCIAL, ConceptCategory.COMPARISON), + (AngleCategory.EMOTIONAL, ConceptCategory.STORYTELLING), + (AngleCategory.SOCIAL_PROOF, ConceptCategory.SOCIAL_PROOF), + (AngleCategory.AUTHORITY, ConceptCategory.AUTHORITY), + (AngleCategory.URGENCY, ConceptCategory.SCROLL_STOPPING), + (AngleCategory.CURIOSITY, ConceptCategory.SCROLL_STOPPING), + (AngleCategory.CONVENIENCE, ConceptCategory.EDUCATIONAL), + (AngleCategory.PROBLEM_SOLUTION, ConceptCategory.STORYTELLING), + ] + + if (angle_cat, concept_cat) in good_pairs: + score += 0.2 + + return min(score, 1.0) + + def _build_prompt_guidance( + self, + angle: Dict[str, Any], + concept: Dict[str, Any] + ) -> str: + """Build prompt guidance for ad generation.""" + return f""" +ANGLE: {angle.get('name')} +- Psychological trigger: {angle.get('trigger')} +- Example hook: "{angle.get('example')}" +- Why it works: Appeals to {angle.get('trigger').lower()} + +CONCEPT: {concept.get('name')} +- Visual structure: {concept.get('structure')} +- Visual guidance: {concept.get('visual')} + +COMBINED APPROACH: +Create an ad that uses the "{angle.get('name')}" angle with a "{concept.get('name')}" visual concept. +The headline should trigger {angle.get('trigger').lower()} while the image follows the {concept.get('structure').lower()} structure. +""".strip() + + def get_matrix_summary( + self, + combinations: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """Get summary statistics for a matrix.""" + if not combinations: + return { + "total_combinations": 0, + "unique_angles": 0, + "unique_concepts": 0, + "average_compatibility": 0.0, + } + + unique_angles = set(c["angle"]["key"] for c in combinations) + unique_concepts = set(c["concept"]["key"] for c in combinations) + avg_compat = sum(c.get("compatibility_score", 0) for c in combinations) / len(combinations) + + return { + "total_combinations": len(combinations), + "unique_angles": len(unique_angles), + "unique_concepts": len(unique_concepts), + "average_compatibility": round(avg_compat, 2), + "angles_used": list(unique_angles), + "concepts_used": list(unique_concepts), + } + + +# Global instance +matrix_service = AngleConceptMatrix() + diff --git a/services/r2_storage.py b/services/r2_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..96ebcd63590724173eb1a33cb50ccbc99ceb03cd --- /dev/null +++ b/services/r2_storage.py @@ -0,0 +1,151 @@ +"""Cloudflare R2 storage service for saving generated images.""" + +import os +import sys +from typing import Optional +from datetime import datetime +import uuid + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + import boto3 + from botocore.exceptions import ClientError, BotoCoreError + BOTO3_AVAILABLE = True +except ImportError: + BOTO3_AVAILABLE = False + print("Warning: boto3 not installed. R2 storage will not be available.") + +from config import settings + + +class R2StorageService: + """Service for uploading images to Cloudflare R2.""" + + def __init__(self): + """Initialize R2 client.""" + if not BOTO3_AVAILABLE: + raise ImportError("boto3 is required for R2 storage. Install it with: pip install boto3") + + if not all([ + settings.r2_endpoint, + settings.r2_bucket_name, + settings.r2_access_key, + settings.r2_secret_key, + ]): + raise ValueError("R2 credentials not configured. Please set R2_ENDPOINT, R2_BUCKET_NAME, R2_ACCESS_KEY, and R2_SECRET_KEY in .env") + + # Initialize S3-compatible client for R2 + self.s3_client = boto3.client( + 's3', + endpoint_url=settings.r2_endpoint, + aws_access_key_id=settings.r2_access_key, + aws_secret_access_key=settings.r2_secret_key, + region_name='auto', # R2 doesn't use regions + ) + self.bucket_name = settings.r2_bucket_name + self.folder = "creative-breakthrough" + + def upload_image( + self, + image_bytes: bytes, + filename: Optional[str] = None, + niche: Optional[str] = None, + ) -> str: + """ + Upload image to R2. + + Args: + image_bytes: Image file bytes + filename: Optional filename (if not provided, generates one) + niche: Optional niche name for filename generation + + Returns: + Public URL of the uploaded image + """ + # Generate filename if not provided + if not filename: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid.uuid4())[:8] + niche_prefix = niche or "ad" + filename = f"{niche_prefix}_{timestamp}_{unique_id}.png" + + # Construct R2 key (path in bucket) + r2_key = f"{self.folder}/{filename}" + + try: + # Upload to R2 + # Note: R2 doesn't support ACL parameter, use custom domain or presigned URLs for public access + self.s3_client.put_object( + Bucket=self.bucket_name, + Key=r2_key, + Body=image_bytes, + ContentType='image/png', + ) + + # Construct public URL + # R2 public URLs work best with a custom domain + # If custom domain is set via R2_PUBLIC_DOMAIN, use it + # Otherwise, generate a presigned URL (valid for 1 year) + # Note: R2 presigned URLs from boto3 include bucket name in path, which is correct for R2 + if hasattr(settings, 'r2_public_domain') and settings.r2_public_domain: + public_url = f"https://{settings.r2_public_domain}/{r2_key}" + else: + # Generate presigned URL (valid for 1 week - R2 maximum) + # R2 presigned URLs include bucket name in path - this is correct for R2 + # R2 limits presigned URLs to max 1 week (604800 seconds) + public_url = self.s3_client.generate_presigned_url( + 'get_object', + Params={'Bucket': self.bucket_name, 'Key': r2_key}, + ExpiresIn=604800 # 1 week (R2 maximum) + ) + + print(f"Successfully uploaded image to R2: {r2_key}") + print(f"R2 URL: {public_url}") + return public_url + + except (ClientError, BotoCoreError) as e: + print(f"Error uploading to R2: {e}") + raise Exception(f"Failed to upload image to R2: {str(e)}") + + def get_public_url(self, filename: str) -> str: + """ + Get public URL for an image in R2. + + Args: + filename: Filename in R2 + + Returns: + Public URL + """ + r2_key = f"{self.folder}/{filename}" + + if hasattr(settings, 'r2_public_domain') and settings.r2_public_domain: + return f"https://{settings.r2_public_domain}/{r2_key}" + else: + # Generate presigned URL (valid for 1 week - R2 maximum) + # R2 presigned URLs include bucket name in path - this is correct for R2 + # R2 limits presigned URLs to max 1 week (604800 seconds) + return self.s3_client.generate_presigned_url( + 'get_object', + Params={'Bucket': self.bucket_name, 'Key': r2_key}, + ExpiresIn=604800 # 1 week (R2 maximum) + ) + + +# Global instance (will be None if R2 not configured) +r2_storage: Optional[R2StorageService] = None + +def get_r2_storage() -> Optional[R2StorageService]: + """Get R2 storage service instance, creating it if needed.""" + global r2_storage + + if r2_storage is None: + try: + r2_storage = R2StorageService() + except (ImportError, ValueError) as e: + print(f"R2 storage not available: {e}") + return None + + return r2_storage diff --git a/services/third_flow.py b/services/third_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..4ca84789d51bf598a01a34fd687c31151d154112 --- /dev/null +++ b/services/third_flow.py @@ -0,0 +1,625 @@ +""" +Extensive Ad Generation Service +Implements the researcher → creative director → designer → copywriter flow +Uses OpenAI GPT models with file search for knowledge retrieval +""" + +import os +import sys +import time +from typing import List +from pydantic import BaseModel + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from openai import OpenAI +from config import settings + + +# Pydantic models for structured outputs +class ImageAdEssentials(BaseModel): + phsychologyTriggers: str + angles: list[str] + concepts: list[str] + + +class ImageAdEssentialsOutput(BaseModel): + output: list[ImageAdEssentials] + + +class Text(BaseModel): + textToBeWrittern: str + color: str + placement: str + + +class CreativeStrategies(BaseModel): + phsychologyTrigger: str + angle: str + concept: str + text: Text + cta: str + visualDirection: str + titleIdeas: str + captionIdeas: str + bodyIdeas: str + + +class CreativeStrategiesOutput(BaseModel): + output: list[CreativeStrategies] + + +class AdImagePrompt(BaseModel): + prompt: str + + +class CopyWriterOutput(BaseModel): + title: str + body: str + description: str + + +class ThirdFlowService: + """Service for extensive ad generation.""" + + def __init__(self): + """Initialize OpenAI client and configuration.""" + self.client = OpenAI(api_key=settings.openai_api_key) + self.search_vector_store_id = "vs_691afcc4f8688191b01487b4a8439607" + self.ads_vector_store_id = "vs_69609db487048191a1e6b7ba0997ee39" + self.gpt_model = getattr(settings, 'third_flow_model', 'gpt-4o') + + def researcher( + self, + target_audience: str, + offer: str, + niche: str = "Home Insurance" + ) -> List[ImageAdEssentials]: + """ + Research psychology triggers, angles, and concepts. + + Args: + target_audience: Target audience description + offer: Offer to run + niche: Niche category + + Returns: + List of ImageAdEssentials with psychology triggers, angles, and concepts + """ + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": """You are the researcher for the affiliate marketing company which does research on trending angles, concepts and psychology triggers based on the user input. + Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale). + A psychology trigger is an emotional or cognitive stimulus that pushes someone toward action—clicking, signing up, or buying—before logic kicks in. + An ad angle is the reason someone should care right now. Same product → different reasons to click → different angles. + An ad concept is the creative execution style or storyline you use to deliver an angle. + In affiliate marketing 'Low-production, realistic often outperform studio creatives' runs most. + + Keeping in mind all this, make sure you provide different angles and concepts we can try based on the psychology triggers for the image ads for the given input based on affiliate marketing. + User will provide you the category on which he needs to run the ads, what is the offer he is providing and what is target audience.""" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": f"""Following are the inputs: + Niche: {niche} + Offer to run: {offer} + Target Audience: {target_audience} + + Provide the different psychology triggers, angles and concept based on the given input.""" + } + ] + } + ] + + try: + completion = self.client.beta.chat.completions.parse( + model=self.gpt_model, + messages=messages, + response_format=ImageAdEssentialsOutput, + ) + + response = completion.choices[0].message + if response.parsed: + return response.parsed.output + else: + print(f"Warning: Researcher refusal: {response.refusal}") + # Return empty list if refused + return [] + except Exception as e: + print(f"Error in researcher: {e}") + return [] + + def retrieve_search( + self, + target_audience: str, + offer: str, + niche: str = "Home Insurance" + ) -> str: + """ + Retrieve marketing book knowledge using file search with vector stores. + Args: + target_audience: Target audience description + offer: Offer to run + niche: Niche category + + Returns: + Retrieved knowledge text from vector store + """ + try: + # Method 1: Try using responses.create + if hasattr(self.client, 'responses') and hasattr(self.client.responses, 'create'): + try: + search = self.client.responses.create( + model="gpt-4o", + input=f"Find {niche} creative strategies relevant to ad images related to target audience: {target_audience} and offer: {offer}. The image ads are associated with performance marketing and affiliate marketing ads. If there is nothing related to this category then take reference from everything and make strategies. Make sure you go through each and every document present and from each file you should give results.", + tools=[ + { + "type": "file_search", + "vector_store_ids": [self.search_vector_store_id] + } + ] + ) + result = search.output[1].content[0].text + print("✓ Used responses.create API for search retrieval") + return result + except Exception as custom_api_error: + error_str = str(custom_api_error) + print(f"⚠️ responses.create API error: {error_str[:100]}...") + raise Exception(f"Failed to retrieve search knowledge via responses.create: {error_str}") from custom_api_error + + # Method 2: Try using Assistants API (official OpenAI method) + query = f"Find {niche} creative strategies relevant to ad images related to target audience: {target_audience} and offer: {offer}. The image ads are associated with performance marketing and affiliate marketing ads. If there is nothing related to this category then take reference from everything and make strategies. Make sure you go through each and every document present and from each file you should give results." + + try: + # Create assistant with vector store + assistant = self.client.beta.assistants.create( + name="Marketing Knowledge Assistant", + instructions="You are a marketing research assistant. Search through the provided documents and extract relevant creative strategies and knowledge.", + model="gpt-4o", + tools=[{"type": "file_search"}], + tool_resources={ + "file_search": { + "vector_store_ids": [self.search_vector_store_id] + } + } + ) + + # Create a thread and run + thread = self.client.beta.threads.create() + message = self.client.beta.threads.messages.create( + thread_id=thread.id, + role="user", + content=query + ) + + # Run the assistant + run = self.client.beta.threads.runs.create( + thread_id=thread.id, + assistant_id=assistant.id + ) + + # Wait for completion + import time + while run.status in ['queued', 'in_progress']: + time.sleep(1) + run = self.client.beta.threads.runs.retrieve( + thread_id=thread.id, + run_id=run.id + ) + + if run.status == 'completed': + # Get the messages + messages = self.client.beta.threads.messages.list( + thread_id=thread.id + ) + # Get the assistant's response + for msg in messages.data: + if msg.role == 'assistant': + if msg.content[0].type == 'text': + result = msg.content[0].text.value + # Clean up + self.client.beta.assistants.delete(assistant.id) + return result + + # Clean up + self.client.beta.assistants.delete(assistant.id) + + except Exception as api_error: + error_str = str(api_error) + print(f"⚠️ Assistants API error: {error_str[:100]}...") + raise Exception(f"Failed to retrieve search knowledge: {error_str}") from api_error + + # If we reach here, both methods failed + raise Exception("Failed to retrieve search knowledge: Both API methods failed") + + except Exception as e: + print(f"Error in retrieve_search: {e}") + raise + + def retrieve_ads( + self, + target_audience: str, + offer: str, + niche: str = "Home Insurance" + ) -> str: + """ + Retrieve old ads knowledge using file search with vector stores. + + Args: + target_audience: Target audience description + offer: Offer to run + niche: Niche category + + Returns: + Retrieved ads knowledge text from vector store + """ + try: + # Method 1: Try using responses.create + if hasattr(self.client, 'responses') and hasattr(self.client.responses, 'create'): + try: + search = self.client.responses.create( + model="gpt-4o", + input=f"Find {niche} creative ad ideas relevant to ad images related to target audience: {target_audience} and offer: {offer}. The image ads are associated with performance marketing and affiliate marketing ads.", + tools=[ + { + "type": "file_search", + "vector_store_ids": [self.ads_vector_store_id] + } + ] + ) + result = search.output[1].content[0].text + print("✓ Used responses.create API for ads retrieval") + return result + except Exception as custom_api_error: + error_str = str(custom_api_error) + print(f"⚠️ responses.create API error: {error_str[:100]}...") + raise Exception(f"Failed to retrieve search knowledge via responses.create: {error_str}") from custom_api_error + + # Method 2: Try using Assistants API (official OpenAI method) + query = f"Find {niche} creative ad ideas relevant to ad images related to target audience: {target_audience} and offer: {offer}. The image ads are associated with performance marketing and affiliate marketing ads." + + try: + # Create assistant with vector store + assistant = self.client.beta.assistants.create( + name="Ads Knowledge Assistant", + instructions="You are a marketing research assistant. Search through the provided ad examples and extract relevant creative ideas and patterns.", + model="gpt-4o", + tools=[{"type": "file_search"}], + tool_resources={ + "file_search": { + "vector_store_ids": [self.ads_vector_store_id] + } + } + ) + + # Create a thread and run + thread = self.client.beta.threads.create() + message = self.client.beta.threads.messages.create( + thread_id=thread.id, + role="user", + content=query + ) + + # Run the assistant + run = self.client.beta.threads.runs.create( + thread_id=thread.id, + assistant_id=assistant.id + ) + + # Wait for completion + import time + while run.status in ['queued', 'in_progress']: + time.sleep(1) + run = self.client.beta.threads.runs.retrieve( + thread_id=thread.id, + run_id=run.id + ) + + if run.status == 'completed': + # Get the messages + messages = self.client.beta.threads.messages.list( + thread_id=thread.id + ) + # Get the assistant's response + for msg in messages.data: + if msg.role == 'assistant': + if msg.content[0].type == 'text': + result = msg.content[0].text.value + # Clean up + self.client.beta.assistants.delete(assistant.id) + return result + + # Clean up + self.client.beta.assistants.delete(assistant.id) + + except Exception as api_error: + error_str = str(api_error) + print(f"⚠️ Assistants API error: {error_str[:100]}...") + raise Exception(f"Failed to retrieve ads knowledge: {error_str}") from api_error + + # If we reach here, both methods failed + raise Exception("Failed to retrieve ads knowledge: Both API methods failed") + + except Exception as e: + print(f"Error in retrieve_ads: {e}") + raise + + def creative_director( + self, + researcher_output: List[ImageAdEssentials], + book_knowledge: str, + ads_knowledge: str, + target_audience: str, + offer: str, + niche: str = "Home Insurance", + n: int = 5 + ) -> List[CreativeStrategies]: + """ + Create creative strategies based on research. + + Args: + researcher_output: Output from researcher function + book_knowledge: Knowledge from marketing books + ads_knowledge: Knowledge from old ads + target_audience: Target audience description + offer: Offer to run + niche: Niche category + n: Number of strategies to generate + + Returns: + List of CreativeStrategies + """ + # Convert researcher_output to string for prompt + researcher_str = "\n".join([ + f"Psychology Triggers: {item.phsychologyTriggers}\n" + f"Angles: {', '.join(item.angles)}\n" + f"Concepts: {', '.join(item.concepts)}" + for item in researcher_output + ]) + + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": f"""You are the Creative Director for the affiliate marketing company which make creative strategies for the image ads on the basis of the research given and user's input. + The research work includes the psychology triggers, angles and different concepts. Your work is to finalise the {n} strategies based on the research. + There will also be researched content from the different marketing books. Along with these there will information about the old ads information which are winner. + Make the strongest patterns for the image ads, which should include about what types of visual should be their, colors, what should be the text, what should be the tone of the text with it's placement and CTA. + Along with this provide the title ideas and description/caption which should be added with the image ad. It will complete the full ad copy. + If the image should include only visuals then text field must return None or NA. + What information you should give make sure you give in brief and well defined. + Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale). + In affiliate marketing 'Low-production, realistic images often outperform studio creatives' runs most. + Role of the Title: Stop the scroll and trigger emotion. + Role of Body: The body is the main paragraph text which should Explain just enough, Reduce anxiety, Stay compliant and Push to the next step. + Role of Description: Reduce friction and justify the click. + + Keeping in mind all this, make sure you provide different creative strategies for the image ads for the given input based on affiliate marketing. + User will provide you the category on which he needs to run the ads, what is the offer he is providing and what is target audience, along with the research.""" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": f"""Following are the inputs: + Researched Content: {researcher_str} + Researched Content from marketing books: {book_knowledge} + Old Ads Data: {ads_knowledge} + Niche: {niche} + Offer to run: {offer} + Target Audience: {target_audience} + + Provide the different creative strategies based on the given input.""" + } + ] + } + ] + + try: + completion = self.client.beta.chat.completions.parse( + model=self.gpt_model, + messages=messages, + response_format=CreativeStrategiesOutput, + ) + + response = completion.choices[0].message + if response.parsed: + return response.parsed.output + else: + print(f"Warning: Creative director refusal: {response.refusal}") + return [] + except Exception as e: + print(f"Error in creative_director: {e}") + return [] + + def creative_designer(self, creative_strategy: CreativeStrategies) -> str: + """ + Generate image prompt from creative strategy. + + Args: + creative_strategy: Creative strategy object + + Returns: + Image generation prompt + """ + strategy_str = f"""Psychology Trigger: {creative_strategy.phsychologyTrigger} + Angle: {creative_strategy.angle} + Concept: {creative_strategy.concept} + Text: {creative_strategy.text.textToBeWrittern if creative_strategy.text.textToBeWrittern not in [None, 'None', 'NA'] else 'No text overlay'} + CTA: {creative_strategy.cta} + Visual Direction: {creative_strategy.visualDirection} + """ + + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": """You are the Creative Designer for the affiliate marketing company which makes the prompt from creative strategy given for the ad images in the affiliate marketing. + Nano Banana image model will be used to generate the images + Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale). + In affiliate marketing 'Low-production, realistic images often outperform studio creatives' runs most. + + For nano banana image model here's structure for the prompt: [The Hook] + [The Subject] + [The Context/Setting] + [The Technical Polish]""" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": f"""Following is the creative strategy: + {strategy_str} + Provide the image prompt for the given creative strategy.""" + } + ] + } + ] + + try: + completion = self.client.beta.chat.completions.parse( + model=self.gpt_model, + messages=messages, + response_format=AdImagePrompt, + ) + + response = completion.choices[0].message + if response.parsed: + return response.parsed.prompt + else: + print(f"Warning: Creative designer refusal: {response.refusal}") + return "" + except Exception as e: + print(f"Error in creative_designer: {e}") + return "" + + def copy_writer(self, creative_strategy: CreativeStrategies) -> CopyWriterOutput: + """ + Generate ad copy from creative strategy. + + Args: + creative_strategy: Creative strategy object + + Returns: + CopyWriterOutput with title, body, and description + """ + strategy_str = f"""Psychology Trigger: {creative_strategy.phsychologyTrigger} + Angle: {creative_strategy.angle} + Concept: {creative_strategy.concept} + CTA: {creative_strategy.cta} + Title Ideas: {creative_strategy.titleIdeas} + Caption Ideas: {creative_strategy.captionIdeas} + Body Ideas: {creative_strategy.bodyIdeas} + """ + + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": """You are the Copy Writer Designer for the affiliate marketing company which makes the ad copies from creative strategy given for the ad images in the affiliate marketing. + The ad copy must include the title, body and description related to the strategies. + Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale). + + Role of the Title: Stop the scroll and trigger emotion. + 1. The title is not for explaining. It's for interrupting attention. + 2. Short titles win because they are scan-friendly. + 3. Use Plain, Human Language. No marketing buzzwords. + 4. Imply, Don't Explain. Leave an open loop. + 5. Avoid Hard Claims (Compliance). Especially for insurance & finance. + + Role of Body: The body is the main paragraph text which should Explain just enough, Reduce anxiety, Stay compliant and Push to the next step. + 1. Body Must Match the Title Emotion. If the title creates fear, the body must relieve it, not amplify it. + 2. Use "Soft Education," Not Sales. The body should feel informational, not promotional. + 3. Add Friction Reducers. You must explicitly reduce effort and risk. + 4. Body Should NOT Contain the CTA. CTA belongs in the button, not the body. + + Role of Description: Reduce friction and justify the click. + 1. Never Repeat the Title. The description should complete the thought. + 2. Answer Silent Objections. + 3. Soft CTA Only. Descriptions should invite, not push.""" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": f"""Following is the creative strategy: + {strategy_str} + Provide the title and description prompt for the given creative strategy.""" + } + ] + } + ] + + try: + completion = self.client.beta.chat.completions.parse( + model=self.gpt_model, + messages=messages, + response_format=CopyWriterOutput, + ) + + response = completion.choices[0].message + if response.parsed: + return response.parsed + else: + print(f"Warning: Copy writer refusal: {response.refusal}") + # Return default values + return CopyWriterOutput( + title="", + body="", + description="" + ) + except Exception as e: + print(f"Error in copy_writer: {e}") + return CopyWriterOutput( + title="", + body="", + description="" + ) + + def process_strategy( + self, + creative_strategy: CreativeStrategies + ) -> tuple[str, str, str, str]: + """ + Process a single creative strategy to generate prompt and copy. + + Args: + creative_strategy: Creative strategy object + + Returns: + Tuple of (prompt, title, body, description) + """ + prompt = self.creative_designer(creative_strategy) + ad_copy = self.copy_writer(creative_strategy) + return ( + prompt, + ad_copy.title, + ad_copy.body, + ad_copy.description + ) + + +# Global service instance +third_flow_service = ThirdFlowService()