Commit
·
f201243
1
Parent(s):
3ea0e86
Initial commit of the Ad Generator Lite project, including backend services, frontend components, and configuration files. Added core functionalities for ad generation, user management, and image processing, along with a structured matrix system for ad testing.
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +129 -0
- Dockerfile +32 -0
- README.md +154 -10
- config.py +70 -0
- create_user.py +185 -0
- data/__init__.py +2 -0
- data/angles.py +297 -0
- data/concepts.py +282 -0
- data/containers.py +434 -0
- data/frameworks.py +393 -0
- data/glp1.py +683 -0
- data/home_insurance.py +741 -0
- data/hooks.py +156 -0
- data/triggers.py +141 -0
- data/visuals.py +126 -0
- frontend/.gitignore +41 -0
- frontend/README.md +144 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/gallery/[id]/page.tsx +512 -0
- frontend/app/gallery/page.tsx +190 -0
- frontend/app/generate/batch/page.tsx +151 -0
- frontend/app/generate/matrix/page.tsx +198 -0
- frontend/app/generate/page.tsx +563 -0
- frontend/app/globals.css +244 -0
- frontend/app/layout.tsx +33 -0
- frontend/app/login/page.tsx +128 -0
- frontend/app/matrix/angles/page.tsx +134 -0
- frontend/app/matrix/concepts/page.tsx +132 -0
- frontend/app/matrix/page.tsx +107 -0
- frontend/app/matrix/testing/page.tsx +30 -0
- frontend/app/page.tsx +270 -0
- frontend/components/gallery/AdCard.tsx +108 -0
- frontend/components/gallery/FilterBar.tsx +80 -0
- frontend/components/gallery/GalleryGrid.tsx +50 -0
- frontend/components/generation/AdPreview.tsx +357 -0
- frontend/components/generation/BatchForm.tsx +139 -0
- frontend/components/generation/CorrectionModal.tsx +352 -0
- frontend/components/generation/ExtensiveForm.tsx +166 -0
- frontend/components/generation/GenerationForm.tsx +105 -0
- frontend/components/generation/GenerationProgress.tsx +217 -0
- frontend/components/layout/ConditionalHeader.tsx +16 -0
- frontend/components/layout/Header.tsx +100 -0
- frontend/components/matrix/AngleSelector.tsx +133 -0
- frontend/components/matrix/ConceptSelector.tsx +208 -0
- frontend/components/matrix/TestingMatrixBuilder.tsx +436 -0
- frontend/components/ui/Button.tsx +57 -0
- frontend/components/ui/Card.tsx +79 -0
- frontend/components/ui/Input.tsx +51 -0
- frontend/components/ui/LoadingSkeleton.tsx +36 -0
- frontend/components/ui/LoadingSpinner.tsx +54 -0
.gitignore
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
share/python-wheels/
|
| 20 |
+
*.egg-info/
|
| 21 |
+
.installed.cfg
|
| 22 |
+
*.egg
|
| 23 |
+
MANIFEST
|
| 24 |
+
|
| 25 |
+
# Virtual Environment
|
| 26 |
+
venv/
|
| 27 |
+
env/
|
| 28 |
+
ENV/
|
| 29 |
+
env.bak/
|
| 30 |
+
venv.bak/
|
| 31 |
+
.venv
|
| 32 |
+
|
| 33 |
+
# Environment variables
|
| 34 |
+
.env
|
| 35 |
+
.env.local
|
| 36 |
+
.env.*.local
|
| 37 |
+
.env.production
|
| 38 |
+
.env.development
|
| 39 |
+
|
| 40 |
+
# IDE / Editors
|
| 41 |
+
.vscode/
|
| 42 |
+
.idea/
|
| 43 |
+
*.swp
|
| 44 |
+
*.swo
|
| 45 |
+
*~
|
| 46 |
+
.DS_Store
|
| 47 |
+
*.sublime-project
|
| 48 |
+
*.sublime-workspace
|
| 49 |
+
|
| 50 |
+
# Jupyter Notebook
|
| 51 |
+
.ipynb_checkpoints
|
| 52 |
+
|
| 53 |
+
# pytest
|
| 54 |
+
.pytest_cache/
|
| 55 |
+
.coverage
|
| 56 |
+
htmlcov/
|
| 57 |
+
.tox/
|
| 58 |
+
.hypothesis/
|
| 59 |
+
|
| 60 |
+
# mypy
|
| 61 |
+
.mypy_cache/
|
| 62 |
+
.dmypy.json
|
| 63 |
+
dmypy.json
|
| 64 |
+
|
| 65 |
+
# Pyre type checker
|
| 66 |
+
.pyre/
|
| 67 |
+
|
| 68 |
+
# pytype static type analyzer
|
| 69 |
+
.pytype/
|
| 70 |
+
|
| 71 |
+
# Node.js / Next.js (root level)
|
| 72 |
+
node_modules/
|
| 73 |
+
npm-debug.log*
|
| 74 |
+
yarn-debug.log*
|
| 75 |
+
yarn-error.log*
|
| 76 |
+
.pnpm-debug.log*
|
| 77 |
+
|
| 78 |
+
# Next.js (if built at root)
|
| 79 |
+
.next/
|
| 80 |
+
out/
|
| 81 |
+
build/
|
| 82 |
+
.vercel
|
| 83 |
+
|
| 84 |
+
# Generated assets
|
| 85 |
+
assets/generated/*
|
| 86 |
+
!assets/generated/.gitkeep
|
| 87 |
+
|
| 88 |
+
# Database
|
| 89 |
+
*.db
|
| 90 |
+
*.sqlite
|
| 91 |
+
*.sqlite3
|
| 92 |
+
|
| 93 |
+
# Logs
|
| 94 |
+
*.log
|
| 95 |
+
logs/
|
| 96 |
+
*.log.*
|
| 97 |
+
|
| 98 |
+
# OS
|
| 99 |
+
.DS_Store
|
| 100 |
+
.DS_Store?
|
| 101 |
+
._*
|
| 102 |
+
.Spotlight-V100
|
| 103 |
+
.Trashes
|
| 104 |
+
ehthumbs.db
|
| 105 |
+
Thumbs.db
|
| 106 |
+
|
| 107 |
+
# Temporary files
|
| 108 |
+
*.tmp
|
| 109 |
+
*.temp
|
| 110 |
+
*.bak
|
| 111 |
+
*.swp
|
| 112 |
+
*~
|
| 113 |
+
|
| 114 |
+
# Docker
|
| 115 |
+
.dockerignore
|
| 116 |
+
|
| 117 |
+
# Testing
|
| 118 |
+
.pytest_cache/
|
| 119 |
+
.coverage
|
| 120 |
+
htmlcov/
|
| 121 |
+
.tox/
|
| 122 |
+
|
| 123 |
+
# TypeScript
|
| 124 |
+
*.tsbuildinfo
|
| 125 |
+
|
| 126 |
+
# Misc
|
| 127 |
+
*.pem
|
| 128 |
+
.cache/
|
| 129 |
+
.temp/
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
gcc \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements first for better caching
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy application code
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Create output directory for generated images
|
| 22 |
+
RUN mkdir -p assets/generated
|
| 23 |
+
|
| 24 |
+
# Expose port (Hugging Face Spaces uses port 7860 by default, but we'll use 8000)
|
| 25 |
+
EXPOSE 8000
|
| 26 |
+
|
| 27 |
+
# Set environment variables
|
| 28 |
+
ENV PYTHONUNBUFFERED=1
|
| 29 |
+
ENV PORT=8000
|
| 30 |
+
|
| 31 |
+
# Run the application
|
| 32 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,154 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ad Generator Lite
|
| 2 |
+
|
| 3 |
+
Generate high-converting ad creatives for Home Insurance and GLP-1 niches using psychological triggers and AI-powered image generation.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Multiple Generation Modes**:
|
| 8 |
+
- Standard generation with randomization
|
| 9 |
+
- Batch generation for multiple ads
|
| 10 |
+
- Angle × Concept matrix system (100 angles × 100 concepts)
|
| 11 |
+
- Extensive generation with researcher → creative director → designer → copywriter flow
|
| 12 |
+
|
| 13 |
+
- **Image Generation**: Supports multiple models (z-image-turbo, nano-banana, nano-banana-pro, imagen-4-ultra, recraft-v3, ideogram-v3, photon, seedream-3)
|
| 14 |
+
|
| 15 |
+
- **Image Correction**: AI-powered image correction for spelling mistakes and visual issues
|
| 16 |
+
|
| 17 |
+
- **Database Storage**: MongoDB integration for storing generated ads
|
| 18 |
+
|
| 19 |
+
- **Authentication**: JWT-based authentication system
|
| 20 |
+
|
| 21 |
+
## Setup
|
| 22 |
+
|
| 23 |
+
### Environment Variables
|
| 24 |
+
|
| 25 |
+
Copy `.env.example` to `.env` and fill in your API keys:
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
cp .env.example .env
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Required variables:
|
| 32 |
+
- `OPENAI_API_KEY`: Your OpenAI API key
|
| 33 |
+
- `REPLICATE_API_TOKEN`: Your Replicate API token
|
| 34 |
+
- `JWT_SECRET_KEY`: Secret key for JWT tokens (change in production!)
|
| 35 |
+
|
| 36 |
+
Optional variables:
|
| 37 |
+
- `MONGODB_URL`: MongoDB connection string (for database features)
|
| 38 |
+
- R2 Storage credentials (for cloud image storage)
|
| 39 |
+
|
| 40 |
+
### Running Locally
|
| 41 |
+
|
| 42 |
+
1. Install dependencies:
|
| 43 |
+
```bash
|
| 44 |
+
pip install -r requirements.txt
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
2. Run the server:
|
| 48 |
+
```bash
|
| 49 |
+
uvicorn main:app --reload
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
3. Access the API at `http://localhost:8000`
|
| 53 |
+
|
| 54 |
+
### API Documentation
|
| 55 |
+
|
| 56 |
+
Once running, visit:
|
| 57 |
+
- API docs: `http://localhost:8000/docs`
|
| 58 |
+
- Alternative docs: `http://localhost:8000/redoc`
|
| 59 |
+
|
| 60 |
+
## Deployment on Hugging Face Spaces
|
| 61 |
+
|
| 62 |
+
This app is configured for deployment on Hugging Face Spaces using Docker.
|
| 63 |
+
|
| 64 |
+
### Steps to Deploy
|
| 65 |
+
|
| 66 |
+
1. **Create a new Space** on Hugging Face:
|
| 67 |
+
- Go to https://huggingface.co/spaces
|
| 68 |
+
- Click "Create new Space"
|
| 69 |
+
- Choose "Docker" as the SDK
|
| 70 |
+
- Name your space (e.g., `your-username/ad-generator-lite`)
|
| 71 |
+
|
| 72 |
+
2. **Push your code** to the Space:
|
| 73 |
+
```bash
|
| 74 |
+
git clone https://huggingface.co/spaces/your-username/ad-generator-lite
|
| 75 |
+
cd ad-generator-lite
|
| 76 |
+
# Copy your files here
|
| 77 |
+
git add .
|
| 78 |
+
git commit -m "Initial commit"
|
| 79 |
+
git push
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
3. **Set Environment Variables**:
|
| 83 |
+
- Go to your Space settings
|
| 84 |
+
- Navigate to "Variables and secrets"
|
| 85 |
+
- Add all required environment variables from `.env.example`
|
| 86 |
+
- **Important**: Set `JWT_SECRET_KEY` to a secure random string
|
| 87 |
+
|
| 88 |
+
4. **Wait for Build**:
|
| 89 |
+
- Hugging Face will automatically build and deploy your Docker container
|
| 90 |
+
- Check the "Logs" tab for build progress
|
| 91 |
+
|
| 92 |
+
### Space Configuration
|
| 93 |
+
|
| 94 |
+
The `huggingface.yml` file configures:
|
| 95 |
+
- Docker-based deployment
|
| 96 |
+
- Port 8000 for the FastAPI app
|
| 97 |
+
- Health check endpoint
|
| 98 |
+
|
| 99 |
+
### Accessing Your Deployed App
|
| 100 |
+
|
| 101 |
+
Once deployed, your app will be available at:
|
| 102 |
+
```
|
| 103 |
+
https://your-username-ad-generator-lite.hf.space
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
## API Endpoints
|
| 107 |
+
|
| 108 |
+
### Authentication
|
| 109 |
+
- `POST /auth/login` - Login and get JWT token
|
| 110 |
+
|
| 111 |
+
### Generation
|
| 112 |
+
- `POST /generate` - Generate single ad (requires auth)
|
| 113 |
+
- `POST /generate/batch` - Generate multiple ads (requires auth)
|
| 114 |
+
- `POST /matrix/generate` - Generate using Angle × Concept matrix (requires auth)
|
| 115 |
+
- `POST /matrix/testing` - Generate testing matrix
|
| 116 |
+
- `POST /extensive/generate` - Extensive generation flow (requires auth)
|
| 117 |
+
|
| 118 |
+
### Matrix System
|
| 119 |
+
- `GET /matrix/angles` - List all 100 angles
|
| 120 |
+
- `GET /matrix/concepts` - List all 100 concepts
|
| 121 |
+
- `GET /matrix/angle/{key}` - Get specific angle details
|
| 122 |
+
- `GET /matrix/concept/{key}` - Get specific concept details
|
| 123 |
+
- `GET /matrix/compatible/{angle_key}` - Get compatible concepts
|
| 124 |
+
|
| 125 |
+
### Image Correction
|
| 126 |
+
- `POST /api/correct` - Correct image for spelling/visual issues (requires auth)
|
| 127 |
+
|
| 128 |
+
### Database
|
| 129 |
+
- `GET /db/stats` - Get database statistics (requires auth)
|
| 130 |
+
- `GET /db/ads` - List stored ads (requires auth)
|
| 131 |
+
- `GET /db/ad/{ad_id}` - Get specific ad
|
| 132 |
+
- `DELETE /db/ad/{ad_id}` - Delete ad (requires auth)
|
| 133 |
+
|
| 134 |
+
### Health
|
| 135 |
+
- `GET /health` - Health check
|
| 136 |
+
- `GET /` - API information
|
| 137 |
+
|
| 138 |
+
## Supported Niches
|
| 139 |
+
|
| 140 |
+
- `home_insurance`: Fear, urgency, savings, authority, guilt strategies
|
| 141 |
+
- `glp1`: Shame, transformation, FOMO, authority, simplicity strategies
|
| 142 |
+
|
| 143 |
+
## Matrix System
|
| 144 |
+
|
| 145 |
+
The app includes a systematic Angle × Concept matrix:
|
| 146 |
+
- **100 Angles**: Psychological triggers (10 categories)
|
| 147 |
+
- **100 Concepts**: Visual approaches (10 categories)
|
| 148 |
+
- **10,000 possible combinations**
|
| 149 |
+
|
| 150 |
+
Formula: 1 Offer → 5-8 Angles → 3-5 Concepts per angle
|
| 151 |
+
|
| 152 |
+
## License
|
| 153 |
+
|
| 154 |
+
[Add your license here]
|
config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration settings for the ad generator."""
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
"""Application settings loaded from environment variables."""
|
| 9 |
+
|
| 10 |
+
model_config = SettingsConfigDict(
|
| 11 |
+
env_file=".env",
|
| 12 |
+
env_file_encoding="utf-8",
|
| 13 |
+
case_sensitive=False,
|
| 14 |
+
extra="ignore",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# OpenAI API Key (required for copy generation)
|
| 18 |
+
openai_api_key: str
|
| 19 |
+
|
| 20 |
+
# Replicate API Token (required for image generation)
|
| 21 |
+
replicate_api_token: str
|
| 22 |
+
|
| 23 |
+
# Database (MongoDB)
|
| 24 |
+
mongodb_url: Optional[str] = None
|
| 25 |
+
mongodb_db_name: str = "creative_breakthrough"
|
| 26 |
+
|
| 27 |
+
# R2 Storage (Cloudflare R2)
|
| 28 |
+
r2_endpoint: Optional[str] = None
|
| 29 |
+
r2_bucket_name: Optional[str] = None
|
| 30 |
+
r2_access_key: Optional[str] = None
|
| 31 |
+
r2_secret_key: Optional[str] = None
|
| 32 |
+
r2_public_domain: Optional[str] = None # Optional: Custom domain for public URLs (e.g., "cdn.example.com")
|
| 33 |
+
|
| 34 |
+
# LLM Settings
|
| 35 |
+
llm_model: str = "gpt-4o-mini" # Cost-effective model
|
| 36 |
+
llm_temperature: float = 0.95 # High for variety
|
| 37 |
+
|
| 38 |
+
# Vision API Settings
|
| 39 |
+
vision_model: str = "gpt-4o" # Vision-capable model for image analysis
|
| 40 |
+
|
| 41 |
+
# Third Flow (Extensive) GPT Model Settings
|
| 42 |
+
third_flow_model: str = "gpt-4o" # Model for researcher, creative_director, designer, copywriter
|
| 43 |
+
# Options: "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4"
|
| 44 |
+
|
| 45 |
+
# Image Generation Settings (same models as creative-breakthrough project)
|
| 46 |
+
image_model: str = "z-image-turbo" # Z-Image Turbo - fast and high quality
|
| 47 |
+
# Alternative models: "nano-banana", "nano-banana-pro", "imagen-4-ultra", "recraft-v3", "ideogram-v3", "photon", "seedream-3", "gpt-image-1.5"
|
| 48 |
+
image_width: int = 1024
|
| 49 |
+
image_height: int = 1024
|
| 50 |
+
|
| 51 |
+
# Output Settings
|
| 52 |
+
output_dir: str = "assets/generated"
|
| 53 |
+
|
| 54 |
+
# Production & Storage Settings
|
| 55 |
+
environment: str = "development" # "development" or "production"
|
| 56 |
+
save_images_locally: bool = True # Whether to save images locally
|
| 57 |
+
local_image_retention_hours: int = 24 # Hours to keep images locally before cleanup (only in production)
|
| 58 |
+
|
| 59 |
+
# Auth Settings
|
| 60 |
+
jwt_secret_key: str = "your-secret-key-change-in-production" # Change this in production!
|
| 61 |
+
jwt_algorithm: str = "HS256"
|
| 62 |
+
jwt_expiration_hours: int = 24
|
| 63 |
+
|
| 64 |
+
# Debug
|
| 65 |
+
debug: bool = False
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# Global settings instance
|
| 69 |
+
settings = Settings()
|
| 70 |
+
|
create_user.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
CLI script to create users manually.
|
| 4 |
+
This script is for backend-only user creation (not exposed in frontend).
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python create_user.py <username> <password>
|
| 8 |
+
|
| 9 |
+
Example:
|
| 10 |
+
python create_user.py admin mypassword123
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import sys
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
# Add current directory to path
|
| 18 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 19 |
+
|
| 20 |
+
from services.database import db_service
|
| 21 |
+
from services.auth import auth_service
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def create_user(username: str, password: str):
|
| 25 |
+
"""Create a new user."""
|
| 26 |
+
# Connect to database
|
| 27 |
+
print("Connecting to database...")
|
| 28 |
+
connected = await db_service.connect()
|
| 29 |
+
if not connected:
|
| 30 |
+
print("❌ Failed to connect to database. Please check your MONGODB_URL configuration.")
|
| 31 |
+
return False
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
# Check if user already exists
|
| 35 |
+
existing_user = await db_service.get_user(username)
|
| 36 |
+
if existing_user:
|
| 37 |
+
print(f"❌ User '{username}' already exists!")
|
| 38 |
+
return False
|
| 39 |
+
|
| 40 |
+
# Hash password
|
| 41 |
+
print(f"Creating user '{username}'...")
|
| 42 |
+
hashed_password = auth_service.hash_password(password)
|
| 43 |
+
|
| 44 |
+
# Create user
|
| 45 |
+
user_id = await db_service.create_user(username, hashed_password)
|
| 46 |
+
|
| 47 |
+
if user_id:
|
| 48 |
+
print(f"✅ User '{username}' created successfully!")
|
| 49 |
+
print(f" User ID: {user_id}")
|
| 50 |
+
print(f"\nYou can now login with:")
|
| 51 |
+
print(f" Username: {username}")
|
| 52 |
+
print(f" Password: {password}")
|
| 53 |
+
return True
|
| 54 |
+
else:
|
| 55 |
+
print(f"❌ Failed to create user '{username}'")
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"❌ Error creating user: {e}")
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
finally:
|
| 63 |
+
await db_service.disconnect()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def list_users():
|
| 67 |
+
"""List all users."""
|
| 68 |
+
print("Connecting to database...")
|
| 69 |
+
connected = await db_service.connect()
|
| 70 |
+
if not connected:
|
| 71 |
+
print("❌ Failed to connect to database. Please check your MONGODB_URL configuration.")
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
users = await db_service.list_users()
|
| 76 |
+
if not users:
|
| 77 |
+
print("No users found.")
|
| 78 |
+
else:
|
| 79 |
+
print(f"\nFound {len(users)} user(s):\n")
|
| 80 |
+
for user in users:
|
| 81 |
+
print(f" - {user['username']}")
|
| 82 |
+
print(f" Created: {user.get('created_at', 'N/A')}")
|
| 83 |
+
print()
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"❌ Error listing users: {e}")
|
| 87 |
+
|
| 88 |
+
finally:
|
| 89 |
+
await db_service.disconnect()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def delete_user(username: str):
|
| 93 |
+
"""Delete a user."""
|
| 94 |
+
print("Connecting to database...")
|
| 95 |
+
connected = await db_service.connect()
|
| 96 |
+
if not connected:
|
| 97 |
+
print("❌ Failed to connect to database. Please check your MONGODB_URL configuration.")
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
# Confirm deletion
|
| 102 |
+
confirm = input(f"Are you sure you want to delete user '{username}'? (yes/no): ")
|
| 103 |
+
if confirm.lower() != "yes":
|
| 104 |
+
print("Cancelled.")
|
| 105 |
+
return False
|
| 106 |
+
|
| 107 |
+
deleted = await db_service.delete_user(username)
|
| 108 |
+
if deleted:
|
| 109 |
+
print(f"✅ User '{username}' deleted successfully!")
|
| 110 |
+
return True
|
| 111 |
+
else:
|
| 112 |
+
print(f"❌ User '{username}' not found or could not be deleted.")
|
| 113 |
+
return False
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(f"❌ Error deleting user: {e}")
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
finally:
|
| 120 |
+
await db_service.disconnect()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def print_usage():
|
| 124 |
+
"""Print usage instructions."""
|
| 125 |
+
print("""
|
| 126 |
+
User Management Script
|
| 127 |
+
|
| 128 |
+
Usage:
|
| 129 |
+
python create_user.py create <username> <password> - Create a new user
|
| 130 |
+
python create_user.py list - List all users
|
| 131 |
+
python create_user.py delete <username> - Delete a user
|
| 132 |
+
|
| 133 |
+
Examples:
|
| 134 |
+
python create_user.py create admin mypassword123
|
| 135 |
+
python create_user.py list
|
| 136 |
+
python create_user.py delete admin
|
| 137 |
+
""")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def main():
|
| 141 |
+
"""Main entry point."""
|
| 142 |
+
if len(sys.argv) < 2:
|
| 143 |
+
print_usage()
|
| 144 |
+
return
|
| 145 |
+
|
| 146 |
+
command = sys.argv[1].lower()
|
| 147 |
+
|
| 148 |
+
if command == "create":
|
| 149 |
+
if len(sys.argv) != 4:
|
| 150 |
+
print("❌ Error: Username and password required")
|
| 151 |
+
print("Usage: python create_user.py create <username> <password>")
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
username = sys.argv[2]
|
| 155 |
+
password = sys.argv[3]
|
| 156 |
+
|
| 157 |
+
if len(username) < 3:
|
| 158 |
+
print("❌ Error: Username must be at least 3 characters")
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
if len(password) < 6:
|
| 162 |
+
print("❌ Error: Password must be at least 6 characters")
|
| 163 |
+
return
|
| 164 |
+
|
| 165 |
+
await create_user(username, password)
|
| 166 |
+
|
| 167 |
+
elif command == "list":
|
| 168 |
+
await list_users()
|
| 169 |
+
|
| 170 |
+
elif command == "delete":
|
| 171 |
+
if len(sys.argv) != 3:
|
| 172 |
+
print("❌ Error: Username required")
|
| 173 |
+
print("Usage: python create_user.py delete <username>")
|
| 174 |
+
return
|
| 175 |
+
|
| 176 |
+
username = sys.argv[2]
|
| 177 |
+
await delete_user(username)
|
| 178 |
+
|
| 179 |
+
else:
|
| 180 |
+
print(f"❌ Unknown command: {command}")
|
| 181 |
+
print_usage()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
if __name__ == "__main__":
|
| 185 |
+
asyncio.run(main())
|
data/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Data module - psychological triggers and patterns
|
| 2 |
+
|
data/angles.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Affiliate Marketing Angles Framework (Lite Version)
|
| 3 |
+
100 angles organized into 10 categories.
|
| 4 |
+
Angles answer: "Why should I care?" (Psychological WHY)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Dict, List, Any, Optional
|
| 8 |
+
from enum import Enum
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AngleCategory(str, Enum):
|
| 13 |
+
"""Angle categories."""
|
| 14 |
+
EMOTIONAL = "emotional"
|
| 15 |
+
FINANCIAL = "financial"
|
| 16 |
+
CONVENIENCE = "convenience"
|
| 17 |
+
IDENTITY = "identity"
|
| 18 |
+
AUTHORITY = "authority"
|
| 19 |
+
SOCIAL_PROOF = "social_proof"
|
| 20 |
+
URGENCY = "urgency"
|
| 21 |
+
INTELLECTUAL = "intellectual"
|
| 22 |
+
CURIOSITY = "curiosity"
|
| 23 |
+
PROBLEM_SOLUTION = "problem_solution"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Complete angles framework - 100 angles (10 per category)
|
| 27 |
+
ANGLES = {
|
| 28 |
+
AngleCategory.EMOTIONAL: {
|
| 29 |
+
"name": "Emotional & Psychological",
|
| 30 |
+
"angles": [
|
| 31 |
+
{"key": "fear_loss", "name": "Fear / Loss Prevention", "trigger": "Fear", "example": "Don't lose your home to disaster"},
|
| 32 |
+
{"key": "anxiety_reduction", "name": "Anxiety Reduction", "trigger": "Relief", "example": "Sleep better knowing you're protected"},
|
| 33 |
+
{"key": "security_safety", "name": "Security / Safety", "trigger": "Security", "example": "Your family's safety is our priority"},
|
| 34 |
+
{"key": "peace_of_mind", "name": "Peace of Mind", "trigger": "Relief", "example": "Peace of mind for just $X/month"},
|
| 35 |
+
{"key": "stress_relief", "name": "Stress Relief", "trigger": "Relief", "example": "End the stress of [problem]"},
|
| 36 |
+
{"key": "relief_escape", "name": "Relief / Escape", "trigger": "Relief", "example": "Finally escape [problem]"},
|
| 37 |
+
{"key": "confidence_boost", "name": "Confidence Boost", "trigger": "Pride", "example": "Feel confident about your future"},
|
| 38 |
+
{"key": "hope_optimism", "name": "Hope / Optimism", "trigger": "Hope", "example": "A brighter future starts today"},
|
| 39 |
+
{"key": "guilt_responsibility", "name": "Guilt (Family)", "trigger": "Guilt", "example": "Do it for your family"},
|
| 40 |
+
{"key": "pride_self_worth", "name": "Pride / Self-worth", "trigger": "Pride", "example": "You deserve the best"},
|
| 41 |
+
{"key": "emotional_connection", "name": "Emotional Connection", "trigger": "Belonging", "example": "Feel connected to what matters"},
|
| 42 |
+
{"key": "nostalgia", "name": "Nostalgia", "trigger": "Emotion", "example": "Remember when things were simpler"},
|
| 43 |
+
{"key": "empowerment", "name": "Empowerment", "trigger": "Pride", "example": "Take control of your future"},
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
AngleCategory.FINANCIAL: {
|
| 47 |
+
"name": "Financial",
|
| 48 |
+
"angles": [
|
| 49 |
+
{"key": "save_money", "name": "Save Money", "trigger": "Greed", "example": "Save $600/year"},
|
| 50 |
+
{"key": "cut_hidden_costs", "name": "Cut Hidden Costs", "trigger": "Anger", "example": "Stop paying hidden fees"},
|
| 51 |
+
{"key": "avoid_overpaying", "name": "Avoid Overpaying", "trigger": "Anger", "example": "Stop overpaying for [service]"},
|
| 52 |
+
{"key": "financial_freedom", "name": "Financial Freedom", "trigger": "Desire", "example": "Achieve financial freedom"},
|
| 53 |
+
{"key": "budget_control", "name": "Budget Control", "trigger": "Control", "example": "Take control of your budget"},
|
| 54 |
+
{"key": "price_comparison", "name": "Price Comparison", "trigger": "Greed", "example": "Compare prices in 30 seconds"},
|
| 55 |
+
{"key": "smart_spending", "name": "Smart Spending", "trigger": "Pride", "example": "Make smart financial choices"},
|
| 56 |
+
{"key": "long_term_value", "name": "Long-Term Value", "trigger": "Greed", "example": "Invest in your future"},
|
| 57 |
+
{"key": "roi_investment", "name": "ROI / Investment", "trigger": "Greed", "example": "Get 3x return on investment"},
|
| 58 |
+
{"key": "cost_transparency", "name": "Cost Transparency", "trigger": "Trust", "example": "100% transparent pricing"},
|
| 59 |
+
{"key": "hidden_fees_exposed", "name": "Hidden Fees Exposed", "trigger": "Anger", "example": "No hidden fees, ever"},
|
| 60 |
+
{"key": "money_back", "name": "Money-Back Guarantee", "trigger": "Security", "example": "Get your money back if not satisfied"},
|
| 61 |
+
{"key": "payment_flexibility", "name": "Payment Flexibility", "trigger": "Convenience", "example": "Pay your way, when you want"},
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
AngleCategory.CONVENIENCE: {
|
| 65 |
+
"name": "Convenience & Ease",
|
| 66 |
+
"angles": [
|
| 67 |
+
{"key": "fast_instant", "name": "Fast / Instant", "trigger": "Convenience", "example": "Get a quote in 30 seconds"},
|
| 68 |
+
{"key": "simple_easy", "name": "Simple / Easy", "trigger": "Convenience", "example": "It's that simple"},
|
| 69 |
+
{"key": "no_paperwork", "name": "No Paperwork", "trigger": "Convenience", "example": "No paperwork required"},
|
| 70 |
+
{"key": "no_phone_calls", "name": "No Phone Calls", "trigger": "Convenience", "example": "No phone calls needed"},
|
| 71 |
+
{"key": "one_click", "name": "One-Click / Few Steps", "trigger": "Convenience", "example": "Get started in 3 steps"},
|
| 72 |
+
{"key": "beginner_friendly", "name": "Beginner-Friendly", "trigger": "Security", "example": "Perfect for beginners"},
|
| 73 |
+
{"key": "done_for_you", "name": "Done-For-You", "trigger": "Convenience", "example": "We handle everything"},
|
| 74 |
+
{"key": "hassle_free", "name": "Hassle-Free", "trigger": "Convenience", "example": "100% hassle-free"},
|
| 75 |
+
{"key": "low_effort", "name": "Low Effort", "trigger": "Convenience", "example": "Minimal effort, maximum results"},
|
| 76 |
+
{"key": "time_saving", "name": "Time Saving", "trigger": "Convenience", "example": "Save 10 hours per week"},
|
| 77 |
+
{"key": "automated_process", "name": "Automated Process", "trigger": "Convenience", "example": "Fully automated, zero work"},
|
| 78 |
+
{"key": "instant_access", "name": "Instant Access", "trigger": "Convenience", "example": "Get instant access now"},
|
| 79 |
+
{"key": "no_waiting", "name": "No Waiting", "trigger": "Convenience", "example": "No waiting, start immediately"},
|
| 80 |
+
]
|
| 81 |
+
},
|
| 82 |
+
AngleCategory.IDENTITY: {
|
| 83 |
+
"name": "Identity & Personalization",
|
| 84 |
+
"angles": [
|
| 85 |
+
{"key": "age_based", "name": "Age-Based", "trigger": "Personalization", "example": "Special rates for seniors 65+"},
|
| 86 |
+
{"key": "location_based", "name": "Location-Based", "trigger": "Personalization", "example": "Best rates in [location]"},
|
| 87 |
+
{"key": "profession_specific", "name": "Profession-Specific", "trigger": "Personalization", "example": "Special rates for teachers"},
|
| 88 |
+
{"key": "life_stage", "name": "Life Stage", "trigger": "Personalization", "example": "Perfect for new homeowners"},
|
| 89 |
+
{"key": "lifestyle_match", "name": "Lifestyle Match", "trigger": "Personalization", "example": "Fits your lifestyle"},
|
| 90 |
+
{"key": "people_like_you", "name": "People Like You", "trigger": "Social Proof", "example": "Join thousands like you"},
|
| 91 |
+
{"key": "custom_fit", "name": "Custom Fit", "trigger": "Personalization", "example": "Customized just for you"},
|
| 92 |
+
{"key": "personal_relevance", "name": "Personal Relevance", "trigger": "Personalization", "example": "Built specifically for you"},
|
| 93 |
+
{"key": "niche_targeting", "name": "Niche Targeting", "trigger": "Personalization", "example": "Designed for [niche]"},
|
| 94 |
+
{"key": "localized_offer", "name": "Localized Offer", "trigger": "Personalization", "example": "Best deals in [city]"},
|
| 95 |
+
{"key": "behavioral_match", "name": "Behavioral Match", "trigger": "Personalization", "example": "Fits your lifestyle perfectly"},
|
| 96 |
+
{"key": "preference_based", "name": "Preference-Based", "trigger": "Personalization", "example": "Tailored to your preferences"},
|
| 97 |
+
{"key": "demographic_specific", "name": "Demographic-Specific", "trigger": "Personalization", "example": "Made for people like you"},
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
AngleCategory.AUTHORITY: {
|
| 101 |
+
"name": "Authority & Trust",
|
| 102 |
+
"angles": [
|
| 103 |
+
{"key": "expert_backed", "name": "Expert-Backed", "trigger": "Authority", "example": "Recommended by experts"},
|
| 104 |
+
{"key": "industry_standard", "name": "Industry Standard", "trigger": "Authority", "example": "The industry standard"},
|
| 105 |
+
{"key": "government_related", "name": "Government-Related", "trigger": "Authority", "example": "Government-approved program"},
|
| 106 |
+
{"key": "trusted_millions", "name": "Trusted by Millions", "trigger": "Social Proof", "example": "Trusted by 2M+ customers"},
|
| 107 |
+
{"key": "years_experience", "name": "Years of Experience", "trigger": "Authority", "example": "20+ years of experience"},
|
| 108 |
+
{"key": "certified_verified", "name": "Certified / Verified", "trigger": "Authority", "example": "Certified and verified"},
|
| 109 |
+
{"key": "brand_reputation", "name": "Brand Reputation", "trigger": "Authority", "example": "Trusted brand name"},
|
| 110 |
+
{"key": "compliance", "name": "Compliance / Regulation", "trigger": "Authority", "example": "Fully compliant and regulated"},
|
| 111 |
+
{"key": "awards_recognition", "name": "Awards / Recognition", "trigger": "Authority", "example": "Award-winning service"},
|
| 112 |
+
{"key": "risk_free", "name": "Risk-Free", "trigger": "Security", "example": "100% risk-free guarantee"},
|
| 113 |
+
{"key": "industry_leader", "name": "Industry Leader", "trigger": "Authority", "example": "The industry leader"},
|
| 114 |
+
{"key": "proven_track_record", "name": "Proven Track Record", "trigger": "Authority", "example": "Proven results over decades"},
|
| 115 |
+
]
|
| 116 |
+
},
|
| 117 |
+
AngleCategory.SOCIAL_PROOF: {
|
| 118 |
+
"name": "Social Proof & Validation",
|
| 119 |
+
"angles": [
|
| 120 |
+
{"key": "testimonials", "name": "Testimonials", "trigger": "Social Proof", "example": "See what customers say"},
|
| 121 |
+
{"key": "reviews_ratings", "name": "Reviews / Ratings", "trigger": "Social Proof", "example": "4.8/5 stars from 10K+ reviews"},
|
| 122 |
+
{"key": "mass_adoption", "name": "Mass Adoption", "trigger": "Social Proof", "example": "Join 2M+ users"},
|
| 123 |
+
{"key": "case_studies", "name": "Case Studies", "trigger": "Social Proof", "example": "See real success stories"},
|
| 124 |
+
{"key": "word_of_mouth", "name": "Word of Mouth", "trigger": "Social Proof", "example": "Recommended by friends"},
|
| 125 |
+
{"key": "community_trust", "name": "Community Trust", "trigger": "Social Proof", "example": "Trusted by our community"},
|
| 126 |
+
{"key": "real_stories", "name": "Real Stories", "trigger": "Social Proof", "example": "Real stories from real customers"},
|
| 127 |
+
{"key": "viral_popularity", "name": "Viral Popularity", "trigger": "FOMO", "example": "Going viral on [platform]"},
|
| 128 |
+
{"key": "trending_now", "name": "Trending Now", "trigger": "FOMO", "example": "Trending now"},
|
| 129 |
+
{"key": "most_chosen", "name": "Most Chosen", "trigger": "Social Proof", "example": "The most chosen option"},
|
| 130 |
+
{"key": "peer_recommendation", "name": "Peer Recommendation", "trigger": "Social Proof", "example": "Recommended by your peers"},
|
| 131 |
+
{"key": "success_rate", "name": "High Success Rate", "trigger": "Social Proof", "example": "95% success rate"},
|
| 132 |
+
]
|
| 133 |
+
},
|
| 134 |
+
AngleCategory.URGENCY: {
|
| 135 |
+
"name": "Urgency & Scarcity",
|
| 136 |
+
"angles": [
|
| 137 |
+
{"key": "limited_time", "name": "Limited Time", "trigger": "FOMO", "example": "Limited time offer - ends Friday"},
|
| 138 |
+
{"key": "ending_soon", "name": "Ending Soon", "trigger": "FOMO", "example": "Offer ending in 48 hours"},
|
| 139 |
+
{"key": "price_increase", "name": "Price Increase Warning", "trigger": "FOMO", "example": "Prices increasing on [date]"},
|
| 140 |
+
{"key": "seasonal_change", "name": "Seasonal Change", "trigger": "FOMO", "example": "Spring special - ends soon"},
|
| 141 |
+
{"key": "renewal_reminder", "name": "Policy Renewal", "trigger": "FOMO", "example": "Renew before [date] to save"},
|
| 142 |
+
{"key": "countdown", "name": "Countdown", "trigger": "FOMO", "example": "Only 24 hours left"},
|
| 143 |
+
{"key": "last_chance", "name": "Last Chance", "trigger": "FOMO", "example": "Last chance to save"},
|
| 144 |
+
{"key": "market_shift", "name": "Market Shift", "trigger": "FOMO", "example": "Market rates changing soon"},
|
| 145 |
+
{"key": "deadline_pressure", "name": "Deadline Pressure", "trigger": "FOMO", "example": "Deadline: [date]"},
|
| 146 |
+
{"key": "miss_out_avoidance", "name": "Miss-Out Avoidance", "trigger": "FOMO", "example": "Don't miss this opportunity"},
|
| 147 |
+
{"key": "early_bird", "name": "Early Bird Special", "trigger": "FOMO", "example": "Early bird pricing ends soon"},
|
| 148 |
+
{"key": "flash_sale", "name": "Flash Sale", "trigger": "FOMO", "example": "Flash sale - 24 hours only"},
|
| 149 |
+
]
|
| 150 |
+
},
|
| 151 |
+
AngleCategory.INTELLECTUAL: {
|
| 152 |
+
"name": "Intellectual / Smart Choice",
|
| 153 |
+
"angles": [
|
| 154 |
+
{"key": "insider_knowledge", "name": "Insider Knowledge", "trigger": "Curiosity", "example": "Insider tip: [secret]"},
|
| 155 |
+
{"key": "avoid_mistakes", "name": "Avoid Common Mistakes", "trigger": "Fear", "example": "Avoid these 5 common mistakes"},
|
| 156 |
+
{"key": "educated_decision", "name": "Educated Decision", "trigger": "Pride", "example": "Make an educated decision"},
|
| 157 |
+
{"key": "comparison_logic", "name": "Comparison Logic", "trigger": "Pride", "example": "Compare and choose wisely"},
|
| 158 |
+
{"key": "transparency", "name": "Transparency", "trigger": "Trust", "example": "100% transparent pricing"},
|
| 159 |
+
{"key": "informed_buyer", "name": "Informed Buyer", "trigger": "Pride", "example": "For informed buyers"},
|
| 160 |
+
{"key": "data_driven", "name": "Data-Driven", "trigger": "Authority", "example": "Backed by data"},
|
| 161 |
+
{"key": "rational_choice", "name": "Rational Choice", "trigger": "Pride", "example": "The rational choice"},
|
| 162 |
+
{"key": "what_experts_do", "name": "What Experts Do", "trigger": "Authority", "example": "What experts do"},
|
| 163 |
+
{"key": "optimization", "name": "Optimization", "trigger": "Pride", "example": "Optimize your [thing]"},
|
| 164 |
+
{"key": "smart_investment", "name": "Smart Investment", "trigger": "Pride", "example": "The smart investment choice"},
|
| 165 |
+
{"key": "evidence_based", "name": "Evidence-Based", "trigger": "Authority", "example": "Backed by research and data"},
|
| 166 |
+
]
|
| 167 |
+
},
|
| 168 |
+
AngleCategory.CURIOSITY: {
|
| 169 |
+
"name": "Curiosity & Pattern Interrupt",
|
| 170 |
+
"angles": [
|
| 171 |
+
{"key": "shocking_stats", "name": "Shocking Stats", "trigger": "Curiosity", "example": "Shocking stat: [number]"},
|
| 172 |
+
{"key": "did_you_know", "name": "Did You Know?", "trigger": "Curiosity", "example": "Did you know [fact]?"},
|
| 173 |
+
{"key": "open_loops", "name": "Open Loops", "trigger": "Curiosity", "example": "Thousands doing THIS instead"},
|
| 174 |
+
{"key": "contrarian", "name": "Contrarian Claims", "trigger": "Curiosity", "example": "Why everyone is wrong about [thing]"},
|
| 175 |
+
{"key": "myth_busting", "name": "Myth Busting", "trigger": "Curiosity", "example": "Myth busted: [myth]"},
|
| 176 |
+
{"key": "unexpected_truth", "name": "Unexpected Truth", "trigger": "Curiosity", "example": "The truth about [thing]"},
|
| 177 |
+
{"key": "hidden_secrets", "name": "Hidden Secrets", "trigger": "Curiosity", "example": "The hidden secret to [thing]"},
|
| 178 |
+
{"key": "scroll_stopper", "name": "Scroll Stopper", "trigger": "Curiosity", "example": "Stop scrolling - read this"},
|
| 179 |
+
{"key": "pattern_break", "name": "Pattern Break", "trigger": "Curiosity", "example": "This breaks all patterns"},
|
| 180 |
+
{"key": "curiosity_gap", "name": "Curiosity Gap", "trigger": "Curiosity", "example": "What is THIS?"},
|
| 181 |
+
{"key": "reveal_secret", "name": "Reveal Secret", "trigger": "Curiosity", "example": "The secret they don't want you to know"},
|
| 182 |
+
{"key": "unexpected_benefit", "name": "Unexpected Benefit", "trigger": "Curiosity", "example": "The benefit nobody talks about"},
|
| 183 |
+
]
|
| 184 |
+
},
|
| 185 |
+
AngleCategory.PROBLEM_SOLUTION: {
|
| 186 |
+
"name": "Problem–Solution",
|
| 187 |
+
"angles": [
|
| 188 |
+
{"key": "pain_point", "name": "Pain Point Highlight", "trigger": "Anger", "example": "Tired of [problem]?"},
|
| 189 |
+
{"key": "frustration_relief", "name": "Frustration Relief", "trigger": "Relief", "example": "End your frustration with [problem]"},
|
| 190 |
+
{"key": "complexity_simplified", "name": "Complexity Simplified", "trigger": "Convenience", "example": "We make [thing] simple"},
|
| 191 |
+
{"key": "confusion_clarity", "name": "Confusion → Clarity", "trigger": "Relief", "example": "From confusion to clarity"},
|
| 192 |
+
{"key": "overwhelm_reduction", "name": "Overwhelm Reduction", "trigger": "Relief", "example": "Stop feeling overwhelmed"},
|
| 193 |
+
{"key": "direct_fix", "name": "Direct Fix", "trigger": "Relief", "example": "The direct fix for [problem]"},
|
| 194 |
+
{"key": "shortcut", "name": "Shortcut", "trigger": "Convenience", "example": "The shortcut to [goal]"},
|
| 195 |
+
{"key": "better_alternative", "name": "Better Alternative", "trigger": "Desire", "example": "A better alternative to [current]"},
|
| 196 |
+
{"key": "replace_old_way", "name": "Replace Old Way", "trigger": "Desire", "example": "Replace the old way"},
|
| 197 |
+
{"key": "modern_solution", "name": "Modern Solution", "trigger": "Desire", "example": "The modern solution to [problem]"},
|
| 198 |
+
{"key": "pain_elimination", "name": "Pain Elimination", "trigger": "Relief", "example": "Eliminate [problem] forever"},
|
| 199 |
+
{"key": "simplified_complexity", "name": "Simplified Complexity", "trigger": "Convenience", "example": "We simplified the complex"},
|
| 200 |
+
{"key": "one_click_solution", "name": "One-Click Solution", "trigger": "Convenience", "example": "Solve it in one click"},
|
| 201 |
+
]
|
| 202 |
+
},
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def get_all_angles() -> List[Dict[str, Any]]:
|
| 207 |
+
"""Get all angles as a flat list."""
|
| 208 |
+
all_angles = []
|
| 209 |
+
for category, data in ANGLES.items():
|
| 210 |
+
for angle in data["angles"]:
|
| 211 |
+
angle_copy = angle.copy()
|
| 212 |
+
angle_copy["category"] = data["name"]
|
| 213 |
+
angle_copy["category_key"] = category
|
| 214 |
+
all_angles.append(angle_copy)
|
| 215 |
+
return all_angles
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def get_angles_by_category(category: AngleCategory) -> List[Dict[str, Any]]:
|
| 219 |
+
"""Get angles for a specific category."""
|
| 220 |
+
return ANGLES.get(category, {}).get("angles", [])
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def get_angle_by_key(key: str) -> Optional[Dict[str, Any]]:
|
| 224 |
+
"""Get a specific angle by key."""
|
| 225 |
+
for category, data in ANGLES.items():
|
| 226 |
+
for angle in data["angles"]:
|
| 227 |
+
if angle["key"] == key:
|
| 228 |
+
angle_copy = angle.copy()
|
| 229 |
+
angle_copy["category"] = data["name"]
|
| 230 |
+
angle_copy["category_key"] = category
|
| 231 |
+
return angle_copy
|
| 232 |
+
return None
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def get_random_angles(count: int = 6, diverse: bool = True) -> List[Dict[str, Any]]:
|
| 236 |
+
"""Get random angles, optionally ensuring diversity across categories."""
|
| 237 |
+
if diverse:
|
| 238 |
+
# Select one from each category first
|
| 239 |
+
selected = []
|
| 240 |
+
categories = list(ANGLES.keys())
|
| 241 |
+
random.shuffle(categories)
|
| 242 |
+
|
| 243 |
+
for category in categories[:count]:
|
| 244 |
+
angles = ANGLES[category]["angles"]
|
| 245 |
+
angle = random.choice(angles).copy()
|
| 246 |
+
angle["category"] = ANGLES[category]["name"]
|
| 247 |
+
angle["category_key"] = category
|
| 248 |
+
selected.append(angle)
|
| 249 |
+
|
| 250 |
+
return selected[:count]
|
| 251 |
+
else:
|
| 252 |
+
all_angles = get_all_angles()
|
| 253 |
+
return random.sample(all_angles, min(count, len(all_angles)))
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def get_angles_for_niche(niche: str) -> List[Dict[str, Any]]:
|
| 257 |
+
"""Get angles best suited for a niche."""
|
| 258 |
+
niche_lower = niche.lower()
|
| 259 |
+
|
| 260 |
+
# Niche-specific angle recommendations
|
| 261 |
+
if "insurance" in niche_lower:
|
| 262 |
+
recommended_keys = [
|
| 263 |
+
"fear_loss", "peace_of_mind", "save_money", "price_comparison",
|
| 264 |
+
"trusted_millions", "limited_time", "avoid_mistakes", "pain_point"
|
| 265 |
+
]
|
| 266 |
+
elif "glp" in niche_lower or "weight" in niche_lower:
|
| 267 |
+
recommended_keys = [
|
| 268 |
+
"confidence_boost", "pride_self_worth", "before_after_shock",
|
| 269 |
+
"testimonials", "trending_now", "modern_solution", "shortcut"
|
| 270 |
+
]
|
| 271 |
+
else:
|
| 272 |
+
# Default mix
|
| 273 |
+
recommended_keys = [
|
| 274 |
+
"save_money", "fast_instant", "testimonials", "limited_time",
|
| 275 |
+
"shocking_stats", "pain_point"
|
| 276 |
+
]
|
| 277 |
+
|
| 278 |
+
angles = []
|
| 279 |
+
for key in recommended_keys:
|
| 280 |
+
angle = get_angle_by_key(key)
|
| 281 |
+
if angle:
|
| 282 |
+
angles.append(angle)
|
| 283 |
+
|
| 284 |
+
return angles
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# Top performing angles for initial testing
|
| 288 |
+
TOP_ANGLES = [
|
| 289 |
+
"save_money", "fear_loss", "fast_instant", "expert_backed",
|
| 290 |
+
"testimonials", "limited_time", "age_based", "pain_point"
|
| 291 |
+
]
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def get_top_angles() -> List[Dict[str, Any]]:
|
| 295 |
+
"""Get top performing angles for initial testing."""
|
| 296 |
+
return [get_angle_by_key(key) for key in TOP_ANGLES if get_angle_by_key(key)]
|
| 297 |
+
|
data/concepts.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Affiliate Marketing Concepts Framework (Lite Version)
|
| 3 |
+
100 concepts organized into 10 categories.
|
| 4 |
+
Concepts answer: "How do I show it visually?" (Creative HOW)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Dict, List, Any, Optional
|
| 8 |
+
from enum import Enum
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ConceptCategory(str, Enum):
|
| 13 |
+
"""Concept categories."""
|
| 14 |
+
VISUAL_STRUCTURE = "visual_structure"
|
| 15 |
+
UGC_NATIVE = "ugc_native"
|
| 16 |
+
STORYTELLING = "storytelling"
|
| 17 |
+
COMPARISON = "comparison"
|
| 18 |
+
AUTHORITY = "authority"
|
| 19 |
+
SOCIAL_PROOF = "social_proof"
|
| 20 |
+
SCROLL_STOPPING = "scroll_stopping"
|
| 21 |
+
EDUCATIONAL = "educational"
|
| 22 |
+
PERSONALIZATION = "personalization"
|
| 23 |
+
CTA_FOCUSED = "cta_focused"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Complete concepts framework - 100 concepts (10 per category)
|
| 27 |
+
CONCEPTS = {
|
| 28 |
+
ConceptCategory.VISUAL_STRUCTURE: {
|
| 29 |
+
"name": "Visual Structure",
|
| 30 |
+
"concepts": [
|
| 31 |
+
{"key": "before_after", "name": "Before vs After", "structure": "Left=pain, Right=solution", "visual": "Split-screen transformation"},
|
| 32 |
+
{"key": "split_screen", "name": "Split Screen", "structure": "Two halves comparison", "visual": "Vertical or horizontal split"},
|
| 33 |
+
{"key": "checklist", "name": "Checklist / Tick Marks", "structure": "List with checkmarks", "visual": "Clean list, prominent checks"},
|
| 34 |
+
{"key": "bold_headline", "name": "Bold Headline Image", "structure": "Headline dominates", "visual": "Large bold typography"},
|
| 35 |
+
{"key": "text_first", "name": "Text-First Image", "structure": "Text primary, image secondary", "visual": "Clear typography hierarchy"},
|
| 36 |
+
{"key": "minimalist", "name": "Minimalist Design", "structure": "White space, minimal elements", "visual": "Clean lines, focused"},
|
| 37 |
+
{"key": "big_numbers", "name": "Big Numbers Visual", "structure": "Numbers dominate", "visual": "Huge numbers, bold type"},
|
| 38 |
+
{"key": "highlight_circle", "name": "Highlight / Circle Focus", "structure": "Circle on key element", "visual": "Red circles, arrows"},
|
| 39 |
+
{"key": "step_by_step", "name": "Step-by-Step Visual", "structure": "Step 1 → 2 → 3", "visual": "Clear flow, numbered"},
|
| 40 |
+
{"key": "icon_based", "name": "Icon-Based Layout", "structure": "Icons represent features", "visual": "Clear icons, organized"},
|
| 41 |
+
{"key": "grid_layout", "name": "Grid Layout", "structure": "Organized grid format", "visual": "Clean grid, easy to scan"},
|
| 42 |
+
{"key": "timeline_visual", "name": "Timeline Visual", "structure": "Time-based progression", "visual": "Clear timeline flow"},
|
| 43 |
+
{"key": "infographic_style", "name": "Infographic Style", "structure": "Information graphics", "visual": "Data visualization"},
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
ConceptCategory.UGC_NATIVE: {
|
| 47 |
+
"name": "UGC & Native",
|
| 48 |
+
"concepts": [
|
| 49 |
+
{"key": "selfie_style", "name": "Selfie-Style Image", "structure": "Phone camera angle", "visual": "Casual, authentic feel"},
|
| 50 |
+
{"key": "casual_phone", "name": "Casual Phone Shot", "structure": "Mobile perspective", "visual": "Unpolished, authentic"},
|
| 51 |
+
{"key": "pov", "name": "POV Perspective", "structure": "First-person view", "visual": "Immersive feel"},
|
| 52 |
+
{"key": "just_found", "name": "Just Found This", "structure": "Discovery moment", "visual": "Excited, sharing"},
|
| 53 |
+
{"key": "organic_feed", "name": "Organic Feed Style", "structure": "Native to platform", "visual": "Doesn't look like ad"},
|
| 54 |
+
{"key": "influencer", "name": "Influencer-Style", "structure": "Polished but authentic", "visual": "Influencer aesthetic"},
|
| 55 |
+
{"key": "low_production", "name": "Low-Production Authentic", "structure": "Raw, unpolished", "visual": "Authentic feel"},
|
| 56 |
+
{"key": "reaction", "name": "Reaction Shot", "structure": "Emotional reaction", "visual": "Clear emotion visible"},
|
| 57 |
+
{"key": "screenshot", "name": "Screenshot-Style", "structure": "Device screenshot", "visual": "Realistic screenshot"},
|
| 58 |
+
{"key": "story_frame", "name": "Story Frame", "structure": "Vertical story format", "visual": "Story-style layout"},
|
| 59 |
+
{"key": "behind_scenes", "name": "Behind the Scenes", "structure": "Raw, unedited look", "visual": "Authentic, unpolished"},
|
| 60 |
+
{"key": "day_in_life", "name": "Day in the Life", "structure": "Daily routine snapshot", "visual": "Relatable daily moments"},
|
| 61 |
+
{"key": "unboxing_style", "name": "Unboxing Style", "structure": "Product reveal moment", "visual": "Discovery excitement"},
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
ConceptCategory.STORYTELLING: {
|
| 65 |
+
"name": "Storytelling",
|
| 66 |
+
"concepts": [
|
| 67 |
+
{"key": "emotional_snapshot", "name": "Emotional Snapshot", "structure": "Single emotional moment", "visual": "Authentic expression"},
|
| 68 |
+
{"key": "relatable_moment", "name": "Relatable Daily Moment", "structure": "Everyday scene", "visual": "Relatable situation"},
|
| 69 |
+
{"key": "micro_story", "name": "Micro-Story Scene", "structure": "Small story moment", "visual": "Narrative feel"},
|
| 70 |
+
{"key": "life_situation", "name": "Life Situation", "structure": "Real-life context", "visual": "Authentic situation"},
|
| 71 |
+
{"key": "decision_moment", "name": "Decision Moment", "structure": "Decision point", "visual": "Contemplative"},
|
| 72 |
+
{"key": "problem_awareness", "name": "Problem Awareness Scene", "structure": "Problem visible", "visual": "Awareness moment"},
|
| 73 |
+
{"key": "turning_point", "name": "Turning Point Frame", "structure": "Transformation moment", "visual": "Change visible"},
|
| 74 |
+
{"key": "relief_moment", "name": "Relief Moment", "structure": "Relief visible", "visual": "Peace visible"},
|
| 75 |
+
{"key": "success_moment", "name": "Success Moment", "structure": "Success visible", "visual": "Achievement visible"},
|
| 76 |
+
{"key": "future_projection", "name": "Future Projection", "structure": "Future vision", "visual": "Aspirational"},
|
| 77 |
+
{"key": "journey_arc", "name": "Journey Arc", "structure": "Complete transformation story", "visual": "Full journey visible"},
|
| 78 |
+
{"key": "milestone_moment", "name": "Milestone Moment", "structure": "Key achievement moment", "visual": "Celebration visible"},
|
| 79 |
+
]
|
| 80 |
+
},
|
| 81 |
+
ConceptCategory.COMPARISON: {
|
| 82 |
+
"name": "Comparison & Logic",
|
| 83 |
+
"concepts": [
|
| 84 |
+
{"key": "side_by_side_table", "name": "Side-by-Side Table", "structure": "Comparison table", "visual": "Checkmarks/crosses"},
|
| 85 |
+
{"key": "old_vs_new", "name": "Old Way vs New Way", "structure": "Old vs new comparison", "visual": "Modern highlighted"},
|
| 86 |
+
{"key": "choice_elimination", "name": "Choice Elimination", "structure": "Others crossed out", "visual": "Winner clear"},
|
| 87 |
+
{"key": "feature_breakdown", "name": "Feature Breakdown", "structure": "Features compared", "visual": "Easy to compare"},
|
| 88 |
+
{"key": "pros_cons", "name": "Pros vs Cons", "structure": "Pros on one side, cons other", "visual": "Balanced view"},
|
| 89 |
+
{"key": "ranking", "name": "Ranking Visual", "structure": "Ranked list", "visual": "Top highlighted"},
|
| 90 |
+
{"key": "winner_highlight", "name": "Winner Highlight", "structure": "Winner highlighted", "visual": "Others dimmed"},
|
| 91 |
+
{"key": "bar_scale", "name": "Bar / Scale Visual", "structure": "Visual bars/scales", "visual": "Easy to compare"},
|
| 92 |
+
{"key": "price_stack", "name": "Price Stack", "structure": "Prices stacked", "visual": "Savings visible"},
|
| 93 |
+
{"key": "value_breakdown", "name": "Value Breakdown", "structure": "Value components", "visual": "Easy to understand"},
|
| 94 |
+
{"key": "versus_comparison", "name": "Versus Comparison", "structure": "Head-to-head comparison", "visual": "Clear winner"},
|
| 95 |
+
{"key": "feature_matrix", "name": "Feature Matrix", "structure": "Feature comparison grid", "visual": "Easy comparison"},
|
| 96 |
+
]
|
| 97 |
+
},
|
| 98 |
+
ConceptCategory.AUTHORITY: {
|
| 99 |
+
"name": "Authority & Trust",
|
| 100 |
+
"concepts": [
|
| 101 |
+
{"key": "expert_portrait", "name": "Expert Portrait", "structure": "Professional portrait", "visual": "Trustworthy appearance"},
|
| 102 |
+
{"key": "badge_seal", "name": "Badge / Seal Visual", "structure": "Badges prominent", "visual": "Official look"},
|
| 103 |
+
{"key": "certification", "name": "Certification Overlay", "structure": "Cert overlaid", "visual": "Official"},
|
| 104 |
+
{"key": "media_mention", "name": "Media Mention Style", "structure": "Media logos/quotes", "visual": "Credible"},
|
| 105 |
+
{"key": "professional_setting", "name": "Professional Setting", "structure": "Office/professional space", "visual": "Clean, organized"},
|
| 106 |
+
{"key": "office_scene", "name": "Office / Desk Scene", "structure": "Office environment", "visual": "Professional"},
|
| 107 |
+
{"key": "institutional", "name": "Institutional Look", "structure": "Official design", "visual": "Institutional"},
|
| 108 |
+
{"key": "data_backed", "name": "Data-Backed Visual", "structure": "Charts, graphs", "visual": "Data visible"},
|
| 109 |
+
{"key": "chart_graph", "name": "Chart / Graph Snapshot", "structure": "Chart dominates", "visual": "Easy to read"},
|
| 110 |
+
{"key": "trust_signals", "name": "Trust Signal Stack", "structure": "Multiple trust elements", "visual": "Organized signals"},
|
| 111 |
+
{"key": "credentials_display", "name": "Credentials Display", "structure": "Certifications shown", "visual": "Official credentials"},
|
| 112 |
+
{"key": "partnership_logos", "name": "Partnership Logos", "structure": "Partner brands visible", "visual": "Trusted partnerships"},
|
| 113 |
+
]
|
| 114 |
+
},
|
| 115 |
+
ConceptCategory.SOCIAL_PROOF: {
|
| 116 |
+
"name": "Social Proof",
|
| 117 |
+
"concepts": [
|
| 118 |
+
{"key": "testimonial_screenshot", "name": "Testimonial Screenshot", "structure": "Screenshot format", "visual": "Authentic testimonial"},
|
| 119 |
+
{"key": "review_stars", "name": "Review Stars Visual", "structure": "Stars dominate", "visual": "Large stars, rating"},
|
| 120 |
+
{"key": "quote_card", "name": "Quote Card", "structure": "Quote as main visual", "visual": "Clear, readable"},
|
| 121 |
+
{"key": "case_study_frame", "name": "Case Study Frame", "structure": "Case study format", "visual": "Results visible"},
|
| 122 |
+
{"key": "crowd", "name": "Crowd Visual", "structure": "Many people visible", "visual": "Popular feel"},
|
| 123 |
+
{"key": "others_like_you", "name": "Others Like You", "structure": "Similar people", "visual": "Relatable, authentic"},
|
| 124 |
+
{"key": "real_customer", "name": "Real Customer Photo", "structure": "Real customer shown", "visual": "Authentic, relatable"},
|
| 125 |
+
{"key": "comment_screenshot", "name": "Comment Screenshot", "structure": "Comments visible", "visual": "Realistic comments"},
|
| 126 |
+
{"key": "ugc_collage", "name": "UGC Collage", "structure": "Multiple UGC pieces", "visual": "Collage format"},
|
| 127 |
+
{"key": "community", "name": "Community Visual", "structure": "Community visible", "visual": "Belonging feel"},
|
| 128 |
+
{"key": "user_spotlight", "name": "User Spotlight", "structure": "Individual user featured", "visual": "Personal success story"},
|
| 129 |
+
{"key": "group_success", "name": "Group Success", "structure": "Multiple success stories", "visual": "Collective achievement"},
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
ConceptCategory.SCROLL_STOPPING: {
|
| 133 |
+
"name": "Scroll-Stopping",
|
| 134 |
+
"concepts": [
|
| 135 |
+
{"key": "shock_headline", "name": "Shock Headline", "structure": "Shocking headline dominates", "visual": "Bold, high contrast"},
|
| 136 |
+
{"key": "red_warning", "name": "Big Red Warning Text", "structure": "Red warning prominent", "visual": "Large red text"},
|
| 137 |
+
{"key": "unusual_contrast", "name": "Unusual Contrast", "structure": "High contrast, unusual colors", "visual": "Stands out"},
|
| 138 |
+
{"key": "pattern_break", "name": "Pattern Break Design", "structure": "Different from expected", "visual": "Unexpected"},
|
| 139 |
+
{"key": "unexpected_image", "name": "Unexpected Image", "structure": "Surprising visual", "visual": "Attention-grabbing"},
|
| 140 |
+
{"key": "bold_claim", "name": "Bold Claim Card", "structure": "Bold claim prominent", "visual": "Large text"},
|
| 141 |
+
{"key": "glitch_highlight", "name": "Glitch / Highlight", "structure": "Glitch effect", "visual": "Modern"},
|
| 142 |
+
{"key": "disruptive_color", "name": "Disruptive Color Use", "structure": "Unexpected colors", "visual": "Disruptive"},
|
| 143 |
+
{"key": "meme_style", "name": "Meme-Style Image", "structure": "Meme format", "visual": "Shareable"},
|
| 144 |
+
{"key": "visual_tension", "name": "Visual Tension", "structure": "Tension in visual", "visual": "Engaging"},
|
| 145 |
+
{"key": "bold_typography", "name": "Bold Typography Focus", "structure": "Typography dominates", "visual": "Text-first impact"},
|
| 146 |
+
{"key": "motion_blur", "name": "Motion Blur Effect", "structure": "Dynamic movement feel", "visual": "Energy and action"},
|
| 147 |
+
]
|
| 148 |
+
},
|
| 149 |
+
ConceptCategory.EDUCATIONAL: {
|
| 150 |
+
"name": "Educational & Explainer",
|
| 151 |
+
"concepts": [
|
| 152 |
+
{"key": "how_it_works", "name": "How It Works", "structure": "Process explanation", "visual": "Step-by-step"},
|
| 153 |
+
{"key": "three_step", "name": "3-Step Process", "structure": "Step 1→2→3", "visual": "Simple flow"},
|
| 154 |
+
{"key": "flow_diagram", "name": "Flow Diagram", "structure": "Flowchart format", "visual": "Easy to follow"},
|
| 155 |
+
{"key": "simple_explainer", "name": "Simple Explainer", "structure": "Simple explanation", "visual": "Easy to understand"},
|
| 156 |
+
{"key": "faq", "name": "FAQ Visual", "structure": "FAQ format", "visual": "Clear Q&A"},
|
| 157 |
+
{"key": "mistake_callout", "name": "Mistake Callout", "structure": "Mistakes highlighted", "visual": "Avoid them"},
|
| 158 |
+
{"key": "do_dont", "name": "Do / Don't Visual", "structure": "Do vs Don't", "visual": "Easy to understand"},
|
| 159 |
+
{"key": "learning_card", "name": "Learning Card", "structure": "Card with content", "visual": "Educational"},
|
| 160 |
+
{"key": "visual_guide", "name": "Visual Guide", "structure": "Guide format", "visual": "Instructional"},
|
| 161 |
+
{"key": "instructional", "name": "Instructional Frame", "structure": "Instruction format", "visual": "Easy to follow"},
|
| 162 |
+
{"key": "tip_card", "name": "Tip Card", "structure": "Quick tip format", "visual": "Actionable advice"},
|
| 163 |
+
{"key": "checklist_visual", "name": "Checklist Visual", "structure": "Checklist format", "visual": "Easy to follow"},
|
| 164 |
+
]
|
| 165 |
+
},
|
| 166 |
+
ConceptCategory.PERSONALIZATION: {
|
| 167 |
+
"name": "Call-Out & Personalization",
|
| 168 |
+
"concepts": [
|
| 169 |
+
{"key": "if_you_are", "name": "If You Are X…", "structure": "Direct callout", "visual": "Specific group"},
|
| 170 |
+
{"key": "age_specific", "name": "Age-Specific Visual", "structure": "Age-specific imagery", "visual": "Relatable"},
|
| 171 |
+
{"key": "location_specific", "name": "Location-Specific", "structure": "Location visible", "visual": "Local feel"},
|
| 172 |
+
{"key": "role_based", "name": "Role-Based Scene", "structure": "Role-specific scene", "visual": "Relatable"},
|
| 173 |
+
{"key": "lifestyle_scene", "name": "Lifestyle Match", "structure": "Lifestyle visible", "visual": "Match"},
|
| 174 |
+
{"key": "identity_mirror", "name": "Identity Mirror", "structure": "Identity reflected", "visual": "Connection"},
|
| 175 |
+
{"key": "targeted_headline", "name": "Targeted Headline", "structure": "Targeted headline", "visual": "Relevant"},
|
| 176 |
+
{"key": "personalized_hook", "name": "Personalized Hook", "structure": "Personalized opening", "visual": "Connection"},
|
| 177 |
+
{"key": "segment_callout", "name": "Segment Callout", "structure": "Segment-specific", "visual": "Targeted"},
|
| 178 |
+
{"key": "direct_address", "name": "Direct Address", "structure": "Direct address format", "visual": "Personal"},
|
| 179 |
+
{"key": "custom_message", "name": "Custom Message", "structure": "Personalized message", "visual": "One-on-one feel"},
|
| 180 |
+
{"key": "targeted_audience", "name": "Targeted Audience Visual", "structure": "Specific audience shown", "visual": "Relatable group"},
|
| 181 |
+
]
|
| 182 |
+
},
|
| 183 |
+
ConceptCategory.CTA_FOCUSED: {
|
| 184 |
+
"name": "CTA-Focused",
|
| 185 |
+
"concepts": [
|
| 186 |
+
{"key": "button_focused", "name": "Button-Focused Image", "structure": "Button prominent", "visual": "Action-oriented"},
|
| 187 |
+
{"key": "arrow_flow", "name": "Arrow-Directed Flow", "structure": "Arrows point to CTA", "visual": "Directional"},
|
| 188 |
+
{"key": "highlighted_cta", "name": "Highlighted CTA Box", "structure": "CTA box stands out", "visual": "Clear"},
|
| 189 |
+
{"key": "action_words", "name": "Action Words Overlay", "structure": "Action words prominent", "visual": "Urgent"},
|
| 190 |
+
{"key": "click_prompt", "name": "Click Prompt Visual", "structure": "Click prompt visible", "visual": "Action-oriented"},
|
| 191 |
+
{"key": "tap_here", "name": "Tap-Here Cue", "structure": "Tap here visible", "visual": "Mobile-friendly"},
|
| 192 |
+
{"key": "next_step", "name": "Next Step Highlight", "structure": "Next step prominent", "visual": "Action-oriented"},
|
| 193 |
+
{"key": "simple_cta", "name": "Simple CTA Card", "structure": "Simple card with CTA", "visual": "Minimal"},
|
| 194 |
+
{"key": "countdown_cta", "name": "Countdown CTA", "structure": "Countdown + CTA", "visual": "Urgent"},
|
| 195 |
+
{"key": "final_push", "name": "Final Push Frame", "structure": "Final push format", "visual": "Urgent, action"},
|
| 196 |
+
{"key": "pulsing_cta", "name": "Pulsing CTA", "structure": "Animated attention", "visual": "Eye-catching action"},
|
| 197 |
+
{"key": "multi_cta", "name": "Multiple CTAs", "structure": "Multiple action options", "visual": "Flexible engagement"},
|
| 198 |
+
]
|
| 199 |
+
},
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def get_all_concepts() -> List[Dict[str, Any]]:
|
| 204 |
+
"""Get all concepts as a flat list."""
|
| 205 |
+
all_concepts = []
|
| 206 |
+
for category, data in CONCEPTS.items():
|
| 207 |
+
for concept in data["concepts"]:
|
| 208 |
+
concept_copy = concept.copy()
|
| 209 |
+
concept_copy["category"] = data["name"]
|
| 210 |
+
concept_copy["category_key"] = category
|
| 211 |
+
all_concepts.append(concept_copy)
|
| 212 |
+
return all_concepts
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def get_concepts_by_category(category: ConceptCategory) -> List[Dict[str, Any]]:
|
| 216 |
+
"""Get concepts for a specific category."""
|
| 217 |
+
return CONCEPTS.get(category, {}).get("concepts", [])
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def get_concept_by_key(key: str) -> Optional[Dict[str, Any]]:
|
| 221 |
+
"""Get a specific concept by key."""
|
| 222 |
+
for category, data in CONCEPTS.items():
|
| 223 |
+
for concept in data["concepts"]:
|
| 224 |
+
if concept["key"] == key:
|
| 225 |
+
concept_copy = concept.copy()
|
| 226 |
+
concept_copy["category"] = data["name"]
|
| 227 |
+
concept_copy["category_key"] = category
|
| 228 |
+
return concept_copy
|
| 229 |
+
return None
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def get_random_concepts(count: int = 5, diverse: bool = True) -> List[Dict[str, Any]]:
|
| 233 |
+
"""Get random concepts, optionally ensuring diversity across categories."""
|
| 234 |
+
if diverse:
|
| 235 |
+
selected = []
|
| 236 |
+
categories = list(CONCEPTS.keys())
|
| 237 |
+
random.shuffle(categories)
|
| 238 |
+
|
| 239 |
+
for category in categories[:count]:
|
| 240 |
+
concepts = CONCEPTS[category]["concepts"]
|
| 241 |
+
concept = random.choice(concepts).copy()
|
| 242 |
+
concept["category"] = CONCEPTS[category]["name"]
|
| 243 |
+
concept["category_key"] = category
|
| 244 |
+
selected.append(concept)
|
| 245 |
+
|
| 246 |
+
return selected[:count]
|
| 247 |
+
else:
|
| 248 |
+
all_concepts = get_all_concepts()
|
| 249 |
+
return random.sample(all_concepts, min(count, len(all_concepts)))
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# Top performing concepts for initial testing
|
| 253 |
+
TOP_CONCEPTS = [
|
| 254 |
+
"before_after", "selfie_style", "problem_awareness",
|
| 255 |
+
"side_by_side_table", "relatable_moment"
|
| 256 |
+
]
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def get_top_concepts() -> List[Dict[str, Any]]:
|
| 260 |
+
"""Get top performing concepts for initial testing."""
|
| 261 |
+
return [get_concept_by_key(key) for key in TOP_CONCEPTS if get_concept_by_key(key)]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def get_compatible_concepts(angle_trigger: str) -> List[Dict[str, Any]]:
|
| 265 |
+
"""Get concepts compatible with a psychological trigger."""
|
| 266 |
+
# Compatibility mapping
|
| 267 |
+
compatibility = {
|
| 268 |
+
"Fear": ["before_after", "shock_headline", "red_warning", "problem_awareness"],
|
| 269 |
+
"Relief": ["before_after", "relief_moment", "success_moment", "turning_point"],
|
| 270 |
+
"Greed": ["big_numbers", "price_stack", "value_breakdown", "side_by_side_table"],
|
| 271 |
+
"FOMO": ["countdown_cta", "red_warning", "crowd", "trending"],
|
| 272 |
+
"Social Proof": ["testimonial_screenshot", "review_stars", "real_customer", "crowd"],
|
| 273 |
+
"Authority": ["expert_portrait", "badge_seal", "data_backed", "certification"],
|
| 274 |
+
"Curiosity": ["shock_headline", "unexpected_image", "pattern_break", "bold_claim"],
|
| 275 |
+
"Pride": ["success_moment", "before_after", "winner_highlight"],
|
| 276 |
+
"Convenience": ["three_step", "how_it_works", "simple_explainer"],
|
| 277 |
+
"Trust": ["trust_signals", "badge_seal", "real_customer", "testimonial_screenshot"],
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
concept_keys = compatibility.get(angle_trigger, [])
|
| 281 |
+
return [get_concept_by_key(key) for key in concept_keys if get_concept_by_key(key)]
|
| 282 |
+
|
data/containers.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Container Types - Visual container styles for ad creatives.
|
| 3 |
+
These simulate different interface styles to make ads feel native and authentic.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, Any, List, Optional
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Complete list of container types with visual guidance
|
| 11 |
+
CONTAINER_TYPES: Dict[str, Dict[str, Any]] = {
|
| 12 |
+
"imessage": {
|
| 13 |
+
"name": "iMessage",
|
| 14 |
+
"description": "iOS iMessage screenshot style",
|
| 15 |
+
"visual_guidance": "iOS iMessage screenshot style, blue/green message bubbles, iPhone interface, authentic conversation look",
|
| 16 |
+
"font_style": "San Francisco, system font",
|
| 17 |
+
"colors": {
|
| 18 |
+
"primary": "#007AFF", # iOS blue
|
| 19 |
+
"secondary": "#34C759", # iOS green
|
| 20 |
+
"background": "#FFFFFF",
|
| 21 |
+
"text": "#000000",
|
| 22 |
+
},
|
| 23 |
+
"best_for": ["personal connection", "conversation", "informal offers"],
|
| 24 |
+
"authenticity_tips": [
|
| 25 |
+
"Include battery %, time, signal bars",
|
| 26 |
+
"Use realistic conversation format",
|
| 27 |
+
"Keep messages short (2-4 messages)",
|
| 28 |
+
],
|
| 29 |
+
},
|
| 30 |
+
"whatsapp": {
|
| 31 |
+
"name": "WhatsApp",
|
| 32 |
+
"description": "WhatsApp chat screenshot style",
|
| 33 |
+
"visual_guidance": "WhatsApp chat interface, green bubbles, checkmarks, authentic conversation feel",
|
| 34 |
+
"font_style": "Helvetica Neue, system font",
|
| 35 |
+
"colors": {
|
| 36 |
+
"primary": "#25D366", # WhatsApp green
|
| 37 |
+
"secondary": "#128C7E",
|
| 38 |
+
"background": "#ECE5DD",
|
| 39 |
+
"text": "#000000",
|
| 40 |
+
},
|
| 41 |
+
"best_for": ["personal recommendations", "peer-to-peer", "urgent messages"],
|
| 42 |
+
"authenticity_tips": [
|
| 43 |
+
"Include double checkmarks (read receipts)",
|
| 44 |
+
"Add timestamps",
|
| 45 |
+
"Use typical WhatsApp formatting",
|
| 46 |
+
],
|
| 47 |
+
},
|
| 48 |
+
"sms": {
|
| 49 |
+
"name": "SMS/Text",
|
| 50 |
+
"description": "Standard SMS text message style",
|
| 51 |
+
"visual_guidance": "Android/iOS SMS interface, simple text bubbles, notification style",
|
| 52 |
+
"font_style": "Roboto or San Francisco",
|
| 53 |
+
"colors": {
|
| 54 |
+
"primary": "#2196F3",
|
| 55 |
+
"secondary": "#4CAF50",
|
| 56 |
+
"background": "#FFFFFF",
|
| 57 |
+
"text": "#000000",
|
| 58 |
+
},
|
| 59 |
+
"best_for": ["urgent alerts", "personal messages", "time-sensitive offers"],
|
| 60 |
+
"authenticity_tips": [
|
| 61 |
+
"Keep messages very short",
|
| 62 |
+
"Use typical SMS abbreviations",
|
| 63 |
+
"Include carrier/time info",
|
| 64 |
+
],
|
| 65 |
+
},
|
| 66 |
+
"bank_alert": {
|
| 67 |
+
"name": "Bank Alert",
|
| 68 |
+
"description": "Bank transaction notification style",
|
| 69 |
+
"visual_guidance": "Bank transaction notification style, red alert box, bank app UI, urgent notification aesthetic",
|
| 70 |
+
"font_style": "Arial, Helvetica, system font",
|
| 71 |
+
"colors": {
|
| 72 |
+
"primary": "#D32F2F", # Alert red
|
| 73 |
+
"secondary": "#1976D2", # Bank blue
|
| 74 |
+
"background": "#FFFFFF",
|
| 75 |
+
"text": "#212121",
|
| 76 |
+
},
|
| 77 |
+
"best_for": ["financial urgency", "savings alerts", "money-related offers"],
|
| 78 |
+
"authenticity_tips": [
|
| 79 |
+
"Use official-looking format",
|
| 80 |
+
"Include dollar amounts",
|
| 81 |
+
"Add bank-style icons",
|
| 82 |
+
],
|
| 83 |
+
},
|
| 84 |
+
"news_chyron": {
|
| 85 |
+
"name": "News Chyron",
|
| 86 |
+
"description": "Breaking news ticker style",
|
| 87 |
+
"visual_guidance": "Breaking news ticker style, red/white scrolling text bar, news channel aesthetic, urgent feel",
|
| 88 |
+
"font_style": "Impact, Arial Black, bold sans-serif",
|
| 89 |
+
"colors": {
|
| 90 |
+
"primary": "#FF0000", # Breaking news red
|
| 91 |
+
"secondary": "#FFFFFF",
|
| 92 |
+
"background": "#000000",
|
| 93 |
+
"text": "#FFFFFF",
|
| 94 |
+
},
|
| 95 |
+
"best_for": ["breaking announcements", "urgent news", "time-sensitive offers"],
|
| 96 |
+
"authenticity_tips": [
|
| 97 |
+
"Use BREAKING/ALERT prefix",
|
| 98 |
+
"Include news channel logo area",
|
| 99 |
+
"Add scrolling ticker effect",
|
| 100 |
+
],
|
| 101 |
+
},
|
| 102 |
+
"email_notification": {
|
| 103 |
+
"name": "Email Notification",
|
| 104 |
+
"description": "Email notification/preview style",
|
| 105 |
+
"visual_guidance": "Email notification style, email client interface, notification badge, real email app look",
|
| 106 |
+
"font_style": "System font, Segoe UI, Roboto",
|
| 107 |
+
"colors": {
|
| 108 |
+
"primary": "#1A73E8", # Gmail blue
|
| 109 |
+
"secondary": "#EA4335", # Notification badge
|
| 110 |
+
"background": "#FFFFFF",
|
| 111 |
+
"text": "#202124",
|
| 112 |
+
},
|
| 113 |
+
"best_for": ["official communications", "professional offers", "formal announcements"],
|
| 114 |
+
"authenticity_tips": [
|
| 115 |
+
"Include sender, subject, preview",
|
| 116 |
+
"Add unread badge if relevant",
|
| 117 |
+
"Use email timestamp format",
|
| 118 |
+
],
|
| 119 |
+
},
|
| 120 |
+
"reddit_post": {
|
| 121 |
+
"name": "Reddit Post",
|
| 122 |
+
"description": "Reddit post/comment style",
|
| 123 |
+
"visual_guidance": "Reddit discussion style, Reddit UI, comment thread appearance, authentic forum look",
|
| 124 |
+
"font_style": "Noto Sans, Arial",
|
| 125 |
+
"colors": {
|
| 126 |
+
"primary": "#FF4500", # Reddit orange
|
| 127 |
+
"secondary": "#0079D3", # Reddit blue
|
| 128 |
+
"background": "#DAE0E6",
|
| 129 |
+
"text": "#1A1A1B",
|
| 130 |
+
},
|
| 131 |
+
"best_for": ["social proof", "user discussions", "authentic testimonials"],
|
| 132 |
+
"authenticity_tips": [
|
| 133 |
+
"Include upvote counts",
|
| 134 |
+
"Add username (anonymous style)",
|
| 135 |
+
"Use subreddit reference",
|
| 136 |
+
],
|
| 137 |
+
},
|
| 138 |
+
"system_notification": {
|
| 139 |
+
"name": "System Notification",
|
| 140 |
+
"description": "iOS/Android system notification popup",
|
| 141 |
+
"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",
|
| 142 |
+
"font_style": "San Francisco, Roboto, system font only",
|
| 143 |
+
"colors": {
|
| 144 |
+
"primary": "#000000",
|
| 145 |
+
"secondary": "#666666",
|
| 146 |
+
"background": "#F2F2F2",
|
| 147 |
+
"text": "#000000",
|
| 148 |
+
},
|
| 149 |
+
"best_for": ["urgent alerts", "app notifications", "system messages"],
|
| 150 |
+
"authenticity_tips": [
|
| 151 |
+
"NO emojis or decorative elements",
|
| 152 |
+
"Keep to 1-2 short lines",
|
| 153 |
+
"Use app icon if relevant",
|
| 154 |
+
],
|
| 155 |
+
"avoid": ["emojis", "decorative elements", "gradients", "colorful backgrounds"],
|
| 156 |
+
},
|
| 157 |
+
"push_notification": {
|
| 158 |
+
"name": "Push Notification",
|
| 159 |
+
"description": "Mobile app push notification style",
|
| 160 |
+
"visual_guidance": "Mobile push notification banner, app icon, brief text, swipe-to-view format",
|
| 161 |
+
"font_style": "System font",
|
| 162 |
+
"colors": {
|
| 163 |
+
"primary": "#007AFF",
|
| 164 |
+
"secondary": "#8E8E93",
|
| 165 |
+
"background": "#FFFFFF",
|
| 166 |
+
"text": "#000000",
|
| 167 |
+
},
|
| 168 |
+
"best_for": ["app alerts", "time-sensitive messages", "quick updates"],
|
| 169 |
+
"authenticity_tips": [
|
| 170 |
+
"Include app icon",
|
| 171 |
+
"Very short headline (5-7 words)",
|
| 172 |
+
"Add timestamp (e.g., 'now', '2m ago')",
|
| 173 |
+
],
|
| 174 |
+
},
|
| 175 |
+
"sticky_note": {
|
| 176 |
+
"name": "Sticky Note",
|
| 177 |
+
"description": "Handwritten sticky note overlay",
|
| 178 |
+
"visual_guidance": "Yellow sticky note overlay on image, handwritten-style text, authentic note appearance, slightly wrinkled paper texture",
|
| 179 |
+
"font_style": "Handwriting fonts, marker style",
|
| 180 |
+
"colors": {
|
| 181 |
+
"primary": "#FFEB3B", # Sticky note yellow
|
| 182 |
+
"secondary": "#FFC107",
|
| 183 |
+
"background": "#FFEB3B",
|
| 184 |
+
"text": "#000000",
|
| 185 |
+
},
|
| 186 |
+
"best_for": ["personal reminders", "quick tips", "informal notes"],
|
| 187 |
+
"authenticity_tips": [
|
| 188 |
+
"Slight angle/tilt",
|
| 189 |
+
"Handwritten font style",
|
| 190 |
+
"Paper texture/wrinkles",
|
| 191 |
+
],
|
| 192 |
+
},
|
| 193 |
+
"memo": {
|
| 194 |
+
"name": "Internal Memo",
|
| 195 |
+
"description": "Office memo/document style",
|
| 196 |
+
"visual_guidance": "Internal memo document style, typewriter font, yellow/white paper, authentic document appearance",
|
| 197 |
+
"font_style": "Courier, typewriter fonts",
|
| 198 |
+
"colors": {
|
| 199 |
+
"primary": "#000000",
|
| 200 |
+
"secondary": "#333333",
|
| 201 |
+
"background": "#FFFFCC", # Paper yellow
|
| 202 |
+
"text": "#000000",
|
| 203 |
+
},
|
| 204 |
+
"best_for": ["official announcements", "leaked documents", "internal secrets"],
|
| 205 |
+
"authenticity_tips": [
|
| 206 |
+
"Add CONFIDENTIAL stamp if relevant",
|
| 207 |
+
"Include date, to/from fields",
|
| 208 |
+
"Paper texture/fold marks",
|
| 209 |
+
],
|
| 210 |
+
},
|
| 211 |
+
"browser_alert": {
|
| 212 |
+
"name": "Browser Alert",
|
| 213 |
+
"description": "Browser popup/alert dialog",
|
| 214 |
+
"visual_guidance": "Browser dialog box, alert icons, OK/Cancel buttons, system alert aesthetic",
|
| 215 |
+
"font_style": "System font, Segoe UI",
|
| 216 |
+
"colors": {
|
| 217 |
+
"primary": "#0078D4", # Windows blue
|
| 218 |
+
"secondary": "#D83B01", # Warning orange
|
| 219 |
+
"background": "#FFFFFF",
|
| 220 |
+
"text": "#000000",
|
| 221 |
+
},
|
| 222 |
+
"best_for": ["urgent warnings", "system alerts", "confirmation messages"],
|
| 223 |
+
"authenticity_tips": [
|
| 224 |
+
"Include browser chrome",
|
| 225 |
+
"Add alert icon",
|
| 226 |
+
"Button styling",
|
| 227 |
+
],
|
| 228 |
+
},
|
| 229 |
+
"social_post": {
|
| 230 |
+
"name": "Social Media Post",
|
| 231 |
+
"description": "Facebook/Instagram post style",
|
| 232 |
+
"visual_guidance": "Social media feed post, user profile, likes/comments, authentic social feel",
|
| 233 |
+
"font_style": "Helvetica, system font",
|
| 234 |
+
"colors": {
|
| 235 |
+
"primary": "#1877F2", # Facebook blue
|
| 236 |
+
"secondary": "#E4405F", # Instagram pink
|
| 237 |
+
"background": "#FFFFFF",
|
| 238 |
+
"text": "#1C1E21",
|
| 239 |
+
},
|
| 240 |
+
"best_for": ["social proof", "user content", "organic feel"],
|
| 241 |
+
"authenticity_tips": [
|
| 242 |
+
"Include profile picture",
|
| 243 |
+
"Add like/comment counts",
|
| 244 |
+
"Use platform-specific formatting",
|
| 245 |
+
],
|
| 246 |
+
},
|
| 247 |
+
"standard": {
|
| 248 |
+
"name": "Standard Ad",
|
| 249 |
+
"description": "Clean, professional ad format",
|
| 250 |
+
"visual_guidance": "Clean ad layout, professional design, clear headline and CTA",
|
| 251 |
+
"font_style": "Clean sans-serif fonts",
|
| 252 |
+
"colors": {
|
| 253 |
+
"primary": "#2196F3",
|
| 254 |
+
"secondary": "#FF9800",
|
| 255 |
+
"background": "#FFFFFF",
|
| 256 |
+
"text": "#212121",
|
| 257 |
+
},
|
| 258 |
+
"best_for": ["professional campaigns", "brand awareness", "general advertising"],
|
| 259 |
+
"authenticity_tips": [
|
| 260 |
+
"Clear visual hierarchy",
|
| 261 |
+
"Prominent CTA",
|
| 262 |
+
"Brand-consistent design",
|
| 263 |
+
],
|
| 264 |
+
},
|
| 265 |
+
"telegram": {
|
| 266 |
+
"name": "Telegram",
|
| 267 |
+
"description": "Telegram chat message style",
|
| 268 |
+
"visual_guidance": "Telegram chat interface, blue message bubbles, Telegram UI, authentic messaging look",
|
| 269 |
+
"font_style": "Roboto, system font",
|
| 270 |
+
"colors": {
|
| 271 |
+
"primary": "#3390EC", # Telegram blue
|
| 272 |
+
"secondary": "#0088CC",
|
| 273 |
+
"background": "#FFFFFF",
|
| 274 |
+
"text": "#000000",
|
| 275 |
+
},
|
| 276 |
+
"best_for": ["personal messages", "group chats", "informal communication"],
|
| 277 |
+
"authenticity_tips": [
|
| 278 |
+
"Include Telegram UI elements",
|
| 279 |
+
"Use typical Telegram formatting",
|
| 280 |
+
"Add read receipts if relevant",
|
| 281 |
+
],
|
| 282 |
+
},
|
| 283 |
+
"slack": {
|
| 284 |
+
"name": "Slack",
|
| 285 |
+
"description": "Slack workspace message style",
|
| 286 |
+
"visual_guidance": "Slack workspace interface, channel messages, Slack UI, professional team communication",
|
| 287 |
+
"font_style": "Lato, Slack font",
|
| 288 |
+
"colors": {
|
| 289 |
+
"primary": "#4A154B", # Slack purple
|
| 290 |
+
"secondary": "#36C5F0",
|
| 291 |
+
"background": "#FFFFFF",
|
| 292 |
+
"text": "#1D1C1D",
|
| 293 |
+
},
|
| 294 |
+
"best_for": ["team communication", "workplace announcements", "professional updates"],
|
| 295 |
+
"authenticity_tips": [
|
| 296 |
+
"Include channel name",
|
| 297 |
+
"Add user avatar",
|
| 298 |
+
"Use Slack message formatting",
|
| 299 |
+
],
|
| 300 |
+
},
|
| 301 |
+
"instagram_story": {
|
| 302 |
+
"name": "Instagram Story",
|
| 303 |
+
"description": "Instagram story frame style",
|
| 304 |
+
"visual_guidance": "Instagram story format, vertical 9:16 aspect ratio, story UI elements, authentic Instagram look",
|
| 305 |
+
"font_style": "Instagram font, system font",
|
| 306 |
+
"colors": {
|
| 307 |
+
"primary": "#E4405F", # Instagram pink
|
| 308 |
+
"secondary": "#833AB4",
|
| 309 |
+
"background": "#000000",
|
| 310 |
+
"text": "#FFFFFF",
|
| 311 |
+
},
|
| 312 |
+
"best_for": ["social media engagement", "story-style content", "mobile-first ads"],
|
| 313 |
+
"authenticity_tips": [
|
| 314 |
+
"Vertical format (9:16)",
|
| 315 |
+
"Include story UI elements",
|
| 316 |
+
"Use Instagram-style fonts",
|
| 317 |
+
],
|
| 318 |
+
},
|
| 319 |
+
"tiktok_style": {
|
| 320 |
+
"name": "TikTok Style",
|
| 321 |
+
"description": "TikTok video frame style",
|
| 322 |
+
"visual_guidance": "TikTok video frame, vertical format, TikTok UI overlay, authentic TikTok appearance",
|
| 323 |
+
"font_style": "TikTok font, bold sans-serif",
|
| 324 |
+
"colors": {
|
| 325 |
+
"primary": "#000000",
|
| 326 |
+
"secondary": "#FE2C55", # TikTok red
|
| 327 |
+
"background": "#000000",
|
| 328 |
+
"text": "#FFFFFF",
|
| 329 |
+
},
|
| 330 |
+
"best_for": ["youth engagement", "viral content", "trending topics"],
|
| 331 |
+
"authenticity_tips": [
|
| 332 |
+
"Vertical video format",
|
| 333 |
+
"Bold text overlays",
|
| 334 |
+
"Trending style elements",
|
| 335 |
+
],
|
| 336 |
+
},
|
| 337 |
+
"linkedin_post": {
|
| 338 |
+
"name": "LinkedIn Post",
|
| 339 |
+
"description": "LinkedIn feed post style",
|
| 340 |
+
"visual_guidance": "LinkedIn feed post, professional network UI, LinkedIn interface, authentic professional look",
|
| 341 |
+
"font_style": "LinkedIn font, professional sans-serif",
|
| 342 |
+
"colors": {
|
| 343 |
+
"primary": "#0077B5", # LinkedIn blue
|
| 344 |
+
"secondary": "#000000",
|
| 345 |
+
"background": "#FFFFFF",
|
| 346 |
+
"text": "#000000",
|
| 347 |
+
},
|
| 348 |
+
"best_for": ["professional networking", "B2B marketing", "career-focused content"],
|
| 349 |
+
"authenticity_tips": [
|
| 350 |
+
"Include profile elements",
|
| 351 |
+
"Professional tone",
|
| 352 |
+
"LinkedIn-style formatting",
|
| 353 |
+
],
|
| 354 |
+
},
|
| 355 |
+
"app_store_listing": {
|
| 356 |
+
"name": "App Store Listing",
|
| 357 |
+
"description": "App store screenshot style",
|
| 358 |
+
"visual_guidance": "App store listing screenshot, app icon, ratings, screenshots, authentic app store appearance",
|
| 359 |
+
"font_style": "San Francisco, system font",
|
| 360 |
+
"colors": {
|
| 361 |
+
"primary": "#007AFF", # iOS blue
|
| 362 |
+
"secondary": "#FF9500",
|
| 363 |
+
"background": "#FFFFFF",
|
| 364 |
+
"text": "#000000",
|
| 365 |
+
},
|
| 366 |
+
"best_for": ["app promotion", "mobile apps", "app features"],
|
| 367 |
+
"authenticity_tips": [
|
| 368 |
+
"Include app icon",
|
| 369 |
+
"Star ratings",
|
| 370 |
+
"App store UI elements",
|
| 371 |
+
],
|
| 372 |
+
},
|
| 373 |
+
"email_signature": {
|
| 374 |
+
"name": "Email Signature",
|
| 375 |
+
"description": "Professional email signature style",
|
| 376 |
+
"visual_guidance": "Professional email signature, contact info, logo, authentic email signature appearance",
|
| 377 |
+
"font_style": "Arial, Helvetica, professional fonts",
|
| 378 |
+
"colors": {
|
| 379 |
+
"primary": "#000000",
|
| 380 |
+
"secondary": "#666666",
|
| 381 |
+
"background": "#FFFFFF",
|
| 382 |
+
"text": "#000000",
|
| 383 |
+
},
|
| 384 |
+
"best_for": ["professional communication", "B2B outreach", "formal announcements"],
|
| 385 |
+
"authenticity_tips": [
|
| 386 |
+
"Include contact information",
|
| 387 |
+
"Professional formatting",
|
| 388 |
+
"Company logo if relevant",
|
| 389 |
+
],
|
| 390 |
+
},
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def get_all_containers() -> Dict[str, Dict[str, Any]]:
|
| 395 |
+
"""Get all available container types."""
|
| 396 |
+
return CONTAINER_TYPES
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def get_container(key: str) -> Optional[Dict[str, Any]]:
|
| 400 |
+
"""Get a specific container type by key."""
|
| 401 |
+
return CONTAINER_TYPES.get(key.lower().replace(" ", "_"))
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def get_random_container() -> Dict[str, Any]:
|
| 405 |
+
"""Get a random container type."""
|
| 406 |
+
key = random.choice(list(CONTAINER_TYPES.keys()))
|
| 407 |
+
return {"key": key, **CONTAINER_TYPES[key]}
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def get_container_visual_guidance(container_key: str) -> str:
|
| 411 |
+
"""Get visual guidance for a container type."""
|
| 412 |
+
container = get_container(container_key)
|
| 413 |
+
if container:
|
| 414 |
+
return container.get("visual_guidance", "")
|
| 415 |
+
return f"Container type: {container_key}, authentic appearance"
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def get_native_containers() -> List[str]:
|
| 419 |
+
"""Get container types that look like native app interfaces."""
|
| 420 |
+
native = ["imessage", "whatsapp", "sms", "system_notification", "push_notification", "email_notification"]
|
| 421 |
+
return native
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def get_ugc_containers() -> List[str]:
|
| 425 |
+
"""Get container types that look like user-generated content."""
|
| 426 |
+
ugc = ["reddit_post", "social_post", "sticky_note", "memo"]
|
| 427 |
+
return ugc
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def get_alert_containers() -> List[str]:
|
| 431 |
+
"""Get container types that create urgency."""
|
| 432 |
+
alerts = ["bank_alert", "news_chyron", "browser_alert", "system_notification"]
|
| 433 |
+
return alerts
|
| 434 |
+
|
data/frameworks.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ad Frameworks - Different structural approaches for ad creatives.
|
| 3 |
+
Each framework has a unique style and is suited for different marketing goals.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, Any, List, Optional
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Complete list of 10 ad frameworks with detailed configurations
|
| 11 |
+
FRAMEWORKS: Dict[str, Dict[str, Any]] = {
|
| 12 |
+
"breaking_news": {
|
| 13 |
+
"name": "Breaking News",
|
| 14 |
+
"description": "Creates urgency through news-style presentation, feels like breaking news",
|
| 15 |
+
"best_for": ["announcements", "limited offers", "new discoveries", "price drops"],
|
| 16 |
+
"visual_style": "News channel aesthetic, red/white ticker, urgent feel",
|
| 17 |
+
"hook_examples": [
|
| 18 |
+
"BREAKING: Home Insurance Rates Drop 40%",
|
| 19 |
+
"URGENT: Limited Time Savings Available Now",
|
| 20 |
+
"ALERT: New Insurance Savings Discovered",
|
| 21 |
+
"FLASH: This Week Only - Save $500",
|
| 22 |
+
"EXCLUSIVE: Secret Savings Method Revealed",
|
| 23 |
+
],
|
| 24 |
+
"headline_style": "ALL CAPS with news prefix (BREAKING:, URGENT:, ALERT:)",
|
| 25 |
+
"tone": "Urgent, newsworthy, time-sensitive",
|
| 26 |
+
"psychological_triggers": ["FOMO", "Urgency", "Curiosity"],
|
| 27 |
+
},
|
| 28 |
+
"mobile_post": {
|
| 29 |
+
"name": "Mobile Post",
|
| 30 |
+
"description": "Optimized for mobile scrolling, short and punchy",
|
| 31 |
+
"best_for": ["quick engagement", "thumb-stopping content", "social feeds"],
|
| 32 |
+
"visual_style": "Mobile-first design, clean, easy to read on small screens",
|
| 33 |
+
"hook_examples": [
|
| 34 |
+
"Save $500/Year in 2 Minutes",
|
| 35 |
+
"Get Your Quote - Tap to Start",
|
| 36 |
+
"See Your Savings Instantly",
|
| 37 |
+
"One tap. Big savings.",
|
| 38 |
+
"30 seconds to lower rates",
|
| 39 |
+
],
|
| 40 |
+
"headline_style": "Short, punchy, action-oriented (under 8 words)",
|
| 41 |
+
"tone": "Quick, casual, conversational",
|
| 42 |
+
"psychological_triggers": ["Convenience", "Speed", "Ease"],
|
| 43 |
+
},
|
| 44 |
+
"before_after": {
|
| 45 |
+
"name": "Before/After",
|
| 46 |
+
"description": "Shows transformation, contrast between old and new state",
|
| 47 |
+
"best_for": ["transformations", "improvements", "results showcase"],
|
| 48 |
+
"visual_style": "Split-screen comparison, clear before/after contrast",
|
| 49 |
+
"hook_examples": [
|
| 50 |
+
"From $200/Month to $50/Month",
|
| 51 |
+
"Before: High Rates. After: Big Savings",
|
| 52 |
+
"Transform Your Insurance Costs Today",
|
| 53 |
+
"See What Changed Everything",
|
| 54 |
+
"The Difference One Call Made",
|
| 55 |
+
],
|
| 56 |
+
"headline_style": "Comparison format, specific numbers, transformation language",
|
| 57 |
+
"tone": "Results-focused, proof-based, transformation",
|
| 58 |
+
"psychological_triggers": ["Transformation", "Proof", "Results"],
|
| 59 |
+
},
|
| 60 |
+
"testimonial": {
|
| 61 |
+
"name": "Testimonial",
|
| 62 |
+
"description": "Social proof through customer stories and reviews",
|
| 63 |
+
"best_for": ["trust building", "social proof", "credibility"],
|
| 64 |
+
"visual_style": "Quote cards, star ratings, real customer photos",
|
| 65 |
+
"hook_examples": [
|
| 66 |
+
"Join 50,000+ Happy Customers",
|
| 67 |
+
"Rated 5 Stars by Thousands",
|
| 68 |
+
"See Why Customers Love Us",
|
| 69 |
+
'"Best decision I ever made"',
|
| 70 |
+
"Real people. Real savings.",
|
| 71 |
+
],
|
| 72 |
+
"headline_style": "Quote format, numbers, social proof indicators",
|
| 73 |
+
"tone": "Trustworthy, relatable, authentic",
|
| 74 |
+
"psychological_triggers": ["Social Proof", "Trust", "Belonging"],
|
| 75 |
+
},
|
| 76 |
+
"lifestyle": {
|
| 77 |
+
"name": "Lifestyle",
|
| 78 |
+
"description": "Aspirational imagery showing the desired lifestyle",
|
| 79 |
+
"best_for": ["aspiration", "emotional connection", "dream selling"],
|
| 80 |
+
"visual_style": "Aspirational imagery, happy people, dream scenarios",
|
| 81 |
+
"hook_examples": [
|
| 82 |
+
"Live Life Fully Protected",
|
| 83 |
+
"Drive with Complete Confidence",
|
| 84 |
+
"Peace of Mind on Every Journey",
|
| 85 |
+
"The Life You Deserve",
|
| 86 |
+
"Freedom Starts Here",
|
| 87 |
+
],
|
| 88 |
+
"headline_style": "Aspirational, emotional, lifestyle-focused",
|
| 89 |
+
"tone": "Aspirational, emotional, inspiring",
|
| 90 |
+
"psychological_triggers": ["Aspiration", "Desire", "Freedom"],
|
| 91 |
+
},
|
| 92 |
+
"educational": {
|
| 93 |
+
"name": "Educational",
|
| 94 |
+
"description": "Provides value through information and tips",
|
| 95 |
+
"best_for": ["authority building", "value-first approach", "complex topics"],
|
| 96 |
+
"visual_style": "Infographic style, lists, clear information hierarchy",
|
| 97 |
+
"hook_examples": [
|
| 98 |
+
"3 Things Your Insurance Agent Won't Tell You",
|
| 99 |
+
"The Hidden Costs of Cheap Insurance",
|
| 100 |
+
"What Every Homeowner Must Know",
|
| 101 |
+
"Learn the Secret to Lower Rates",
|
| 102 |
+
"5 Mistakes That Cost You Money",
|
| 103 |
+
],
|
| 104 |
+
"headline_style": "Numbers, curiosity gaps, valuable information promise",
|
| 105 |
+
"tone": "Informative, helpful, authoritative",
|
| 106 |
+
"psychological_triggers": ["Curiosity", "Knowledge", "Fear of Missing Out"],
|
| 107 |
+
},
|
| 108 |
+
"comparison": {
|
| 109 |
+
"name": "Comparison",
|
| 110 |
+
"description": "Compares to competitors or alternatives",
|
| 111 |
+
"best_for": ["differentiation", "competitive positioning", "value demonstration"],
|
| 112 |
+
"visual_style": "Side-by-side comparison, checkmarks vs X marks",
|
| 113 |
+
"hook_examples": [
|
| 114 |
+
"Why Customers Are Switching",
|
| 115 |
+
"The Difference Is Clear",
|
| 116 |
+
"Compare and Save Hundreds",
|
| 117 |
+
"What Others Charge vs Our Price",
|
| 118 |
+
"See the Better Option",
|
| 119 |
+
],
|
| 120 |
+
"headline_style": "Comparison language, competitive positioning",
|
| 121 |
+
"tone": "Confident, comparative, fact-based",
|
| 122 |
+
"psychological_triggers": ["Comparison", "Value", "Smart Choice"],
|
| 123 |
+
},
|
| 124 |
+
"storytelling": {
|
| 125 |
+
"name": "Storytelling",
|
| 126 |
+
"description": "Narrative-driven content that tells a story",
|
| 127 |
+
"best_for": ["emotional connection", "memorable content", "brand building"],
|
| 128 |
+
"visual_style": "Narrative imagery, sequential scenes, story elements",
|
| 129 |
+
"hook_examples": [
|
| 130 |
+
"When Sarah Lost Everything...",
|
| 131 |
+
"The Day That Changed My Life",
|
| 132 |
+
"I Never Thought It Would Happen to Me",
|
| 133 |
+
"Here's What Happened Next",
|
| 134 |
+
"The Story Nobody Tells You",
|
| 135 |
+
],
|
| 136 |
+
"headline_style": "Narrative hooks, story beginnings, curiosity builders",
|
| 137 |
+
"tone": "Narrative, emotional, personal",
|
| 138 |
+
"psychological_triggers": ["Empathy", "Curiosity", "Emotion"],
|
| 139 |
+
},
|
| 140 |
+
"problem_solution": {
|
| 141 |
+
"name": "Problem/Solution",
|
| 142 |
+
"description": "Identifies a problem and presents the solution",
|
| 143 |
+
"best_for": ["pain point targeting", "solution selling", "problem awareness"],
|
| 144 |
+
"visual_style": "Problem visualization, solution presentation, relief imagery",
|
| 145 |
+
"hook_examples": [
|
| 146 |
+
"Tired of High Insurance Rates?",
|
| 147 |
+
"Finally, a Solution That Works",
|
| 148 |
+
"The Problem Nobody Talks About",
|
| 149 |
+
"End Your Insurance Frustration",
|
| 150 |
+
"There's a Better Way",
|
| 151 |
+
],
|
| 152 |
+
"headline_style": "Problem questions, solution statements, relief language",
|
| 153 |
+
"tone": "Empathetic, problem-aware, solution-focused",
|
| 154 |
+
"psychological_triggers": ["Pain Relief", "Problem Awareness", "Hope"],
|
| 155 |
+
},
|
| 156 |
+
"authority": {
|
| 157 |
+
"name": "Authority",
|
| 158 |
+
"description": "Establishes expertise and credibility",
|
| 159 |
+
"best_for": ["credibility building", "expert positioning", "trust establishment"],
|
| 160 |
+
"visual_style": "Professional imagery, credentials, expert endorsements",
|
| 161 |
+
"hook_examples": [
|
| 162 |
+
"Expert-Recommended Insurance",
|
| 163 |
+
"Backed by 50 Years of Experience",
|
| 164 |
+
"What the Pros Know About Insurance",
|
| 165 |
+
"Industry-Leading Protection",
|
| 166 |
+
"Trusted by Professionals",
|
| 167 |
+
],
|
| 168 |
+
"headline_style": "Authority indicators, credentials, expert language",
|
| 169 |
+
"tone": "Professional, authoritative, trustworthy",
|
| 170 |
+
"psychological_triggers": ["Authority", "Trust", "Expertise"],
|
| 171 |
+
},
|
| 172 |
+
"scarcity": {
|
| 173 |
+
"name": "Scarcity",
|
| 174 |
+
"description": "Creates urgency through limited availability",
|
| 175 |
+
"best_for": ["limited offers", "exclusive deals", "time-sensitive promotions"],
|
| 176 |
+
"visual_style": "Countdown timers, limited stock indicators, exclusive badges",
|
| 177 |
+
"hook_examples": [
|
| 178 |
+
"Only 50 Spots Left This Month",
|
| 179 |
+
"Limited Availability - Act Fast",
|
| 180 |
+
"Exclusive Offer for First 100",
|
| 181 |
+
"While Supplies Last",
|
| 182 |
+
"Don't Miss Your Chance",
|
| 183 |
+
],
|
| 184 |
+
"headline_style": "Numbers, time limits, availability language",
|
| 185 |
+
"tone": "Urgent, exclusive, time-sensitive",
|
| 186 |
+
"psychological_triggers": ["FOMO", "Urgency", "Exclusivity"],
|
| 187 |
+
},
|
| 188 |
+
"benefit_stack": {
|
| 189 |
+
"name": "Benefit Stack",
|
| 190 |
+
"description": "Lists multiple benefits in quick succession",
|
| 191 |
+
"best_for": ["value demonstration", "feature highlights", "quick scanning"],
|
| 192 |
+
"visual_style": "Bullet points, checkmarks, organized list format",
|
| 193 |
+
"hook_examples": [
|
| 194 |
+
"Save Money, Save Time, Save Stress",
|
| 195 |
+
"3 Benefits in One Solution",
|
| 196 |
+
"Everything You Need, All in One",
|
| 197 |
+
"More Coverage, Lower Cost, Better Service",
|
| 198 |
+
"Protection + Savings + Peace of Mind",
|
| 199 |
+
],
|
| 200 |
+
"headline_style": "Multiple benefits, parallel structure, value stacking",
|
| 201 |
+
"tone": "Value-focused, comprehensive, efficient",
|
| 202 |
+
"psychological_triggers": ["Value", "Convenience", "Completeness"],
|
| 203 |
+
},
|
| 204 |
+
"risk_reversal": {
|
| 205 |
+
"name": "Risk Reversal",
|
| 206 |
+
"description": "Removes risk and uncertainty from the decision",
|
| 207 |
+
"best_for": ["overcoming objections", "building confidence", "reducing hesitation"],
|
| 208 |
+
"visual_style": "Guarantee badges, risk-free indicators, confidence builders",
|
| 209 |
+
"hook_examples": [
|
| 210 |
+
"100% Risk-Free Guarantee",
|
| 211 |
+
"Try It Free - No Commitment",
|
| 212 |
+
"Cancel Anytime, No Questions",
|
| 213 |
+
"Money-Back Guarantee",
|
| 214 |
+
"No Risk, All Reward",
|
| 215 |
+
],
|
| 216 |
+
"headline_style": "Guarantee language, risk removal, confidence builders",
|
| 217 |
+
"tone": "Reassuring, confident, risk-free",
|
| 218 |
+
"psychological_triggers": ["Security", "Trust", "Risk Reduction"],
|
| 219 |
+
},
|
| 220 |
+
"contrarian": {
|
| 221 |
+
"name": "Contrarian",
|
| 222 |
+
"description": "Challenges conventional wisdom or expectations",
|
| 223 |
+
"best_for": ["differentiation", "attention-grabbing", "thought leadership"],
|
| 224 |
+
"visual_style": "Bold statements, unexpected visuals, pattern breaks",
|
| 225 |
+
"hook_examples": [
|
| 226 |
+
"Why Everything You Know About Insurance Is Wrong",
|
| 227 |
+
"The Unpopular Truth About Rates",
|
| 228 |
+
"Stop Following the Crowd",
|
| 229 |
+
"The Counter-Intuitive Way to Save",
|
| 230 |
+
"What They Don't Want You to Know",
|
| 231 |
+
],
|
| 232 |
+
"headline_style": "Contrarian statements, pattern breaks, unexpected angles",
|
| 233 |
+
"tone": "Bold, provocative, thought-provoking",
|
| 234 |
+
"psychological_triggers": ["Curiosity", "Differentiation", "Intellectual"],
|
| 235 |
+
},
|
| 236 |
+
"case_study": {
|
| 237 |
+
"name": "Case Study",
|
| 238 |
+
"description": "Shows real results and specific outcomes",
|
| 239 |
+
"best_for": ["proof demonstration", "results showcase", "credibility"],
|
| 240 |
+
"visual_style": "Before/after data, specific numbers, real examples",
|
| 241 |
+
"hook_examples": [
|
| 242 |
+
"How Sarah Saved $1,200 in 6 Months",
|
| 243 |
+
"Real Results from Real Customers",
|
| 244 |
+
"The Exact Steps That Worked",
|
| 245 |
+
"See the Numbers That Matter",
|
| 246 |
+
"From Problem to Solution: The Journey",
|
| 247 |
+
],
|
| 248 |
+
"headline_style": "Specific examples, real names, concrete results",
|
| 249 |
+
"tone": "Proof-based, specific, results-focused",
|
| 250 |
+
"psychological_triggers": ["Proof", "Social Proof", "Results"],
|
| 251 |
+
},
|
| 252 |
+
"interactive": {
|
| 253 |
+
"name": "Interactive",
|
| 254 |
+
"description": "Engages through questions, quizzes, or participation",
|
| 255 |
+
"best_for": ["engagement", "personalization", "interaction"],
|
| 256 |
+
"visual_style": "Interactive elements, questions, quiz formats",
|
| 257 |
+
"hook_examples": [
|
| 258 |
+
"Take Our 30-Second Quiz",
|
| 259 |
+
"Answer 3 Questions, Get Your Rate",
|
| 260 |
+
"See If You Qualify in 60 Seconds",
|
| 261 |
+
"What's Your Insurance Personality?",
|
| 262 |
+
"Find Your Perfect Match",
|
| 263 |
+
],
|
| 264 |
+
"headline_style": "Questions, interactive prompts, participation language",
|
| 265 |
+
"tone": "Engaging, interactive, personalized",
|
| 266 |
+
"psychological_triggers": ["Engagement", "Personalization", "Curiosity"],
|
| 267 |
+
},
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
# Framework examples by niche
|
| 271 |
+
NICHE_FRAMEWORK_EXAMPLES: Dict[str, Dict[str, List[str]]] = {
|
| 272 |
+
"home_insurance": {
|
| 273 |
+
"breaking_news": [
|
| 274 |
+
"BREAKING: Home Insurance Rates Drop 40%",
|
| 275 |
+
"ALERT: New Homeowner Discounts Available",
|
| 276 |
+
"URGENT: Rate Freeze Ends Friday",
|
| 277 |
+
],
|
| 278 |
+
"mobile_post": [
|
| 279 |
+
"Protect Your Home in 3 Minutes",
|
| 280 |
+
"One Quote. Big Savings.",
|
| 281 |
+
"Tap to See Your Rate",
|
| 282 |
+
],
|
| 283 |
+
"before_after": [
|
| 284 |
+
"Before: $2,400/year. After: $1,200/year",
|
| 285 |
+
"Old policy vs New savings",
|
| 286 |
+
"What switching saved me",
|
| 287 |
+
],
|
| 288 |
+
"testimonial": [
|
| 289 |
+
'"I saved $1,200 on my first year"',
|
| 290 |
+
"Join 100,000+ protected homeowners",
|
| 291 |
+
"Rated #1 by customers like you",
|
| 292 |
+
],
|
| 293 |
+
"problem_solution": [
|
| 294 |
+
"Worried your home isn't covered?",
|
| 295 |
+
"Stop overpaying for insurance",
|
| 296 |
+
"End the coverage gaps",
|
| 297 |
+
],
|
| 298 |
+
},
|
| 299 |
+
"glp1": {
|
| 300 |
+
"breaking_news": [
|
| 301 |
+
"NEW: FDA-Approved Weight Loss Solution",
|
| 302 |
+
"ALERT: Limited Appointments Available",
|
| 303 |
+
"EXCLUSIVE: Online Consultations Now Open",
|
| 304 |
+
],
|
| 305 |
+
"before_after": [
|
| 306 |
+
"Her 60-Day Transformation",
|
| 307 |
+
"What Changed in 90 Days",
|
| 308 |
+
"The Results Speak for Themselves",
|
| 309 |
+
],
|
| 310 |
+
"testimonial": [
|
| 311 |
+
'"I finally found what works"',
|
| 312 |
+
"Thousands have transformed",
|
| 313 |
+
"Real patients. Real results.",
|
| 314 |
+
],
|
| 315 |
+
"lifestyle": [
|
| 316 |
+
"Feel Confident Again",
|
| 317 |
+
"The Energy to Live Fully",
|
| 318 |
+
"Your New Chapter Starts Here",
|
| 319 |
+
],
|
| 320 |
+
"authority": [
|
| 321 |
+
"Doctor-Recommended Solution",
|
| 322 |
+
"Clinically Proven Results",
|
| 323 |
+
"Backed by Medical Research",
|
| 324 |
+
],
|
| 325 |
+
"scarcity": [
|
| 326 |
+
"Only 20 Appointments This Week",
|
| 327 |
+
"Limited Spots Available",
|
| 328 |
+
"Join the Waitlist Now",
|
| 329 |
+
],
|
| 330 |
+
"risk_reversal": [
|
| 331 |
+
"100% Satisfaction Guarantee",
|
| 332 |
+
"Try Risk-Free for 30 Days",
|
| 333 |
+
"No Commitment Required",
|
| 334 |
+
],
|
| 335 |
+
"case_study": [
|
| 336 |
+
"How Maria Lost 30 Pounds in 3 Months",
|
| 337 |
+
"Real Patient Results",
|
| 338 |
+
"The Transformation Journey",
|
| 339 |
+
],
|
| 340 |
+
},
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def get_all_frameworks() -> Dict[str, Dict[str, Any]]:
|
| 345 |
+
"""Get all available frameworks."""
|
| 346 |
+
return FRAMEWORKS
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def get_framework(key: str) -> Optional[Dict[str, Any]]:
|
| 350 |
+
"""Get a specific framework by key."""
|
| 351 |
+
return FRAMEWORKS.get(key)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def get_random_framework() -> Dict[str, Any]:
|
| 355 |
+
"""Get a random framework."""
|
| 356 |
+
key = random.choice(list(FRAMEWORKS.keys()))
|
| 357 |
+
return {"key": key, **FRAMEWORKS[key]}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def get_frameworks_for_niche(niche: str, count: int = 3) -> List[Dict[str, Any]]:
|
| 361 |
+
"""Get recommended frameworks for a niche."""
|
| 362 |
+
niche_lower = niche.lower().replace(" ", "_").replace("-", "_")
|
| 363 |
+
|
| 364 |
+
# Niche-specific framework preferences
|
| 365 |
+
niche_preferences = {
|
| 366 |
+
"home_insurance": ["testimonial", "problem_solution", "authority", "before_after", "lifestyle"],
|
| 367 |
+
"glp1": ["before_after", "testimonial", "lifestyle", "authority", "problem_solution"],
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
# Get preferred frameworks or use all
|
| 371 |
+
preferred_keys = niche_preferences.get(niche_lower, list(FRAMEWORKS.keys()))
|
| 372 |
+
|
| 373 |
+
# Add remaining frameworks for variety
|
| 374 |
+
all_keys = preferred_keys + [k for k in FRAMEWORKS.keys() if k not in preferred_keys]
|
| 375 |
+
|
| 376 |
+
# Select count frameworks with some randomization
|
| 377 |
+
selected = all_keys[:count]
|
| 378 |
+
random.shuffle(selected)
|
| 379 |
+
|
| 380 |
+
return [{"key": k, **FRAMEWORKS[k]} for k in selected]
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def get_framework_hook_examples(framework_key: str, niche: Optional[str] = None) -> List[str]:
|
| 384 |
+
"""Get hook examples for a framework, optionally niche-specific."""
|
| 385 |
+
if niche:
|
| 386 |
+
niche_key = niche.lower().replace(" ", "_").replace("-", "_")
|
| 387 |
+
niche_examples = NICHE_FRAMEWORK_EXAMPLES.get(niche_key, {}).get(framework_key, [])
|
| 388 |
+
if niche_examples:
|
| 389 |
+
return niche_examples
|
| 390 |
+
|
| 391 |
+
framework = FRAMEWORKS.get(framework_key)
|
| 392 |
+
return framework.get("hook_examples", []) if framework else []
|
| 393 |
+
|
data/glp1.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GLP-1 (Weight Loss) - Complete Psychological Arsenal
|
| 3 |
+
All strategies: shame, transformation, FOMO, authority, simplicity, etc.
|
| 4 |
+
Updated with winning ad patterns from high-converting creative analysis.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
# Strategy categories with hooks and descriptions
|
| 8 |
+
STRATEGIES = {
|
| 9 |
+
# ==========================================================================
|
| 10 |
+
# NEW: WINNING STRATEGIES FROM HIGH-CONVERTING AD ANALYSIS
|
| 11 |
+
# ==========================================================================
|
| 12 |
+
|
| 13 |
+
"accusation_opener": {
|
| 14 |
+
"name": "Accusation Opener",
|
| 15 |
+
"description": "Direct accusation triggers immediate emotional response",
|
| 16 |
+
"hooks": [
|
| 17 |
+
"Still Overweight?",
|
| 18 |
+
"Another Failed Diet?",
|
| 19 |
+
"Still Hiding Your Body?",
|
| 20 |
+
"Tired Of The Lies?",
|
| 21 |
+
"How Many More Diets Will Fail?",
|
| 22 |
+
"Still Making Excuses?",
|
| 23 |
+
"Wasted Money On Diets That Don't Work?",
|
| 24 |
+
"Why Are You Still Struggling?",
|
| 25 |
+
"Avoiding The Mirror Again?",
|
| 26 |
+
"How Long Will You Wait?",
|
| 27 |
+
"Still Wearing Baggy Clothes To Hide?",
|
| 28 |
+
"Done Lying To Yourself?",
|
| 29 |
+
],
|
| 30 |
+
"visual_styles": [
|
| 31 |
+
"person on scale looking frustrated, candid documentary",
|
| 32 |
+
"oversized clothes on person, accusatory framing",
|
| 33 |
+
"mirror reflection with accusatory text overlay",
|
| 34 |
+
"pile of diet books and unused gym equipment",
|
| 35 |
+
"frustrated person looking at reflection, vintage feel",
|
| 36 |
+
"before photo style, raw and honest",
|
| 37 |
+
],
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
"curiosity_gap": {
|
| 41 |
+
"name": "Curiosity Gap",
|
| 42 |
+
"description": "Open loop with 'THIS' or 'Secret' demands click",
|
| 43 |
+
"hooks": [
|
| 44 |
+
"Thousands Are Losing Weight After Discovering THIS",
|
| 45 |
+
"Doctors Are Prescribing THIS Instead Of Diets",
|
| 46 |
+
"What Celebrities Have Used For Years",
|
| 47 |
+
"The Weight Loss Secret They Tried To Hide",
|
| 48 |
+
"Everyone's Losing Weight Except You. Here's Why.",
|
| 49 |
+
"What Hollywood Knew For Years Is Now Available",
|
| 50 |
+
"After THIS, Dieting Becomes Obsolete",
|
| 51 |
+
"The ONE Thing That Actually Works",
|
| 52 |
+
"What Your Doctor Isn't Telling You",
|
| 53 |
+
"People Are Ditching Diets For THIS",
|
| 54 |
+
"The Method That's Changing Everything",
|
| 55 |
+
"Discover What Everyone's Talking About",
|
| 56 |
+
],
|
| 57 |
+
"visual_styles": [
|
| 58 |
+
"transformation collage with 'THIS' callout, documentary style",
|
| 59 |
+
"person revealing secret, candid testimonial",
|
| 60 |
+
"before/after with mysterious 'secret' overlay",
|
| 61 |
+
"celebrity transformation reference, tabloid aesthetic",
|
| 62 |
+
"dramatic reveal moment, old footage feel",
|
| 63 |
+
"person sharing discovery, authentic UGC style",
|
| 64 |
+
],
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
"specific_numbers": {
|
| 68 |
+
"name": "Specific Numbers",
|
| 69 |
+
"description": "Oddly specific numbers create instant believability",
|
| 70 |
+
"hooks": [
|
| 71 |
+
"Lost 47 lbs In 90 Days",
|
| 72 |
+
"Down 23 lbs In 6 Weeks",
|
| 73 |
+
"Dropped 4 Dress Sizes In 8 Weeks",
|
| 74 |
+
"Lost 67 lbs Without Exercise",
|
| 75 |
+
"From 247 lbs to 168 lbs",
|
| 76 |
+
"12 lbs Gone In The First Month",
|
| 77 |
+
"Lost 38 lbs Before Her Reunion",
|
| 78 |
+
"Dropped 52 lbs After Starting THIS",
|
| 79 |
+
"91 Days: Lost 41 lbs",
|
| 80 |
+
"From Size 18 to Size 8",
|
| 81 |
+
"Lost 1.2 lbs Per Day",
|
| 82 |
+
"Down 29 lbs In 10 Weeks",
|
| 83 |
+
],
|
| 84 |
+
"visual_styles": [
|
| 85 |
+
"scale showing specific number, close-up documentary",
|
| 86 |
+
"before/after with specific weight numbers visible",
|
| 87 |
+
"measuring tape showing inches lost",
|
| 88 |
+
"old jeans that are now too big, specific sizes shown",
|
| 89 |
+
"calendar tracking progress with numbers",
|
| 90 |
+
"weight loss chart with specific data points",
|
| 91 |
+
],
|
| 92 |
+
},
|
| 93 |
+
|
| 94 |
+
"before_after_proof": {
|
| 95 |
+
"name": "Before/After Proof",
|
| 96 |
+
"description": "Transformation proof creates immediate desire",
|
| 97 |
+
"hooks": [
|
| 98 |
+
"Same Person. 90 Days Apart.",
|
| 99 |
+
"Is This Even The Same Person?",
|
| 100 |
+
"The Transformation That Shocked Everyone",
|
| 101 |
+
"From THIS to THIS In 12 Weeks",
|
| 102 |
+
"Unrecognizable In 90 Days",
|
| 103 |
+
"Her Friends Didn't Recognize Her",
|
| 104 |
+
"The Picture That Went Viral",
|
| 105 |
+
"From Hiding To Showing Off",
|
| 106 |
+
"Watch The Transformation",
|
| 107 |
+
"Before Anyone Asks: Yes, It's The Same Person",
|
| 108 |
+
"Jaw-Dropping 90-Day Results",
|
| 109 |
+
"The Change Everyone's Talking About",
|
| 110 |
+
],
|
| 111 |
+
"visual_styles": [
|
| 112 |
+
"dramatic split-screen before/after, same outfit",
|
| 113 |
+
"timeline showing 30/60/90 day progress",
|
| 114 |
+
"same location before/after comparison",
|
| 115 |
+
"loose pants/old clothes proof shot",
|
| 116 |
+
"side-by-side full body transformation",
|
| 117 |
+
"progress photos documentary style",
|
| 118 |
+
],
|
| 119 |
+
},
|
| 120 |
+
|
| 121 |
+
"quiz_interactive": {
|
| 122 |
+
"name": "Quiz/Interactive",
|
| 123 |
+
"description": "Quiz format drives engagement and self-selection",
|
| 124 |
+
"hooks": [
|
| 125 |
+
"How Much Weight Could You Lose?",
|
| 126 |
+
"Take The 30-Second Quiz",
|
| 127 |
+
"What's Your Weight Loss Type?",
|
| 128 |
+
"See If You Qualify",
|
| 129 |
+
"Answer 3 Questions. See Your Results.",
|
| 130 |
+
"Calculate Your Potential Weight Loss",
|
| 131 |
+
"Find Your Starting Point",
|
| 132 |
+
"Check Your Eligibility Now",
|
| 133 |
+
"Tap Your Age To See Results",
|
| 134 |
+
"What's Your Goal Weight?",
|
| 135 |
+
"Select Your Starting Weight",
|
| 136 |
+
"How Much Do You Want To Lose?",
|
| 137 |
+
],
|
| 138 |
+
"visual_styles": [
|
| 139 |
+
"quiz interface with weight options, app screenshot",
|
| 140 |
+
"goal weight selector, clean UI",
|
| 141 |
+
"Notes app style checklist: 10-20 lbs, 20-40 lbs, 40+ lbs",
|
| 142 |
+
"interactive calculator showing potential results",
|
| 143 |
+
"age/weight selector buttons",
|
| 144 |
+
"eligibility checker interface mockup",
|
| 145 |
+
],
|
| 146 |
+
},
|
| 147 |
+
|
| 148 |
+
"authority_transfer": {
|
| 149 |
+
"name": "Authority Transfer",
|
| 150 |
+
"description": "Transfer trust from medical/celebrity authority",
|
| 151 |
+
"hooks": [
|
| 152 |
+
"FDA-Approved Weight Loss",
|
| 153 |
+
"Doctor-Prescribed. Clinically Proven.",
|
| 154 |
+
"Used By 15 Million Patients",
|
| 155 |
+
"What Doctors Prescribe Their Own Families",
|
| 156 |
+
"The Medical Breakthrough Of The Decade",
|
| 157 |
+
"Backed By Harvard Research",
|
| 158 |
+
"Endorsed By Leading Endocrinologists",
|
| 159 |
+
"The Prescription Celebrities Pay Thousands For",
|
| 160 |
+
"Clinically Proven 20%+ Weight Loss",
|
| 161 |
+
"What Oprah, Elon And Millions Know",
|
| 162 |
+
"Peer-Reviewed Science. Real Results.",
|
| 163 |
+
"The Treatment Doctors Trust",
|
| 164 |
+
],
|
| 165 |
+
"visual_styles": [
|
| 166 |
+
"FDA approval badge, medical document aesthetic",
|
| 167 |
+
"doctor in white coat, professional clinical setting",
|
| 168 |
+
"scientific study graphics, research paper style",
|
| 169 |
+
"celebrity transformation reference, tabloid feel",
|
| 170 |
+
"medical prescription pad aesthetic",
|
| 171 |
+
"clinical trial results visualization",
|
| 172 |
+
],
|
| 173 |
+
},
|
| 174 |
+
|
| 175 |
+
"identity_targeting": {
|
| 176 |
+
"name": "Identity Targeting",
|
| 177 |
+
"description": "Direct demographic callout creates self-selection",
|
| 178 |
+
"hooks": [
|
| 179 |
+
"Women Over 40: This Changes Everything",
|
| 180 |
+
"If You've Tried Every Diet And Failed...",
|
| 181 |
+
"For People Who've Struggled For Years",
|
| 182 |
+
"Busy Moms Are Losing Weight Without Dieting",
|
| 183 |
+
"Over 50 And Struggling To Lose Weight?",
|
| 184 |
+
"If Nothing Has Worked For You Before...",
|
| 185 |
+
"For Anyone Who's Given Up On Diets",
|
| 186 |
+
"People With 30+ lbs To Lose",
|
| 187 |
+
"If You've Been Overweight For 10+ Years",
|
| 188 |
+
"Yo-Yo Dieters: This Is Different",
|
| 189 |
+
"For Those Who Hate The Gym",
|
| 190 |
+
"If Willpower Has Never Been Enough",
|
| 191 |
+
],
|
| 192 |
+
"visual_styles": [
|
| 193 |
+
"relatable person matching demographic, candid shot",
|
| 194 |
+
"real woman 40-60 in transformation moment",
|
| 195 |
+
"busy mom in everyday setting, authentic",
|
| 196 |
+
"person in regular clothes, not fitness model",
|
| 197 |
+
"testimonial portrait, trustworthy face",
|
| 198 |
+
"before/after of relatable person",
|
| 199 |
+
],
|
| 200 |
+
},
|
| 201 |
+
|
| 202 |
+
"insider_secret": {
|
| 203 |
+
"name": "Insider Secret",
|
| 204 |
+
"description": "Exclusivity and hidden knowledge framing",
|
| 205 |
+
"hooks": [
|
| 206 |
+
"Hollywood's Best-Kept Secret",
|
| 207 |
+
"What Big Pharma Doesn't Want You To Know",
|
| 208 |
+
"The Industry Secret Finally Revealed",
|
| 209 |
+
"How Celebrities Really Lose Weight",
|
| 210 |
+
"The Method They Tried To Keep Hidden",
|
| 211 |
+
"What Doctors Know But Won't Tell You",
|
| 212 |
+
"The Weight Loss Trick No One Talks About",
|
| 213 |
+
"Finally: The Truth About Weight Loss",
|
| 214 |
+
"What The Diet Industry Hides",
|
| 215 |
+
"The Secret Behind Celebrity Transformations",
|
| 216 |
+
"Insider Knowledge Now Available To Everyone",
|
| 217 |
+
"The Method They Don't Advertise",
|
| 218 |
+
],
|
| 219 |
+
"visual_styles": [
|
| 220 |
+
"reveal/exposed documentary style",
|
| 221 |
+
"person sharing secret, intimate UGC feel",
|
| 222 |
+
"tabloid expose aesthetic, revealing truth",
|
| 223 |
+
"hidden document being shown",
|
| 224 |
+
"before/after celebrity transformation style",
|
| 225 |
+
"insider testimonial, candid shot",
|
| 226 |
+
],
|
| 227 |
+
},
|
| 228 |
+
|
| 229 |
+
# ==========================================================================
|
| 230 |
+
# ORIGINAL STRATEGIES (UPDATED WITH VINTAGE VISUAL STYLES)
|
| 231 |
+
# ==========================================================================
|
| 232 |
+
|
| 233 |
+
"shame_insecurity": {
|
| 234 |
+
"name": "Shame & Insecurity",
|
| 235 |
+
"description": "Trigger personal shame and body insecurity",
|
| 236 |
+
"hooks": [
|
| 237 |
+
"Tired of hiding your body?",
|
| 238 |
+
"Still wearing baggy clothes?",
|
| 239 |
+
"How long will you keep lying to yourself?",
|
| 240 |
+
"The mirror doesn't lie",
|
| 241 |
+
"Everyone notices. They just don't say it.",
|
| 242 |
+
"Summer is coming...",
|
| 243 |
+
"When's the last time you felt confident?",
|
| 244 |
+
"Still avoiding photos?",
|
| 245 |
+
"How many more events will you skip?",
|
| 246 |
+
"Your body is holding you back",
|
| 247 |
+
"Stop making excuses",
|
| 248 |
+
"The scale doesn't lie. Neither does your reflection.",
|
| 249 |
+
"Are you embarrassed to undress?",
|
| 250 |
+
"How many diets have failed you?",
|
| 251 |
+
],
|
| 252 |
+
"visual_styles": [
|
| 253 |
+
"person looking in mirror, disappointed, vintage film grain",
|
| 254 |
+
"oversized clothes, hiding body, documentary candid",
|
| 255 |
+
"scale showing high number, old footage aesthetic",
|
| 256 |
+
"person avoiding camera, raw authentic moment",
|
| 257 |
+
"closet full of clothes that don't fit, nostalgic feel",
|
| 258 |
+
"person looking at old photos, sepia tones",
|
| 259 |
+
],
|
| 260 |
+
},
|
| 261 |
+
|
| 262 |
+
"transformation_desire": {
|
| 263 |
+
"name": "Transformation & Desire",
|
| 264 |
+
"description": "Appeal to transformation and aspiration",
|
| 265 |
+
"hooks": [
|
| 266 |
+
"Imagine fitting into your old jeans",
|
| 267 |
+
"Get your confidence back",
|
| 268 |
+
"Become the person you were meant to be",
|
| 269 |
+
"Your best self is waiting",
|
| 270 |
+
"Turn heads again",
|
| 271 |
+
"Feel sexy for the first time in years",
|
| 272 |
+
"Reclaim your body",
|
| 273 |
+
"The glow-up you deserve",
|
| 274 |
+
"Finally love what you see in the mirror",
|
| 275 |
+
"Your transformation starts today",
|
| 276 |
+
"Become unrecognizable in 90 days",
|
| 277 |
+
"The body you've always wanted",
|
| 278 |
+
"Unlock the best version of yourself",
|
| 279 |
+
"From invisible to irresistible",
|
| 280 |
+
],
|
| 281 |
+
"visual_styles": [
|
| 282 |
+
"dramatic before/after transformation",
|
| 283 |
+
"confident person in fitted clothes",
|
| 284 |
+
"person checking out reflection, smiling",
|
| 285 |
+
"beach body, confidence pose",
|
| 286 |
+
"person getting compliments",
|
| 287 |
+
"transformation timeline photos",
|
| 288 |
+
],
|
| 289 |
+
},
|
| 290 |
+
|
| 291 |
+
"fomo": {
|
| 292 |
+
"name": "Fear of Missing Out",
|
| 293 |
+
"description": "Create fear of being left behind",
|
| 294 |
+
"hooks": [
|
| 295 |
+
"Hollywood's secret is finally available",
|
| 296 |
+
"Celebrities have used this for years",
|
| 297 |
+
"Everyone is losing weight except you",
|
| 298 |
+
"Don't be the last to know",
|
| 299 |
+
"Limited supply - high demand",
|
| 300 |
+
"Waitlist growing daily",
|
| 301 |
+
"The weight loss revolution you're missing",
|
| 302 |
+
"While you're reading this, someone is transforming",
|
| 303 |
+
"Why does everyone look better except you?",
|
| 304 |
+
"The secret they tried to hide from you",
|
| 305 |
+
"TikTok's viral weight loss trend",
|
| 306 |
+
"The A-list solution now available to you",
|
| 307 |
+
"What Oprah, Elon, and millions know",
|
| 308 |
+
"The shortage is real. Act now.",
|
| 309 |
+
],
|
| 310 |
+
"visual_styles": [
|
| 311 |
+
"celebrity transformation reference",
|
| 312 |
+
"trending hashtag graphics",
|
| 313 |
+
"viral social media aesthetic",
|
| 314 |
+
"before/after celebrity style",
|
| 315 |
+
"exclusive access imagery",
|
| 316 |
+
"sold out, limited availability badges",
|
| 317 |
+
],
|
| 318 |
+
},
|
| 319 |
+
|
| 320 |
+
"social_rejection_acceptance": {
|
| 321 |
+
"name": "Social Rejection & Acceptance",
|
| 322 |
+
"description": "Leverage social dynamics and dating",
|
| 323 |
+
"hooks": [
|
| 324 |
+
"They'll finally see the REAL you",
|
| 325 |
+
"Stop being invisible",
|
| 326 |
+
"Dating becomes easier",
|
| 327 |
+
"People treat you differently when you're thin",
|
| 328 |
+
"Your ex will regret leaving",
|
| 329 |
+
"Make them jealous",
|
| 330 |
+
"Get the attention you deserve",
|
| 331 |
+
"Stop being overlooked",
|
| 332 |
+
"First impressions matter. Yours is failing.",
|
| 333 |
+
"They're judging you. Change the verdict.",
|
| 334 |
+
"Imagine being the hot one in your friend group",
|
| 335 |
+
"Turn rejection into attraction",
|
| 336 |
+
"From friend-zoned to desired",
|
| 337 |
+
"Become someone people want to be around",
|
| 338 |
+
],
|
| 339 |
+
"visual_styles": [
|
| 340 |
+
"confident person at social event",
|
| 341 |
+
"person getting attention, admired",
|
| 342 |
+
"dating app success aesthetic",
|
| 343 |
+
"group of friends, confident person",
|
| 344 |
+
"romantic couple, attractive partners",
|
| 345 |
+
"person commanding room, confident",
|
| 346 |
+
],
|
| 347 |
+
},
|
| 348 |
+
|
| 349 |
+
"medical_authority": {
|
| 350 |
+
"name": "Medical Authority",
|
| 351 |
+
"description": "Leverage medical credibility and science",
|
| 352 |
+
"hooks": [
|
| 353 |
+
"FDA-approved",
|
| 354 |
+
"Doctor-prescribed",
|
| 355 |
+
"Clinically proven",
|
| 356 |
+
"Used by 15 million patients",
|
| 357 |
+
"Backed by Harvard research",
|
| 358 |
+
"Your doctor won't tell you about this",
|
| 359 |
+
"Big Pharma's best-kept secret",
|
| 360 |
+
"The science is undeniable",
|
| 361 |
+
"Medical breakthrough of the decade",
|
| 362 |
+
"Doctors are prescribing this to their own families",
|
| 363 |
+
"Clinical trials prove 20%+ weight loss",
|
| 364 |
+
"The medication celebrities pay thousands for",
|
| 365 |
+
"Peer-reviewed. Science-backed. Life-changing.",
|
| 366 |
+
"What every endocrinologist knows",
|
| 367 |
+
],
|
| 368 |
+
"visual_styles": [
|
| 369 |
+
"doctor in white coat, professional",
|
| 370 |
+
"FDA approval badge",
|
| 371 |
+
"clinical study graphics",
|
| 372 |
+
"medical office setting",
|
| 373 |
+
"scientific research aesthetic",
|
| 374 |
+
"prescription medication style",
|
| 375 |
+
],
|
| 376 |
+
},
|
| 377 |
+
|
| 378 |
+
"urgency_scarcity": {
|
| 379 |
+
"name": "Urgency & Scarcity",
|
| 380 |
+
"description": "Create time pressure and limited availability",
|
| 381 |
+
"hooks": [
|
| 382 |
+
"Shortage alert - limited doses available",
|
| 383 |
+
"Insurance may stop covering soon",
|
| 384 |
+
"Prices increasing next month",
|
| 385 |
+
"Get in before the waitlist",
|
| 386 |
+
"Supply chain issues - act now",
|
| 387 |
+
"Only accepting 50 new patients this month",
|
| 388 |
+
"The shortage is getting worse",
|
| 389 |
+
"Pharmacy stock running low",
|
| 390 |
+
"Manufacturer limiting supply",
|
| 391 |
+
"Price hike coming in 30 days",
|
| 392 |
+
"Don't miss the enrollment window",
|
| 393 |
+
"Available for a limited time",
|
| 394 |
+
],
|
| 395 |
+
"visual_styles": [
|
| 396 |
+
"countdown timer, urgent",
|
| 397 |
+
"low stock warning",
|
| 398 |
+
"sold out badges",
|
| 399 |
+
"limited availability graphics",
|
| 400 |
+
"pharmacy shelves nearly empty",
|
| 401 |
+
"urgent notification style",
|
| 402 |
+
],
|
| 403 |
+
},
|
| 404 |
+
|
| 405 |
+
"guilt": {
|
| 406 |
+
"name": "Guilt",
|
| 407 |
+
"description": "Trigger guilt about health and family",
|
| 408 |
+
"hooks": [
|
| 409 |
+
"Your kids need a healthy parent",
|
| 410 |
+
"Don't miss their graduation",
|
| 411 |
+
"How many more years will you lose?",
|
| 412 |
+
"Life is passing you by",
|
| 413 |
+
"You deserve to feel good",
|
| 414 |
+
"Stop punishing yourself",
|
| 415 |
+
"Your family needs you around",
|
| 416 |
+
"What example are you setting?",
|
| 417 |
+
"They're worried about you",
|
| 418 |
+
"You're not just hurting yourself",
|
| 419 |
+
"Your grandkids deserve to know you",
|
| 420 |
+
"Every pound is a year off your life",
|
| 421 |
+
"You owe it to yourself",
|
| 422 |
+
"Stop letting yourself down",
|
| 423 |
+
],
|
| 424 |
+
"visual_styles": [
|
| 425 |
+
"parent with children, health focus",
|
| 426 |
+
"family moments, active lifestyle",
|
| 427 |
+
"grandparent playing with grandkids",
|
| 428 |
+
"health warning, medical imagery",
|
| 429 |
+
"funeral aesthetic, mortality reminder",
|
| 430 |
+
"empty chair at family gathering",
|
| 431 |
+
],
|
| 432 |
+
},
|
| 433 |
+
|
| 434 |
+
"simplicity_laziness": {
|
| 435 |
+
"name": "Simplicity & Laziness",
|
| 436 |
+
"description": "Emphasize ease and minimal effort",
|
| 437 |
+
"hooks": [
|
| 438 |
+
"No diet. No exercise. Just results.",
|
| 439 |
+
"One injection, that's it",
|
| 440 |
+
"Lose weight while you sleep",
|
| 441 |
+
"The lazy way to get thin",
|
| 442 |
+
"Stop torturing yourself with diets",
|
| 443 |
+
"Finally, something that actually works",
|
| 444 |
+
"No gym required",
|
| 445 |
+
"Eat what you want and still lose weight",
|
| 446 |
+
"The effortless weight loss solution",
|
| 447 |
+
"Works while you Netflix",
|
| 448 |
+
"No willpower needed",
|
| 449 |
+
"The anti-diet diet",
|
| 450 |
+
"Stop counting calories forever",
|
| 451 |
+
"The 10-second daily routine that melts fat",
|
| 452 |
+
],
|
| 453 |
+
"visual_styles": [
|
| 454 |
+
"person relaxing, effortless",
|
| 455 |
+
"simple injection graphic",
|
| 456 |
+
"no gym, no diet icons",
|
| 457 |
+
"person eating favorite foods",
|
| 458 |
+
"couch to confidence transformation",
|
| 459 |
+
"simple 1-2-3 step process",
|
| 460 |
+
],
|
| 461 |
+
},
|
| 462 |
+
|
| 463 |
+
"comparison_envy": {
|
| 464 |
+
"name": "Comparison & Envy",
|
| 465 |
+
"description": "Compare to others who succeeded",
|
| 466 |
+
"hooks": [
|
| 467 |
+
"She lost 47 lbs. What's your excuse?",
|
| 468 |
+
"Same age. Different body.",
|
| 469 |
+
"While you're reading this, someone is getting results",
|
| 470 |
+
"Your coworker's secret weapon",
|
| 471 |
+
"Why does she look 10 years younger?",
|
| 472 |
+
"They started where you are now",
|
| 473 |
+
"Same starting point. Different ending.",
|
| 474 |
+
"She was bigger than you. Look at her now.",
|
| 475 |
+
"What's her secret? (Now it's yours)",
|
| 476 |
+
"Your high school reunion is coming...",
|
| 477 |
+
"Everyone's getting results except you",
|
| 478 |
+
"They found the answer. Why haven't you?",
|
| 479 |
+
],
|
| 480 |
+
"visual_styles": [
|
| 481 |
+
"side by side comparison",
|
| 482 |
+
"success story testimonial",
|
| 483 |
+
"before/after same person",
|
| 484 |
+
"two people comparison",
|
| 485 |
+
"results showcase gallery",
|
| 486 |
+
"transformation collage",
|
| 487 |
+
],
|
| 488 |
+
},
|
| 489 |
+
|
| 490 |
+
"before_after_shock": {
|
| 491 |
+
"name": "Before/After Shock",
|
| 492 |
+
"description": "Dramatic visual transformations",
|
| 493 |
+
"hooks": [
|
| 494 |
+
"90 days. 50 lbs. Real person.",
|
| 495 |
+
"The same person. Unbelievable.",
|
| 496 |
+
"Is this even the same person?",
|
| 497 |
+
"Wait until you see the after...",
|
| 498 |
+
"Jaw-dropping transformation",
|
| 499 |
+
"You won't believe your eyes",
|
| 500 |
+
"The most incredible transformation ever",
|
| 501 |
+
"From XL to S in 4 months",
|
| 502 |
+
"Lost half her body weight",
|
| 503 |
+
"From plus-size to model",
|
| 504 |
+
"The picture that broke the internet",
|
| 505 |
+
"This can be you",
|
| 506 |
+
],
|
| 507 |
+
"visual_styles": [
|
| 508 |
+
"dramatic before/after split screen",
|
| 509 |
+
"timeline transformation 30/60/90 days",
|
| 510 |
+
"same outfit, different body",
|
| 511 |
+
"scale showing massive loss",
|
| 512 |
+
"measuring tape victory",
|
| 513 |
+
"loose pants, weight loss proof",
|
| 514 |
+
],
|
| 515 |
+
},
|
| 516 |
+
|
| 517 |
+
"future_pacing": {
|
| 518 |
+
"name": "Future Pacing",
|
| 519 |
+
"description": "Help them visualize their future self",
|
| 520 |
+
"hooks": [
|
| 521 |
+
"Picture yourself at your goal weight",
|
| 522 |
+
"6 months from now...",
|
| 523 |
+
"Your future self will thank you",
|
| 524 |
+
"This time next year...",
|
| 525 |
+
"Imagine summer with your dream body",
|
| 526 |
+
"What will you do when you hit your goal?",
|
| 527 |
+
"The vacation you've been putting off",
|
| 528 |
+
"The dress you've been saving",
|
| 529 |
+
"Finally, that beach trip",
|
| 530 |
+
"Your wedding, your reunion, your moment",
|
| 531 |
+
"Close your eyes. See yourself thin.",
|
| 532 |
+
"The life that's waiting for you",
|
| 533 |
+
],
|
| 534 |
+
"visual_styles": [
|
| 535 |
+
"aspirational lifestyle imagery",
|
| 536 |
+
"beach vacation, confident person",
|
| 537 |
+
"dream wedding, fitting dress",
|
| 538 |
+
"vision board aesthetic",
|
| 539 |
+
"future self visualization",
|
| 540 |
+
"bucket list experiences",
|
| 541 |
+
],
|
| 542 |
+
},
|
| 543 |
+
|
| 544 |
+
"loss_aversion": {
|
| 545 |
+
"name": "Loss Aversion",
|
| 546 |
+
"description": "Emphasize what they're losing by not acting",
|
| 547 |
+
"hooks": [
|
| 548 |
+
"Every day you wait is another day wasted",
|
| 549 |
+
"How many more years will you lose to this?",
|
| 550 |
+
"You're not getting younger",
|
| 551 |
+
"Time is running out on your metabolism",
|
| 552 |
+
"Each year makes it harder",
|
| 553 |
+
"Your 20s are gone. Don't waste your 30s too.",
|
| 554 |
+
"The best years of your life - spent overweight?",
|
| 555 |
+
"Missed opportunities don't come back",
|
| 556 |
+
"Your youth is slipping away",
|
| 557 |
+
"Obesity is stealing your life",
|
| 558 |
+
"The experiences you're missing",
|
| 559 |
+
"Life is passing you by while you stay stuck",
|
| 560 |
+
],
|
| 561 |
+
"visual_styles": [
|
| 562 |
+
"hourglass, time running out",
|
| 563 |
+
"calendar pages flipping",
|
| 564 |
+
"missed opportunities montage",
|
| 565 |
+
"aging comparison, health decline",
|
| 566 |
+
"before it's too late imagery",
|
| 567 |
+
"now vs never contrast",
|
| 568 |
+
],
|
| 569 |
+
},
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
# All hooks flattened for random selection
|
| 573 |
+
ALL_HOOKS = []
|
| 574 |
+
for strategy in STRATEGIES.values():
|
| 575 |
+
ALL_HOOKS.extend(strategy["hooks"])
|
| 576 |
+
|
| 577 |
+
# All visual styles flattened
|
| 578 |
+
ALL_VISUAL_STYLES = []
|
| 579 |
+
for strategy in STRATEGIES.values():
|
| 580 |
+
ALL_VISUAL_STYLES.extend(strategy["visual_styles"])
|
| 581 |
+
|
| 582 |
+
# Strategy names for random selection
|
| 583 |
+
STRATEGY_NAMES = list(STRATEGIES.keys())
|
| 584 |
+
|
| 585 |
+
# Creative directions for variety - Updated with winning patterns
|
| 586 |
+
# Note: These are TONE/APPROACH, not structural formats (use frameworks.py for structure)
|
| 587 |
+
CREATIVE_DIRECTIONS = [
|
| 588 |
+
"accusatory", # Still overweight? style
|
| 589 |
+
"curiosity-driven", # THIS/Secret style
|
| 590 |
+
"numbers-focused", # 47 lbs in 90 days style
|
| 591 |
+
"proof-transformation", # Before/after evidence (use "before_after" framework for structure)
|
| 592 |
+
"quiz-interactive", # How much could you lose?
|
| 593 |
+
"authority-medical", # FDA/Doctor style (use "authority" framework for structure)
|
| 594 |
+
"identity-targeted", # Women over 40...
|
| 595 |
+
"insider-reveal", # Hollywood secret
|
| 596 |
+
"urgent",
|
| 597 |
+
"aspirational",
|
| 598 |
+
]
|
| 599 |
+
|
| 600 |
+
# Visual aesthetic styles (documentary/authentic formats for ad images)
|
| 601 |
+
# Note: These are aesthetic styles, not emotional moods (see data/visuals.py for emotional moods)
|
| 602 |
+
VISUAL_MOODS = [
|
| 603 |
+
"documentary-candid", # Real people, unposed
|
| 604 |
+
"vintage-authentic", # Old footage feel
|
| 605 |
+
"transformation-proof", # Before/after evidence
|
| 606 |
+
"ui-screenshot", # Quiz, calculator
|
| 607 |
+
"medical-clinical", # Doctor/FDA aesthetic
|
| 608 |
+
"celebrity-tabloid", # Reveal/transformation
|
| 609 |
+
"raw-testimonial", # UGC feel
|
| 610 |
+
"warm-nostalgic", # Amber, sepia tones
|
| 611 |
+
]
|
| 612 |
+
|
| 613 |
+
# Ad copy templates
|
| 614 |
+
COPY_TEMPLATES = [
|
| 615 |
+
{
|
| 616 |
+
"structure": "hook_then_cta",
|
| 617 |
+
"format": "{hook}\n\n{supporting_text}\n\n👉 {cta}",
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"structure": "question_answer",
|
| 621 |
+
"format": "{question}\n\n{answer}\n\n{cta}",
|
| 622 |
+
},
|
| 623 |
+
{
|
| 624 |
+
"structure": "before_after",
|
| 625 |
+
"format": "BEFORE: {before}\n\nAFTER: {after}\n\n{cta}",
|
| 626 |
+
},
|
| 627 |
+
{
|
| 628 |
+
"structure": "stat_hook",
|
| 629 |
+
"format": "⚠️ {statistic}\n\n{explanation}\n\n{cta}",
|
| 630 |
+
},
|
| 631 |
+
{
|
| 632 |
+
"structure": "story_hook",
|
| 633 |
+
"format": "{story_opening}\n\n{story_middle}\n\n{cta}",
|
| 634 |
+
},
|
| 635 |
+
{
|
| 636 |
+
"structure": "testimonial",
|
| 637 |
+
"format": '"{testimonial}"\n\n- {name}, lost {weight}\n\n{cta}',
|
| 638 |
+
},
|
| 639 |
+
]
|
| 640 |
+
|
| 641 |
+
# CTAs for variety - Updated with high-converting patterns
|
| 642 |
+
CTAS = [
|
| 643 |
+
# Discovery/Eligibility CTAs (highest conversion)
|
| 644 |
+
"See If You Qualify",
|
| 645 |
+
"Check Your Eligibility",
|
| 646 |
+
"Take The Quiz",
|
| 647 |
+
"Calculate Your Results",
|
| 648 |
+
"See How Much You Could Lose",
|
| 649 |
+
|
| 650 |
+
# Action CTAs
|
| 651 |
+
"Start Your Transformation",
|
| 652 |
+
"Get Your Personalized Plan",
|
| 653 |
+
"Claim Your Consultation",
|
| 654 |
+
"Learn More",
|
| 655 |
+
"See Your Options",
|
| 656 |
+
|
| 657 |
+
# Urgency CTAs
|
| 658 |
+
"Start Now",
|
| 659 |
+
"Don't Wait Another Day",
|
| 660 |
+
"Begin Today",
|
| 661 |
+
"Get Started",
|
| 662 |
+
|
| 663 |
+
# Specific CTAs
|
| 664 |
+
"Get Your Prescription",
|
| 665 |
+
"Join Thousands Who Transformed",
|
| 666 |
+
"See Real Results",
|
| 667 |
+
]
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def get_niche_data():
|
| 671 |
+
"""Return all GLP-1 data for the generator."""
|
| 672 |
+
return {
|
| 673 |
+
"niche": "glp1",
|
| 674 |
+
"strategies": STRATEGIES,
|
| 675 |
+
"all_hooks": ALL_HOOKS,
|
| 676 |
+
"all_visual_styles": ALL_VISUAL_STYLES,
|
| 677 |
+
"strategy_names": STRATEGY_NAMES,
|
| 678 |
+
"creative_directions": CREATIVE_DIRECTIONS,
|
| 679 |
+
"visual_moods": VISUAL_MOODS,
|
| 680 |
+
"copy_templates": COPY_TEMPLATES,
|
| 681 |
+
"ctas": CTAS,
|
| 682 |
+
}
|
| 683 |
+
|
data/home_insurance.py
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Home Insurance - Complete Psychological Arsenal
|
| 3 |
+
All strategies: fear, shame, urgency, greed, authority, loss aversion, etc.
|
| 4 |
+
Updated with winning ad patterns from high-converting creative analysis.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
# ============================================================================
|
| 8 |
+
# SECTION 1: PSYCHOLOGICAL STRATEGIES
|
| 9 |
+
# ============================================================================
|
| 10 |
+
|
| 11 |
+
STRATEGIES = {
|
| 12 |
+
# ------------------------------------------------------------------------
|
| 13 |
+
# Winning Strategies (High-Converting Patterns)
|
| 14 |
+
# ------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
"accusation_opener": {
|
| 17 |
+
"name": "Accusation Opener",
|
| 18 |
+
"description": "Direct accusation triggers immediate loss aversion - 'OVERPAYING?' style",
|
| 19 |
+
"hooks": [
|
| 20 |
+
"OVERPAYING?",
|
| 21 |
+
"Still Overpaying For Home Insurance?",
|
| 22 |
+
"Wasting $1,247/year On Insurance?",
|
| 23 |
+
"Are You Being Overcharged?",
|
| 24 |
+
"Paying Too Much? Most Homeowners Are.",
|
| 25 |
+
"Your Insurance Company Is Overcharging You",
|
| 26 |
+
"Stop Getting Ripped Off",
|
| 27 |
+
"You're Probably Paying Double",
|
| 28 |
+
"Why Are You Still Paying Full Price?",
|
| 29 |
+
"Being Overcharged Without Knowing It?",
|
| 30 |
+
"Throwing Money Away Every Month?",
|
| 31 |
+
"Your Premium Is Too High. Here's Proof.",
|
| 32 |
+
],
|
| 33 |
+
"visual_styles": [
|
| 34 |
+
"person holding fan of $100 bills, hiding face, accusatory",
|
| 35 |
+
"frustrated senior looking at insurance bill, red 'OVERPAYING?' text",
|
| 36 |
+
"money flying away from house, loss visualization",
|
| 37 |
+
"comparison of high vs low price with red X on high price",
|
| 38 |
+
"person counting money with worried expression",
|
| 39 |
+
"wallet being emptied, coins and bills falling out",
|
| 40 |
+
],
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
"curiosity_gap": {
|
| 44 |
+
"name": "Curiosity Gap",
|
| 45 |
+
"description": "Open loop with 'THIS' or 'Instead' demands click - highest CTR pattern",
|
| 46 |
+
"hooks": [
|
| 47 |
+
"Seniors Are Ditching Their Home Insurance & Doing This Instead",
|
| 48 |
+
"Thousands of homeowners are dropping their home insurance after THIS",
|
| 49 |
+
"Homeowners Over 50 Are Switching To THIS",
|
| 50 |
+
"What Smart Seniors Know About Home Insurance",
|
| 51 |
+
"The Secret Insurance Companies Don't Want You To Know",
|
| 52 |
+
"Everyone's Switching. Here's Why.",
|
| 53 |
+
"This Is Why Your Neighbors Pay Less",
|
| 54 |
+
"What 847 Homeowners In Your Area Just Discovered",
|
| 55 |
+
"The Loophole Seniors Are Using",
|
| 56 |
+
"They Found Something Better. Have You?",
|
| 57 |
+
"After Seeing THIS, You'll Never Overpay Again",
|
| 58 |
+
"One Simple Change Saves Thousands",
|
| 59 |
+
],
|
| 60 |
+
"visual_styles": [
|
| 61 |
+
"senior man looking at utility meter or electric box, candid documentary",
|
| 62 |
+
"collage with Social Security card and government building",
|
| 63 |
+
"person reading document with surprised expression",
|
| 64 |
+
"group of seniors with knowing expressions",
|
| 65 |
+
"person pointing at hidden information, reveal moment",
|
| 66 |
+
"before/after document comparison with circled numbers",
|
| 67 |
+
],
|
| 68 |
+
},
|
| 69 |
+
|
| 70 |
+
"specific_price_anchor": {
|
| 71 |
+
"name": "Specific Price Anchor",
|
| 72 |
+
"description": "Oddly specific prices ($97.33 not $100) create instant believability",
|
| 73 |
+
"hooks": [
|
| 74 |
+
"Home Insurance for as low as $43/month",
|
| 75 |
+
"Home Insurance with coverage as low as $43/month",
|
| 76 |
+
"$97.33/month For Full Coverage",
|
| 77 |
+
"Pay Just $47/month Instead of $200+",
|
| 78 |
+
"Locked In At $52/month",
|
| 79 |
+
"Full Protection For $1.50/day",
|
| 80 |
+
"Coverage From $39/month",
|
| 81 |
+
"$67/month Beats Your Current Rate",
|
| 82 |
+
"Most Seniors Qualify For $49/month",
|
| 83 |
+
"Switch And Pay Just $83/month",
|
| 84 |
+
"Rates Starting At $37.50/month",
|
| 85 |
+
],
|
| 86 |
+
"visual_styles": [
|
| 87 |
+
"giant price number $43 in teal/bold color, age buttons below",
|
| 88 |
+
"clean white background, price dominant, age selector: 21-40, 41-64, 65+",
|
| 89 |
+
"BLACK FRIDAY style with specific price and gold balloons",
|
| 90 |
+
"price comparison showing crossed-out high price, new low price",
|
| 91 |
+
"calculator interface showing specific savings number",
|
| 92 |
+
"receipt or bill showing exact monthly amount",
|
| 93 |
+
],
|
| 94 |
+
},
|
| 95 |
+
|
| 96 |
+
"before_after_proof": {
|
| 97 |
+
"name": "Before/After Proof",
|
| 98 |
+
"description": "Specific savings numbers with visual proof creates social proof",
|
| 99 |
+
"hooks": [
|
| 100 |
+
"WAS: $1,701 → NOW: $583",
|
| 101 |
+
"How I Dropped My Premium By $1,118/year",
|
| 102 |
+
"Paid $2,400. Now Pay $876.",
|
| 103 |
+
"From $189/month To $67/month",
|
| 104 |
+
"Cut My Bill In Half. Here's How.",
|
| 105 |
+
"Saved $1,247 In 5 Minutes",
|
| 106 |
+
"Before: $2,100/year After: $720/year",
|
| 107 |
+
"My Neighbor Showed Me How To Save $800",
|
| 108 |
+
"Real Savings: $1,456 Less Per Year",
|
| 109 |
+
"Went From Overpaying To Saving Big",
|
| 110 |
+
"The 5-Minute Switch That Saved Me $1,100",
|
| 111 |
+
],
|
| 112 |
+
"visual_styles": [
|
| 113 |
+
"real woman 50-60 holding document with circled numbers, testimonial",
|
| 114 |
+
"before/after price bar: red 'WAS' vs green 'NOW'",
|
| 115 |
+
"split screen: old bill vs new bill with circles",
|
| 116 |
+
"person giving thumbs up with savings numbers visible",
|
| 117 |
+
"calculator or phone showing savings calculation",
|
| 118 |
+
"happy couple reviewing lower insurance bill together",
|
| 119 |
+
],
|
| 120 |
+
},
|
| 121 |
+
|
| 122 |
+
"quiz_interactive": {
|
| 123 |
+
"name": "Quiz/Interactive",
|
| 124 |
+
"description": "Quiz format drives engagement and self-selection",
|
| 125 |
+
"hooks": [
|
| 126 |
+
"What Year Was Your House Built?",
|
| 127 |
+
"Tap Your Age To Calculate Your New Payment",
|
| 128 |
+
"Answer 3 Questions. See Your Rate.",
|
| 129 |
+
"How Old Is Your Home?",
|
| 130 |
+
"Check Your Eligibility In 60 Seconds",
|
| 131 |
+
"Take The 30-Second Quiz",
|
| 132 |
+
"Select Your Age Bracket",
|
| 133 |
+
"What's Your Home Worth?",
|
| 134 |
+
"Find Your New Rate - 2 Questions",
|
| 135 |
+
"See If You Qualify",
|
| 136 |
+
"Quick Quiz: How Much Can You Save?",
|
| 137 |
+
"Select Your State To See Rates",
|
| 138 |
+
],
|
| 139 |
+
"visual_styles": [
|
| 140 |
+
"iPhone Notes app dark mode with checkboxes: Before 1970, 1970-1999, etc.",
|
| 141 |
+
"age selector buttons: 21-40 (yellow), 41-64 (blue), 65+ (red)",
|
| 142 |
+
"quiz interface with multiple choice options",
|
| 143 |
+
"clean UI with 'Tap below to see your rate'",
|
| 144 |
+
"interactive calculator mockup",
|
| 145 |
+
"state selection dropdown or map interface",
|
| 146 |
+
],
|
| 147 |
+
},
|
| 148 |
+
|
| 149 |
+
"authority_transfer": {
|
| 150 |
+
"name": "Authority Transfer",
|
| 151 |
+
"description": "Transfer trust from government/institutions - highest trust pattern",
|
| 152 |
+
"hooks": [
|
| 153 |
+
"State Farm Brings Welfare!",
|
| 154 |
+
"Sponsored by the US Government and State Farm",
|
| 155 |
+
"Government Program For Senior Homeowners",
|
| 156 |
+
"New State Program Cuts Insurance Costs",
|
| 157 |
+
"Federal Assistance For Homeowners Over 50",
|
| 158 |
+
"Official: Seniors Qualify For Reduced Rates",
|
| 159 |
+
"State-Approved Savings Program",
|
| 160 |
+
"Government-Backed Insurance Savings",
|
| 161 |
+
"Medicare-Age Homeowners: New Benefit Available",
|
| 162 |
+
"Social Security Recipients: Check Eligibility",
|
| 163 |
+
"Official Notice: Rate Reduction Program",
|
| 164 |
+
"State Insurance Commission Announces Savings",
|
| 165 |
+
],
|
| 166 |
+
"visual_styles": [
|
| 167 |
+
"government eagle seal, official document aesthetic",
|
| 168 |
+
"Social Security card with government building background",
|
| 169 |
+
"official letterhead style with seal and formal typography",
|
| 170 |
+
"state capitol building with official banner",
|
| 171 |
+
"government form aesthetic with checkboxes",
|
| 172 |
+
"presidential seal or state seal visible",
|
| 173 |
+
],
|
| 174 |
+
},
|
| 175 |
+
|
| 176 |
+
"identity_targeting": {
|
| 177 |
+
"name": "Identity Targeting",
|
| 178 |
+
"description": "Direct demographic callout creates instant self-selection",
|
| 179 |
+
"hooks": [
|
| 180 |
+
"Seniors Won't Have To Pay More Than $49 A Month",
|
| 181 |
+
"Homeowners Over 50: Check Your Eligibility",
|
| 182 |
+
"Senior homeowners over the age of 50...",
|
| 183 |
+
"If You're 50+ And Own A Home, Read This",
|
| 184 |
+
"Attention: Homeowners Born Before 1975",
|
| 185 |
+
"For Homeowners 55 And Older",
|
| 186 |
+
"65+ Homeowners: New Rate Available",
|
| 187 |
+
"Baby Boomers: Insurance Relief Is Here",
|
| 188 |
+
"Retired Homeowners: Special Program",
|
| 189 |
+
"If You Own A Home And You're Over 50...",
|
| 190 |
+
"Senior Citizen Home Insurance Rates",
|
| 191 |
+
"Homeowners Turning 65 This Year",
|
| 192 |
+
],
|
| 193 |
+
"visual_styles": [
|
| 194 |
+
"four senior faces in portrait style, dignified, relatable",
|
| 195 |
+
"real senior couple in front of their home",
|
| 196 |
+
"senior looking at camera, trustworthy expression",
|
| 197 |
+
"multiple seniors of different ethnicities, inclusive",
|
| 198 |
+
"senior holding document, testimonial style",
|
| 199 |
+
"elderly hands holding house keys or insurance papers",
|
| 200 |
+
],
|
| 201 |
+
},
|
| 202 |
+
|
| 203 |
+
"insider_secret": {
|
| 204 |
+
"name": "Insider Secret",
|
| 205 |
+
"description": "Exclusivity and hidden knowledge framing",
|
| 206 |
+
"hooks": [
|
| 207 |
+
"The Easiest Way To Cut Home Insurance Bills",
|
| 208 |
+
"What Insurance Companies Don't Want You To Know",
|
| 209 |
+
"The Loophole That Saves Thousands",
|
| 210 |
+
"Former Agent Reveals Industry Secret",
|
| 211 |
+
"The Trick Your Insurance Company Hides",
|
| 212 |
+
"Why Insiders Pay 40% Less",
|
| 213 |
+
"The One Thing That Cuts Your Premium In Half",
|
| 214 |
+
"What They Don't Tell You About Home Insurance",
|
| 215 |
+
"Insurance Industry Insider Speaks Out",
|
| 216 |
+
"The Secret Smart Homeowners Use",
|
| 217 |
+
"Hidden Discount Most People Miss",
|
| 218 |
+
"The Backdoor To Lower Rates",
|
| 219 |
+
],
|
| 220 |
+
"visual_styles": [
|
| 221 |
+
"person whispering or revealing secret, documentary candid",
|
| 222 |
+
"document being unveiled or revealed",
|
| 223 |
+
"insider/whistleblower aesthetic, anonymous feel",
|
| 224 |
+
"magnifying glass over insurance document",
|
| 225 |
+
"hidden text being exposed or highlighted",
|
| 226 |
+
"person looking over shoulder, sharing secret",
|
| 227 |
+
],
|
| 228 |
+
},
|
| 229 |
+
|
| 230 |
+
# ------------------------------------------------------------------------
|
| 231 |
+
# Core Psychological Strategies
|
| 232 |
+
# ------------------------------------------------------------------------
|
| 233 |
+
|
| 234 |
+
"fear_based": {
|
| 235 |
+
"name": "Fear-Based",
|
| 236 |
+
"description": "Trigger fear of loss, disaster, and worst-case scenarios",
|
| 237 |
+
"hooks": [
|
| 238 |
+
"Your home could be gone tomorrow",
|
| 239 |
+
"One spark. Everything lost.",
|
| 240 |
+
"93% of homeowners are UNDERINSURED",
|
| 241 |
+
"Will your family be homeless?",
|
| 242 |
+
"Storm season is HERE - are you ready?",
|
| 243 |
+
"Your neighbor's house burned down last week",
|
| 244 |
+
"What if you can't afford to rebuild?",
|
| 245 |
+
"Bankruptcy from one disaster",
|
| 246 |
+
"Fire doesn't wait. Neither should you.",
|
| 247 |
+
"8 minutes. That's how fast you can lose everything.",
|
| 248 |
+
"The average house fire costs $287,000",
|
| 249 |
+
"Are you gambling with your family's safety?",
|
| 250 |
+
],
|
| 251 |
+
"visual_styles": [
|
| 252 |
+
"burning house at night with vintage film grain, documentary footage",
|
| 253 |
+
"flooded living room with old VHS quality, damage visible",
|
| 254 |
+
"storm damage to roof, aged documentary photography",
|
| 255 |
+
"worried family looking at damaged home, candid shot",
|
| 256 |
+
"fire truck responding, old news footage aesthetic",
|
| 257 |
+
"tornado approaching, vintage weather broadcast style",
|
| 258 |
+
],
|
| 259 |
+
},
|
| 260 |
+
|
| 261 |
+
"urgency_scarcity": {
|
| 262 |
+
"name": "Urgency & Scarcity",
|
| 263 |
+
"description": "Create time pressure and limited availability",
|
| 264 |
+
"hooks": [
|
| 265 |
+
"Rates increasing in 48 hours",
|
| 266 |
+
"Last chance for 2024 pricing",
|
| 267 |
+
"Only 23 spots left at this rate",
|
| 268 |
+
"Offer expires midnight",
|
| 269 |
+
"Insurers are DROPPING coverage in your area",
|
| 270 |
+
"Lock in before it's too late",
|
| 271 |
+
"Price hike coming January 1st",
|
| 272 |
+
"Limited-time discount ending soon",
|
| 273 |
+
"Your quote expires in 24 hours",
|
| 274 |
+
"Enrollment window closing",
|
| 275 |
+
"Act now or pay 30% more next month",
|
| 276 |
+
"Final warning: rates going up",
|
| 277 |
+
],
|
| 278 |
+
"visual_styles": [
|
| 279 |
+
"countdown timer graphic, urgent red colors",
|
| 280 |
+
"calendar with deadline circled",
|
| 281 |
+
"clock showing almost midnight",
|
| 282 |
+
"red URGENT stamp on documents",
|
| 283 |
+
"limited time banner, expiring offer",
|
| 284 |
+
],
|
| 285 |
+
},
|
| 286 |
+
|
| 287 |
+
"social_proof_fomo": {
|
| 288 |
+
"name": "Social Proof & FOMO",
|
| 289 |
+
"description": "Show others are doing it, create fear of missing out",
|
| 290 |
+
"hooks": [
|
| 291 |
+
"847 homeowners in your area switched THIS WEEK",
|
| 292 |
+
"Your neighbors are protected. Are you?",
|
| 293 |
+
"Join 2.3 million smart homeowners",
|
| 294 |
+
"Why is everyone switching?",
|
| 295 |
+
"The #1 choice for homeowners in 2024",
|
| 296 |
+
"9 out of 10 homeowners recommend this",
|
| 297 |
+
"Everyone on your street has coverage. Except you?",
|
| 298 |
+
"Don't be the last one unprotected",
|
| 299 |
+
"Over 500,000 claims paid this year",
|
| 300 |
+
"Rated #1 by Consumer Reports",
|
| 301 |
+
"The insurance your neighbors trust",
|
| 302 |
+
"Smart homeowners are making the switch",
|
| 303 |
+
],
|
| 304 |
+
"visual_styles": [
|
| 305 |
+
"happy neighborhood, protected homes",
|
| 306 |
+
"map showing covered homes in area",
|
| 307 |
+
"crowd of satisfied customers",
|
| 308 |
+
"testimonial collage of happy families",
|
| 309 |
+
"5-star rating badges, trust indicators",
|
| 310 |
+
],
|
| 311 |
+
},
|
| 312 |
+
|
| 313 |
+
"guilt_shame": {
|
| 314 |
+
"name": "Guilt & Shame",
|
| 315 |
+
"description": "Trigger guilt about family responsibility",
|
| 316 |
+
"hooks": [
|
| 317 |
+
"Can you look your family in the eye without protection?",
|
| 318 |
+
"Your kids are counting on you",
|
| 319 |
+
"Don't let them down",
|
| 320 |
+
"Responsible homeowners don't gamble with their family's future",
|
| 321 |
+
"What will you tell your kids when there's nothing left?",
|
| 322 |
+
"A real parent protects their family",
|
| 323 |
+
"Your spouse trusts you to keep them safe",
|
| 324 |
+
"Failure to protect is a choice",
|
| 325 |
+
"They're depending on you. Don't fail them.",
|
| 326 |
+
"Would your family forgive you?",
|
| 327 |
+
"Every night without coverage is a risk to your family",
|
| 328 |
+
"What kind of homeowner are you?",
|
| 329 |
+
],
|
| 330 |
+
"visual_styles": [
|
| 331 |
+
"parent looking worried at sleeping children",
|
| 332 |
+
"family photo with protective imagery",
|
| 333 |
+
"father looking at burned home, regret",
|
| 334 |
+
"mother holding child, concerned expression",
|
| 335 |
+
"empty picture frame, lost memories",
|
| 336 |
+
],
|
| 337 |
+
},
|
| 338 |
+
|
| 339 |
+
"greed_savings": {
|
| 340 |
+
"name": "Greed & Savings",
|
| 341 |
+
"description": "Appeal to desire to save money and get more",
|
| 342 |
+
"hooks": [
|
| 343 |
+
"You're overpaying by $1,247/year",
|
| 344 |
+
"Stop throwing money away",
|
| 345 |
+
"Get $500 back instantly",
|
| 346 |
+
"Why pay more for less?",
|
| 347 |
+
"Save up to 40% on your premium",
|
| 348 |
+
"Free quote reveals your savings",
|
| 349 |
+
"Most homeowners can save $800+",
|
| 350 |
+
"You're leaving money on the table",
|
| 351 |
+
"Switch and save in 5 minutes",
|
| 352 |
+
"Same coverage. Half the price.",
|
| 353 |
+
"Get more coverage for less money",
|
| 354 |
+
"Stop wasting money on overpriced insurance",
|
| 355 |
+
],
|
| 356 |
+
"visual_styles": [
|
| 357 |
+
"stack of cash, money savings",
|
| 358 |
+
"piggy bank overflowing with coins",
|
| 359 |
+
"comparison chart showing savings",
|
| 360 |
+
"happy couple reviewing lower bills",
|
| 361 |
+
"calculator showing big savings number",
|
| 362 |
+
"wallet with money, financial freedom",
|
| 363 |
+
],
|
| 364 |
+
},
|
| 365 |
+
|
| 366 |
+
"authority_trust": {
|
| 367 |
+
"name": "Authority & Trust",
|
| 368 |
+
"description": "Leverage expert credibility and insider knowledge",
|
| 369 |
+
"hooks": [
|
| 370 |
+
"Former agent reveals the truth",
|
| 371 |
+
"Industry insider secret exposed",
|
| 372 |
+
"A+ rated, 50 years trusted",
|
| 373 |
+
"Backed by Warren Buffett",
|
| 374 |
+
"Exposed: The coverage gap trap",
|
| 375 |
+
"Insurance agent confessions",
|
| 376 |
+
"The dirty secret of cheap policies",
|
| 377 |
+
"What your agent isn't telling you",
|
| 378 |
+
"BBB accredited with zero complaints",
|
| 379 |
+
"Trusted by Fortune 500 companies",
|
| 380 |
+
"Licensed in all 50 states",
|
| 381 |
+
],
|
| 382 |
+
"visual_styles": [
|
| 383 |
+
"professional insurance agent, trustworthy",
|
| 384 |
+
"A+ rating badge, gold seal",
|
| 385 |
+
"official documents, certificates",
|
| 386 |
+
"expert in business attire",
|
| 387 |
+
"trust badges, security icons",
|
| 388 |
+
"newspaper headline style, exposed",
|
| 389 |
+
],
|
| 390 |
+
},
|
| 391 |
+
|
| 392 |
+
"loss_aversion": {
|
| 393 |
+
"name": "Loss Aversion",
|
| 394 |
+
"description": "Emphasize what they stand to lose",
|
| 395 |
+
"hooks": [
|
| 396 |
+
"Everything you've worked for - GONE in 8 minutes",
|
| 397 |
+
"Average fire destroys $287,000 in belongings",
|
| 398 |
+
"You can't get back what's already ash",
|
| 399 |
+
"Your memories. Your savings. Your future. Gone.",
|
| 400 |
+
"One lawsuit could take everything",
|
| 401 |
+
"Imagine losing it all tomorrow",
|
| 402 |
+
"The average flood destroys 20 years of memories",
|
| 403 |
+
"Your life's work, wiped out in an hour",
|
| 404 |
+
"What would you save if you only had 2 minutes?",
|
| 405 |
+
"Some things can never be replaced",
|
| 406 |
+
"Everything you own could be gone by morning",
|
| 407 |
+
"Your equity, your memories, your peace of mind",
|
| 408 |
+
],
|
| 409 |
+
"visual_styles": [
|
| 410 |
+
"before/after disaster comparison",
|
| 411 |
+
"pile of ash where home used to be",
|
| 412 |
+
"family photo partially burned",
|
| 413 |
+
"empty lot where house stood",
|
| 414 |
+
"destroyed personal belongings",
|
| 415 |
+
"wallet with nothing inside",
|
| 416 |
+
],
|
| 417 |
+
},
|
| 418 |
+
|
| 419 |
+
"anchoring": {
|
| 420 |
+
"name": "Anchoring",
|
| 421 |
+
"description": "Compare high value to low cost",
|
| 422 |
+
"hooks": [
|
| 423 |
+
"Coverage worth $500,000 for just $47/month",
|
| 424 |
+
"Compared to losing everything, $1.50/day is nothing",
|
| 425 |
+
"Full protection for less than your Netflix subscription",
|
| 426 |
+
"Your home is worth $400K. Protection is $39/month.",
|
| 427 |
+
"Rebuild cost: $350,000. Coverage cost: $52/month.",
|
| 428 |
+
"Insurance: $40/month. Disaster recovery: $500,000.",
|
| 429 |
+
"Skip one coffee a day. Protect everything you own.",
|
| 430 |
+
"The cost of not having insurance: everything",
|
| 431 |
+
"$1.25/day protects $500,000 in assets",
|
| 432 |
+
"Cheaper than your daily coffee habit",
|
| 433 |
+
],
|
| 434 |
+
"visual_styles": [
|
| 435 |
+
"scale comparing cost vs value",
|
| 436 |
+
"coffee cup vs house comparison",
|
| 437 |
+
"small price tag vs big house",
|
| 438 |
+
"simple math equation graphic",
|
| 439 |
+
"price comparison infographic",
|
| 440 |
+
],
|
| 441 |
+
},
|
| 442 |
+
|
| 443 |
+
"simplicity": {
|
| 444 |
+
"name": "Simplicity & Ease",
|
| 445 |
+
"description": "Emphasize how easy it is to get covered",
|
| 446 |
+
"hooks": [
|
| 447 |
+
"Get covered in 3 minutes",
|
| 448 |
+
"One click. Full protection.",
|
| 449 |
+
"No paperwork. No hassle.",
|
| 450 |
+
"Quote in 60 seconds",
|
| 451 |
+
"The easiest insurance you'll ever buy",
|
| 452 |
+
"Set it and forget it protection",
|
| 453 |
+
"Online in minutes, protected for years",
|
| 454 |
+
"Skip the agent. Save time and money.",
|
| 455 |
+
"Apply from your couch",
|
| 456 |
+
"Instant quote, instant coverage",
|
| 457 |
+
"The lazy homeowner's insurance solution",
|
| 458 |
+
"Why is getting insurance still this hard? (It isn't anymore)",
|
| 459 |
+
],
|
| 460 |
+
"visual_styles": [
|
| 461 |
+
"person on phone, relaxed",
|
| 462 |
+
"simple 3-step process graphic",
|
| 463 |
+
"checkmark, done, complete icons",
|
| 464 |
+
"happy person on couch with laptop",
|
| 465 |
+
"clean, minimal interface screenshot",
|
| 466 |
+
],
|
| 467 |
+
},
|
| 468 |
+
|
| 469 |
+
"comparison_envy": {
|
| 470 |
+
"name": "Comparison & Envy",
|
| 471 |
+
"description": "Compare to others who are better protected",
|
| 472 |
+
"hooks": [
|
| 473 |
+
"Your neighbor pays less and gets more coverage",
|
| 474 |
+
"Why are smart homeowners switching?",
|
| 475 |
+
"They're protected. Why aren't you?",
|
| 476 |
+
"Same house. Same street. Half the premium.",
|
| 477 |
+
"What do they know that you don't?",
|
| 478 |
+
"The Jones family just saved $800. Your turn.",
|
| 479 |
+
"Your colleague's home is protected. Is yours?",
|
| 480 |
+
"Everyone's switching. What are you waiting for?",
|
| 481 |
+
"Don't be the only unprotected house on the block",
|
| 482 |
+
"Your neighbor's claim was covered. Would yours be?",
|
| 483 |
+
],
|
| 484 |
+
"visual_styles": [
|
| 485 |
+
"two houses side by side, one protected",
|
| 486 |
+
"neighbor comparison graphic",
|
| 487 |
+
"protected house vs exposed house",
|
| 488 |
+
"happy neighbor vs worried neighbor",
|
| 489 |
+
"community map showing coverage",
|
| 490 |
+
],
|
| 491 |
+
},
|
| 492 |
+
|
| 493 |
+
"transformation": {
|
| 494 |
+
"name": "Transformation & Peace",
|
| 495 |
+
"description": "Show the transformation from worry to peace",
|
| 496 |
+
"hooks": [
|
| 497 |
+
"From worried to worry-free in 5 minutes",
|
| 498 |
+
"Sleep soundly knowing you're covered",
|
| 499 |
+
"Finally, peace of mind for your family",
|
| 500 |
+
"Stop worrying. Start living.",
|
| 501 |
+
"Imagine never worrying about disasters again",
|
| 502 |
+
"The weight off your shoulders",
|
| 503 |
+
"From stressed to blessed",
|
| 504 |
+
"Live your life. We'll protect your home.",
|
| 505 |
+
"Worry-free homeownership starts here",
|
| 506 |
+
"Breathe easy. You're protected.",
|
| 507 |
+
],
|
| 508 |
+
"visual_styles": [
|
| 509 |
+
"relaxed family in protected home",
|
| 510 |
+
"person sleeping peacefully",
|
| 511 |
+
"before/after: worried vs happy homeowner",
|
| 512 |
+
"sunny day, secure home",
|
| 513 |
+
"family enjoying life, not worrying",
|
| 514 |
+
],
|
| 515 |
+
},
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
# ============================================================================
|
| 519 |
+
# SECTION 2: HIGH-CONVERTING VISUAL LIBRARY
|
| 520 |
+
# ============================================================================
|
| 521 |
+
|
| 522 |
+
PROTECTION_SAFETY_VISUALS = [
|
| 523 |
+
"family inside home, warm lights on, night outside",
|
| 524 |
+
"house surrounded by subtle glowing shield",
|
| 525 |
+
"parent locking the front door while kids inside",
|
| 526 |
+
"calm home while storm clouds gather in distance",
|
| 527 |
+
"hands holding a small house icon",
|
| 528 |
+
"roof + checkmark overlay",
|
| 529 |
+
]
|
| 530 |
+
|
| 531 |
+
DISASTER_FEAR_VISUALS = [
|
| 532 |
+
"half image: safe home / damaged neighborhood",
|
| 533 |
+
"flood water stopping at doorstep",
|
| 534 |
+
"fire smoke behind intact house",
|
| 535 |
+
"fallen tree near but not on the house",
|
| 536 |
+
"cracked wall close-up",
|
| 537 |
+
"burnt house blurred in background, intact one sharp",
|
| 538 |
+
]
|
| 539 |
+
|
| 540 |
+
FAMILY_EMOTIONAL_VISUALS = [
|
| 541 |
+
"parents hugging kids inside living room",
|
| 542 |
+
"child doing homework at dining table",
|
| 543 |
+
"family movie night at home",
|
| 544 |
+
"newborn in nursery",
|
| 545 |
+
"elderly parents sitting peacefully at home",
|
| 546 |
+
]
|
| 547 |
+
|
| 548 |
+
FIRST_TIME_HOMEBUYER_VISUALS = [
|
| 549 |
+
"couple holding house keys",
|
| 550 |
+
"empty living room with boxes",
|
| 551 |
+
"first night sleeping on mattress on floor",
|
| 552 |
+
"SOLD sign outside house",
|
| 553 |
+
"smiling couple + nervous body language",
|
| 554 |
+
]
|
| 555 |
+
|
| 556 |
+
ASSET_INVESTMENT_VISUALS = [
|
| 557 |
+
"beautiful home at sunset",
|
| 558 |
+
"clean driveway + car parked",
|
| 559 |
+
"home with subtle price tag icon",
|
| 560 |
+
"blueprint / house plan overlay",
|
| 561 |
+
"before/after renovation shots",
|
| 562 |
+
]
|
| 563 |
+
|
| 564 |
+
PROBLEM_RISK_VISUALS = [
|
| 565 |
+
"leaking ceiling",
|
| 566 |
+
"broken window",
|
| 567 |
+
"short-circuit sparks (safe depiction)",
|
| 568 |
+
"burst pipe under sink",
|
| 569 |
+
"mold on wall",
|
| 570 |
+
"roof damage after storm",
|
| 571 |
+
]
|
| 572 |
+
|
| 573 |
+
RELIEF_VISUALS = [
|
| 574 |
+
"sunlight after rain over house",
|
| 575 |
+
"rainbow behind neighborhood",
|
| 576 |
+
"family relaxing on couch",
|
| 577 |
+
"home with covered tag",
|
| 578 |
+
"coffee mug + window rain outside",
|
| 579 |
+
]
|
| 580 |
+
|
| 581 |
+
MORTGAGE_BANK_VISUALS = [
|
| 582 |
+
"official-looking documents",
|
| 583 |
+
"laptop with insurance form open",
|
| 584 |
+
"house + bank icon",
|
| 585 |
+
"EMI letter on table",
|
| 586 |
+
"calculator + paperwork",
|
| 587 |
+
]
|
| 588 |
+
|
| 589 |
+
COMPARISON_CHOICE_VISUALS = [
|
| 590 |
+
"covered vs uncovered home split",
|
| 591 |
+
"cheap insurance vs proper coverage",
|
| 592 |
+
"umbrella over house vs rain",
|
| 593 |
+
"shield vs lightning bolt",
|
| 594 |
+
]
|
| 595 |
+
|
| 596 |
+
MINIMAL_SYMBOLIC_VISUALS = [
|
| 597 |
+
"simple house icon + shield",
|
| 598 |
+
"line-art house with lock",
|
| 599 |
+
"home inside heart shape",
|
| 600 |
+
"roof outline with checkmark",
|
| 601 |
+
"key + house silhouette",
|
| 602 |
+
]
|
| 603 |
+
|
| 604 |
+
LIFESTYLE_VISUALS = [
|
| 605 |
+
"quiet suburban morning",
|
| 606 |
+
"weekend BBQ in backyard",
|
| 607 |
+
"dog running in yard",
|
| 608 |
+
"home decorated for festivals",
|
| 609 |
+
"neighborhood aerial shot",
|
| 610 |
+
]
|
| 611 |
+
|
| 612 |
+
TEXT_FIRST_VISUALS = [
|
| 613 |
+
"text overlay: 'This home is insured. Is yours?'",
|
| 614 |
+
"text overlay: 'Most homeowners are underinsured.'",
|
| 615 |
+
"text overlay: 'Hope is not a plan.'",
|
| 616 |
+
"text overlay: 'One storm can change everything.'",
|
| 617 |
+
]
|
| 618 |
+
|
| 619 |
+
SEASONAL_VISUALS = [
|
| 620 |
+
"monsoon rain (flood angle)",
|
| 621 |
+
"summer heat (fire risk)",
|
| 622 |
+
"winter storms",
|
| 623 |
+
"festival decorations (emotional peak)",
|
| 624 |
+
]
|
| 625 |
+
|
| 626 |
+
HIGH_CONVERTING_VISUAL_LIBRARY = (
|
| 627 |
+
PROTECTION_SAFETY_VISUALS +
|
| 628 |
+
DISASTER_FEAR_VISUALS +
|
| 629 |
+
FAMILY_EMOTIONAL_VISUALS +
|
| 630 |
+
FIRST_TIME_HOMEBUYER_VISUALS +
|
| 631 |
+
ASSET_INVESTMENT_VISUALS +
|
| 632 |
+
PROBLEM_RISK_VISUALS +
|
| 633 |
+
RELIEF_VISUALS +
|
| 634 |
+
MORTGAGE_BANK_VISUALS +
|
| 635 |
+
COMPARISON_CHOICE_VISUALS +
|
| 636 |
+
MINIMAL_SYMBOLIC_VISUALS +
|
| 637 |
+
LIFESTYLE_VISUALS +
|
| 638 |
+
TEXT_FIRST_VISUALS +
|
| 639 |
+
SEASONAL_VISUALS
|
| 640 |
+
)
|
| 641 |
+
|
| 642 |
+
# ============================================================================
|
| 643 |
+
# SECTION 3: CREATIVE ELEMENTS
|
| 644 |
+
# ============================================================================
|
| 645 |
+
|
| 646 |
+
CREATIVE_DIRECTIONS = [
|
| 647 |
+
"accusatory", # Direct accusation style
|
| 648 |
+
"curiosity-driven", # Open loop/secret style
|
| 649 |
+
"price-focused", # Price anchor emphasis
|
| 650 |
+
"proof-based", # Evidence/testimonial style (note: use testimonial framework for structure)
|
| 651 |
+
"quiz-interactive", # Interactive/engagement style
|
| 652 |
+
"authority-backed", # Authority/trust transfer
|
| 653 |
+
"identity-targeted", # Demographic callout
|
| 654 |
+
"insider-reveal", # Exclusive/hidden knowledge
|
| 655 |
+
"urgent", # Time-sensitive urgency
|
| 656 |
+
# Note: "testimonial" removed - use "testimonial" framework instead for structure
|
| 657 |
+
]
|
| 658 |
+
|
| 659 |
+
# Visual aesthetic styles (documentary/authentic formats for ad images)
|
| 660 |
+
# Note: These are aesthetic styles, not emotional moods (see data/visuals.py for emotional moods)
|
| 661 |
+
VISUAL_MOODS = [
|
| 662 |
+
"documentary-candid", # Documentary photography style
|
| 663 |
+
"vintage-authentic", # Vintage/retro aesthetic
|
| 664 |
+
"proof-testimonial", # Testimonial/evidence style
|
| 665 |
+
"ui-screenshot", # Native app interface style
|
| 666 |
+
"official-institutional", # Official/document style
|
| 667 |
+
"warm-nostalgic", # Warm, nostalgic tones
|
| 668 |
+
"raw-unpolished", # Raw, unpolished UGC feel
|
| 669 |
+
"news-expose", # News/editorial style
|
| 670 |
+
]
|
| 671 |
+
|
| 672 |
+
COPY_TEMPLATES = [
|
| 673 |
+
{
|
| 674 |
+
"structure": "hook_then_cta",
|
| 675 |
+
"format": "{hook}\n\n{supporting_text}\n\n👉 {cta}",
|
| 676 |
+
},
|
| 677 |
+
{
|
| 678 |
+
"structure": "question_answer",
|
| 679 |
+
"format": "{question}\n\n{answer}\n\n{cta}",
|
| 680 |
+
},
|
| 681 |
+
{
|
| 682 |
+
"structure": "stat_hook",
|
| 683 |
+
"format": "⚠️ {statistic}\n\n{explanation}\n\n{cta}",
|
| 684 |
+
},
|
| 685 |
+
{
|
| 686 |
+
"structure": "story_hook",
|
| 687 |
+
"format": "{story_opening}\n\n{story_middle}\n\n{cta}",
|
| 688 |
+
},
|
| 689 |
+
]
|
| 690 |
+
|
| 691 |
+
CTAS = [
|
| 692 |
+
"Check Your Eligibility",
|
| 693 |
+
"See If You Qualify",
|
| 694 |
+
"Check Eligibility Now",
|
| 695 |
+
"Tap To See Your Rate",
|
| 696 |
+
"Calculate Your Savings",
|
| 697 |
+
"Get Your Free Quote",
|
| 698 |
+
"See Your New Rate",
|
| 699 |
+
"Find Out How Much You Can Save",
|
| 700 |
+
"Click To See Your Savings",
|
| 701 |
+
"Get Protected Now",
|
| 702 |
+
"Start Saving Today",
|
| 703 |
+
"Don't Miss This",
|
| 704 |
+
"Claim Your Rate",
|
| 705 |
+
"Seniors: Check Your Rate",
|
| 706 |
+
"See Senior Rates",
|
| 707 |
+
"50+: Get Your Quote",
|
| 708 |
+
]
|
| 709 |
+
|
| 710 |
+
# ============================================================================
|
| 711 |
+
# SECTION 4: AGGREGATED DATA
|
| 712 |
+
# ============================================================================
|
| 713 |
+
|
| 714 |
+
STRATEGY_NAMES = list(STRATEGIES.keys())
|
| 715 |
+
|
| 716 |
+
ALL_HOOKS = []
|
| 717 |
+
for strategy in STRATEGIES.values():
|
| 718 |
+
ALL_HOOKS.extend(strategy["hooks"])
|
| 719 |
+
|
| 720 |
+
ALL_VISUAL_STYLES = []
|
| 721 |
+
for strategy in STRATEGIES.values():
|
| 722 |
+
ALL_VISUAL_STYLES.extend(strategy["visual_styles"])
|
| 723 |
+
ALL_VISUAL_STYLES.extend(HIGH_CONVERTING_VISUAL_LIBRARY)
|
| 724 |
+
|
| 725 |
+
# ============================================================================
|
| 726 |
+
# SECTION 5: DATA EXPORT
|
| 727 |
+
# ============================================================================
|
| 728 |
+
|
| 729 |
+
def get_niche_data():
|
| 730 |
+
"""Return all home insurance data for the generator."""
|
| 731 |
+
return {
|
| 732 |
+
"niche": "home_insurance",
|
| 733 |
+
"strategies": STRATEGIES,
|
| 734 |
+
"all_hooks": ALL_HOOKS,
|
| 735 |
+
"all_visual_styles": ALL_VISUAL_STYLES,
|
| 736 |
+
"strategy_names": STRATEGY_NAMES,
|
| 737 |
+
"creative_directions": CREATIVE_DIRECTIONS,
|
| 738 |
+
"visual_moods": VISUAL_MOODS,
|
| 739 |
+
"copy_templates": COPY_TEMPLATES,
|
| 740 |
+
"ctas": CTAS,
|
| 741 |
+
}
|
data/hooks.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hook Styles and Power Words - Language elements for compelling ad copy.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Dict, Any, List, Optional
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
HOOK_STYLES: Dict[str, Dict[str, Any]] = {
|
| 9 |
+
"question": {
|
| 10 |
+
"name": "Question Hook",
|
| 11 |
+
"description": "Starts with a question to engage curiosity",
|
| 12 |
+
"examples": ["What if you could save $500/year?", "Are you overpaying for insurance?", "Tired of high premiums?"],
|
| 13 |
+
"trigger": "Curiosity",
|
| 14 |
+
},
|
| 15 |
+
"statement": {
|
| 16 |
+
"name": "Statement Hook",
|
| 17 |
+
"description": "Bold declaration that demands attention",
|
| 18 |
+
"examples": ["The secret to affordable insurance", "Most people don't know this trick", "The truth about insurance rates"],
|
| 19 |
+
"trigger": "Curiosity",
|
| 20 |
+
},
|
| 21 |
+
"number": {
|
| 22 |
+
"name": "Number Hook",
|
| 23 |
+
"description": "Uses specific numbers for credibility",
|
| 24 |
+
"examples": ["3 ways to cut insurance costs", "Save $847 in 5 minutes", "97% of users see results"],
|
| 25 |
+
"trigger": "Logic",
|
| 26 |
+
},
|
| 27 |
+
"transformation": {
|
| 28 |
+
"name": "Transformation Hook",
|
| 29 |
+
"description": "Shows before/after change",
|
| 30 |
+
"examples": ["From $200/month to $50/month", "Before: worried. After: protected.", "The change that saved everything"],
|
| 31 |
+
"trigger": "Transformation",
|
| 32 |
+
},
|
| 33 |
+
"urgency": {
|
| 34 |
+
"name": "Urgency Hook",
|
| 35 |
+
"description": "Creates time pressure",
|
| 36 |
+
"examples": ["Limited time: Save 40% today", "Offer expires Friday", "Last chance for this rate"],
|
| 37 |
+
"trigger": "FOMO",
|
| 38 |
+
},
|
| 39 |
+
"notification": {
|
| 40 |
+
"name": "Notification-Style Hook",
|
| 41 |
+
"description": "Mimics system notifications",
|
| 42 |
+
"examples": ["ALERT: Rates dropping now", "New message: Your savings are waiting", "Notice: Rate change detected"],
|
| 43 |
+
"trigger": "Urgency",
|
| 44 |
+
},
|
| 45 |
+
"curiosity_gap": {
|
| 46 |
+
"name": "Curiosity Gap Hook",
|
| 47 |
+
"description": "Creates information gap that needs closing",
|
| 48 |
+
"examples": ["The reason your rates are so high", "What they don't want you to know", "The hidden trick insurance companies hate"],
|
| 49 |
+
"trigger": "Curiosity",
|
| 50 |
+
},
|
| 51 |
+
"pattern_interrupt": {
|
| 52 |
+
"name": "Pattern Interrupt Hook",
|
| 53 |
+
"description": "Breaks expected patterns to grab attention",
|
| 54 |
+
"examples": ["Stop saving money (hear me out)", "I was wrong about insurance", "The worst advice that actually works"],
|
| 55 |
+
"trigger": "Surprise",
|
| 56 |
+
},
|
| 57 |
+
"social_proof": {
|
| 58 |
+
"name": "Social Proof Hook",
|
| 59 |
+
"description": "Leverages others' actions or opinions",
|
| 60 |
+
"examples": ["Join 50,000+ satisfied customers", "Why thousands are switching", "Rated 5 stars by 10,000+ users"],
|
| 61 |
+
"trigger": "Social Proof",
|
| 62 |
+
},
|
| 63 |
+
"benefit": {
|
| 64 |
+
"name": "Benefit-First Hook",
|
| 65 |
+
"description": "Leads with the primary benefit",
|
| 66 |
+
"examples": ["Save $500/year on home insurance", "Get coverage in 5 minutes", "Lower rates, better coverage"],
|
| 67 |
+
"trigger": "Greed",
|
| 68 |
+
},
|
| 69 |
+
"shocking_revelation": {
|
| 70 |
+
"name": "Shocking Revelation Hook",
|
| 71 |
+
"description": "Reveals surprising or shocking information",
|
| 72 |
+
"examples": ["The insurance trick that saves you $1,000", "What insurance companies don't want you to know", "The hidden cost of cheap insurance"],
|
| 73 |
+
"trigger": "Curiosity",
|
| 74 |
+
},
|
| 75 |
+
"direct_command": {
|
| 76 |
+
"name": "Direct Command Hook",
|
| 77 |
+
"description": "Tells the reader exactly what to do",
|
| 78 |
+
"examples": ["Stop overpaying today", "Get your quote now", "Compare rates in 60 seconds"],
|
| 79 |
+
"trigger": "Action",
|
| 80 |
+
},
|
| 81 |
+
"statistic_driven": {
|
| 82 |
+
"name": "Statistic-Driven Hook",
|
| 83 |
+
"description": "Uses compelling statistics to grab attention",
|
| 84 |
+
"examples": ["9 out of 10 people overpay", "Save an average of $847 per year", "97% see results in 30 days"],
|
| 85 |
+
"trigger": "Logic",
|
| 86 |
+
},
|
| 87 |
+
"story_opener": {
|
| 88 |
+
"name": "Story Opener Hook",
|
| 89 |
+
"description": "Begins a narrative that draws the reader in",
|
| 90 |
+
"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..."],
|
| 91 |
+
"trigger": "Curiosity",
|
| 92 |
+
},
|
| 93 |
+
"contrarian_statement": {
|
| 94 |
+
"name": "Contrarian Statement Hook",
|
| 95 |
+
"description": "Challenges conventional wisdom",
|
| 96 |
+
"examples": ["Everything you know about insurance is wrong", "The worst advice that actually works", "Why the popular choice is the wrong choice"],
|
| 97 |
+
"trigger": "Curiosity",
|
| 98 |
+
},
|
| 99 |
+
"empathy_first": {
|
| 100 |
+
"name": "Empathy-First Hook",
|
| 101 |
+
"description": "Shows understanding of the reader's situation",
|
| 102 |
+
"examples": ["Tired of confusing insurance policies?", "Frustrated with rising rates?", "Wish insurance was simpler?"],
|
| 103 |
+
"trigger": "Empathy",
|
| 104 |
+
},
|
| 105 |
+
"exclusive_access": {
|
| 106 |
+
"name": "Exclusive Access Hook",
|
| 107 |
+
"description": "Offers special or limited access",
|
| 108 |
+
"examples": ["Exclusive offer for new customers only", "VIP access to special rates", "Invitation-only pricing"],
|
| 109 |
+
"trigger": "Exclusivity",
|
| 110 |
+
},
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
POWER_WORDS: Dict[str, List[str]] = {
|
| 114 |
+
"urgency": ["Now", "Today", "Hurry", "Instant", "Fast", "Quick", "Limited", "Deadline", "Expires", "Final", "Last chance", "Act now"],
|
| 115 |
+
"exclusivity": ["Exclusive", "Secret", "Hidden", "Private", "VIP", "Insider", "Confidential", "Elite", "Special", "Rare"],
|
| 116 |
+
"savings": ["Free", "Save", "Discount", "Deal", "Bargain", "Value", "Affordable", "Bonus", "Extra", "Reward", "Cashback"],
|
| 117 |
+
"trust": ["Guaranteed", "Proven", "Certified", "Verified", "Official", "Trusted", "Reliable", "Secure", "Safe", "Protected"],
|
| 118 |
+
"transformation": ["Transform", "Change", "Unlock", "Discover", "Reveal", "Breakthrough", "Revolutionary", "New", "Improved", "Ultimate"],
|
| 119 |
+
"emotion": ["Love", "Happy", "Joy", "Peace", "Confident", "Proud", "Relieved", "Excited", "Amazing", "Incredible"],
|
| 120 |
+
"fear": ["Warning", "Danger", "Risk", "Threat", "Avoid", "Mistake", "Problem", "Crisis", "Emergency", "Alert"],
|
| 121 |
+
"curiosity": ["Discover", "Learn", "Find out", "See why", "Uncover", "Secret", "Mystery", "Surprising", "Shocking", "Revealed"],
|
| 122 |
+
"social_proof": ["Popular", "Trending", "Best-selling", "Top-rated", "Award-winning", "Recommended", "Thousands", "Millions", "Join"],
|
| 123 |
+
"action": ["Get", "Start", "Try", "Claim", "Grab", "Take", "Join", "Sign up", "Subscribe", "Download", "Order", "Apply"],
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
CTA_TEMPLATES: Dict[str, List[str]] = {
|
| 127 |
+
"action": ["Get Started", "Get Your Quote", "Start Saving", "Claim Your Discount", "See Your Rate", "Get Protected"],
|
| 128 |
+
"urgency": ["Get It Now", "Claim Today", "Don't Miss Out", "Act Now", "Limited Time", "Last Chance"],
|
| 129 |
+
"curiosity": ["Learn More", "See How", "Find Out", "Discover More", "See If You Qualify", "Check Eligibility"],
|
| 130 |
+
"value": ["Save Now", "Get Free Quote", "Compare & Save", "See Your Savings", "Unlock Savings", "Get Best Rate"],
|
| 131 |
+
"low_commitment": ["Try Free", "No Obligation", "See for Yourself", "Take a Look", "Explore Options", "Check It Out"],
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
def get_all_hook_styles() -> Dict[str, Dict[str, Any]]:
|
| 135 |
+
return HOOK_STYLES
|
| 136 |
+
|
| 137 |
+
def get_hook_style(key: str) -> Optional[Dict[str, Any]]:
|
| 138 |
+
return HOOK_STYLES.get(key)
|
| 139 |
+
|
| 140 |
+
def get_random_hook_style() -> Dict[str, Any]:
|
| 141 |
+
key = random.choice(list(HOOK_STYLES.keys()))
|
| 142 |
+
return {"key": key, **HOOK_STYLES[key]}
|
| 143 |
+
|
| 144 |
+
def get_power_words(category: Optional[str] = None, count: int = 5) -> List[str]:
|
| 145 |
+
if category and category in POWER_WORDS:
|
| 146 |
+
words = POWER_WORDS[category]
|
| 147 |
+
else:
|
| 148 |
+
words = [w for cat_words in POWER_WORDS.values() for w in cat_words]
|
| 149 |
+
return random.sample(words, min(count, len(words)))
|
| 150 |
+
|
| 151 |
+
def get_random_cta(style: Optional[str] = None) -> str:
|
| 152 |
+
if style and style in CTA_TEMPLATES:
|
| 153 |
+
ctas = CTA_TEMPLATES[style]
|
| 154 |
+
else:
|
| 155 |
+
ctas = [c for style_ctas in CTA_TEMPLATES.values() for c in style_ctas]
|
| 156 |
+
return random.choice(ctas)
|
data/triggers.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Psychological Triggers - Core emotional drivers that make ads convert.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Dict, Any, List, Optional
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
PSYCHOLOGICAL_TRIGGERS: Dict[str, Dict[str, Any]] = {
|
| 9 |
+
"fear": {
|
| 10 |
+
"name": "Fear / Loss Aversion",
|
| 11 |
+
"description": "Fear of losing what you have or missing out on protection",
|
| 12 |
+
"copy_angles": ["Don't risk losing your home", "Protect what matters most", "Before disaster strikes"],
|
| 13 |
+
"visual_cues": ["warning signs", "protection imagery", "security elements"],
|
| 14 |
+
"color_palette": "warning",
|
| 15 |
+
"best_for": ["insurance", "security", "protection services"],
|
| 16 |
+
},
|
| 17 |
+
"greed": {
|
| 18 |
+
"name": "Greed / Savings",
|
| 19 |
+
"description": "Desire to save money, get more value, maximize gain",
|
| 20 |
+
"copy_angles": ["Save $500+ per year", "Get more for less", "Maximize your savings"],
|
| 21 |
+
"visual_cues": ["money imagery", "savings charts", "discount badges"],
|
| 22 |
+
"color_palette": "savings",
|
| 23 |
+
"best_for": ["financial products", "deals", "discounts"],
|
| 24 |
+
},
|
| 25 |
+
"fomo": {
|
| 26 |
+
"name": "FOMO (Fear of Missing Out)",
|
| 27 |
+
"description": "Anxiety about missing a limited opportunity",
|
| 28 |
+
"copy_angles": ["Limited time offer", "Only 50 spots left", "Offer expires soon"],
|
| 29 |
+
"visual_cues": ["countdown timers", "urgency badges", "scarcity indicators"],
|
| 30 |
+
"color_palette": "urgency",
|
| 31 |
+
"best_for": ["sales", "launches", "limited offers"],
|
| 32 |
+
},
|
| 33 |
+
"authority": {
|
| 34 |
+
"name": "Authority / Expertise",
|
| 35 |
+
"description": "Trust in experts, credentials, and established institutions",
|
| 36 |
+
"copy_angles": ["Doctor recommended", "Expert approved", "Industry leading"],
|
| 37 |
+
"visual_cues": ["credentials", "certifications", "professional imagery"],
|
| 38 |
+
"color_palette": "trust",
|
| 39 |
+
"best_for": ["health", "professional services", "premium products"],
|
| 40 |
+
},
|
| 41 |
+
"social_proof": {
|
| 42 |
+
"name": "Social Proof",
|
| 43 |
+
"description": "Following what others are doing, trust in numbers",
|
| 44 |
+
"copy_angles": ["Join 50,000+ customers", "5-star rated", "Most popular choice"],
|
| 45 |
+
"visual_cues": ["star ratings", "customer counts", "testimonials"],
|
| 46 |
+
"color_palette": "trust",
|
| 47 |
+
"best_for": ["consumer products", "services", "subscriptions"],
|
| 48 |
+
},
|
| 49 |
+
"curiosity": {
|
| 50 |
+
"name": "Curiosity",
|
| 51 |
+
"description": "Desire to learn, discover, and fill knowledge gaps",
|
| 52 |
+
"copy_angles": ["The secret they don't want you to know", "Discover how", "What they won't tell you"],
|
| 53 |
+
"visual_cues": ["mystery elements", "reveal moments", "hidden information"],
|
| 54 |
+
"color_palette": "premium",
|
| 55 |
+
"best_for": ["educational content", "reveals", "discoveries"],
|
| 56 |
+
},
|
| 57 |
+
"belonging": {
|
| 58 |
+
"name": "Belonging / Community",
|
| 59 |
+
"description": "Desire to be part of a group, tribe, or community",
|
| 60 |
+
"copy_angles": ["Join the community", "Be part of something", "Welcome to the family"],
|
| 61 |
+
"visual_cues": ["group imagery", "community symbols", "togetherness"],
|
| 62 |
+
"color_palette": "calm",
|
| 63 |
+
"best_for": ["memberships", "communities", "brands"],
|
| 64 |
+
},
|
| 65 |
+
"transformation": {
|
| 66 |
+
"name": "Transformation",
|
| 67 |
+
"description": "Desire for change, improvement, and becoming better",
|
| 68 |
+
"copy_angles": ["Transform your life", "Become the best version", "Change everything"],
|
| 69 |
+
"visual_cues": ["before/after", "progression imagery", "improvement indicators"],
|
| 70 |
+
"color_palette": "energy",
|
| 71 |
+
"best_for": ["fitness", "personal development", "lifestyle"],
|
| 72 |
+
},
|
| 73 |
+
"relief": {
|
| 74 |
+
"name": "Relief / Pain Relief",
|
| 75 |
+
"description": "Escaping pain, stress, or uncomfortable situations",
|
| 76 |
+
"copy_angles": ["End the stress", "Finally get relief", "Stop the pain"],
|
| 77 |
+
"visual_cues": ["relaxation imagery", "relief moments", "calm scenes"],
|
| 78 |
+
"color_palette": "calm",
|
| 79 |
+
"best_for": ["health", "stress relief", "solutions"],
|
| 80 |
+
},
|
| 81 |
+
"pride": {
|
| 82 |
+
"name": "Pride / Self-Worth",
|
| 83 |
+
"description": "Feeling good about oneself, achievements, and status",
|
| 84 |
+
"copy_angles": ["You deserve the best", "Treat yourself", "You've earned it"],
|
| 85 |
+
"visual_cues": ["success imagery", "achievement symbols", "premium elements"],
|
| 86 |
+
"color_palette": "premium",
|
| 87 |
+
"best_for": ["luxury", "premium products", "self-care"],
|
| 88 |
+
},
|
| 89 |
+
"urgency": {
|
| 90 |
+
"name": "Urgency / Time Pressure",
|
| 91 |
+
"description": "Need to act quickly before time runs out",
|
| 92 |
+
"copy_angles": ["Act now", "Today only", "Time is running out"],
|
| 93 |
+
"visual_cues": ["clocks", "timers", "urgent badges"],
|
| 94 |
+
"color_palette": "urgency",
|
| 95 |
+
"best_for": ["sales", "deadlines", "limited offers"],
|
| 96 |
+
},
|
| 97 |
+
"exclusivity": {
|
| 98 |
+
"name": "Exclusivity",
|
| 99 |
+
"description": "Desire for special access, VIP treatment, insider status",
|
| 100 |
+
"copy_angles": ["Exclusive offer", "VIP access", "By invitation only"],
|
| 101 |
+
"visual_cues": ["VIP badges", "exclusive tags", "premium styling"],
|
| 102 |
+
"color_palette": "premium",
|
| 103 |
+
"best_for": ["premium products", "memberships", "exclusive deals"],
|
| 104 |
+
},
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
# Trigger combinations that work well together
|
| 108 |
+
TRIGGER_COMBINATIONS: List[Dict[str, Any]] = [
|
| 109 |
+
{"primary": "fear", "secondary": "urgency", "name": "Fear + Urgency", "description": "Creates immediate action through fear of loss"},
|
| 110 |
+
{"primary": "greed", "secondary": "fomo", "name": "Savings + FOMO", "description": "Limited-time savings opportunity"},
|
| 111 |
+
{"primary": "social_proof", "secondary": "authority", "name": "Proof + Authority", "description": "Expert-backed with customer validation"},
|
| 112 |
+
{"primary": "transformation", "secondary": "curiosity", "name": "Transform + Curiosity", "description": "Discover the secret to transformation"},
|
| 113 |
+
{"primary": "relief", "secondary": "social_proof", "name": "Relief + Proof", "description": "Join thousands who found relief"},
|
| 114 |
+
{"primary": "exclusivity", "secondary": "urgency", "name": "Exclusive + Urgent", "description": "Limited VIP opportunity"},
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
def get_all_triggers() -> Dict[str, Dict[str, Any]]:
|
| 118 |
+
return PSYCHOLOGICAL_TRIGGERS
|
| 119 |
+
|
| 120 |
+
def get_trigger(key: str) -> Optional[Dict[str, Any]]:
|
| 121 |
+
return PSYCHOLOGICAL_TRIGGERS.get(key)
|
| 122 |
+
|
| 123 |
+
def get_random_trigger() -> Dict[str, Any]:
|
| 124 |
+
key = random.choice(list(PSYCHOLOGICAL_TRIGGERS.keys()))
|
| 125 |
+
return {"key": key, **PSYCHOLOGICAL_TRIGGERS[key]}
|
| 126 |
+
|
| 127 |
+
def get_trigger_combination() -> Dict[str, Any]:
|
| 128 |
+
return random.choice(TRIGGER_COMBINATIONS)
|
| 129 |
+
|
| 130 |
+
def get_triggers_for_niche(niche: str) -> List[Dict[str, Any]]:
|
| 131 |
+
niche_lower = niche.lower().replace(" ", "_").replace("-", "_")
|
| 132 |
+
niche_triggers = {
|
| 133 |
+
"home_insurance": ["fear", "greed", "social_proof", "authority", "relief"],
|
| 134 |
+
"glp1": ["transformation", "pride", "social_proof", "authority", "relief"],
|
| 135 |
+
}
|
| 136 |
+
trigger_keys = niche_triggers.get(niche_lower, list(PSYCHOLOGICAL_TRIGGERS.keys())[:5])
|
| 137 |
+
return [{"key": k, **PSYCHOLOGICAL_TRIGGERS[k]} for k in trigger_keys if k in PSYCHOLOGICAL_TRIGGERS]
|
| 138 |
+
|
| 139 |
+
def get_copy_angles_for_trigger(trigger_key: str) -> List[str]:
|
| 140 |
+
trigger = get_trigger(trigger_key)
|
| 141 |
+
return trigger.get("copy_angles", []) if trigger else []
|
data/visuals.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Visual Elements - Colors, typography, styles, and image generation guidance.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Dict, Any, List, Optional
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
COLOR_PALETTES: Dict[str, Dict[str, Any]] = {
|
| 9 |
+
"urgency": {"primary": "#FF0000", "secondary": "#FF4500", "accent": "#FFD700", "background": "#000000", "text": "#FFFFFF"},
|
| 10 |
+
"trust": {"primary": "#1976D2", "secondary": "#0D47A1", "accent": "#4CAF50", "background": "#FFFFFF", "text": "#212121"},
|
| 11 |
+
"savings": {"primary": "#4CAF50", "secondary": "#2E7D32", "accent": "#FFD700", "background": "#FFFFFF", "text": "#212121"},
|
| 12 |
+
"energy": {"primary": "#FF9800", "secondary": "#F57C00", "accent": "#FFEB3B", "background": "#FFFFFF", "text": "#212121"},
|
| 13 |
+
"premium": {"primary": "#212121", "secondary": "#424242", "accent": "#FFD700", "background": "#FFFFFF", "text": "#212121"},
|
| 14 |
+
"calm": {"primary": "#00BCD4", "secondary": "#0097A7", "accent": "#B2EBF2", "background": "#FFFFFF", "text": "#212121"},
|
| 15 |
+
"health": {"primary": "#4CAF50", "secondary": "#81C784", "accent": "#2196F3", "background": "#E8F5E9", "text": "#212121"},
|
| 16 |
+
"warning": {"primary": "#F44336", "secondary": "#D32F2F", "accent": "#FFEB3B", "background": "#FFF3E0", "text": "#212121"},
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
TYPOGRAPHY_STYLES: Dict[str, Dict[str, Any]] = {
|
| 20 |
+
"system": {"name": "System/Native", "fonts": ["Arial", "Helvetica", "San Francisco", "Roboto"], "best_for": ["notifications", "system alerts"]},
|
| 21 |
+
"bold_impact": {"name": "Bold Impact", "fonts": ["Impact", "Arial Black", "Bebas Neue", "Anton"], "best_for": ["headlines", "breaking news"]},
|
| 22 |
+
"modern_clean": {"name": "Modern Clean", "fonts": ["Montserrat", "Lato", "Open Sans", "Poppins"], "best_for": ["professional ads", "lifestyle"]},
|
| 23 |
+
"handwritten": {"name": "Handwritten", "fonts": ["Indie Flower", "Dancing Script", "Pacifico", "Caveat"], "best_for": ["testimonials", "personal notes"]},
|
| 24 |
+
"typewriter": {"name": "Typewriter", "fonts": ["Courier", "Courier New", "American Typewriter"], "best_for": ["memos", "documents"]},
|
| 25 |
+
"newspaper": {"name": "Newspaper", "fonts": ["Times New Roman", "Georgia", "Playfair Display"], "best_for": ["news style", "editorial"]},
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
VISUAL_STYLES: List[Dict[str, Any]] = [
|
| 29 |
+
{"key": "ugc_authentic", "name": "UGC Authentic", "prompt_guidance": "Casual phone camera snapshot, authentic feel, slightly imperfect, natural lighting"},
|
| 30 |
+
{"key": "clean_minimal", "name": "Clean Minimal", "prompt_guidance": "Clean minimal design, white space, simple composition, modern aesthetic"},
|
| 31 |
+
{"key": "bold_graphic", "name": "Bold Graphic", "prompt_guidance": "Bold graphic design, high contrast, strong colors, impactful visual"},
|
| 32 |
+
{"key": "lifestyle_aspirational", "name": "Lifestyle Aspirational", "prompt_guidance": "Aspirational lifestyle photography, happy people, warm lighting"},
|
| 33 |
+
{"key": "documentary_real", "name": "Documentary Real", "prompt_guidance": "Documentary style, candid shots, real moments, natural light"},
|
| 34 |
+
{"key": "screenshot_native", "name": "Screenshot Native", "prompt_guidance": "Mobile app screenshot, native UI elements, authentic interface"},
|
| 35 |
+
{"key": "news_editorial", "name": "News Editorial", "prompt_guidance": "News publication style, editorial photography, professional"},
|
| 36 |
+
{"key": "retro_nostalgic", "name": "Retro Nostalgic", "prompt_guidance": "Retro aesthetic, vintage colors, nostalgic feel"},
|
| 37 |
+
{"key": "dark_dramatic", "name": "Dark Dramatic", "prompt_guidance": "Dark dramatic lighting, moody atmosphere, cinematic"},
|
| 38 |
+
{"key": "bright_optimistic", "name": "Bright Optimistic", "prompt_guidance": "Bright cheerful photography, optimistic feel, positive energy"},
|
| 39 |
+
{"key": "corporate_professional", "name": "Corporate Professional", "prompt_guidance": "Corporate photography, business setting, professional attire, clean office environment"},
|
| 40 |
+
{"key": "street_style", "name": "Street Style", "prompt_guidance": "Urban street photography, candid moments, city backdrop, authentic urban feel"},
|
| 41 |
+
{"key": "studio_clean", "name": "Studio Clean", "prompt_guidance": "Studio photography, controlled lighting, clean background, product-focused"},
|
| 42 |
+
{"key": "cinematic_epic", "name": "Cinematic Epic", "prompt_guidance": "Cinematic wide shots, epic scale, dramatic composition, film-like quality"},
|
| 43 |
+
{"key": "handheld_casual", "name": "Handheld Casual", "prompt_guidance": "Handheld camera feel, slight motion blur, casual framing, everyday moments"},
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
CAMERA_ANGLES: List[str] = [
|
| 47 |
+
"eye level shot", "slightly low angle (empowering)", "slightly high angle (overview)", "close-up portrait",
|
| 48 |
+
"medium shot", "wide establishing shot", "over-the-shoulder shot", "POV first-person perspective", "candid angle",
|
| 49 |
+
"dutch angle (tilted)", "bird's eye view", "worm's eye view", "extreme close-up", "two-shot (two people)",
|
| 50 |
+
"group shot", "profile shot", "three-quarter angle", "top-down view", "oblique angle",
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
LIGHTING_STYLES: List[str] = [
|
| 54 |
+
"natural daylight", "golden hour warm light", "soft diffused light", "harsh direct sunlight",
|
| 55 |
+
"indoor ambient lighting", "dramatic side lighting", "backlit silhouette", "overcast soft light",
|
| 56 |
+
"rim lighting", "softbox lighting", "window light", "neon lighting", "candlelight warm",
|
| 57 |
+
"studio lighting", "natural window light", "dramatic chiaroscuro", "soft ambient glow",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
COMPOSITIONS: List[str] = [
|
| 61 |
+
"rule of thirds", "centered subject", "leading lines to subject", "frame within frame",
|
| 62 |
+
"negative space emphasis", "symmetrical balance", "asymmetrical dynamic", "diagonal composition",
|
| 63 |
+
"golden ratio", "triangular composition", "circular composition", "layered depth",
|
| 64 |
+
"foreground/background separation", "depth of field focus", "repetitive patterns", "contrasting elements",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
VISUAL_MOODS: List[str] = [
|
| 68 |
+
"urgent and alarming", "calm and reassuring", "exciting and energetic", "trustworthy and professional",
|
| 69 |
+
"warm and friendly", "bold and confident", "subtle and sophisticated", "raw and authentic",
|
| 70 |
+
"hopeful and optimistic", "serious and authoritative", "playful and fun", "mysterious and intriguing",
|
| 71 |
+
"inspiring and motivational", "comfortable and cozy", "dynamic and action-packed", "peaceful and serene",
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
NEGATIVE_PROMPTS: List[str] = [
|
| 75 |
+
"no watermarks", "no logos", "no brand marks", "no stock photo aesthetic", "no overly polished professional photography",
|
| 76 |
+
"no perfect studio lighting", "no corporate photography style", "no artificial perfection", "no text errors",
|
| 77 |
+
"no distorted faces", "no extra limbs", "no AI artifacts", "no blurry main subjects", "no over-processed HDR look",
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
NICHE_VISUAL_GUIDANCE: Dict[str, Dict[str, Any]] = {
|
| 81 |
+
"home_insurance": {
|
| 82 |
+
"subjects": ["family in front of home", "house exterior", "homeowner looking confident", "couple reviewing papers"],
|
| 83 |
+
"props": ["insurance documents", "house keys", "tablet showing coverage", "family photos"],
|
| 84 |
+
"avoid": ["disasters", "fire or floods", "stressed expressions", "dark settings"],
|
| 85 |
+
"color_preference": "trust",
|
| 86 |
+
},
|
| 87 |
+
"glp1": {
|
| 88 |
+
"subjects": ["confident person smiling", "active lifestyle scenes", "healthy meal preparation", "doctor consultation"],
|
| 89 |
+
"props": ["fitness equipment", "healthy food", "comfortable clothing"],
|
| 90 |
+
"avoid": ["before/after weight comparisons", "measuring tapes", "scales prominently", "needle close-ups"],
|
| 91 |
+
"color_preference": "health",
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
def get_color_palette(trigger: str) -> Dict[str, str]:
|
| 96 |
+
return COLOR_PALETTES.get(trigger, COLOR_PALETTES["trust"])
|
| 97 |
+
|
| 98 |
+
def get_random_visual_style() -> Dict[str, Any]:
|
| 99 |
+
return random.choice(VISUAL_STYLES)
|
| 100 |
+
|
| 101 |
+
def get_random_camera_angle() -> str:
|
| 102 |
+
return random.choice(CAMERA_ANGLES)
|
| 103 |
+
|
| 104 |
+
def get_random_lighting() -> str:
|
| 105 |
+
return random.choice(LIGHTING_STYLES)
|
| 106 |
+
|
| 107 |
+
def get_random_composition() -> str:
|
| 108 |
+
return random.choice(COMPOSITIONS)
|
| 109 |
+
|
| 110 |
+
def get_random_mood() -> str:
|
| 111 |
+
return random.choice(VISUAL_MOODS)
|
| 112 |
+
|
| 113 |
+
def get_niche_visual_guidance(niche: str) -> Optional[Dict[str, Any]]:
|
| 114 |
+
return NICHE_VISUAL_GUIDANCE.get(niche.lower().replace(" ", "_").replace("-", "_"))
|
| 115 |
+
|
| 116 |
+
def build_negative_prompt() -> str:
|
| 117 |
+
return ", ".join(NEGATIVE_PROMPTS)
|
| 118 |
+
|
| 119 |
+
def get_random_visual_elements() -> Dict[str, Any]:
|
| 120 |
+
return {
|
| 121 |
+
"style": get_random_visual_style(),
|
| 122 |
+
"camera_angle": get_random_camera_angle(),
|
| 123 |
+
"lighting": get_random_lighting(),
|
| 124 |
+
"composition": get_random_composition(),
|
| 125 |
+
"mood": get_random_mood(),
|
| 126 |
+
}
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
frontend/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ad Generator Lite - Frontend
|
| 2 |
+
|
| 3 |
+
Modern Next.js dashboard for generating and managing ad creatives for Home Insurance and GLP-1 niches.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Single Ad Generation**: Generate individual ads with randomized strategies
|
| 8 |
+
- **Batch Generation**: Create multiple ads at once for testing
|
| 9 |
+
- **Matrix System**: Generate ads using specific angle × concept combinations
|
| 10 |
+
- **Gallery**: Browse, filter, and manage all generated ads
|
| 11 |
+
- **Testing Matrix Builder**: Create systematic testing matrices
|
| 12 |
+
- **Export**: Download images, export JSON/CSV, copy ad copy
|
| 13 |
+
|
| 14 |
+
## Tech Stack
|
| 15 |
+
|
| 16 |
+
- **Next.js 16** with App Router
|
| 17 |
+
- **TypeScript** for type safety
|
| 18 |
+
- **Tailwind CSS** for styling
|
| 19 |
+
- **Zustand** for state management
|
| 20 |
+
- **React Hook Form** + **Zod** for form validation
|
| 21 |
+
- **Axios** for API calls
|
| 22 |
+
- **React Hot Toast** for notifications
|
| 23 |
+
|
| 24 |
+
## Getting Started
|
| 25 |
+
|
| 26 |
+
### Prerequisites
|
| 27 |
+
|
| 28 |
+
- Node.js 18+ and npm
|
| 29 |
+
- Backend API running on `http://localhost:8000` (or configure `NEXT_PUBLIC_API_URL`)
|
| 30 |
+
|
| 31 |
+
### Installation
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# Install dependencies
|
| 35 |
+
npm install
|
| 36 |
+
|
| 37 |
+
# Run development server
|
| 38 |
+
npm run dev
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
The app will be available at `http://localhost:3000`
|
| 42 |
+
|
| 43 |
+
### Environment Variables
|
| 44 |
+
|
| 45 |
+
Create a `.env.local` file:
|
| 46 |
+
|
| 47 |
+
```env
|
| 48 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## Project Structure
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
frontend/
|
| 55 |
+
├── app/ # Next.js pages (App Router)
|
| 56 |
+
│ ├── page.tsx # Dashboard
|
| 57 |
+
│ ├── generate/ # Generation pages
|
| 58 |
+
│ ├── gallery/ # Gallery pages
|
| 59 |
+
│ └── matrix/ # Matrix system pages
|
| 60 |
+
├── components/ # React components
|
| 61 |
+
│ ├── ui/ # Base UI components
|
| 62 |
+
│ ├── generation/ # Generation components
|
| 63 |
+
│ ├── gallery/ # Gallery components
|
| 64 |
+
│ └── matrix/ # Matrix components
|
| 65 |
+
├── lib/ # Utilities
|
| 66 |
+
│ ├── api/ # API client
|
| 67 |
+
│ ├── hooks/ # Custom hooks
|
| 68 |
+
│ └── utils/ # Utility functions
|
| 69 |
+
├── store/ # Zustand stores
|
| 70 |
+
├── types/ # TypeScript types
|
| 71 |
+
└── styles/ # Global styles
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
## Available Scripts
|
| 75 |
+
|
| 76 |
+
- `npm run dev` - Start development server
|
| 77 |
+
- `npm run build` - Build for production
|
| 78 |
+
- `npm run start` - Start production server
|
| 79 |
+
- `npm run lint` - Run ESLint
|
| 80 |
+
|
| 81 |
+
## API Integration
|
| 82 |
+
|
| 83 |
+
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`.
|
| 84 |
+
|
| 85 |
+
## Features Overview
|
| 86 |
+
|
| 87 |
+
### Dashboard
|
| 88 |
+
- Quick stats (total ads, by niche, system status)
|
| 89 |
+
- Recent ads preview
|
| 90 |
+
- Quick action buttons
|
| 91 |
+
|
| 92 |
+
### Generation
|
| 93 |
+
- **Single**: Generate one ad with configurable image count
|
| 94 |
+
- **Batch**: Generate multiple ads (1-20) with 1-3 images each
|
| 95 |
+
- **Matrix**: Select specific angle and concept combinations
|
| 96 |
+
|
| 97 |
+
### Gallery
|
| 98 |
+
- Grid view of all ads
|
| 99 |
+
- Filter by niche, method, search
|
| 100 |
+
- Pagination
|
| 101 |
+
- Bulk actions (select, delete)
|
| 102 |
+
- Ad detail view with full copy and metadata
|
| 103 |
+
|
| 104 |
+
### Matrix System
|
| 105 |
+
- Browse all 100 angles and 100 concepts
|
| 106 |
+
- View compatibility between angles and concepts
|
| 107 |
+
- Generate testing matrices for systematic optimization
|
| 108 |
+
- Export matrices as JSON/CSV
|
| 109 |
+
|
| 110 |
+
## Development
|
| 111 |
+
|
| 112 |
+
### Adding New Components
|
| 113 |
+
|
| 114 |
+
1. Create component in appropriate `components/` subdirectory
|
| 115 |
+
2. Use TypeScript for type safety
|
| 116 |
+
3. Follow existing patterns for styling (Tailwind CSS)
|
| 117 |
+
4. Use UI components from `components/ui/`
|
| 118 |
+
|
| 119 |
+
### State Management
|
| 120 |
+
|
| 121 |
+
Use Zustand stores in `store/`:
|
| 122 |
+
- `generationStore` - Current generation state
|
| 123 |
+
- `galleryStore` - Gallery filters, pagination, selection
|
| 124 |
+
- `matrixStore` - Matrix angles, concepts, selections
|
| 125 |
+
|
| 126 |
+
### API Calls
|
| 127 |
+
|
| 128 |
+
All API calls should use functions from `lib/api/endpoints.ts` which provide:
|
| 129 |
+
- Type safety
|
| 130 |
+
- Error handling
|
| 131 |
+
- Automatic toast notifications
|
| 132 |
+
|
| 133 |
+
## Building for Production
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
npm run build
|
| 137 |
+
npm start
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
The production build will be optimized and ready for deployment.
|
| 141 |
+
|
| 142 |
+
## License
|
| 143 |
+
|
| 144 |
+
Same as the main project.
|
frontend/app/favicon.ico
ADDED
|
|
frontend/app/gallery/[id]/page.tsx
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState, useCallback } from "react";
|
| 4 |
+
import { useParams, useRouter } from "next/navigation";
|
| 5 |
+
import Link from "next/link";
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 7 |
+
import { Button } from "@/components/ui/Button";
|
| 8 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 9 |
+
import { getAd, deleteAd, listAds } from "@/lib/api/endpoints";
|
| 10 |
+
import { formatDate, formatNiche, getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters";
|
| 11 |
+
import { downloadImage, copyToClipboard, exportAsJSON } from "@/lib/utils/export";
|
| 12 |
+
import { toast } from "react-hot-toast";
|
| 13 |
+
import { ArrowLeft, ArrowRight, Download, Copy, Trash2, FileJson, Wand2 } from "lucide-react";
|
| 14 |
+
import type { AdCreativeDB, ImageCorrectResponse } from "@/types/api";
|
| 15 |
+
import { CorrectionModal } from "@/components/generation/CorrectionModal";
|
| 16 |
+
|
| 17 |
+
export default function AdDetailPage() {
|
| 18 |
+
const params = useParams();
|
| 19 |
+
const router = useRouter();
|
| 20 |
+
const adId = params.id as string;
|
| 21 |
+
|
| 22 |
+
const [ad, setAd] = useState<AdCreativeDB | null>(null);
|
| 23 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 24 |
+
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
| 25 |
+
const [imageError, setImageError] = useState(false);
|
| 26 |
+
const [allAds, setAllAds] = useState<AdCreativeDB[]>([]);
|
| 27 |
+
const [currentIndex, setCurrentIndex] = useState<number>(-1);
|
| 28 |
+
const [showCorrectionModal, setShowCorrectionModal] = useState(false);
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
loadAd();
|
| 32 |
+
loadAllAds();
|
| 33 |
+
}, [adId]);
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
if (ad) {
|
| 37 |
+
const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename);
|
| 38 |
+
setImageSrc(primary || fallback);
|
| 39 |
+
setImageError(false);
|
| 40 |
+
const index = allAds.findIndex((a) => a.id === ad.id);
|
| 41 |
+
setCurrentIndex(index);
|
| 42 |
+
} else {
|
| 43 |
+
setImageSrc(null);
|
| 44 |
+
setImageError(false);
|
| 45 |
+
}
|
| 46 |
+
}, [ad, allAds]);
|
| 47 |
+
|
| 48 |
+
const navigateToPrevious = useCallback(() => {
|
| 49 |
+
if (currentIndex > 0 && allAds.length > 0) {
|
| 50 |
+
const previousAd = allAds[currentIndex - 1];
|
| 51 |
+
router.push(`/gallery/${previousAd.id}`);
|
| 52 |
+
}
|
| 53 |
+
}, [currentIndex, allAds, router]);
|
| 54 |
+
|
| 55 |
+
const navigateToNext = useCallback(() => {
|
| 56 |
+
if (currentIndex >= 0 && currentIndex < allAds.length - 1) {
|
| 57 |
+
const nextAd = allAds[currentIndex + 1];
|
| 58 |
+
router.push(`/gallery/${nextAd.id}`);
|
| 59 |
+
}
|
| 60 |
+
}, [currentIndex, allAds, router]);
|
| 61 |
+
|
| 62 |
+
const hasPrevious = currentIndex > 0;
|
| 63 |
+
const hasNext = currentIndex >= 0 && currentIndex < allAds.length - 1;
|
| 64 |
+
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
const handleKeyPress = (e: KeyboardEvent) => {
|
| 67 |
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
if (e.key === "ArrowLeft" && hasPrevious) {
|
| 71 |
+
e.preventDefault();
|
| 72 |
+
navigateToPrevious();
|
| 73 |
+
} else if (e.key === "ArrowRight" && hasNext) {
|
| 74 |
+
e.preventDefault();
|
| 75 |
+
navigateToNext();
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
window.addEventListener("keydown", handleKeyPress);
|
| 79 |
+
return () => window.removeEventListener("keydown", handleKeyPress);
|
| 80 |
+
}, [hasPrevious, hasNext, navigateToPrevious, navigateToNext]);
|
| 81 |
+
|
| 82 |
+
const loadAd = async () => {
|
| 83 |
+
setIsLoading(true);
|
| 84 |
+
try {
|
| 85 |
+
const data = await getAd(adId);
|
| 86 |
+
setAd(data);
|
| 87 |
+
} catch (error: any) {
|
| 88 |
+
toast.error("Failed to load ad");
|
| 89 |
+
router.push("/gallery");
|
| 90 |
+
} finally {
|
| 91 |
+
setIsLoading(false);
|
| 92 |
+
}
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const loadAllAds = async () => {
|
| 96 |
+
try {
|
| 97 |
+
const response = await listAds({ limit: 1000, offset: 0 });
|
| 98 |
+
setAllAds(response.ads);
|
| 99 |
+
} catch (error: any) {
|
| 100 |
+
console.error("Failed to load ads list:", error);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const handleDelete = async () => {
|
| 105 |
+
if (!confirm("Are you sure you want to delete this ad?")) return;
|
| 106 |
+
try {
|
| 107 |
+
await deleteAd(adId);
|
| 108 |
+
toast.success("Ad deleted");
|
| 109 |
+
router.push("/gallery");
|
| 110 |
+
} catch (error: any) {
|
| 111 |
+
toast.error("Failed to delete ad");
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const handleDownloadImage = async () => {
|
| 116 |
+
if (!ad?.image_url && !ad?.image_filename) {
|
| 117 |
+
toast.error("No image available");
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
try {
|
| 121 |
+
const imageUrl = getImageUrl(ad.image_url, ad.image_filename);
|
| 122 |
+
if (imageUrl) {
|
| 123 |
+
await downloadImage(imageUrl, ad.image_filename || `ad-${ad.id}.png`);
|
| 124 |
+
toast.success("Image downloaded");
|
| 125 |
+
}
|
| 126 |
+
} catch (error) {
|
| 127 |
+
toast.error("Failed to download image");
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const handleCopyText = async (text: string, label: string) => {
|
| 132 |
+
try {
|
| 133 |
+
await copyToClipboard(text);
|
| 134 |
+
toast.success(`${label} copied`);
|
| 135 |
+
} catch (error) {
|
| 136 |
+
toast.error("Failed to copy");
|
| 137 |
+
}
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const handleExportJSON = () => {
|
| 141 |
+
if (!ad) return;
|
| 142 |
+
exportAsJSON(ad, `ad-${ad.id}.json`);
|
| 143 |
+
toast.success("JSON exported");
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
const handleImageError = () => {
|
| 147 |
+
if (!ad) return;
|
| 148 |
+
const { fallback } = getImageUrlFallback(ad.image_url, ad.image_filename);
|
| 149 |
+
if (!imageError && fallback && imageSrc) {
|
| 150 |
+
setImageSrc(fallback);
|
| 151 |
+
setImageError(true);
|
| 152 |
+
} else {
|
| 153 |
+
setImageSrc(null);
|
| 154 |
+
}
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
if (isLoading) {
|
| 158 |
+
return (
|
| 159 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 160 |
+
<LoadingSpinner size="lg" />
|
| 161 |
+
</div>
|
| 162 |
+
);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if (!ad) {
|
| 166 |
+
return (
|
| 167 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 168 |
+
<div className="text-center">
|
| 169 |
+
<p className="text-gray-500 mb-4">Ad not found</p>
|
| 170 |
+
<Link href="/gallery">
|
| 171 |
+
<Button variant="primary">Back to Gallery</Button>
|
| 172 |
+
</Link>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-cyan-50/30">
|
| 180 |
+
{/* Header */}
|
| 181 |
+
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur-md border-b border-blue-100 shadow-sm">
|
| 182 |
+
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
| 183 |
+
<Link href="/gallery">
|
| 184 |
+
<Button variant="ghost" size="sm" className="text-blue-600 hover:text-blue-700 hover:bg-blue-50">
|
| 185 |
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
| 186 |
+
Gallery
|
| 187 |
+
</Button>
|
| 188 |
+
</Link>
|
| 189 |
+
|
| 190 |
+
{allAds.length > 0 && currentIndex >= 0 && (
|
| 191 |
+
<div className="flex items-center gap-2">
|
| 192 |
+
<div className="relative group">
|
| 193 |
+
<Button
|
| 194 |
+
variant="ghost"
|
| 195 |
+
size="sm"
|
| 196 |
+
onClick={navigateToPrevious}
|
| 197 |
+
disabled={!hasPrevious}
|
| 198 |
+
className="text-blue-600 hover:bg-blue-50 disabled:text-gray-300"
|
| 199 |
+
>
|
| 200 |
+
<ArrowLeft className="h-4 w-4" />
|
| 201 |
+
</Button>
|
| 202 |
+
{hasPrevious && (
|
| 203 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
| 204 |
+
Previous (←)
|
| 205 |
+
</span>
|
| 206 |
+
)}
|
| 207 |
+
</div>
|
| 208 |
+
<span className="text-sm font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent min-w-[60px] text-center">
|
| 209 |
+
{currentIndex + 1} / {allAds.length}
|
| 210 |
+
</span>
|
| 211 |
+
<div className="relative group">
|
| 212 |
+
<Button
|
| 213 |
+
variant="ghost"
|
| 214 |
+
size="sm"
|
| 215 |
+
onClick={navigateToNext}
|
| 216 |
+
disabled={!hasNext}
|
| 217 |
+
className="text-blue-600 hover:bg-blue-50 disabled:text-gray-300"
|
| 218 |
+
>
|
| 219 |
+
<ArrowRight className="h-4 w-4" />
|
| 220 |
+
</Button>
|
| 221 |
+
{hasNext && (
|
| 222 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
| 223 |
+
Next (→)
|
| 224 |
+
</span>
|
| 225 |
+
)}
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
)}
|
| 229 |
+
|
| 230 |
+
<div className="flex items-center gap-1">
|
| 231 |
+
<div className="relative group">
|
| 232 |
+
<Button
|
| 233 |
+
variant="ghost"
|
| 234 |
+
size="sm"
|
| 235 |
+
onClick={() => setShowCorrectionModal(true)}
|
| 236 |
+
className="text-amber-600 hover:bg-amber-50"
|
| 237 |
+
>
|
| 238 |
+
<Wand2 className="h-4 w-4" />
|
| 239 |
+
</Button>
|
| 240 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
| 241 |
+
Correct Image
|
| 242 |
+
</span>
|
| 243 |
+
</div>
|
| 244 |
+
<div className="relative group">
|
| 245 |
+
<Button variant="ghost" size="sm" onClick={handleDownloadImage} className="text-emerald-600 hover:bg-emerald-50">
|
| 246 |
+
<Download className="h-4 w-4" />
|
| 247 |
+
</Button>
|
| 248 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
| 249 |
+
Download Image
|
| 250 |
+
</span>
|
| 251 |
+
</div>
|
| 252 |
+
<div className="relative group">
|
| 253 |
+
<Button variant="ghost" size="sm" onClick={handleExportJSON} className="text-violet-600 hover:bg-violet-50">
|
| 254 |
+
<FileJson className="h-4 w-4" />
|
| 255 |
+
</Button>
|
| 256 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
| 257 |
+
Export JSON
|
| 258 |
+
</span>
|
| 259 |
+
</div>
|
| 260 |
+
<div className="relative group">
|
| 261 |
+
<Button variant="ghost" size="sm" onClick={handleDelete} className="text-red-500 hover:text-red-600 hover:bg-red-50">
|
| 262 |
+
<Trash2 className="h-4 w-4" />
|
| 263 |
+
</Button>
|
| 264 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-red-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
| 265 |
+
Delete Ad
|
| 266 |
+
</span>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
{/* Main Content */}
|
| 273 |
+
<div className="max-w-7xl mx-auto px-4 py-8">
|
| 274 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 275 |
+
{/* Left - Image */}
|
| 276 |
+
<div className="space-y-4">
|
| 277 |
+
{imageSrc ? (
|
| 278 |
+
<div className="bg-white rounded-2xl shadow-lg shadow-blue-100/50 overflow-hidden ring-1 ring-blue-100">
|
| 279 |
+
<img
|
| 280 |
+
src={imageSrc}
|
| 281 |
+
alt={ad.headline}
|
| 282 |
+
className="w-full h-auto"
|
| 283 |
+
onError={handleImageError}
|
| 284 |
+
/>
|
| 285 |
+
</div>
|
| 286 |
+
) : (
|
| 287 |
+
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 rounded-2xl shadow-lg aspect-square flex items-center justify-center ring-1 ring-blue-100">
|
| 288 |
+
<div className="text-blue-300 text-center">
|
| 289 |
+
<svg className="w-16 h-16 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 290 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
| 291 |
+
</svg>
|
| 292 |
+
<p className="text-sm">No image</p>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
)}
|
| 296 |
+
|
| 297 |
+
{/* Metadata */}
|
| 298 |
+
<div className="bg-white rounded-2xl shadow-lg p-6 border border-gray-100">
|
| 299 |
+
<h3 className="text-xs font-bold text-blue-600 uppercase tracking-wider mb-4">Details</h3>
|
| 300 |
+
<div className="grid grid-cols-2 gap-3 text-sm">
|
| 301 |
+
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl p-3 border border-blue-100">
|
| 302 |
+
<p className="text-blue-500 text-xs font-medium mb-1">Niche</p>
|
| 303 |
+
<p className="font-semibold text-gray-800">{formatNiche(ad.niche)}</p>
|
| 304 |
+
</div>
|
| 305 |
+
{ad.generation_method && (
|
| 306 |
+
<div className="bg-gradient-to-br from-cyan-50 to-teal-50 rounded-xl p-3 border border-cyan-100">
|
| 307 |
+
<p className="text-cyan-600 text-xs font-medium mb-1">Method</p>
|
| 308 |
+
<p className="font-semibold text-gray-800">{ad.generation_method}</p>
|
| 309 |
+
</div>
|
| 310 |
+
)}
|
| 311 |
+
{ad.angle_name && (
|
| 312 |
+
<div className="bg-gradient-to-br from-pink-50 to-rose-50 rounded-xl p-3 border border-pink-100">
|
| 313 |
+
<p className="text-pink-500 text-xs font-medium mb-1">Angle</p>
|
| 314 |
+
<p className="font-semibold text-gray-800">{ad.angle_name}</p>
|
| 315 |
+
</div>
|
| 316 |
+
)}
|
| 317 |
+
{ad.concept_name && (
|
| 318 |
+
<div className="bg-gradient-to-br from-violet-50 to-purple-50 rounded-xl p-3 border border-violet-100">
|
| 319 |
+
<p className="text-violet-500 text-xs font-medium mb-1">Concept</p>
|
| 320 |
+
<p className="font-semibold text-gray-800">{ad.concept_name}</p>
|
| 321 |
+
</div>
|
| 322 |
+
)}
|
| 323 |
+
{ad.image_model && (
|
| 324 |
+
<div className="bg-gradient-to-br from-emerald-50 to-green-50 rounded-xl p-3 border border-emerald-100">
|
| 325 |
+
<p className="text-emerald-600 text-xs font-medium mb-1">Image Model</p>
|
| 326 |
+
<p className="font-semibold text-gray-800">{ad.image_model}</p>
|
| 327 |
+
</div>
|
| 328 |
+
)}
|
| 329 |
+
{ad.created_at && (
|
| 330 |
+
<div className="bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-3 border border-amber-100">
|
| 331 |
+
<p className="text-amber-600 text-xs font-medium mb-1">Created</p>
|
| 332 |
+
<p className="font-semibold text-gray-800">{formatDate(ad.created_at)}</p>
|
| 333 |
+
</div>
|
| 334 |
+
)}
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
{/* Right - Ad Copy */}
|
| 340 |
+
<div className="space-y-5">
|
| 341 |
+
{/* Headline */}
|
| 342 |
+
<div className="bg-white rounded-2xl shadow-lg shadow-blue-100/30 p-6 border-l-4 border-blue-500">
|
| 343 |
+
<div className="flex items-start justify-between gap-4">
|
| 344 |
+
<div className="flex-1">
|
| 345 |
+
{ad.title && (
|
| 346 |
+
<p className="text-sm text-blue-600 font-medium mb-1">{ad.title}</p>
|
| 347 |
+
)}
|
| 348 |
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent leading-tight">
|
| 349 |
+
{ad.headline}
|
| 350 |
+
</h1>
|
| 351 |
+
</div>
|
| 352 |
+
<div className="relative group shrink-0">
|
| 353 |
+
<Button
|
| 354 |
+
variant="ghost"
|
| 355 |
+
size="sm"
|
| 356 |
+
onClick={() => handleCopyText(ad.headline, "Headline")}
|
| 357 |
+
className="text-blue-500 hover:bg-blue-50"
|
| 358 |
+
>
|
| 359 |
+
<Copy className="h-4 w-4" />
|
| 360 |
+
</Button>
|
| 361 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-blue-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 362 |
+
Copy Headline
|
| 363 |
+
</span>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
|
| 368 |
+
{/* Primary Text */}
|
| 369 |
+
{ad.primary_text && (
|
| 370 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-cyan-500">
|
| 371 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 372 |
+
<h3 className="text-xs font-bold text-cyan-600 uppercase tracking-wider">Primary Text</h3>
|
| 373 |
+
<div className="relative group">
|
| 374 |
+
<Button
|
| 375 |
+
variant="ghost"
|
| 376 |
+
size="sm"
|
| 377 |
+
onClick={() => handleCopyText(ad.primary_text!, "Primary Text")}
|
| 378 |
+
className="text-cyan-500 hover:bg-cyan-50"
|
| 379 |
+
>
|
| 380 |
+
<Copy className="h-4 w-4" />
|
| 381 |
+
</Button>
|
| 382 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-cyan-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 383 |
+
Copy Text
|
| 384 |
+
</span>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
<p className="text-gray-700 whitespace-pre-line leading-relaxed">{ad.primary_text}</p>
|
| 388 |
+
</div>
|
| 389 |
+
)}
|
| 390 |
+
|
| 391 |
+
{/* Description */}
|
| 392 |
+
{ad.description && (
|
| 393 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-violet-500">
|
| 394 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 395 |
+
<h3 className="text-xs font-bold text-violet-600 uppercase tracking-wider">Description</h3>
|
| 396 |
+
<div className="relative group">
|
| 397 |
+
<Button
|
| 398 |
+
variant="ghost"
|
| 399 |
+
size="sm"
|
| 400 |
+
onClick={() => handleCopyText(ad.description!, "Description")}
|
| 401 |
+
className="text-violet-500 hover:bg-violet-50"
|
| 402 |
+
>
|
| 403 |
+
<Copy className="h-4 w-4" />
|
| 404 |
+
</Button>
|
| 405 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-violet-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 406 |
+
Copy Description
|
| 407 |
+
</span>
|
| 408 |
+
</div>
|
| 409 |
+
</div>
|
| 410 |
+
<p className="text-gray-700 leading-relaxed">{ad.description}</p>
|
| 411 |
+
</div>
|
| 412 |
+
)}
|
| 413 |
+
|
| 414 |
+
{/* Body Story */}
|
| 415 |
+
{ad.body_story && (
|
| 416 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-amber-500">
|
| 417 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 418 |
+
<h3 className="text-xs font-bold text-amber-600 uppercase tracking-wider">Body Story</h3>
|
| 419 |
+
<div className="relative group">
|
| 420 |
+
<Button
|
| 421 |
+
variant="ghost"
|
| 422 |
+
size="sm"
|
| 423 |
+
onClick={() => handleCopyText(ad.body_story!, "Body Story")}
|
| 424 |
+
className="text-amber-500 hover:bg-amber-50"
|
| 425 |
+
>
|
| 426 |
+
<Copy className="h-4 w-4" />
|
| 427 |
+
</Button>
|
| 428 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-amber-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 429 |
+
Copy Story
|
| 430 |
+
</span>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
<p className="text-gray-700 whitespace-pre-line leading-relaxed">{ad.body_story}</p>
|
| 434 |
+
</div>
|
| 435 |
+
)}
|
| 436 |
+
|
| 437 |
+
{/* CTA */}
|
| 438 |
+
{ad.cta && (
|
| 439 |
+
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-2xl shadow-md p-6 border border-emerald-200">
|
| 440 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 441 |
+
<h3 className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Call to Action</h3>
|
| 442 |
+
<div className="relative group">
|
| 443 |
+
<Button
|
| 444 |
+
variant="ghost"
|
| 445 |
+
size="sm"
|
| 446 |
+
onClick={() => handleCopyText(ad.cta!, "CTA")}
|
| 447 |
+
className="text-emerald-600 hover:bg-emerald-100"
|
| 448 |
+
>
|
| 449 |
+
<Copy className="h-4 w-4" />
|
| 450 |
+
</Button>
|
| 451 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-emerald-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 452 |
+
Copy CTA
|
| 453 |
+
</span>
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
+
<p className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">{ad.cta}</p>
|
| 457 |
+
</div>
|
| 458 |
+
)}
|
| 459 |
+
|
| 460 |
+
{/* Psychological Angle */}
|
| 461 |
+
{ad.psychological_angle && (
|
| 462 |
+
<div className="bg-gradient-to-br from-pink-50 via-purple-50 to-blue-50 rounded-2xl p-6 border border-purple-200 shadow-md">
|
| 463 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 464 |
+
<h3 className="text-xs font-bold text-purple-600 uppercase tracking-wider">🧠 Psychological Angle</h3>
|
| 465 |
+
<div className="relative group">
|
| 466 |
+
<Button
|
| 467 |
+
variant="ghost"
|
| 468 |
+
size="sm"
|
| 469 |
+
onClick={() => handleCopyText(ad.psychological_angle!, "Angle")}
|
| 470 |
+
className="text-purple-500 hover:bg-purple-100"
|
| 471 |
+
>
|
| 472 |
+
<Copy className="h-4 w-4" />
|
| 473 |
+
</Button>
|
| 474 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-purple-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 475 |
+
Copy Angle
|
| 476 |
+
</span>
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
<p className="text-gray-700 leading-relaxed">{ad.psychological_angle}</p>
|
| 480 |
+
{ad.why_it_works && (
|
| 481 |
+
<div className="mt-4 pt-4 border-t border-purple-200">
|
| 482 |
+
<p className="text-xs font-bold text-purple-600 uppercase tracking-wider mb-2">💡 Why It Works</p>
|
| 483 |
+
<p className="text-gray-600 text-sm leading-relaxed">{ad.why_it_works}</p>
|
| 484 |
+
</div>
|
| 485 |
+
)}
|
| 486 |
+
</div>
|
| 487 |
+
)}
|
| 488 |
+
</div>
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
|
| 492 |
+
{/* Correction Modal */}
|
| 493 |
+
<CorrectionModal
|
| 494 |
+
isOpen={showCorrectionModal}
|
| 495 |
+
onClose={() => setShowCorrectionModal(false)}
|
| 496 |
+
adId={adId}
|
| 497 |
+
ad={ad}
|
| 498 |
+
onSuccess={(result: ImageCorrectResponse) => {
|
| 499 |
+
// Update the displayed image if correction was successful
|
| 500 |
+
if (result.corrected_image?.image_url) {
|
| 501 |
+
setImageSrc(result.corrected_image.image_url);
|
| 502 |
+
}
|
| 503 |
+
// If a new ad was created, optionally navigate to it or reload the gallery
|
| 504 |
+
if (result.corrected_image?.ad_id) {
|
| 505 |
+
toast.success("Corrected image saved to gallery!");
|
| 506 |
+
// Optionally: router.push(`/gallery/${result.corrected_image.ad_id}`);
|
| 507 |
+
}
|
| 508 |
+
}}
|
| 509 |
+
/>
|
| 510 |
+
</div>
|
| 511 |
+
);
|
| 512 |
+
}
|
frontend/app/gallery/page.tsx
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState, useCallback } from "react";
|
| 4 |
+
import { GalleryGrid } from "@/components/gallery/GalleryGrid";
|
| 5 |
+
import { FilterBar } from "@/components/gallery/FilterBar";
|
| 6 |
+
import { Button } from "@/components/ui/Button";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 8 |
+
import { listAds, deleteAd } from "@/lib/api/endpoints";
|
| 9 |
+
import { useGalleryStore } from "@/store/galleryStore";
|
| 10 |
+
import { toast } from "react-hot-toast";
|
| 11 |
+
import { Download, Trash2, CheckSquare, Square } from "lucide-react";
|
| 12 |
+
import type { AdFilters } from "@/types";
|
| 13 |
+
|
| 14 |
+
export default function GalleryPage() {
|
| 15 |
+
const {
|
| 16 |
+
ads,
|
| 17 |
+
total,
|
| 18 |
+
limit,
|
| 19 |
+
offset,
|
| 20 |
+
filters,
|
| 21 |
+
selectedAds,
|
| 22 |
+
isLoading,
|
| 23 |
+
setAds,
|
| 24 |
+
setFilters,
|
| 25 |
+
setOffset,
|
| 26 |
+
toggleAdSelection,
|
| 27 |
+
clearSelection,
|
| 28 |
+
selectAll,
|
| 29 |
+
setIsLoading,
|
| 30 |
+
removeAd,
|
| 31 |
+
} = useGalleryStore();
|
| 32 |
+
|
| 33 |
+
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
| 34 |
+
|
| 35 |
+
const loadAds = useCallback(async () => {
|
| 36 |
+
setIsLoading(true);
|
| 37 |
+
try {
|
| 38 |
+
// Map generation_method filter values to backend values
|
| 39 |
+
let generationMethod: string | null | undefined = filters.generation_method;
|
| 40 |
+
if (generationMethod === "original") {
|
| 41 |
+
generationMethod = "standard"; // Backend uses "standard" for original method
|
| 42 |
+
}
|
| 43 |
+
// "angle_concept_matrix" and "extensive" map directly
|
| 44 |
+
|
| 45 |
+
const response = await listAds({
|
| 46 |
+
niche: filters.niche || undefined,
|
| 47 |
+
generation_method: generationMethod || undefined,
|
| 48 |
+
limit,
|
| 49 |
+
offset,
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
// Filter client-side for search only (text search is better done client-side)
|
| 53 |
+
let filteredAds = response.ads;
|
| 54 |
+
|
| 55 |
+
if (filters.search) {
|
| 56 |
+
const searchLower = filters.search.toLowerCase();
|
| 57 |
+
filteredAds = filteredAds.filter(
|
| 58 |
+
(ad) =>
|
| 59 |
+
ad.headline?.toLowerCase().includes(searchLower) ||
|
| 60 |
+
ad.title?.toLowerCase().includes(searchLower) ||
|
| 61 |
+
ad.primary_text?.toLowerCase().includes(searchLower) ||
|
| 62 |
+
ad.description?.toLowerCase().includes(searchLower)
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Use the total from backend (it's already filtered by niche and generation_method)
|
| 67 |
+
// For search, we show the filtered count but this is only for current page
|
| 68 |
+
// In a production app, you'd want server-side search for accurate totals
|
| 69 |
+
const total = filters.search ? filteredAds.length : response.total;
|
| 70 |
+
setAds(filteredAds, total);
|
| 71 |
+
} catch (error: any) {
|
| 72 |
+
toast.error("Failed to load ads");
|
| 73 |
+
console.error(error);
|
| 74 |
+
} finally {
|
| 75 |
+
setIsLoading(false);
|
| 76 |
+
}
|
| 77 |
+
}, [filters, limit, offset, setAds, setIsLoading]);
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
loadAds();
|
| 81 |
+
}, [loadAds]);
|
| 82 |
+
|
| 83 |
+
const handleBulkDelete = async () => {
|
| 84 |
+
if (selectedAds.length === 0) return;
|
| 85 |
+
|
| 86 |
+
if (!confirm(`Are you sure you want to delete ${selectedAds.length} ad(s)?`)) {
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
try {
|
| 91 |
+
await Promise.all(selectedAds.map((id) => deleteAd(id)));
|
| 92 |
+
selectedAds.forEach((id) => removeAd(id));
|
| 93 |
+
toast.success(`Deleted ${selectedAds.length} ad(s)`);
|
| 94 |
+
clearSelection();
|
| 95 |
+
loadAds();
|
| 96 |
+
} catch (error: any) {
|
| 97 |
+
toast.error("Failed to delete some ads");
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const handlePageChange = (newOffset: number) => {
|
| 102 |
+
setOffset(newOffset);
|
| 103 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const totalPages = Math.ceil(total / limit);
|
| 107 |
+
const currentPage = Math.floor(offset / limit) + 1;
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
<div className="min-h-screen pb-12">
|
| 111 |
+
{/* Hero Section */}
|
| 112 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 113 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 114 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 115 |
+
<div className="text-center animate-fade-in">
|
| 116 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 117 |
+
<span className="gradient-text">Gallery</span>
|
| 118 |
+
</h1>
|
| 119 |
+
<p className="text-lg text-gray-600">
|
| 120 |
+
{total} {total === 1 ? "ad" : "ads"} total
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 127 |
+
<div className="mb-8">
|
| 128 |
+
<div className="flex items-center justify-between">
|
| 129 |
+
<div className="flex items-center space-x-2">
|
| 130 |
+
{selectedAds.length > 0 && (
|
| 131 |
+
<>
|
| 132 |
+
<Button variant="outline" size="sm" onClick={clearSelection}>
|
| 133 |
+
<Square className="h-4 w-4 mr-1" />
|
| 134 |
+
Deselect ({selectedAds.length})
|
| 135 |
+
</Button>
|
| 136 |
+
<Button variant="danger" size="sm" onClick={handleBulkDelete}>
|
| 137 |
+
<Trash2 className="h-4 w-4 mr-1" />
|
| 138 |
+
Delete Selected
|
| 139 |
+
</Button>
|
| 140 |
+
</>
|
| 141 |
+
)}
|
| 142 |
+
{selectedAds.length === 0 && ads.length > 0 && (
|
| 143 |
+
<Button variant="outline" size="sm" onClick={selectAll}>
|
| 144 |
+
<CheckSquare className="h-4 w-4 mr-1" />
|
| 145 |
+
Select All
|
| 146 |
+
</Button>
|
| 147 |
+
)}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<FilterBar filters={filters} onFiltersChange={setFilters} />
|
| 153 |
+
|
| 154 |
+
<div className="mt-6">
|
| 155 |
+
<GalleryGrid
|
| 156 |
+
ads={ads}
|
| 157 |
+
selectedAds={selectedAds}
|
| 158 |
+
onAdSelect={toggleAdSelection}
|
| 159 |
+
isLoading={isLoading}
|
| 160 |
+
/>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{/* Pagination */}
|
| 164 |
+
{totalPages > 1 && (
|
| 165 |
+
<div className="mt-8 flex items-center justify-center space-x-2">
|
| 166 |
+
<Button
|
| 167 |
+
variant="outline"
|
| 168 |
+
size="sm"
|
| 169 |
+
onClick={() => handlePageChange(Math.max(0, offset - limit))}
|
| 170 |
+
disabled={offset === 0}
|
| 171 |
+
>
|
| 172 |
+
Previous
|
| 173 |
+
</Button>
|
| 174 |
+
<span className="text-sm text-gray-600">
|
| 175 |
+
Page {currentPage} of {totalPages}
|
| 176 |
+
</span>
|
| 177 |
+
<Button
|
| 178 |
+
variant="outline"
|
| 179 |
+
size="sm"
|
| 180 |
+
onClick={() => handlePageChange(offset + limit)}
|
| 181 |
+
disabled={offset + limit >= total}
|
| 182 |
+
>
|
| 183 |
+
Next
|
| 184 |
+
</Button>
|
| 185 |
+
</div>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
);
|
| 190 |
+
}
|
frontend/app/generate/batch/page.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { BatchForm } from "@/components/generation/BatchForm";
|
| 5 |
+
import { ProgressBar } from "@/components/ui/ProgressBar";
|
| 6 |
+
import { Card, CardContent } from "@/components/ui/Card";
|
| 7 |
+
import { generateBatch } from "@/lib/api/endpoints";
|
| 8 |
+
import { toast } from "react-hot-toast";
|
| 9 |
+
import { AdPreview } from "@/components/generation/AdPreview";
|
| 10 |
+
import { Sparkles } from "lucide-react";
|
| 11 |
+
import type { Niche, GenerateResponse } from "@/types/api";
|
| 12 |
+
|
| 13 |
+
export default function BatchGeneratePage() {
|
| 14 |
+
const [results, setResults] = useState<GenerateResponse[]>([]);
|
| 15 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 16 |
+
const [progress, setProgress] = useState(0);
|
| 17 |
+
const [currentIndex, setCurrentIndex] = useState(0);
|
| 18 |
+
|
| 19 |
+
const handleGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => {
|
| 20 |
+
setResults([]);
|
| 21 |
+
setIsGenerating(true);
|
| 22 |
+
setProgress(0);
|
| 23 |
+
setCurrentIndex(0);
|
| 24 |
+
|
| 25 |
+
// Estimate time per ad (roughly 30-60 seconds per ad)
|
| 26 |
+
const estimatedTimePerAd = 45; // seconds
|
| 27 |
+
const totalEstimatedTime = data.count * estimatedTimePerAd;
|
| 28 |
+
let elapsedTime = 0;
|
| 29 |
+
const progressInterval = 500; // Update every 500ms
|
| 30 |
+
|
| 31 |
+
// Start progress simulation
|
| 32 |
+
const progressIntervalId = setInterval(() => {
|
| 33 |
+
elapsedTime += progressInterval / 1000; // Convert to seconds
|
| 34 |
+
// Calculate progress: start at 5%, reach 90% by estimated time
|
| 35 |
+
const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85);
|
| 36 |
+
setProgress(progress);
|
| 37 |
+
}, progressInterval);
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
const result = await generateBatch(data);
|
| 41 |
+
clearInterval(progressIntervalId);
|
| 42 |
+
setResults(result.ads);
|
| 43 |
+
setProgress(100);
|
| 44 |
+
toast.success(`Successfully generated ${result.count} ads!`);
|
| 45 |
+
} catch (error: any) {
|
| 46 |
+
clearInterval(progressIntervalId);
|
| 47 |
+
setProgress(0);
|
| 48 |
+
toast.error(error.message || "Failed to generate batch");
|
| 49 |
+
} finally {
|
| 50 |
+
setIsGenerating(false);
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className="min-h-screen pb-12">
|
| 56 |
+
{/* Hero Section */}
|
| 57 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 58 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 59 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 60 |
+
<div className="text-center animate-fade-in">
|
| 61 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 62 |
+
<span className="gradient-text">Batch</span>
|
| 63 |
+
<span className="text-gray-900"> Generation</span>
|
| 64 |
+
</h1>
|
| 65 |
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
| 66 |
+
Generate multiple ads at once for testing
|
| 67 |
+
</p>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 73 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 74 |
+
{/* Left Column - Form */}
|
| 75 |
+
<div className="lg:col-span-1">
|
| 76 |
+
<BatchForm onSubmit={handleGenerate} isLoading={isGenerating} />
|
| 77 |
+
|
| 78 |
+
{isGenerating && (
|
| 79 |
+
<Card variant="glass" className="mt-6 animate-scale-in">
|
| 80 |
+
<CardContent className="pt-6">
|
| 81 |
+
<div className="space-y-4">
|
| 82 |
+
<div className="flex items-center justify-between">
|
| 83 |
+
<div className="flex items-center space-x-3">
|
| 84 |
+
<div className="relative">
|
| 85 |
+
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full animate-ping opacity-20"></div>
|
| 86 |
+
<div className="relative bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full p-2">
|
| 87 |
+
<Sparkles className="h-5 w-5 text-white animate-pulse" />
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div>
|
| 91 |
+
<p className="font-semibold text-gray-900">Generating Batch Ads</p>
|
| 92 |
+
<p className="text-sm text-gray-600">Creating multiple ad variations...</p>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
<ProgressBar
|
| 97 |
+
progress={progress}
|
| 98 |
+
label="Batch Generation Progress"
|
| 99 |
+
showPercentage={true}
|
| 100 |
+
/>
|
| 101 |
+
</div>
|
| 102 |
+
</CardContent>
|
| 103 |
+
</Card>
|
| 104 |
+
)}
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Right Column - Results */}
|
| 108 |
+
<div className="lg:col-span-2">
|
| 109 |
+
{results.length > 0 && (
|
| 110 |
+
<div className="space-y-6">
|
| 111 |
+
<div className="flex items-center justify-between">
|
| 112 |
+
<h2 className="text-xl font-semibold text-gray-900">
|
| 113 |
+
Generated Ads ({results.length})
|
| 114 |
+
</h2>
|
| 115 |
+
<div className="flex space-x-2">
|
| 116 |
+
{results.map((_, index) => (
|
| 117 |
+
<button
|
| 118 |
+
key={index}
|
| 119 |
+
onClick={() => setCurrentIndex(index)}
|
| 120 |
+
className={`px-3 py-1 rounded text-sm ${
|
| 121 |
+
currentIndex === index
|
| 122 |
+
? "bg-blue-600 text-white"
|
| 123 |
+
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
| 124 |
+
}`}
|
| 125 |
+
>
|
| 126 |
+
{index + 1}
|
| 127 |
+
</button>
|
| 128 |
+
))}
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
{results[currentIndex] && (
|
| 133 |
+
<AdPreview ad={results[currentIndex]} />
|
| 134 |
+
)}
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
|
| 138 |
+
{!isGenerating && results.length === 0 && (
|
| 139 |
+
<Card variant="glass" className="text-center py-16">
|
| 140 |
+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 mb-4">
|
| 141 |
+
<Sparkles className="h-8 w-8 text-white" />
|
| 142 |
+
</div>
|
| 143 |
+
<p className="text-gray-500 text-lg">Fill out the form and click "Generate Batch" to create multiple ads</p>
|
| 144 |
+
</Card>
|
| 145 |
+
)}
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
frontend/app/generate/matrix/page.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { AngleSelector } from "@/components/matrix/AngleSelector";
|
| 5 |
+
import { ConceptSelector } from "@/components/matrix/ConceptSelector";
|
| 6 |
+
import { GenerationForm } from "@/components/generation/GenerationForm";
|
| 7 |
+
import { GenerationProgressComponent } from "@/components/generation/GenerationProgress";
|
| 8 |
+
import { AdPreview } from "@/components/generation/AdPreview";
|
| 9 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 10 |
+
import { Button } from "@/components/ui/Button";
|
| 11 |
+
import { generateMatrixAd } from "@/lib/api/endpoints";
|
| 12 |
+
import { Sparkles } from "lucide-react";
|
| 13 |
+
import { useGenerationStore } from "@/store/generationStore";
|
| 14 |
+
import { useMatrixStore } from "@/store/matrixStore";
|
| 15 |
+
import { toast } from "react-hot-toast";
|
| 16 |
+
import { IMAGE_MODELS } from "@/lib/constants/models";
|
| 17 |
+
import type { Niche, MatrixGenerateResponse, AngleInfo, ConceptInfo } from "@/types/api";
|
| 18 |
+
import type { GenerationProgress } from "@/types";
|
| 19 |
+
|
| 20 |
+
export default function MatrixGeneratePage() {
|
| 21 |
+
const {
|
| 22 |
+
currentGeneration,
|
| 23 |
+
progress,
|
| 24 |
+
isGenerating,
|
| 25 |
+
setCurrentGeneration,
|
| 26 |
+
setProgress,
|
| 27 |
+
setIsGenerating,
|
| 28 |
+
setError,
|
| 29 |
+
reset,
|
| 30 |
+
} = useGenerationStore();
|
| 31 |
+
|
| 32 |
+
const { selectedAngle, selectedConcept, setSelectedAngle, setSelectedConcept } = useMatrixStore();
|
| 33 |
+
|
| 34 |
+
const [niche, setNiche] = useState<Niche>("home_insurance");
|
| 35 |
+
const [numImages, setNumImages] = useState(1);
|
| 36 |
+
const [imageModel, setImageModel] = useState<string | null>(null);
|
| 37 |
+
|
| 38 |
+
const handleGenerate = async () => {
|
| 39 |
+
if (!selectedAngle || !selectedConcept) {
|
| 40 |
+
toast.error("Please select both an angle and a concept");
|
| 41 |
+
return;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
reset();
|
| 45 |
+
setIsGenerating(true);
|
| 46 |
+
setProgress({
|
| 47 |
+
step: "copy",
|
| 48 |
+
progress: 10,
|
| 49 |
+
message: "Generating ad with selected angle and concept...",
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
const result = await generateMatrixAd({
|
| 54 |
+
niche,
|
| 55 |
+
angle_key: selectedAngle.key,
|
| 56 |
+
concept_key: selectedConcept.key,
|
| 57 |
+
num_images: numImages,
|
| 58 |
+
image_model: imageModel,
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
setCurrentGeneration(result);
|
| 62 |
+
setProgress({
|
| 63 |
+
step: "complete",
|
| 64 |
+
progress: 100,
|
| 65 |
+
message: "Ad generated successfully!",
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
toast.success("Ad generated successfully!");
|
| 69 |
+
} catch (error: any) {
|
| 70 |
+
setError(error.message || "Failed to generate ad");
|
| 71 |
+
setProgress({
|
| 72 |
+
step: "error",
|
| 73 |
+
progress: 0,
|
| 74 |
+
message: error.message || "An error occurred",
|
| 75 |
+
});
|
| 76 |
+
toast.error(error.message || "Failed to generate ad");
|
| 77 |
+
} finally {
|
| 78 |
+
setIsGenerating(false);
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
return (
|
| 83 |
+
<div className="min-h-screen pb-12">
|
| 84 |
+
{/* Hero Section */}
|
| 85 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 86 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 87 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 88 |
+
<div className="text-center animate-fade-in">
|
| 89 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 90 |
+
<span className="gradient-text">Matrix</span>
|
| 91 |
+
<span className="text-gray-900"> Generation</span>
|
| 92 |
+
</h1>
|
| 93 |
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
| 94 |
+
Generate ads using specific angle × concept combinations
|
| 95 |
+
</p>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 101 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 102 |
+
{/* Left Column - Selection */}
|
| 103 |
+
<div className="lg:col-span-1 space-y-6">
|
| 104 |
+
<Card variant="glass" className="animate-slide-in">
|
| 105 |
+
<CardHeader>
|
| 106 |
+
<CardTitle>Configuration</CardTitle>
|
| 107 |
+
</CardHeader>
|
| 108 |
+
<CardContent className="space-y-4">
|
| 109 |
+
<div>
|
| 110 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">Niche</label>
|
| 111 |
+
<select
|
| 112 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
|
| 113 |
+
value={niche}
|
| 114 |
+
onChange={(e) => setNiche(e.target.value as Niche)}
|
| 115 |
+
>
|
| 116 |
+
<option value="home_insurance">Home Insurance</option>
|
| 117 |
+
<option value="glp1">GLP-1</option>
|
| 118 |
+
</select>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div>
|
| 122 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">Image Model</label>
|
| 123 |
+
<select
|
| 124 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
|
| 125 |
+
value={imageModel || ""}
|
| 126 |
+
onChange={(e) => setImageModel(e.target.value || null)}
|
| 127 |
+
>
|
| 128 |
+
{IMAGE_MODELS.map((model) => (
|
| 129 |
+
<option key={model.value} value={model.value}>
|
| 130 |
+
{model.label}
|
| 131 |
+
</option>
|
| 132 |
+
))}
|
| 133 |
+
</select>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<div>
|
| 137 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 138 |
+
Number of Images: <span className="text-blue-600 font-bold">{numImages}</span>
|
| 139 |
+
</label>
|
| 140 |
+
<input
|
| 141 |
+
type="range"
|
| 142 |
+
min="1"
|
| 143 |
+
max="5"
|
| 144 |
+
step="1"
|
| 145 |
+
className="w-full accent-blue-500"
|
| 146 |
+
value={numImages}
|
| 147 |
+
onChange={(e) => setNumImages(Number(e.target.value))}
|
| 148 |
+
/>
|
| 149 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1 font-medium">
|
| 150 |
+
<span>1</span>
|
| 151 |
+
<span>5</span>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</CardContent>
|
| 155 |
+
</Card>
|
| 156 |
+
|
| 157 |
+
<AngleSelector
|
| 158 |
+
onSelect={setSelectedAngle}
|
| 159 |
+
selectedAngle={selectedAngle}
|
| 160 |
+
/>
|
| 161 |
+
|
| 162 |
+
<ConceptSelector
|
| 163 |
+
onSelect={setSelectedConcept}
|
| 164 |
+
selectedConcept={selectedConcept}
|
| 165 |
+
angleKey={selectedAngle?.key}
|
| 166 |
+
/>
|
| 167 |
+
|
| 168 |
+
<Button
|
| 169 |
+
variant="primary"
|
| 170 |
+
size="lg"
|
| 171 |
+
className="w-full"
|
| 172 |
+
onClick={handleGenerate}
|
| 173 |
+
isLoading={isGenerating}
|
| 174 |
+
disabled={!selectedAngle || !selectedConcept}
|
| 175 |
+
>
|
| 176 |
+
Generate Ad
|
| 177 |
+
</Button>
|
| 178 |
+
|
| 179 |
+
{isGenerating && (
|
| 180 |
+
<GenerationProgressComponent progress={progress} />
|
| 181 |
+
)}
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
{/* Right Column - Preview */}
|
| 185 |
+
<div className="lg:col-span-2">
|
| 186 |
+
{currentGeneration ? (
|
| 187 |
+
<AdPreview ad={currentGeneration} />
|
| 188 |
+
) : (
|
| 189 |
+
<div className="text-center py-12 text-gray-500">
|
| 190 |
+
<p>Select an angle and concept, then click "Generate Ad"</p>
|
| 191 |
+
</div>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
);
|
| 198 |
+
}
|
frontend/app/generate/page.tsx
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { GenerationForm } from "@/components/generation/GenerationForm";
|
| 5 |
+
import { BatchForm } from "@/components/generation/BatchForm";
|
| 6 |
+
import { ExtensiveForm } from "@/components/generation/ExtensiveForm";
|
| 7 |
+
import { GenerationProgressComponent } from "@/components/generation/GenerationProgress";
|
| 8 |
+
import { AdPreview } from "@/components/generation/AdPreview";
|
| 9 |
+
import { AngleSelector } from "@/components/matrix/AngleSelector";
|
| 10 |
+
import { ConceptSelector } from "@/components/matrix/ConceptSelector";
|
| 11 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 12 |
+
import { Button } from "@/components/ui/Button";
|
| 13 |
+
import { ProgressBar } from "@/components/ui/ProgressBar";
|
| 14 |
+
import { generateAd, generateMatrixAd, generateBatch, generateExtensiveAd } from "@/lib/api/endpoints";
|
| 15 |
+
import { useGenerationStore } from "@/store/generationStore";
|
| 16 |
+
import { useMatrixStore } from "@/store/matrixStore";
|
| 17 |
+
import { toast } from "react-hot-toast";
|
| 18 |
+
import { Sparkles, Zap, Layers, Package, Workflow } from "lucide-react";
|
| 19 |
+
import { IMAGE_MODELS } from "@/lib/constants/models";
|
| 20 |
+
import type { Niche, GenerateResponse } from "@/types/api";
|
| 21 |
+
|
| 22 |
+
type GenerationMode = "standard" | "matrix" | "batch" | "extensive";
|
| 23 |
+
|
| 24 |
+
export default function GeneratePage() {
|
| 25 |
+
const [mode, setMode] = useState<GenerationMode>("standard");
|
| 26 |
+
const [niche, setNiche] = useState<Niche>("home_insurance");
|
| 27 |
+
const [numImages, setNumImages] = useState(1);
|
| 28 |
+
const [imageModel, setImageModel] = useState<string | null>(null);
|
| 29 |
+
const [batchResults, setBatchResults] = useState<GenerateResponse[]>([]);
|
| 30 |
+
const [currentBatchIndex, setCurrentBatchIndex] = useState(0);
|
| 31 |
+
const [batchProgress, setBatchProgress] = useState(0);
|
| 32 |
+
|
| 33 |
+
const {
|
| 34 |
+
currentGeneration,
|
| 35 |
+
progress,
|
| 36 |
+
isGenerating,
|
| 37 |
+
setCurrentGeneration,
|
| 38 |
+
setProgress,
|
| 39 |
+
setIsGenerating,
|
| 40 |
+
setError,
|
| 41 |
+
reset,
|
| 42 |
+
} = useGenerationStore();
|
| 43 |
+
|
| 44 |
+
const { selectedAngle, selectedConcept, setSelectedAngle, setSelectedConcept } = useMatrixStore();
|
| 45 |
+
|
| 46 |
+
const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null }) => {
|
| 47 |
+
reset();
|
| 48 |
+
setIsGenerating(true);
|
| 49 |
+
setProgress({
|
| 50 |
+
step: "copy",
|
| 51 |
+
progress: 10,
|
| 52 |
+
message: "Generating ad copy...",
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
try {
|
| 56 |
+
// Simulate progress updates
|
| 57 |
+
let currentProgress = 20;
|
| 58 |
+
const progressInterval = setInterval(() => {
|
| 59 |
+
currentProgress = Math.min(90, currentProgress + 5);
|
| 60 |
+
setProgress({
|
| 61 |
+
step: currentProgress < 50 ? "copy" : "image",
|
| 62 |
+
progress: currentProgress,
|
| 63 |
+
message: currentProgress < 50 ? "Generating ad copy..." : "Generating images...",
|
| 64 |
+
});
|
| 65 |
+
}, 1000);
|
| 66 |
+
|
| 67 |
+
// Generate ad
|
| 68 |
+
const result = await generateAd(data);
|
| 69 |
+
|
| 70 |
+
clearInterval(progressInterval);
|
| 71 |
+
|
| 72 |
+
setProgress({
|
| 73 |
+
step: "saving",
|
| 74 |
+
progress: 90,
|
| 75 |
+
message: "Saving to database...",
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
setCurrentGeneration(result);
|
| 79 |
+
setProgress({
|
| 80 |
+
step: "complete",
|
| 81 |
+
progress: 100,
|
| 82 |
+
message: "Ad generated successfully!",
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
toast.success("Ad generated successfully!");
|
| 86 |
+
} catch (error: any) {
|
| 87 |
+
setError(error.message || "Failed to generate ad");
|
| 88 |
+
setProgress({
|
| 89 |
+
step: "error",
|
| 90 |
+
progress: 0,
|
| 91 |
+
message: error.message || "An error occurred",
|
| 92 |
+
});
|
| 93 |
+
toast.error(error.message || "Failed to generate ad");
|
| 94 |
+
} finally {
|
| 95 |
+
setIsGenerating(false);
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const handleMatrixGenerate = async () => {
|
| 100 |
+
if (!selectedAngle || !selectedConcept) {
|
| 101 |
+
toast.error("Please select both an angle and a concept");
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
reset();
|
| 106 |
+
setIsGenerating(true);
|
| 107 |
+
setProgress({
|
| 108 |
+
step: "copy",
|
| 109 |
+
progress: 10,
|
| 110 |
+
message: "Generating ad with selected angle and concept...",
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
try {
|
| 114 |
+
const result = await generateMatrixAd({
|
| 115 |
+
niche,
|
| 116 |
+
angle_key: selectedAngle.key,
|
| 117 |
+
concept_key: selectedConcept.key,
|
| 118 |
+
num_images: numImages,
|
| 119 |
+
image_model: imageModel,
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
setCurrentGeneration(result);
|
| 123 |
+
setProgress({
|
| 124 |
+
step: "complete",
|
| 125 |
+
progress: 100,
|
| 126 |
+
message: "Ad generated successfully!",
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
toast.success("Ad generated successfully!");
|
| 130 |
+
} catch (error: any) {
|
| 131 |
+
setError(error.message || "Failed to generate ad");
|
| 132 |
+
setProgress({
|
| 133 |
+
step: "error",
|
| 134 |
+
progress: 0,
|
| 135 |
+
message: error.message || "An error occurred",
|
| 136 |
+
});
|
| 137 |
+
toast.error(error.message || "Failed to generate ad");
|
| 138 |
+
} finally {
|
| 139 |
+
setIsGenerating(false);
|
| 140 |
+
}
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => {
|
| 144 |
+
setBatchResults([]);
|
| 145 |
+
setIsGenerating(true);
|
| 146 |
+
setBatchProgress(0);
|
| 147 |
+
setCurrentBatchIndex(0);
|
| 148 |
+
|
| 149 |
+
// Estimate time per ad (roughly 30-60 seconds per ad)
|
| 150 |
+
const estimatedTimePerAd = 45; // seconds
|
| 151 |
+
const totalEstimatedTime = data.count * estimatedTimePerAd;
|
| 152 |
+
let elapsedTime = 0;
|
| 153 |
+
const progressInterval = 500; // Update every 500ms
|
| 154 |
+
|
| 155 |
+
// Start progress simulation
|
| 156 |
+
const progressIntervalId = setInterval(() => {
|
| 157 |
+
elapsedTime += progressInterval / 1000; // Convert to seconds
|
| 158 |
+
// Calculate progress: start at 5%, reach 90% by estimated time
|
| 159 |
+
const progress = Math.min(90, 5 + (elapsedTime / totalEstimatedTime) * 85);
|
| 160 |
+
setBatchProgress(progress);
|
| 161 |
+
}, progressInterval);
|
| 162 |
+
|
| 163 |
+
try {
|
| 164 |
+
const result = await generateBatch(data);
|
| 165 |
+
clearInterval(progressIntervalId);
|
| 166 |
+
setBatchResults(result.ads);
|
| 167 |
+
setBatchProgress(100);
|
| 168 |
+
toast.success(`Successfully generated ${result.count} ads!`);
|
| 169 |
+
} catch (error: any) {
|
| 170 |
+
clearInterval(progressIntervalId);
|
| 171 |
+
setBatchProgress(0);
|
| 172 |
+
toast.error(error.message || "Failed to generate batch");
|
| 173 |
+
} finally {
|
| 174 |
+
setIsGenerating(false);
|
| 175 |
+
}
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
const handleExtensiveGenerate = async (data: {
|
| 179 |
+
niche: Niche;
|
| 180 |
+
target_audience: string;
|
| 181 |
+
offer: string;
|
| 182 |
+
num_images: number;
|
| 183 |
+
num_strategies: number;
|
| 184 |
+
image_model?: string | null;
|
| 185 |
+
}) => {
|
| 186 |
+
reset();
|
| 187 |
+
setIsGenerating(true);
|
| 188 |
+
setProgress({
|
| 189 |
+
step: "copy",
|
| 190 |
+
progress: 10,
|
| 191 |
+
message: "Researching psychology triggers, angles, and concepts...",
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
try {
|
| 195 |
+
// Simulate progress updates for extensive
|
| 196 |
+
let currentProgress = 20;
|
| 197 |
+
const progressSteps = [
|
| 198 |
+
{ step: "copy" as const, progress: 20, message: "Researching psychology triggers..." },
|
| 199 |
+
{ step: "copy" as const, progress: 35, message: "Retrieving marketing knowledge..." },
|
| 200 |
+
{ step: "copy" as const, progress: 50, message: "Creating creative strategies..." },
|
| 201 |
+
{ step: "copy" as const, progress: 70, message: "Generating image prompts and copy..." },
|
| 202 |
+
{ step: "image" as const, progress: 85, message: "Generating images..." },
|
| 203 |
+
];
|
| 204 |
+
let stepIndex = 0;
|
| 205 |
+
|
| 206 |
+
const progressInterval = setInterval(() => {
|
| 207 |
+
if (stepIndex < progressSteps.length) {
|
| 208 |
+
setProgress(progressSteps[stepIndex]);
|
| 209 |
+
stepIndex++;
|
| 210 |
+
} else {
|
| 211 |
+
currentProgress = Math.min(95, currentProgress + 2);
|
| 212 |
+
setProgress({
|
| 213 |
+
step: "image",
|
| 214 |
+
progress: currentProgress,
|
| 215 |
+
message: "Generating images...",
|
| 216 |
+
});
|
| 217 |
+
}
|
| 218 |
+
}, 2000);
|
| 219 |
+
|
| 220 |
+
// Generate ad using extensive
|
| 221 |
+
const result = await generateExtensiveAd(data);
|
| 222 |
+
|
| 223 |
+
clearInterval(progressInterval);
|
| 224 |
+
|
| 225 |
+
setProgress({
|
| 226 |
+
step: "saving",
|
| 227 |
+
progress: 90,
|
| 228 |
+
message: "Saving to database...",
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
setCurrentGeneration(result);
|
| 232 |
+
setProgress({
|
| 233 |
+
step: "complete",
|
| 234 |
+
progress: 100,
|
| 235 |
+
message: "Ad generated successfully!",
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
toast.success("Ad generated successfully using Extensive!");
|
| 239 |
+
} catch (error: any) {
|
| 240 |
+
setError(error.message || "Failed to generate ad");
|
| 241 |
+
setProgress({
|
| 242 |
+
step: "error",
|
| 243 |
+
progress: 0,
|
| 244 |
+
message: error.message || "An error occurred",
|
| 245 |
+
});
|
| 246 |
+
toast.error(error.message || "Failed to generate ad");
|
| 247 |
+
} finally {
|
| 248 |
+
setIsGenerating(false);
|
| 249 |
+
}
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
return (
|
| 253 |
+
<div className="min-h-screen pb-12">
|
| 254 |
+
{/* Hero Section */}
|
| 255 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 256 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 257 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 258 |
+
<div className="text-center animate-fade-in">
|
| 259 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 260 |
+
<span className="gradient-text">Generate</span>
|
| 261 |
+
<span className="text-gray-900"> Ad</span>
|
| 262 |
+
</h1>
|
| 263 |
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto mb-6">
|
| 264 |
+
Create high-converting ad creatives using standard, matrix, batch, or Extensive generation
|
| 265 |
+
</p>
|
| 266 |
+
|
| 267 |
+
{/* Mode Toggle */}
|
| 268 |
+
<div className="flex items-center justify-center gap-3 flex-wrap">
|
| 269 |
+
<button
|
| 270 |
+
onClick={() => setMode("standard")}
|
| 271 |
+
className={`flex items-center gap-2 px-6 py-3 rounded-xl font-semibold transition-all duration-300 ${
|
| 272 |
+
mode === "standard"
|
| 273 |
+
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg scale-105"
|
| 274 |
+
: "bg-white/80 backdrop-blur-sm text-gray-700 hover:bg-white/90 hover:scale-105 border border-gray-200"
|
| 275 |
+
}`}
|
| 276 |
+
>
|
| 277 |
+
<Zap className="h-5 w-5" />
|
| 278 |
+
Standard
|
| 279 |
+
</button>
|
| 280 |
+
<button
|
| 281 |
+
onClick={() => setMode("matrix")}
|
| 282 |
+
className={`flex items-center gap-2 px-6 py-3 rounded-xl font-semibold transition-all duration-300 ${
|
| 283 |
+
mode === "matrix"
|
| 284 |
+
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg scale-105"
|
| 285 |
+
: "bg-white/80 backdrop-blur-sm text-gray-700 hover:bg-white/90 hover:scale-105 border border-gray-200"
|
| 286 |
+
}`}
|
| 287 |
+
>
|
| 288 |
+
<Layers className="h-5 w-5" />
|
| 289 |
+
Matrix
|
| 290 |
+
</button>
|
| 291 |
+
<button
|
| 292 |
+
onClick={() => setMode("batch")}
|
| 293 |
+
className={`flex items-center gap-2 px-6 py-3 rounded-xl font-semibold transition-all duration-300 ${
|
| 294 |
+
mode === "batch"
|
| 295 |
+
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg scale-105"
|
| 296 |
+
: "bg-white/80 backdrop-blur-sm text-gray-700 hover:bg-white/90 hover:scale-105 border border-gray-200"
|
| 297 |
+
}`}
|
| 298 |
+
>
|
| 299 |
+
<Package className="h-5 w-5" />
|
| 300 |
+
Batch
|
| 301 |
+
</button>
|
| 302 |
+
<button
|
| 303 |
+
onClick={() => setMode("extensive")}
|
| 304 |
+
className={`flex items-center gap-2 px-6 py-3 rounded-xl font-semibold transition-all duration-300 ${
|
| 305 |
+
mode === "extensive"
|
| 306 |
+
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg scale-105"
|
| 307 |
+
: "bg-white/80 backdrop-blur-sm text-gray-700 hover:bg-white/90 hover:scale-105 border border-gray-200"
|
| 308 |
+
}`}
|
| 309 |
+
>
|
| 310 |
+
<Workflow className="h-5 w-5" />
|
| 311 |
+
Extensive
|
| 312 |
+
</button>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 319 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 320 |
+
{/* Left Column - Form and Configuration */}
|
| 321 |
+
<div className="lg:col-span-1 space-y-6">
|
| 322 |
+
{mode === "standard" ? (
|
| 323 |
+
<>
|
| 324 |
+
<div className="animate-slide-in">
|
| 325 |
+
<GenerationForm
|
| 326 |
+
onSubmit={handleStandardGenerate}
|
| 327 |
+
isLoading={isGenerating}
|
| 328 |
+
/>
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
{isGenerating && (
|
| 332 |
+
<div className="animate-scale-in">
|
| 333 |
+
<GenerationProgressComponent progress={progress} />
|
| 334 |
+
</div>
|
| 335 |
+
)}
|
| 336 |
+
</>
|
| 337 |
+
) : mode === "matrix" ? (
|
| 338 |
+
<>
|
| 339 |
+
<Card variant="glass" className="animate-slide-in">
|
| 340 |
+
<CardHeader>
|
| 341 |
+
<CardTitle>Configuration</CardTitle>
|
| 342 |
+
</CardHeader>
|
| 343 |
+
<CardContent className="space-y-4">
|
| 344 |
+
<div>
|
| 345 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">Niche</label>
|
| 346 |
+
<select
|
| 347 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
|
| 348 |
+
value={niche}
|
| 349 |
+
onChange={(e) => setNiche(e.target.value as Niche)}
|
| 350 |
+
>
|
| 351 |
+
<option value="home_insurance">Home Insurance</option>
|
| 352 |
+
<option value="glp1">GLP-1</option>
|
| 353 |
+
</select>
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
<div>
|
| 357 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">Image Model</label>
|
| 358 |
+
<select
|
| 359 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
|
| 360 |
+
value={imageModel || ""}
|
| 361 |
+
onChange={(e) => setImageModel(e.target.value || null)}
|
| 362 |
+
>
|
| 363 |
+
{IMAGE_MODELS.map((model) => (
|
| 364 |
+
<option key={model.value} value={model.value}>
|
| 365 |
+
{model.label}
|
| 366 |
+
</option>
|
| 367 |
+
))}
|
| 368 |
+
</select>
|
| 369 |
+
</div>
|
| 370 |
+
|
| 371 |
+
<div>
|
| 372 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 373 |
+
Number of Variations: <span className="text-blue-600 font-bold">{numImages}</span>
|
| 374 |
+
</label>
|
| 375 |
+
<input
|
| 376 |
+
type="range"
|
| 377 |
+
min="1"
|
| 378 |
+
max="5"
|
| 379 |
+
step="1"
|
| 380 |
+
className="w-full accent-blue-500"
|
| 381 |
+
value={numImages}
|
| 382 |
+
onChange={(e) => setNumImages(Number(e.target.value))}
|
| 383 |
+
/>
|
| 384 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1 font-medium">
|
| 385 |
+
<span>1</span>
|
| 386 |
+
<span>5</span>
|
| 387 |
+
</div>
|
| 388 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 389 |
+
Each variation will have a unique image and slight copy variations
|
| 390 |
+
</p>
|
| 391 |
+
</div>
|
| 392 |
+
</CardContent>
|
| 393 |
+
</Card>
|
| 394 |
+
|
| 395 |
+
<AngleSelector
|
| 396 |
+
onSelect={setSelectedAngle}
|
| 397 |
+
selectedAngle={selectedAngle}
|
| 398 |
+
/>
|
| 399 |
+
|
| 400 |
+
<ConceptSelector
|
| 401 |
+
onSelect={setSelectedConcept}
|
| 402 |
+
selectedConcept={selectedConcept}
|
| 403 |
+
angleKey={selectedAngle?.key}
|
| 404 |
+
/>
|
| 405 |
+
|
| 406 |
+
<Button
|
| 407 |
+
variant="primary"
|
| 408 |
+
size="lg"
|
| 409 |
+
className="w-full"
|
| 410 |
+
onClick={handleMatrixGenerate}
|
| 411 |
+
isLoading={isGenerating}
|
| 412 |
+
disabled={!selectedAngle || !selectedConcept}
|
| 413 |
+
>
|
| 414 |
+
Generate Ad
|
| 415 |
+
</Button>
|
| 416 |
+
|
| 417 |
+
{isGenerating && (
|
| 418 |
+
<GenerationProgressComponent progress={progress} />
|
| 419 |
+
)}
|
| 420 |
+
</>
|
| 421 |
+
) : mode === "batch" ? (
|
| 422 |
+
<>
|
| 423 |
+
<div className="animate-slide-in">
|
| 424 |
+
<BatchForm
|
| 425 |
+
onSubmit={handleBatchGenerate}
|
| 426 |
+
isLoading={isGenerating}
|
| 427 |
+
/>
|
| 428 |
+
</div>
|
| 429 |
+
|
| 430 |
+
{isGenerating && (
|
| 431 |
+
<Card variant="glass" className="mt-6 animate-scale-in">
|
| 432 |
+
<CardContent className="pt-6">
|
| 433 |
+
<div className="space-y-4">
|
| 434 |
+
<div className="flex items-center justify-between">
|
| 435 |
+
<div className="flex items-center space-x-3">
|
| 436 |
+
<div className="relative">
|
| 437 |
+
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full animate-ping opacity-20"></div>
|
| 438 |
+
<div className="relative bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full p-2">
|
| 439 |
+
<Sparkles className="h-5 w-5 text-white animate-pulse" />
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
<div>
|
| 443 |
+
<p className="font-semibold text-gray-900">Generating Batch Ads</p>
|
| 444 |
+
<p className="text-sm text-gray-600">Creating multiple ad variations...</p>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
</div>
|
| 448 |
+
<ProgressBar
|
| 449 |
+
progress={batchProgress}
|
| 450 |
+
label="Batch Generation Progress"
|
| 451 |
+
showPercentage={true}
|
| 452 |
+
/>
|
| 453 |
+
</div>
|
| 454 |
+
</CardContent>
|
| 455 |
+
</Card>
|
| 456 |
+
)}
|
| 457 |
+
</>
|
| 458 |
+
) : (
|
| 459 |
+
<>
|
| 460 |
+
<div className="animate-slide-in">
|
| 461 |
+
<ExtensiveForm
|
| 462 |
+
onSubmit={handleExtensiveGenerate}
|
| 463 |
+
isLoading={isGenerating}
|
| 464 |
+
/>
|
| 465 |
+
</div>
|
| 466 |
+
|
| 467 |
+
{isGenerating && (
|
| 468 |
+
<div className="animate-scale-in">
|
| 469 |
+
<GenerationProgressComponent progress={progress} />
|
| 470 |
+
</div>
|
| 471 |
+
)}
|
| 472 |
+
</>
|
| 473 |
+
)}
|
| 474 |
+
</div>
|
| 475 |
+
|
| 476 |
+
{/* Right Column - Preview */}
|
| 477 |
+
<div className="lg:col-span-2">
|
| 478 |
+
{mode === "batch" && batchResults.length > 0 ? (
|
| 479 |
+
<div className="space-y-6 animate-fade-in">
|
| 480 |
+
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg p-6 border border-gray-200">
|
| 481 |
+
<div className="flex items-center justify-between flex-wrap gap-4">
|
| 482 |
+
<div>
|
| 483 |
+
<h2 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
|
| 484 |
+
Generated Ads
|
| 485 |
+
</h2>
|
| 486 |
+
<p className="text-sm text-gray-600 mt-1">
|
| 487 |
+
{batchResults.length} {batchResults.length === 1 ? "ad" : "ads"} created
|
| 488 |
+
</p>
|
| 489 |
+
</div>
|
| 490 |
+
<div className="flex space-x-2 flex-wrap gap-2">
|
| 491 |
+
{batchResults.map((_, index) => (
|
| 492 |
+
<button
|
| 493 |
+
key={index}
|
| 494 |
+
onClick={() => setCurrentBatchIndex(index)}
|
| 495 |
+
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all duration-300 ${
|
| 496 |
+
currentBatchIndex === index
|
| 497 |
+
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg scale-105"
|
| 498 |
+
: "bg-white/80 backdrop-blur-sm text-gray-700 hover:bg-white hover:scale-105 border border-gray-200 shadow-sm"
|
| 499 |
+
}`}
|
| 500 |
+
>
|
| 501 |
+
{index + 1}
|
| 502 |
+
</button>
|
| 503 |
+
))}
|
| 504 |
+
</div>
|
| 505 |
+
</div>
|
| 506 |
+
</div>
|
| 507 |
+
|
| 508 |
+
{batchResults[currentBatchIndex] && (
|
| 509 |
+
<div className="bg-gradient-to-br from-slate-50 via-blue-50/30 to-cyan-50/30 rounded-2xl p-6">
|
| 510 |
+
<AdPreview ad={batchResults[currentBatchIndex]} />
|
| 511 |
+
</div>
|
| 512 |
+
)}
|
| 513 |
+
</div>
|
| 514 |
+
) : currentGeneration ? (
|
| 515 |
+
<div className="animate-fade-in bg-gradient-to-br from-slate-50 via-blue-50/30 to-cyan-50/30 rounded-2xl p-6">
|
| 516 |
+
<AdPreview ad={currentGeneration} />
|
| 517 |
+
</div>
|
| 518 |
+
) : (
|
| 519 |
+
<Card variant="glass" className="text-center py-20 border-2 border-dashed border-gray-300">
|
| 520 |
+
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 mb-6 shadow-lg">
|
| 521 |
+
<Sparkles className="h-10 w-10 text-white" />
|
| 522 |
+
</div>
|
| 523 |
+
<h3 className="text-xl font-bold text-gray-800 mb-3">
|
| 524 |
+
{mode === "standard"
|
| 525 |
+
? "Ready to Generate"
|
| 526 |
+
: mode === "matrix"
|
| 527 |
+
? "Select Your Combination"
|
| 528 |
+
: mode === "batch"
|
| 529 |
+
? "Batch Generation Ready"
|
| 530 |
+
: "Extensive Generation Ready"
|
| 531 |
+
}
|
| 532 |
+
</h3>
|
| 533 |
+
<p className="text-gray-600 text-base mb-4 max-w-md mx-auto leading-relaxed">
|
| 534 |
+
{mode === "standard"
|
| 535 |
+
? "Fill out the form and click 'Generate Ad' to create your ad creative"
|
| 536 |
+
: mode === "matrix"
|
| 537 |
+
? "Select an angle and concept, then click 'Generate Ad' to create your ad creative"
|
| 538 |
+
: mode === "batch"
|
| 539 |
+
? "Fill out the form and click 'Generate Batch' to create multiple ads at once"
|
| 540 |
+
: "Fill out the form with target audience and offer, then click 'Generate with Extensive'"
|
| 541 |
+
}
|
| 542 |
+
</p>
|
| 543 |
+
<div className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-50 to-cyan-50 rounded-xl border border-blue-200">
|
| 544 |
+
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
|
| 545 |
+
<p className="text-sm text-gray-600 font-medium">
|
| 546 |
+
{mode === "standard"
|
| 547 |
+
? "Uses randomized strategies for variety"
|
| 548 |
+
: mode === "matrix"
|
| 549 |
+
? "Uses specific angle × concept combinations for targeted testing"
|
| 550 |
+
: mode === "batch"
|
| 551 |
+
? "Generate 1-20 ads at once for comprehensive testing and variety"
|
| 552 |
+
: "Researcher → Creative Director → Designer → Copywriter flow with knowledge retrieval"
|
| 553 |
+
}
|
| 554 |
+
</p>
|
| 555 |
+
</div>
|
| 556 |
+
</Card>
|
| 557 |
+
)}
|
| 558 |
+
</div>
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
</div>
|
| 562 |
+
);
|
| 563 |
+
}
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
/* Color System - Vibrant Gradients */
|
| 5 |
+
--color-primary-start: #3b82f6; /* blue-500 */
|
| 6 |
+
--color-primary-end: #06b6d4; /* cyan-500 */
|
| 7 |
+
--color-secondary-start: #fb923c; /* orange-400 */
|
| 8 |
+
--color-secondary-end: #ec4899; /* pink-500 */
|
| 9 |
+
--color-success: #10b981;
|
| 10 |
+
--color-warning: #f59e0b;
|
| 11 |
+
--color-error: #ef4444;
|
| 12 |
+
|
| 13 |
+
/* Background */
|
| 14 |
+
--background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
|
| 15 |
+
--foreground: #111827;
|
| 16 |
+
|
| 17 |
+
/* Glassmorphism */
|
| 18 |
+
--glass-bg: rgba(255, 255, 255, 0.7);
|
| 19 |
+
--glass-border: rgba(255, 255, 255, 0.18);
|
| 20 |
+
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
| 21 |
+
|
| 22 |
+
/* Shadows */
|
| 23 |
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
| 24 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
| 25 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
| 26 |
+
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 27 |
+
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3);
|
| 28 |
+
|
| 29 |
+
/* Spacing */
|
| 30 |
+
--spacing-xs: 0.25rem; /* 4px */
|
| 31 |
+
--spacing-sm: 0.5rem; /* 8px */
|
| 32 |
+
--spacing-md: 1rem; /* 16px */
|
| 33 |
+
--spacing-lg: 1.5rem; /* 24px */
|
| 34 |
+
--spacing-xl: 2rem; /* 32px */
|
| 35 |
+
--spacing-2xl: 3rem; /* 48px */
|
| 36 |
+
--spacing-3xl: 4rem; /* 64px */
|
| 37 |
+
|
| 38 |
+
/* Border Radius */
|
| 39 |
+
--radius-sm: 0.5rem; /* 8px */
|
| 40 |
+
--radius-md: 0.75rem; /* 12px */
|
| 41 |
+
--radius-lg: 1rem; /* 16px */
|
| 42 |
+
--radius-xl: 1.5rem; /* 24px */
|
| 43 |
+
|
| 44 |
+
/* Transitions */
|
| 45 |
+
--transition-fast: 150ms ease-out;
|
| 46 |
+
--transition-base: 250ms ease-out;
|
| 47 |
+
--transition-slow: 350ms ease-out;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
body {
|
| 51 |
+
background: var(--background);
|
| 52 |
+
background-attachment: fixed;
|
| 53 |
+
color: var(--foreground);
|
| 54 |
+
font-family: var(--font-inter), system-ui, -apple-system, sans-serif;
|
| 55 |
+
min-height: 100vh;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* Gradient Background Animation */
|
| 59 |
+
@keyframes gradient-shift {
|
| 60 |
+
0%, 100% {
|
| 61 |
+
background-position: 0% 50%;
|
| 62 |
+
}
|
| 63 |
+
50% {
|
| 64 |
+
background-position: 100% 50%;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.gradient-bg {
|
| 69 |
+
background: linear-gradient(-45deg, #3b82f6, #06b6d4, #fb923c, #ec4899);
|
| 70 |
+
background-size: 400% 400%;
|
| 71 |
+
animation: gradient-shift 15s ease infinite;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Glassmorphism Effect */
|
| 75 |
+
.glass {
|
| 76 |
+
background: var(--glass-bg);
|
| 77 |
+
backdrop-filter: blur(10px);
|
| 78 |
+
-webkit-backdrop-filter: blur(10px);
|
| 79 |
+
border: 1px solid var(--glass-border);
|
| 80 |
+
box-shadow: var(--glass-shadow);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* Smooth Animations */
|
| 84 |
+
@keyframes fadeIn {
|
| 85 |
+
from {
|
| 86 |
+
opacity: 0;
|
| 87 |
+
transform: translateY(10px);
|
| 88 |
+
}
|
| 89 |
+
to {
|
| 90 |
+
opacity: 1;
|
| 91 |
+
transform: translateY(0);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
@keyframes slideIn {
|
| 96 |
+
from {
|
| 97 |
+
opacity: 0;
|
| 98 |
+
transform: translateX(-20px);
|
| 99 |
+
}
|
| 100 |
+
to {
|
| 101 |
+
opacity: 1;
|
| 102 |
+
transform: translateX(0);
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
@keyframes scaleIn {
|
| 107 |
+
from {
|
| 108 |
+
opacity: 0;
|
| 109 |
+
transform: scale(0.95);
|
| 110 |
+
}
|
| 111 |
+
to {
|
| 112 |
+
opacity: 1;
|
| 113 |
+
transform: scale(1);
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
@keyframes pulse-glow {
|
| 118 |
+
0%, 100% {
|
| 119 |
+
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
| 120 |
+
}
|
| 121 |
+
50% {
|
| 122 |
+
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.animate-fade-in {
|
| 127 |
+
animation: fadeIn 0.5s ease-out;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.animate-slide-in {
|
| 131 |
+
animation: slideIn 0.4s ease-out;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.animate-scale-in {
|
| 135 |
+
animation: scaleIn 0.3s ease-out;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.animate-pulse-glow {
|
| 139 |
+
animation: pulse-glow 2s ease-in-out infinite;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* Hover Effects */
|
| 143 |
+
.hover-lift {
|
| 144 |
+
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.hover-lift:hover {
|
| 148 |
+
transform: translateY(-4px);
|
| 149 |
+
box-shadow: var(--shadow-xl);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.hover-glow {
|
| 153 |
+
transition: box-shadow var(--transition-base);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.hover-glow:hover {
|
| 157 |
+
box-shadow: var(--shadow-glow);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* Gradient Text */
|
| 161 |
+
.gradient-text {
|
| 162 |
+
background: linear-gradient(135deg, var(--color-primary-start), var(--color-primary-end));
|
| 163 |
+
-webkit-background-clip: text;
|
| 164 |
+
-webkit-text-fill-color: transparent;
|
| 165 |
+
background-clip: text;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.gradient-text-secondary {
|
| 169 |
+
background: linear-gradient(135deg, var(--color-secondary-start), var(--color-secondary-end));
|
| 170 |
+
-webkit-background-clip: text;
|
| 171 |
+
-webkit-text-fill-color: transparent;
|
| 172 |
+
background-clip: text;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* Scrollbar Styling */
|
| 176 |
+
::-webkit-scrollbar {
|
| 177 |
+
width: 10px;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
::-webkit-scrollbar-track {
|
| 181 |
+
background: #f1f1f1;
|
| 182 |
+
border-radius: 10px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
::-webkit-scrollbar-thumb {
|
| 186 |
+
background: linear-gradient(135deg, var(--color-primary-start), var(--color-primary-end));
|
| 187 |
+
border-radius: 10px;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
::-webkit-scrollbar-thumb:hover {
|
| 191 |
+
background: linear-gradient(135deg, var(--color-primary-end), var(--color-primary-start));
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* Selection */
|
| 195 |
+
::selection {
|
| 196 |
+
background: rgba(59, 130, 246, 0.3);
|
| 197 |
+
color: inherit;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* Shimmer animation for skeleton loaders */
|
| 201 |
+
@keyframes shimmer {
|
| 202 |
+
0% {
|
| 203 |
+
background-position: -200% 0;
|
| 204 |
+
}
|
| 205 |
+
100% {
|
| 206 |
+
background-position: 200% 0;
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.animate-shimmer {
|
| 211 |
+
animation: shimmer 2s linear infinite;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/* Grid pattern background */
|
| 215 |
+
.bg-grid-pattern {
|
| 216 |
+
background-image:
|
| 217 |
+
linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px),
|
| 218 |
+
linear-gradient(to bottom, rgba(0,0,0,0.1) 1px, transparent 1px);
|
| 219 |
+
background-size: 20px 20px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* Slow spin animation for icons */
|
| 223 |
+
@keyframes spin-slow {
|
| 224 |
+
from {
|
| 225 |
+
transform: rotate(0deg);
|
| 226 |
+
}
|
| 227 |
+
to {
|
| 228 |
+
transform: rotate(360deg);
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.animate-spin-slow {
|
| 233 |
+
animation: spin-slow 3s linear infinite;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/* Enhanced gradient shift for progress bar */
|
| 237 |
+
@keyframes gradient-shift {
|
| 238 |
+
0%, 100% {
|
| 239 |
+
background-position: 0% 50%;
|
| 240 |
+
}
|
| 241 |
+
50% {
|
| 242 |
+
background-position: 100% 50%;
|
| 243 |
+
}
|
| 244 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { Toaster } from "react-hot-toast";
|
| 5 |
+
import { ConditionalHeader } from "@/components/layout/ConditionalHeader";
|
| 6 |
+
|
| 7 |
+
const inter = Inter({
|
| 8 |
+
subsets: ["latin"],
|
| 9 |
+
variable: "--font-inter",
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
export const metadata: Metadata = {
|
| 13 |
+
title: "Creative Breakthrough",
|
| 14 |
+
description: "Generate high-converting ad creatives for Home Insurance and GLP-1 niches",
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default function RootLayout({
|
| 18 |
+
children,
|
| 19 |
+
}: Readonly<{
|
| 20 |
+
children: React.ReactNode;
|
| 21 |
+
}>) {
|
| 22 |
+
return (
|
| 23 |
+
<html lang="en">
|
| 24 |
+
<body className={`${inter.variable} font-sans antialiased bg-gray-50`}>
|
| 25 |
+
<ConditionalHeader />
|
| 26 |
+
<main className="min-h-screen">
|
| 27 |
+
{children}
|
| 28 |
+
</main>
|
| 29 |
+
<Toaster position="top-right" />
|
| 30 |
+
</body>
|
| 31 |
+
</html>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/app/login/page.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { useForm } from "react-hook-form";
|
| 6 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 7 |
+
import { z } from "zod";
|
| 8 |
+
import { Input } from "@/components/ui/Input";
|
| 9 |
+
import { Button } from "@/components/ui/Button";
|
| 10 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 11 |
+
import { useAuthStore } from "@/store/authStore";
|
| 12 |
+
import { login } from "@/lib/api/endpoints";
|
| 13 |
+
import { Lock, User, Rocket, AlertCircle } from "lucide-react";
|
| 14 |
+
import { toast } from "react-hot-toast";
|
| 15 |
+
|
| 16 |
+
const loginSchema = z.object({
|
| 17 |
+
username: z.string().min(1, "Username is required"),
|
| 18 |
+
password: z.string().min(1, "Password is required"),
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
type LoginFormData = z.infer<typeof loginSchema>;
|
| 22 |
+
|
| 23 |
+
export default function LoginPage() {
|
| 24 |
+
const router = useRouter();
|
| 25 |
+
const { isAuthenticated, login: setAuth } = useAuthStore();
|
| 26 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 27 |
+
const {
|
| 28 |
+
register,
|
| 29 |
+
handleSubmit,
|
| 30 |
+
formState: { errors },
|
| 31 |
+
} = useForm<LoginFormData>({
|
| 32 |
+
resolver: zodResolver(loginSchema),
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
// Redirect if already authenticated
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (isAuthenticated) {
|
| 38 |
+
router.push("/");
|
| 39 |
+
}
|
| 40 |
+
}, [isAuthenticated, router]);
|
| 41 |
+
|
| 42 |
+
const onSubmit = async (data: LoginFormData) => {
|
| 43 |
+
setIsLoading(true);
|
| 44 |
+
try {
|
| 45 |
+
const response = await login(data.username, data.password);
|
| 46 |
+
setAuth(response.token, response.username);
|
| 47 |
+
toast.success("Login successful!");
|
| 48 |
+
router.push("/");
|
| 49 |
+
} catch (error: any) {
|
| 50 |
+
const errorMessage =
|
| 51 |
+
error.response?.data?.detail || error.message || "Login failed. Please check your credentials.";
|
| 52 |
+
toast.error(errorMessage);
|
| 53 |
+
} finally {
|
| 54 |
+
setIsLoading(false);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 px-4 sm:px-6 lg:px-8">
|
| 60 |
+
<div className="max-w-md w-full space-y-8">
|
| 61 |
+
{/* Logo and Title */}
|
| 62 |
+
<div className="text-center animate-fade-in">
|
| 63 |
+
<div className="flex justify-center mb-4">
|
| 64 |
+
<div className="relative">
|
| 65 |
+
<Rocket className="h-16 w-16 text-blue-500 animate-bounce" />
|
| 66 |
+
<div className="absolute inset-0 bg-blue-500/20 rounded-full blur-xl"></div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<h1 className="text-4xl font-extrabold mb-2">
|
| 70 |
+
<span className="gradient-text">Creative</span>
|
| 71 |
+
<span className="text-gray-900"> Breakthrough</span>
|
| 72 |
+
</h1>
|
| 73 |
+
<p className="text-gray-600 text-lg">Sign in to your account</p>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
{/* Login Card */}
|
| 77 |
+
<Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.2s" }}>
|
| 78 |
+
<CardHeader>
|
| 79 |
+
<CardTitle className="text-2xl text-center">Login</CardTitle>
|
| 80 |
+
</CardHeader>
|
| 81 |
+
<CardContent>
|
| 82 |
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
| 83 |
+
<div>
|
| 84 |
+
<Input
|
| 85 |
+
label="Username"
|
| 86 |
+
type="text"
|
| 87 |
+
placeholder="Enter your username"
|
| 88 |
+
error={errors.username?.message}
|
| 89 |
+
{...register("username")}
|
| 90 |
+
/>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div>
|
| 94 |
+
<Input
|
| 95 |
+
label="Password"
|
| 96 |
+
type="password"
|
| 97 |
+
placeholder="Enter your password"
|
| 98 |
+
error={errors.password?.message}
|
| 99 |
+
{...register("password")}
|
| 100 |
+
/>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<Button
|
| 104 |
+
type="submit"
|
| 105 |
+
variant="primary"
|
| 106 |
+
size="lg"
|
| 107 |
+
className="w-full"
|
| 108 |
+
isLoading={isLoading}
|
| 109 |
+
>
|
| 110 |
+
Sign In
|
| 111 |
+
</Button>
|
| 112 |
+
</form>
|
| 113 |
+
|
| 114 |
+
<div className="mt-6 p-4 bg-blue-50/50 rounded-xl border border-blue-200/50">
|
| 115 |
+
<div className="flex items-start space-x-3">
|
| 116 |
+
<AlertCircle className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
| 117 |
+
<div className="text-sm text-blue-800">
|
| 118 |
+
<p className="font-semibold mb-1">Note:</p>
|
| 119 |
+
<p>Credentials are managed manually. Please contact your administrator for access.</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</CardContent>
|
| 124 |
+
</Card>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
);
|
| 128 |
+
}
|
frontend/app/matrix/angles/page.tsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState } from "react";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 5 |
+
import { Input } from "@/components/ui/Input";
|
| 6 |
+
import { Select } from "@/components/ui/Select";
|
| 7 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 8 |
+
import { getAllAngles } from "@/lib/api/endpoints";
|
| 9 |
+
import type { AnglesResponse } from "@/types/api";
|
| 10 |
+
|
| 11 |
+
export default function AnglesPage() {
|
| 12 |
+
const [angles, setAngles] = useState<AnglesResponse | null>(null);
|
| 13 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 14 |
+
const [searchTerm, setSearchTerm] = useState("");
|
| 15 |
+
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
loadAngles();
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const loadAngles = async () => {
|
| 22 |
+
try {
|
| 23 |
+
const data = await getAllAngles();
|
| 24 |
+
setAngles(data);
|
| 25 |
+
} catch (error) {
|
| 26 |
+
console.error("Failed to load angles:", error);
|
| 27 |
+
} finally {
|
| 28 |
+
setIsLoading(false);
|
| 29 |
+
}
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
if (isLoading) {
|
| 33 |
+
return (
|
| 34 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 35 |
+
<div className="flex items-center justify-center h-64">
|
| 36 |
+
<LoadingSpinner size="lg" />
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (!angles) {
|
| 43 |
+
return (
|
| 44 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 45 |
+
<div className="text-center py-12">
|
| 46 |
+
<p className="text-gray-500">Failed to load angles</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Filter angles
|
| 53 |
+
let filteredAngles: Array<{ category: string; angle: any }> = [];
|
| 54 |
+
|
| 55 |
+
Object.entries(angles.categories).forEach(([catKey, catData]) => {
|
| 56 |
+
if (selectedCategory && catKey !== selectedCategory) return;
|
| 57 |
+
|
| 58 |
+
catData.angles.forEach((angle) => {
|
| 59 |
+
if (
|
| 60 |
+
!searchTerm ||
|
| 61 |
+
angle.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 62 |
+
angle.trigger.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 63 |
+
angle.key.toLowerCase().includes(searchTerm.toLowerCase())
|
| 64 |
+
) {
|
| 65 |
+
filteredAngles.push({ category: catData.name, angle });
|
| 66 |
+
}
|
| 67 |
+
});
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
const categories = Object.entries(angles.categories).map(([key, data]) => ({
|
| 71 |
+
value: key,
|
| 72 |
+
label: `${data.name} (${data.angle_count})`,
|
| 73 |
+
}));
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<div className="min-h-screen pb-12">
|
| 77 |
+
{/* Hero Section */}
|
| 78 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 79 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 80 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 81 |
+
<div className="text-center animate-fade-in">
|
| 82 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 83 |
+
<span className="gradient-text">Angles</span>
|
| 84 |
+
</h1>
|
| 85 |
+
<p className="text-lg text-gray-600">
|
| 86 |
+
Browse all {angles.total_angles} available angles
|
| 87 |
+
</p>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 93 |
+
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 94 |
+
<Input
|
| 95 |
+
placeholder="Search angles..."
|
| 96 |
+
value={searchTerm}
|
| 97 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
| 98 |
+
/>
|
| 99 |
+
<Select
|
| 100 |
+
options={[{ value: "", label: "All Categories" }, ...categories]}
|
| 101 |
+
value={selectedCategory}
|
| 102 |
+
onChange={(e) => setSelectedCategory(e.target.value)}
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 107 |
+
{filteredAngles.map(({ category, angle }, index) => (
|
| 108 |
+
<Card key={angle.key} variant="glass" className="animate-scale-in hover-lift" style={{ animationDelay: `${index * 0.05}s` }}>
|
| 109 |
+
<CardContent className="pt-6">
|
| 110 |
+
<div className="mb-3">
|
| 111 |
+
<span className="text-xs font-bold px-3 py-1 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full">
|
| 112 |
+
{category}
|
| 113 |
+
</span>
|
| 114 |
+
</div>
|
| 115 |
+
<h3 className="font-bold text-gray-900 mb-2 text-lg">{angle.name}</h3>
|
| 116 |
+
<p className="text-sm text-gray-600 mb-3">{angle.trigger}</p>
|
| 117 |
+
{angle.example && (
|
| 118 |
+
<p className="text-xs text-gray-500 italic mb-3">"{angle.example}"</p>
|
| 119 |
+
)}
|
| 120 |
+
<p className="text-xs text-gray-400 font-mono">{angle.key}</p>
|
| 121 |
+
</CardContent>
|
| 122 |
+
</Card>
|
| 123 |
+
))}
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{filteredAngles.length === 0 && (
|
| 127 |
+
<div className="text-center py-12">
|
| 128 |
+
<p className="text-gray-500">No angles found matching your filters</p>
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
);
|
| 134 |
+
}
|
frontend/app/matrix/concepts/page.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState } from "react";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 5 |
+
import { Input } from "@/components/ui/Input";
|
| 6 |
+
import { Select } from "@/components/ui/Select";
|
| 7 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 8 |
+
import { getAllConcepts } from "@/lib/api/endpoints";
|
| 9 |
+
import type { ConceptsResponse } from "@/types/api";
|
| 10 |
+
|
| 11 |
+
export default function ConceptsPage() {
|
| 12 |
+
const [concepts, setConcepts] = useState<ConceptsResponse | null>(null);
|
| 13 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 14 |
+
const [searchTerm, setSearchTerm] = useState("");
|
| 15 |
+
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
loadConcepts();
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const loadConcepts = async () => {
|
| 22 |
+
try {
|
| 23 |
+
const data = await getAllConcepts();
|
| 24 |
+
setConcepts(data);
|
| 25 |
+
} catch (error) {
|
| 26 |
+
console.error("Failed to load concepts:", error);
|
| 27 |
+
} finally {
|
| 28 |
+
setIsLoading(false);
|
| 29 |
+
}
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
if (isLoading) {
|
| 33 |
+
return (
|
| 34 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 35 |
+
<div className="flex items-center justify-center h-64">
|
| 36 |
+
<LoadingSpinner size="lg" />
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (!concepts) {
|
| 43 |
+
return (
|
| 44 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 45 |
+
<div className="text-center py-12">
|
| 46 |
+
<p className="text-gray-500">Failed to load concepts</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Filter concepts
|
| 53 |
+
let filteredConcepts: Array<{ category: string; concept: any }> = [];
|
| 54 |
+
|
| 55 |
+
Object.entries(concepts.categories).forEach(([catKey, catData]) => {
|
| 56 |
+
if (selectedCategory && catKey !== selectedCategory) return;
|
| 57 |
+
|
| 58 |
+
catData.concepts.forEach((concept) => {
|
| 59 |
+
if (
|
| 60 |
+
!searchTerm ||
|
| 61 |
+
concept.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 62 |
+
concept.structure.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 63 |
+
concept.key.toLowerCase().includes(searchTerm.toLowerCase())
|
| 64 |
+
) {
|
| 65 |
+
filteredConcepts.push({ category: catData.name, concept });
|
| 66 |
+
}
|
| 67 |
+
});
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
const categories = Object.entries(concepts.categories).map(([key, data]) => ({
|
| 71 |
+
value: key,
|
| 72 |
+
label: `${data.name} (${data.concept_count})`,
|
| 73 |
+
}));
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<div className="min-h-screen pb-12">
|
| 77 |
+
{/* Hero Section */}
|
| 78 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 79 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 80 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 81 |
+
<div className="text-center animate-fade-in">
|
| 82 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 83 |
+
<span className="gradient-text">Concepts</span>
|
| 84 |
+
</h1>
|
| 85 |
+
<p className="text-lg text-gray-600">
|
| 86 |
+
Browse all {concepts.total_concepts} available concepts
|
| 87 |
+
</p>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 93 |
+
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 94 |
+
<Input
|
| 95 |
+
placeholder="Search concepts..."
|
| 96 |
+
value={searchTerm}
|
| 97 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
| 98 |
+
/>
|
| 99 |
+
<Select
|
| 100 |
+
options={[{ value: "", label: "All Categories" }, ...categories]}
|
| 101 |
+
value={selectedCategory}
|
| 102 |
+
onChange={(e) => setSelectedCategory(e.target.value)}
|
| 103 |
+
/>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 107 |
+
{filteredConcepts.map(({ category, concept }, index) => (
|
| 108 |
+
<Card key={concept.key} variant="glass" className="animate-scale-in hover-lift" style={{ animationDelay: `${index * 0.05}s` }}>
|
| 109 |
+
<CardContent className="pt-6">
|
| 110 |
+
<div className="mb-3">
|
| 111 |
+
<span className="text-xs font-bold px-3 py-1 bg-gradient-to-r from-cyan-500 to-pink-600 text-white rounded-full">
|
| 112 |
+
{category}
|
| 113 |
+
</span>
|
| 114 |
+
</div>
|
| 115 |
+
<h3 className="font-bold text-gray-900 mb-2 text-lg">{concept.name}</h3>
|
| 116 |
+
<p className="text-sm text-gray-600 mb-2">{concept.structure}</p>
|
| 117 |
+
<p className="text-xs text-gray-500 mb-3">{concept.visual}</p>
|
| 118 |
+
<p className="text-xs text-gray-400 font-mono">{concept.key}</p>
|
| 119 |
+
</CardContent>
|
| 120 |
+
</Card>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{filteredConcepts.length === 0 && (
|
| 125 |
+
<div className="text-center py-12">
|
| 126 |
+
<p className="text-gray-500">No concepts found matching your filters</p>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
frontend/app/matrix/page.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
|
| 6 |
+
import { Button } from "@/components/ui/Button";
|
| 7 |
+
import { Layers, Search, TestTube, Sparkles } from "lucide-react";
|
| 8 |
+
|
| 9 |
+
export default function MatrixPage() {
|
| 10 |
+
return (
|
| 11 |
+
<div className="min-h-screen pb-12">
|
| 12 |
+
{/* Hero Section */}
|
| 13 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 14 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 15 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 16 |
+
<div className="text-center animate-fade-in">
|
| 17 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 18 |
+
<span className="gradient-text">Matrix</span>
|
| 19 |
+
<span className="text-gray-900"> System</span>
|
| 20 |
+
</h1>
|
| 21 |
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
| 22 |
+
Explore the Angle × Concept matrix for systematic ad generation
|
| 23 |
+
</p>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 29 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 30 |
+
<Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.1s" }}>
|
| 31 |
+
<CardHeader>
|
| 32 |
+
<div className="p-3 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 w-fit mb-3">
|
| 33 |
+
<Layers className="h-6 w-6 text-white" />
|
| 34 |
+
</div>
|
| 35 |
+
<CardTitle>Generate with Matrix</CardTitle>
|
| 36 |
+
<CardDescription>
|
| 37 |
+
Select specific angle and concept combinations
|
| 38 |
+
</CardDescription>
|
| 39 |
+
</CardHeader>
|
| 40 |
+
<CardContent>
|
| 41 |
+
<Link href="/generate/matrix">
|
| 42 |
+
<Button variant="primary" className="w-full group">
|
| 43 |
+
<Sparkles className="h-4 w-4 mr-2 group-hover:rotate-12 transition-transform duration-300" />
|
| 44 |
+
Generate Ad
|
| 45 |
+
</Button>
|
| 46 |
+
</Link>
|
| 47 |
+
</CardContent>
|
| 48 |
+
</Card>
|
| 49 |
+
|
| 50 |
+
<Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.2s" }}>
|
| 51 |
+
<CardHeader>
|
| 52 |
+
<div className="p-3 rounded-xl bg-gradient-to-br from-cyan-500 to-pink-600 w-fit mb-3">
|
| 53 |
+
<Search className="h-6 w-6 text-white" />
|
| 54 |
+
</div>
|
| 55 |
+
<CardTitle>Browse Angles</CardTitle>
|
| 56 |
+
<CardDescription>
|
| 57 |
+
Explore all 100 available angles
|
| 58 |
+
</CardDescription>
|
| 59 |
+
</CardHeader>
|
| 60 |
+
<CardContent>
|
| 61 |
+
<Link href="/matrix/angles">
|
| 62 |
+
<Button variant="outline" className="w-full">View Angles</Button>
|
| 63 |
+
</Link>
|
| 64 |
+
</CardContent>
|
| 65 |
+
</Card>
|
| 66 |
+
|
| 67 |
+
<Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.3s" }}>
|
| 68 |
+
<CardHeader>
|
| 69 |
+
<div className="p-3 rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 w-fit mb-3">
|
| 70 |
+
<Search className="h-6 w-6 text-white" />
|
| 71 |
+
</div>
|
| 72 |
+
<CardTitle>Browse Concepts</CardTitle>
|
| 73 |
+
<CardDescription>
|
| 74 |
+
Explore all 100 available concepts
|
| 75 |
+
</CardDescription>
|
| 76 |
+
</CardHeader>
|
| 77 |
+
<CardContent>
|
| 78 |
+
<Link href="/matrix/concepts">
|
| 79 |
+
<Button variant="outline" className="w-full">View Concepts</Button>
|
| 80 |
+
</Link>
|
| 81 |
+
</CardContent>
|
| 82 |
+
</Card>
|
| 83 |
+
|
| 84 |
+
<Card variant="glass" className="md:col-span-2 lg:col-span-3 animate-scale-in" style={{ animationDelay: "0.4s" }}>
|
| 85 |
+
<CardHeader>
|
| 86 |
+
<div className="p-3 rounded-xl bg-gradient-to-br from-orange-500 to-pink-600 w-fit mb-3">
|
| 87 |
+
<TestTube className="h-6 w-6 text-white" />
|
| 88 |
+
</div>
|
| 89 |
+
<CardTitle>Testing Matrix Builder</CardTitle>
|
| 90 |
+
<CardDescription>
|
| 91 |
+
Generate systematic testing matrices for optimization
|
| 92 |
+
</CardDescription>
|
| 93 |
+
</CardHeader>
|
| 94 |
+
<CardContent>
|
| 95 |
+
<Link href="/matrix/testing">
|
| 96 |
+
<Button variant="secondary" className="w-full group">
|
| 97 |
+
<TestTube className="h-4 w-4 mr-2 group-hover:rotate-12 transition-transform duration-300" />
|
| 98 |
+
Build Testing Matrix
|
| 99 |
+
</Button>
|
| 100 |
+
</Link>
|
| 101 |
+
</CardContent>
|
| 102 |
+
</Card>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
);
|
| 107 |
+
}
|
frontend/app/matrix/testing/page.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { TestingMatrixBuilder } from "@/components/matrix/TestingMatrixBuilder";
|
| 5 |
+
|
| 6 |
+
export default function TestingMatrixPage() {
|
| 7 |
+
return (
|
| 8 |
+
<div className="min-h-screen pb-12">
|
| 9 |
+
{/* Hero Section */}
|
| 10 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-12 mb-8">
|
| 11 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 12 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 13 |
+
<div className="text-center animate-fade-in">
|
| 14 |
+
<h1 className="text-4xl md:text-5xl font-extrabold mb-4">
|
| 15 |
+
<span className="gradient-text">Testing Matrix</span>
|
| 16 |
+
<span className="text-gray-900"> Builder</span>
|
| 17 |
+
</h1>
|
| 18 |
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
| 19 |
+
Create systematic testing matrices for ad optimization
|
| 20 |
+
</p>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 26 |
+
<TestingMatrixBuilder />
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState, memo, useCallback } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import Image from "next/image";
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 7 |
+
import { Button } from "@/components/ui/Button";
|
| 8 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 9 |
+
import { getDbStats, listAds } from "@/lib/api/endpoints";
|
| 10 |
+
import { formatRelativeDate, formatNiche, getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters";
|
| 11 |
+
import { Home, Sparkles, Grid, TrendingUp, Database } from "lucide-react";
|
| 12 |
+
import type { DbStatsResponse, AdCreativeDB } from "@/types/api";
|
| 13 |
+
|
| 14 |
+
// Component for dashboard ad cards with image error handling
|
| 15 |
+
const DashboardAdCard = memo(function DashboardAdCard({
|
| 16 |
+
ad,
|
| 17 |
+
initialImageSrc,
|
| 18 |
+
fallback,
|
| 19 |
+
index
|
| 20 |
+
}: {
|
| 21 |
+
ad: AdCreativeDB;
|
| 22 |
+
initialImageSrc: string | null;
|
| 23 |
+
fallback: string | null;
|
| 24 |
+
index: number;
|
| 25 |
+
}) {
|
| 26 |
+
const [imageSrc, setImageSrc] = useState<string | null>(initialImageSrc);
|
| 27 |
+
const [imageError, setImageError] = useState(false);
|
| 28 |
+
|
| 29 |
+
const handleImageError = useCallback(() => {
|
| 30 |
+
if (!imageError && fallback && imageSrc === initialImageSrc) {
|
| 31 |
+
setImageSrc(fallback);
|
| 32 |
+
setImageError(true);
|
| 33 |
+
} else {
|
| 34 |
+
setImageSrc(null);
|
| 35 |
+
}
|
| 36 |
+
}, [imageError, fallback, imageSrc, initialImageSrc]);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<Link
|
| 40 |
+
href={`/gallery/${ad.id}`}
|
| 41 |
+
className="block group animate-scale-in"
|
| 42 |
+
style={{ animationDelay: `${index * 0.1}s` }}
|
| 43 |
+
>
|
| 44 |
+
<Card variant="elevated" className="h-full overflow-hidden">
|
| 45 |
+
<CardContent className="p-0">
|
| 46 |
+
{(imageSrc || ad.image_filename || ad.image_url) && (
|
| 47 |
+
<div className="aspect-video bg-gradient-to-br from-gray-100 to-gray-200 rounded-t-2xl overflow-hidden relative flex items-center justify-center">
|
| 48 |
+
{imageSrc ? (
|
| 49 |
+
<Image
|
| 50 |
+
src={imageSrc}
|
| 51 |
+
alt={ad.headline}
|
| 52 |
+
fill
|
| 53 |
+
className="object-contain group-hover:scale-105 transition-transform duration-500"
|
| 54 |
+
onError={handleImageError}
|
| 55 |
+
loading="lazy"
|
| 56 |
+
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
| 57 |
+
unoptimized={imageSrc.startsWith('http://') || imageSrc.includes('r2.cloudflarestorage.com') || imageSrc.includes('replicate.delivery')}
|
| 58 |
+
/>
|
| 59 |
+
) : (
|
| 60 |
+
<div className="w-full h-full flex items-center justify-center">
|
| 61 |
+
<div className="text-center text-gray-400">
|
| 62 |
+
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 63 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
| 64 |
+
</svg>
|
| 65 |
+
<p className="text-xs">Image unavailable</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
)}
|
| 69 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
| 70 |
+
</div>
|
| 71 |
+
)}
|
| 72 |
+
<div className="p-5">
|
| 73 |
+
<div className="flex items-center justify-between mb-3">
|
| 74 |
+
<span className="text-xs font-bold px-3 py-1 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full">
|
| 75 |
+
{formatNiche(ad.niche)}
|
| 76 |
+
</span>
|
| 77 |
+
<span className="text-xs text-gray-500 font-medium">
|
| 78 |
+
{formatRelativeDate(ad.created_at)}
|
| 79 |
+
</span>
|
| 80 |
+
</div>
|
| 81 |
+
<h3 className="font-bold text-gray-900 line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">
|
| 82 |
+
{ad.headline}
|
| 83 |
+
</h3>
|
| 84 |
+
{ad.title && (
|
| 85 |
+
<p className="text-sm text-gray-600 line-clamp-1">
|
| 86 |
+
{ad.title}
|
| 87 |
+
</p>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
</CardContent>
|
| 91 |
+
</Card>
|
| 92 |
+
</Link>
|
| 93 |
+
);
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
export default function Dashboard() {
|
| 97 |
+
const [stats, setStats] = useState<DbStatsResponse | null>(null);
|
| 98 |
+
const [recentAds, setRecentAds] = useState<AdCreativeDB[]>([]);
|
| 99 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 100 |
+
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
const loadData = async () => {
|
| 103 |
+
try {
|
| 104 |
+
// Load stats
|
| 105 |
+
const statsData = await getDbStats();
|
| 106 |
+
setStats(statsData);
|
| 107 |
+
|
| 108 |
+
// Load recent ads
|
| 109 |
+
const adsData = await listAds({ limit: 6 });
|
| 110 |
+
setRecentAds(adsData.ads);
|
| 111 |
+
} catch (error) {
|
| 112 |
+
console.error("Error loading dashboard data:", error);
|
| 113 |
+
} finally {
|
| 114 |
+
setIsLoading(false);
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
loadData();
|
| 119 |
+
}, []);
|
| 120 |
+
|
| 121 |
+
if (isLoading) {
|
| 122 |
+
return (
|
| 123 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
| 124 |
+
<div className="flex items-center justify-center h-64">
|
| 125 |
+
<LoadingSpinner size="lg" />
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
return (
|
| 132 |
+
<div className="min-h-screen pb-12">
|
| 133 |
+
{/* Hero Section */}
|
| 134 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-16 mb-12">
|
| 135 |
+
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 136 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 137 |
+
<div className="text-center animate-fade-in">
|
| 138 |
+
<h1 className="text-5xl md:text-6xl font-extrabold mb-4">
|
| 139 |
+
<span className="gradient-text">Creative</span>
|
| 140 |
+
<span className="text-gray-900"> Breakthrough</span>
|
| 141 |
+
</h1>
|
| 142 |
+
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
| 143 |
+
Create high-converting ad creatives for Home Insurance and GLP-1 niches with AI-powered generation
|
| 144 |
+
</p>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 150 |
+
{/* Stats Grid */}
|
| 151 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
| 152 |
+
<Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.1s" }}>
|
| 153 |
+
<CardContent className="pt-6">
|
| 154 |
+
<div className="flex items-center justify-between">
|
| 155 |
+
<div>
|
| 156 |
+
<p className="text-sm font-semibold text-gray-600 mb-1">Total Ads</p>
|
| 157 |
+
<p className="text-3xl font-bold text-gray-900">
|
| 158 |
+
{stats?.total_ads ?? 0}
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
<div className="p-3 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 shadow-lg">
|
| 162 |
+
<Database className="h-8 w-8 text-white" />
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</CardContent>
|
| 166 |
+
</Card>
|
| 167 |
+
|
| 168 |
+
<Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.2s" }}>
|
| 169 |
+
<CardContent className="pt-6">
|
| 170 |
+
<div className="flex items-center justify-between">
|
| 171 |
+
<div>
|
| 172 |
+
<p className="text-sm font-semibold text-gray-600 mb-1">Home Insurance</p>
|
| 173 |
+
<p className="text-3xl font-bold text-gray-900">
|
| 174 |
+
{stats?.by_niche?.home_insurance ?? 0}
|
| 175 |
+
</p>
|
| 176 |
+
</div>
|
| 177 |
+
<div className="p-3 rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg">
|
| 178 |
+
<Home className="h-8 w-8 text-white" />
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</CardContent>
|
| 182 |
+
</Card>
|
| 183 |
+
|
| 184 |
+
<Card variant="glass" className="animate-scale-in" style={{ animationDelay: "0.3s" }}>
|
| 185 |
+
<CardContent className="pt-6">
|
| 186 |
+
<div className="flex items-center justify-between">
|
| 187 |
+
<div>
|
| 188 |
+
<p className="text-sm font-semibold text-gray-600 mb-1">GLP-1</p>
|
| 189 |
+
<p className="text-3xl font-bold text-gray-900">
|
| 190 |
+
{stats?.by_niche?.glp1 ?? 0}
|
| 191 |
+
</p>
|
| 192 |
+
</div>
|
| 193 |
+
<div className="p-3 rounded-xl bg-gradient-to-br from-cyan-500 to-pink-600 shadow-lg">
|
| 194 |
+
<TrendingUp className="h-8 w-8 text-white" />
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</CardContent>
|
| 198 |
+
</Card>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
{/* Quick Actions */}
|
| 202 |
+
<Card variant="glass" className="mb-8 animate-fade-in">
|
| 203 |
+
<CardHeader>
|
| 204 |
+
<CardTitle>Quick Actions</CardTitle>
|
| 205 |
+
</CardHeader>
|
| 206 |
+
<CardContent>
|
| 207 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 208 |
+
<Link href="/generate">
|
| 209 |
+
<Button variant="primary" size="lg" className="w-full group">
|
| 210 |
+
<Sparkles className="h-5 w-5 mr-2 group-hover:rotate-12 transition-transform duration-300" />
|
| 211 |
+
Generate New Ad
|
| 212 |
+
</Button>
|
| 213 |
+
</Link>
|
| 214 |
+
<Link href="/gallery">
|
| 215 |
+
<Button variant="secondary" size="lg" className="w-full group">
|
| 216 |
+
<Grid className="h-5 w-5 mr-2 group-hover:rotate-12 transition-transform duration-300" />
|
| 217 |
+
View Gallery
|
| 218 |
+
</Button>
|
| 219 |
+
</Link>
|
| 220 |
+
</div>
|
| 221 |
+
</CardContent>
|
| 222 |
+
</Card>
|
| 223 |
+
|
| 224 |
+
{/* Recent Ads */}
|
| 225 |
+
<Card variant="glass" className="animate-fade-in">
|
| 226 |
+
<CardHeader>
|
| 227 |
+
<div className="flex items-center justify-between">
|
| 228 |
+
<CardTitle>Recent Ads</CardTitle>
|
| 229 |
+
<Link href="/gallery">
|
| 230 |
+
<Button variant="ghost" size="sm">View All</Button>
|
| 231 |
+
</Link>
|
| 232 |
+
</div>
|
| 233 |
+
</CardHeader>
|
| 234 |
+
<CardContent>
|
| 235 |
+
{recentAds.length === 0 ? (
|
| 236 |
+
<div className="text-center py-16">
|
| 237 |
+
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 mb-4">
|
| 238 |
+
<Sparkles className="h-10 w-10 text-white" />
|
| 239 |
+
</div>
|
| 240 |
+
<p className="text-gray-500 text-lg mb-4">No ads generated yet</p>
|
| 241 |
+
<Link href="/generate">
|
| 242 |
+
<Button variant="primary">
|
| 243 |
+
Generate Your First Ad
|
| 244 |
+
</Button>
|
| 245 |
+
</Link>
|
| 246 |
+
</div>
|
| 247 |
+
) : (
|
| 248 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 249 |
+
{recentAds.map((ad, index) => {
|
| 250 |
+
const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename);
|
| 251 |
+
const initialSrc = primary || fallback;
|
| 252 |
+
|
| 253 |
+
return (
|
| 254 |
+
<DashboardAdCard
|
| 255 |
+
key={ad.id}
|
| 256 |
+
ad={ad}
|
| 257 |
+
initialImageSrc={initialSrc}
|
| 258 |
+
fallback={fallback}
|
| 259 |
+
index={index}
|
| 260 |
+
/>
|
| 261 |
+
);
|
| 262 |
+
})}
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
</CardContent>
|
| 266 |
+
</Card>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
);
|
| 270 |
+
}
|
frontend/components/gallery/AdCard.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, memo } from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import Image from "next/image";
|
| 6 |
+
import { Card, CardContent } from "@/components/ui/Card";
|
| 7 |
+
import { formatRelativeDate, formatNiche, getImageUrl, getImageUrlFallback, truncateText } from "@/lib/utils/formatters";
|
| 8 |
+
import type { AdCreativeDB } from "@/types/api";
|
| 9 |
+
|
| 10 |
+
interface AdCardProps {
|
| 11 |
+
ad: AdCreativeDB;
|
| 12 |
+
isSelected?: boolean;
|
| 13 |
+
onSelect?: (adId: string) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const AdCard: React.FC<AdCardProps> = memo(({
|
| 17 |
+
ad,
|
| 18 |
+
isSelected = false,
|
| 19 |
+
onSelect,
|
| 20 |
+
}) => {
|
| 21 |
+
const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename);
|
| 22 |
+
const [imageSrc, setImageSrc] = useState<string | null>(primary || fallback);
|
| 23 |
+
const [imageError, setImageError] = useState(false);
|
| 24 |
+
|
| 25 |
+
const handleImageError = () => {
|
| 26 |
+
// Try fallback if primary failed
|
| 27 |
+
if (!imageError && fallback && imageSrc === primary) {
|
| 28 |
+
setImageSrc(fallback);
|
| 29 |
+
setImageError(true);
|
| 30 |
+
} else {
|
| 31 |
+
// Both failed, show placeholder
|
| 32 |
+
setImageSrc(null);
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<Card
|
| 38 |
+
variant={isSelected ? "glass" : "elevated"}
|
| 39 |
+
className={`cursor-pointer transition-all duration-300 group ${
|
| 40 |
+
isSelected ? "ring-4 ring-blue-500 ring-opacity-50 scale-105" : "hover:scale-105"
|
| 41 |
+
}`}
|
| 42 |
+
onClick={() => onSelect?.(ad.id)}
|
| 43 |
+
>
|
| 44 |
+
<Link href={`/gallery/${ad.id}`} onClick={(e) => e.stopPropagation()}>
|
| 45 |
+
<CardContent className="p-0">
|
| 46 |
+
{(imageSrc || ad.image_filename || ad.image_url) && (
|
| 47 |
+
<div className="aspect-video bg-gradient-to-br from-gray-100 to-gray-200 rounded-t-2xl overflow-hidden relative flex items-center justify-center">
|
| 48 |
+
{imageSrc ? (
|
| 49 |
+
<Image
|
| 50 |
+
src={imageSrc}
|
| 51 |
+
alt={ad.headline}
|
| 52 |
+
fill
|
| 53 |
+
className="object-contain group-hover:scale-105 transition-transform duration-500"
|
| 54 |
+
onError={handleImageError}
|
| 55 |
+
loading="lazy"
|
| 56 |
+
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
| 57 |
+
unoptimized={imageSrc.startsWith('http://') || imageSrc.includes('r2.cloudflarestorage.com') || imageSrc.includes('replicate.delivery')}
|
| 58 |
+
/>
|
| 59 |
+
) : (
|
| 60 |
+
<div className="w-full h-full flex items-center justify-center">
|
| 61 |
+
<div className="text-center text-gray-400">
|
| 62 |
+
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 63 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
| 64 |
+
</svg>
|
| 65 |
+
<p className="text-xs">Image unavailable</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
)}
|
| 69 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
| 70 |
+
</div>
|
| 71 |
+
)}
|
| 72 |
+
<div className="p-5">
|
| 73 |
+
<div className="flex items-center justify-between mb-3">
|
| 74 |
+
<span className="text-xs font-bold px-3 py-1 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full">
|
| 75 |
+
{formatNiche(ad.niche)}
|
| 76 |
+
</span>
|
| 77 |
+
<span className="text-xs text-gray-500 font-medium">
|
| 78 |
+
{formatRelativeDate(ad.created_at)}
|
| 79 |
+
</span>
|
| 80 |
+
</div>
|
| 81 |
+
<h3 className="font-bold text-gray-900 line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">
|
| 82 |
+
{truncateText(ad.headline, 60)}
|
| 83 |
+
</h3>
|
| 84 |
+
{ad.title && (
|
| 85 |
+
<p className="text-sm text-gray-600 line-clamp-1">
|
| 86 |
+
{ad.title}
|
| 87 |
+
</p>
|
| 88 |
+
)}
|
| 89 |
+
{ad.psychological_angle && (
|
| 90 |
+
<p className="text-xs text-gray-500 mt-2 line-clamp-1">
|
| 91 |
+
{truncateText(ad.psychological_angle, 50)}
|
| 92 |
+
</p>
|
| 93 |
+
)}
|
| 94 |
+
</div>
|
| 95 |
+
</CardContent>
|
| 96 |
+
</Link>
|
| 97 |
+
</Card>
|
| 98 |
+
);
|
| 99 |
+
}, (prevProps, nextProps) => {
|
| 100 |
+
// Only re-render if ad data or selection state changes
|
| 101 |
+
return (
|
| 102 |
+
prevProps.ad.id === nextProps.ad.id &&
|
| 103 |
+
prevProps.isSelected === nextProps.isSelected &&
|
| 104 |
+
prevProps.ad.image_url === nextProps.ad.image_url
|
| 105 |
+
);
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
AdCard.displayName = "AdCard";
|
frontend/components/gallery/FilterBar.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { Input } from "@/components/ui/Input";
|
| 5 |
+
import { Select } from "@/components/ui/Select";
|
| 6 |
+
import { Button } from "@/components/ui/Button";
|
| 7 |
+
import { Card } from "@/components/ui/Card";
|
| 8 |
+
import { X } from "lucide-react";
|
| 9 |
+
import type { AdFilters } from "@/types";
|
| 10 |
+
|
| 11 |
+
interface FilterBarProps {
|
| 12 |
+
filters: AdFilters;
|
| 13 |
+
onFiltersChange: (filters: Partial<AdFilters>) => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const FilterBar: React.FC<FilterBarProps> = ({
|
| 17 |
+
filters,
|
| 18 |
+
onFiltersChange,
|
| 19 |
+
}) => {
|
| 20 |
+
const handleFilterChange = (key: keyof AdFilters, value: string | null) => {
|
| 21 |
+
onFiltersChange({ [key]: value || null });
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
const clearFilters = () => {
|
| 25 |
+
onFiltersChange({
|
| 26 |
+
niche: null,
|
| 27 |
+
generation_method: null,
|
| 28 |
+
search: null,
|
| 29 |
+
date_from: null,
|
| 30 |
+
date_to: null,
|
| 31 |
+
});
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const hasActiveFilters = Object.values(filters).some((v) => v !== null && v !== undefined);
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<Card variant="glass">
|
| 38 |
+
<div className="p-6 space-y-4">
|
| 39 |
+
<div className="flex items-center justify-between">
|
| 40 |
+
<h3 className="text-lg font-bold gradient-text">Filters</h3>
|
| 41 |
+
{hasActiveFilters && (
|
| 42 |
+
<Button variant="ghost" size="sm" onClick={clearFilters} className="group">
|
| 43 |
+
<X className="h-4 w-4 mr-1 group-hover:rotate-90 transition-transform duration-300" />
|
| 44 |
+
Clear
|
| 45 |
+
</Button>
|
| 46 |
+
)}
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 50 |
+
<Input
|
| 51 |
+
placeholder="Search by headline or title..."
|
| 52 |
+
value={filters.search || ""}
|
| 53 |
+
onChange={(e) => handleFilterChange("search", e.target.value)}
|
| 54 |
+
/>
|
| 55 |
+
|
| 56 |
+
<Select
|
| 57 |
+
options={[
|
| 58 |
+
{ value: "", label: "All Niches" },
|
| 59 |
+
{ value: "home_insurance", label: "Home Insurance" },
|
| 60 |
+
{ value: "glp1", label: "GLP-1" },
|
| 61 |
+
]}
|
| 62 |
+
value={filters.niche || ""}
|
| 63 |
+
onChange={(e) => handleFilterChange("niche", e.target.value || null)}
|
| 64 |
+
/>
|
| 65 |
+
|
| 66 |
+
<Select
|
| 67 |
+
options={[
|
| 68 |
+
{ value: "", label: "All Methods" },
|
| 69 |
+
{ value: "original", label: "Original" },
|
| 70 |
+
{ value: "angle_concept_matrix", label: "Matrix" },
|
| 71 |
+
{ value: "extensive", label: "Extensive" },
|
| 72 |
+
]}
|
| 73 |
+
value={filters.generation_method || ""}
|
| 74 |
+
onChange={(e) => handleFilterChange("generation_method", e.target.value || null)}
|
| 75 |
+
/>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
</Card>
|
| 79 |
+
);
|
| 80 |
+
};
|
frontend/components/gallery/GalleryGrid.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { AdCard } from "./AdCard";
|
| 5 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 6 |
+
import type { AdCreativeDB } from "@/types/api";
|
| 7 |
+
|
| 8 |
+
interface GalleryGridProps {
|
| 9 |
+
ads: AdCreativeDB[];
|
| 10 |
+
selectedAds: string[];
|
| 11 |
+
onAdSelect: (adId: string) => void;
|
| 12 |
+
isLoading?: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const GalleryGrid: React.FC<GalleryGridProps> = ({
|
| 16 |
+
ads,
|
| 17 |
+
selectedAds,
|
| 18 |
+
onAdSelect,
|
| 19 |
+
isLoading = false,
|
| 20 |
+
}) => {
|
| 21 |
+
if (isLoading) {
|
| 22 |
+
return (
|
| 23 |
+
<div className="flex items-center justify-center py-12">
|
| 24 |
+
<LoadingSpinner size="lg" />
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (ads.length === 0) {
|
| 30 |
+
return (
|
| 31 |
+
<div className="text-center py-12">
|
| 32 |
+
<p className="text-gray-500 mb-4">No ads found</p>
|
| 33 |
+
<p className="text-sm text-gray-400">Try adjusting your filters or generate a new ad</p>
|
| 34 |
+
</div>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
| 40 |
+
{ads.map((ad) => (
|
| 41 |
+
<AdCard
|
| 42 |
+
key={ad.id}
|
| 43 |
+
ad={ad}
|
| 44 |
+
isSelected={selectedAds.includes(ad.id)}
|
| 45 |
+
onSelect={onAdSelect}
|
| 46 |
+
/>
|
| 47 |
+
))}
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
};
|
frontend/components/generation/AdPreview.tsx
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/Button";
|
| 5 |
+
import { Download, Copy } from "lucide-react";
|
| 6 |
+
import { downloadImage, copyToClipboard } from "@/lib/utils/export";
|
| 7 |
+
import { getImageUrl, getImageUrlFallback } from "@/lib/utils/formatters";
|
| 8 |
+
import { toast } from "react-hot-toast";
|
| 9 |
+
import type { GenerateResponse, MatrixGenerateResponse } from "@/types/api";
|
| 10 |
+
|
| 11 |
+
interface AdPreviewProps {
|
| 12 |
+
ad: GenerateResponse | MatrixGenerateResponse;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const AdPreview: React.FC<AdPreviewProps> = ({ ad }) => {
|
| 16 |
+
const [imageErrors, setImageErrors] = useState<Record<number, boolean>>({});
|
| 17 |
+
|
| 18 |
+
const handleDownloadImage = async (imageUrl: string | null | undefined, filename: string | null | undefined) => {
|
| 19 |
+
if (!imageUrl && !filename) {
|
| 20 |
+
toast.error("No image URL available");
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const url = getImageUrl(imageUrl, filename);
|
| 26 |
+
if (url) {
|
| 27 |
+
await downloadImage(url, filename || `ad-${ad.id}.png`);
|
| 28 |
+
toast.success("Image downloaded");
|
| 29 |
+
}
|
| 30 |
+
} catch (error) {
|
| 31 |
+
toast.error("Failed to download image");
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const handleCopyText = async (text: string, label: string) => {
|
| 36 |
+
try {
|
| 37 |
+
await copyToClipboard(text);
|
| 38 |
+
toast.success(`${label} copied`);
|
| 39 |
+
} catch (error) {
|
| 40 |
+
toast.error("Failed to copy");
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const handleImageError = (index: number, image: { image_url?: string | null; filename?: string | null }) => {
|
| 45 |
+
if (!imageErrors[index]) {
|
| 46 |
+
const { fallback } = getImageUrlFallback(image.image_url, image.filename);
|
| 47 |
+
if (fallback) {
|
| 48 |
+
setImageErrors((prev) => ({ ...prev, [index]: true }));
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="space-y-6">
|
| 55 |
+
{/* Images */}
|
| 56 |
+
{ad.images && ad.images.length > 0 && (
|
| 57 |
+
<div className="space-y-4">
|
| 58 |
+
{ad.images.length === 1 ? (
|
| 59 |
+
// Single image - full width with better styling
|
| 60 |
+
<div className="bg-white rounded-2xl shadow-lg shadow-blue-100/50 overflow-hidden ring-1 ring-blue-100">
|
| 61 |
+
{(() => {
|
| 62 |
+
const image = ad.images[0];
|
| 63 |
+
const { primary, fallback } = getImageUrlFallback(image.image_url, image.filename);
|
| 64 |
+
const imageUrl = imageErrors[0] ? fallback : (primary || fallback);
|
| 65 |
+
|
| 66 |
+
return imageUrl ? (
|
| 67 |
+
<div className="relative group">
|
| 68 |
+
<img
|
| 69 |
+
src={imageUrl}
|
| 70 |
+
alt={ad.headline || "Ad image"}
|
| 71 |
+
className="w-full h-auto"
|
| 72 |
+
onError={() => handleImageError(0, image)}
|
| 73 |
+
/>
|
| 74 |
+
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 75 |
+
<div className="relative group/btn">
|
| 76 |
+
<Button
|
| 77 |
+
variant="primary"
|
| 78 |
+
size="sm"
|
| 79 |
+
onClick={() => handleDownloadImage(image.image_url, image.filename)}
|
| 80 |
+
className="shadow-lg"
|
| 81 |
+
>
|
| 82 |
+
<Download className="h-4 w-4" />
|
| 83 |
+
</Button>
|
| 84 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover/btn:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 85 |
+
Download Image
|
| 86 |
+
</span>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
) : (
|
| 91 |
+
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 rounded-2xl aspect-square flex items-center justify-center ring-1 ring-blue-100">
|
| 92 |
+
<div className="text-blue-300 text-center">
|
| 93 |
+
<svg className="w-16 h-16 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 94 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
| 95 |
+
</svg>
|
| 96 |
+
<p className="text-sm">No image</p>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
})()}
|
| 101 |
+
{ad.images[0].error && (
|
| 102 |
+
<div className="p-4 bg-red-50 border-t border-red-200">
|
| 103 |
+
<p className="text-sm text-red-800">Error: {ad.images[0].error}</p>
|
| 104 |
+
</div>
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
) : (
|
| 108 |
+
// Multiple images - grid layout
|
| 109 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 110 |
+
{ad.images.map((image, index) => {
|
| 111 |
+
const { primary, fallback } = getImageUrlFallback(image.image_url, image.filename);
|
| 112 |
+
const imageUrl = imageErrors[index] ? fallback : (primary || fallback);
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div key={index} className="bg-white rounded-2xl shadow-lg shadow-blue-100/50 overflow-hidden ring-1 ring-blue-100">
|
| 116 |
+
{imageUrl ? (
|
| 117 |
+
<div className="relative group">
|
| 118 |
+
<img
|
| 119 |
+
src={imageUrl}
|
| 120 |
+
alt={`Ad image ${index + 1}`}
|
| 121 |
+
className="w-full h-auto"
|
| 122 |
+
onError={() => handleImageError(index, image)}
|
| 123 |
+
/>
|
| 124 |
+
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 125 |
+
<div className="relative group/btn">
|
| 126 |
+
<Button
|
| 127 |
+
variant="primary"
|
| 128 |
+
size="sm"
|
| 129 |
+
onClick={() => handleDownloadImage(image.image_url, image.filename)}
|
| 130 |
+
className="shadow-lg"
|
| 131 |
+
>
|
| 132 |
+
<Download className="h-4 w-4" />
|
| 133 |
+
</Button>
|
| 134 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover/btn:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 135 |
+
Download
|
| 136 |
+
</span>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
) : (
|
| 141 |
+
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 aspect-square flex items-center justify-center">
|
| 142 |
+
<div className="text-blue-300 text-center">
|
| 143 |
+
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 144 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
| 145 |
+
</svg>
|
| 146 |
+
<p className="text-xs">No image</p>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
{image.error && (
|
| 151 |
+
<div className="p-3 bg-red-50 border-t border-red-200">
|
| 152 |
+
<p className="text-xs text-red-800">Error: {image.error}</p>
|
| 153 |
+
</div>
|
| 154 |
+
)}
|
| 155 |
+
</div>
|
| 156 |
+
);
|
| 157 |
+
})}
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
</div>
|
| 161 |
+
)}
|
| 162 |
+
|
| 163 |
+
{/* Ad Copy Section */}
|
| 164 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 165 |
+
{/* Left Column - Main Copy */}
|
| 166 |
+
<div className="space-y-5">
|
| 167 |
+
{/* Title */}
|
| 168 |
+
{ad.title && (
|
| 169 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-indigo-500">
|
| 170 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 171 |
+
<h3 className="text-xs font-bold text-indigo-600 uppercase tracking-wider">Title</h3>
|
| 172 |
+
<div className="relative group">
|
| 173 |
+
<Button
|
| 174 |
+
variant="ghost"
|
| 175 |
+
size="sm"
|
| 176 |
+
onClick={() => handleCopyText(ad.title!, "Title")}
|
| 177 |
+
className="text-indigo-500 hover:bg-indigo-50"
|
| 178 |
+
>
|
| 179 |
+
<Copy className="h-4 w-4" />
|
| 180 |
+
</Button>
|
| 181 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-indigo-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 182 |
+
Copy Title
|
| 183 |
+
</span>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
<p className="text-lg font-semibold text-gray-800">{ad.title}</p>
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
|
| 190 |
+
{/* Headline */}
|
| 191 |
+
<div className="bg-white rounded-2xl shadow-lg shadow-blue-100/30 p-6 border-l-4 border-blue-500">
|
| 192 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 193 |
+
<h3 className="text-xs font-bold text-blue-600 uppercase tracking-wider">Headline</h3>
|
| 194 |
+
<div className="relative group">
|
| 195 |
+
<Button
|
| 196 |
+
variant="ghost"
|
| 197 |
+
size="sm"
|
| 198 |
+
onClick={() => handleCopyText(ad.headline, "Headline")}
|
| 199 |
+
className="text-blue-500 hover:bg-blue-50"
|
| 200 |
+
>
|
| 201 |
+
<Copy className="h-4 w-4" />
|
| 202 |
+
</Button>
|
| 203 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-blue-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 204 |
+
Copy Headline
|
| 205 |
+
</span>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent leading-tight">
|
| 209 |
+
{ad.headline}
|
| 210 |
+
</h1>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* Primary Text */}
|
| 214 |
+
{ad.primary_text && (
|
| 215 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-cyan-500">
|
| 216 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 217 |
+
<h3 className="text-xs font-bold text-cyan-600 uppercase tracking-wider">Primary Text</h3>
|
| 218 |
+
<div className="relative group">
|
| 219 |
+
<Button
|
| 220 |
+
variant="ghost"
|
| 221 |
+
size="sm"
|
| 222 |
+
onClick={() => handleCopyText(ad.primary_text!, "Primary Text")}
|
| 223 |
+
className="text-cyan-500 hover:bg-cyan-50"
|
| 224 |
+
>
|
| 225 |
+
<Copy className="h-4 w-4" />
|
| 226 |
+
</Button>
|
| 227 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-cyan-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 228 |
+
Copy Text
|
| 229 |
+
</span>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
<p className="text-gray-700 whitespace-pre-line leading-relaxed">{ad.primary_text}</p>
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
|
| 236 |
+
{/* Description */}
|
| 237 |
+
{ad.description && (
|
| 238 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-violet-500">
|
| 239 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 240 |
+
<h3 className="text-xs font-bold text-violet-600 uppercase tracking-wider">Description</h3>
|
| 241 |
+
<div className="relative group">
|
| 242 |
+
<Button
|
| 243 |
+
variant="ghost"
|
| 244 |
+
size="sm"
|
| 245 |
+
onClick={() => handleCopyText(ad.description!, "Description")}
|
| 246 |
+
className="text-violet-500 hover:bg-violet-50"
|
| 247 |
+
>
|
| 248 |
+
<Copy className="h-4 w-4" />
|
| 249 |
+
</Button>
|
| 250 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-violet-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 251 |
+
Copy Description
|
| 252 |
+
</span>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
<p className="text-gray-700 leading-relaxed">{ad.description}</p>
|
| 256 |
+
</div>
|
| 257 |
+
)}
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
{/* Right Column - Additional Info */}
|
| 261 |
+
<div className="space-y-5">
|
| 262 |
+
{/* Body Story */}
|
| 263 |
+
{ad.body_story && (
|
| 264 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-amber-500">
|
| 265 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 266 |
+
<h3 className="text-xs font-bold text-amber-600 uppercase tracking-wider">Body Story</h3>
|
| 267 |
+
<div className="relative group">
|
| 268 |
+
<Button
|
| 269 |
+
variant="ghost"
|
| 270 |
+
size="sm"
|
| 271 |
+
onClick={() => handleCopyText(ad.body_story!, "Body Story")}
|
| 272 |
+
className="text-amber-500 hover:bg-amber-50"
|
| 273 |
+
>
|
| 274 |
+
<Copy className="h-4 w-4" />
|
| 275 |
+
</Button>
|
| 276 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-amber-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 277 |
+
Copy Story
|
| 278 |
+
</span>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
<p className="text-gray-700 whitespace-pre-line leading-relaxed">{ad.body_story}</p>
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
|
| 285 |
+
{/* CTA */}
|
| 286 |
+
{ad.cta && (
|
| 287 |
+
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-2xl shadow-md p-6 border border-emerald-200">
|
| 288 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 289 |
+
<h3 className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Call to Action</h3>
|
| 290 |
+
<div className="relative group">
|
| 291 |
+
<Button
|
| 292 |
+
variant="ghost"
|
| 293 |
+
size="sm"
|
| 294 |
+
onClick={() => handleCopyText(ad.cta!, "CTA")}
|
| 295 |
+
className="text-emerald-600 hover:bg-emerald-100"
|
| 296 |
+
>
|
| 297 |
+
<Copy className="h-4 w-4" />
|
| 298 |
+
</Button>
|
| 299 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-emerald-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 300 |
+
Copy CTA
|
| 301 |
+
</span>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
<p className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">{ad.cta}</p>
|
| 305 |
+
</div>
|
| 306 |
+
)}
|
| 307 |
+
|
| 308 |
+
{/* Psychological Angle */}
|
| 309 |
+
<div className="bg-gradient-to-br from-pink-50 via-purple-50 to-blue-50 rounded-2xl p-6 border border-purple-200 shadow-md">
|
| 310 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 311 |
+
<h3 className="text-xs font-bold text-purple-600 uppercase tracking-wider">🧠 Psychological Angle</h3>
|
| 312 |
+
<div className="relative group">
|
| 313 |
+
<Button
|
| 314 |
+
variant="ghost"
|
| 315 |
+
size="sm"
|
| 316 |
+
onClick={() => handleCopyText(ad.psychological_angle, "Angle")}
|
| 317 |
+
className="text-purple-500 hover:bg-purple-100"
|
| 318 |
+
>
|
| 319 |
+
<Copy className="h-4 w-4" />
|
| 320 |
+
</Button>
|
| 321 |
+
<span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-purple-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
| 322 |
+
Copy Angle
|
| 323 |
+
</span>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
<p className="text-gray-700 leading-relaxed">{ad.psychological_angle}</p>
|
| 327 |
+
{ad.why_it_works && (
|
| 328 |
+
<div className="mt-4 pt-4 border-t border-purple-200">
|
| 329 |
+
<p className="text-xs font-bold text-purple-600 uppercase tracking-wider mb-2">💡 Why It Works</p>
|
| 330 |
+
<p className="text-gray-600 text-sm leading-relaxed">{ad.why_it_works}</p>
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
{/* Matrix Details */}
|
| 336 |
+
{"matrix" in ad && ad.matrix && (
|
| 337 |
+
<div className="bg-white rounded-2xl shadow-md p-6 border border-gray-200">
|
| 338 |
+
<h3 className="text-xs font-bold text-gray-600 uppercase tracking-wider mb-4">Matrix Details</h3>
|
| 339 |
+
<div className="space-y-4">
|
| 340 |
+
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl p-4 border border-blue-100">
|
| 341 |
+
<p className="text-blue-500 text-xs font-medium mb-1">Angle</p>
|
| 342 |
+
<p className="font-semibold text-gray-800">{ad.matrix.angle.name}</p>
|
| 343 |
+
<p className="text-xs text-gray-500 mt-1">{ad.matrix.angle.trigger}</p>
|
| 344 |
+
</div>
|
| 345 |
+
<div className="bg-gradient-to-br from-violet-50 to-purple-50 rounded-xl p-4 border border-violet-100">
|
| 346 |
+
<p className="text-violet-500 text-xs font-medium mb-1">Concept</p>
|
| 347 |
+
<p className="font-semibold text-gray-800">{ad.matrix.concept.name}</p>
|
| 348 |
+
<p className="text-xs text-gray-500 mt-1">{ad.matrix.concept.structure}</p>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
)}
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
);
|
| 357 |
+
};
|
frontend/components/generation/BatchForm.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { useForm } from "react-hook-form";
|
| 5 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 6 |
+
import { generateBatchSchema } from "@/lib/utils/validators";
|
| 7 |
+
import { Input } from "@/components/ui/Input";
|
| 8 |
+
import { Select } from "@/components/ui/Select";
|
| 9 |
+
import { Button } from "@/components/ui/Button";
|
| 10 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
|
| 11 |
+
import { IMAGE_MODELS } from "@/lib/constants/models";
|
| 12 |
+
import type { Niche } from "@/types/api";
|
| 13 |
+
|
| 14 |
+
interface BatchFormProps {
|
| 15 |
+
onSubmit: (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => Promise<void>;
|
| 16 |
+
isLoading: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const BatchForm: React.FC<BatchFormProps> = ({
|
| 20 |
+
onSubmit,
|
| 21 |
+
isLoading,
|
| 22 |
+
}) => {
|
| 23 |
+
const {
|
| 24 |
+
register,
|
| 25 |
+
handleSubmit,
|
| 26 |
+
formState: { errors },
|
| 27 |
+
watch,
|
| 28 |
+
} = useForm({
|
| 29 |
+
resolver: zodResolver(generateBatchSchema),
|
| 30 |
+
defaultValues: {
|
| 31 |
+
niche: "home_insurance" as Niche,
|
| 32 |
+
count: 5,
|
| 33 |
+
images_per_ad: 1,
|
| 34 |
+
image_model: null,
|
| 35 |
+
},
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
const count = watch("count");
|
| 39 |
+
const imagesPerAd = watch("images_per_ad");
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<Card variant="glass">
|
| 43 |
+
<CardHeader>
|
| 44 |
+
<CardTitle>Batch Generation</CardTitle>
|
| 45 |
+
<CardDescription>
|
| 46 |
+
Generate multiple ads at once for testing and variety
|
| 47 |
+
</CardDescription>
|
| 48 |
+
</CardHeader>
|
| 49 |
+
<CardContent>
|
| 50 |
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
| 51 |
+
<Select
|
| 52 |
+
label="Niche"
|
| 53 |
+
options={[
|
| 54 |
+
{ value: "home_insurance", label: "Home Insurance" },
|
| 55 |
+
{ value: "glp1", label: "GLP-1" },
|
| 56 |
+
]}
|
| 57 |
+
error={errors.niche?.message}
|
| 58 |
+
{...register("niche")}
|
| 59 |
+
/>
|
| 60 |
+
|
| 61 |
+
<Select
|
| 62 |
+
label="Image Model"
|
| 63 |
+
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
| 64 |
+
error={errors.image_model?.message}
|
| 65 |
+
{...register("image_model")}
|
| 66 |
+
/>
|
| 67 |
+
|
| 68 |
+
<div>
|
| 69 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 70 |
+
Number of Ads: {count}
|
| 71 |
+
</label>
|
| 72 |
+
<input
|
| 73 |
+
type="range"
|
| 74 |
+
min="1"
|
| 75 |
+
max="20"
|
| 76 |
+
step="1"
|
| 77 |
+
className="w-full"
|
| 78 |
+
{...register("count", { valueAsNumber: true })}
|
| 79 |
+
/>
|
| 80 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
| 81 |
+
<span>1</span>
|
| 82 |
+
<span>20</span>
|
| 83 |
+
</div>
|
| 84 |
+
{errors.count && (
|
| 85 |
+
<p className="mt-1 text-sm text-red-600">
|
| 86 |
+
{errors.count.message}
|
| 87 |
+
</p>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div>
|
| 92 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 93 |
+
Variations per Ad: <span className="text-blue-600 font-bold">{imagesPerAd}</span>
|
| 94 |
+
</label>
|
| 95 |
+
<input
|
| 96 |
+
type="range"
|
| 97 |
+
min="1"
|
| 98 |
+
max="3"
|
| 99 |
+
step="1"
|
| 100 |
+
className="w-full accent-blue-500"
|
| 101 |
+
{...register("images_per_ad", { valueAsNumber: true })}
|
| 102 |
+
/>
|
| 103 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1 font-medium">
|
| 104 |
+
<span>1</span>
|
| 105 |
+
<span>3</span>
|
| 106 |
+
</div>
|
| 107 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 108 |
+
Each variation will have a unique image and slight copy variations
|
| 109 |
+
</p>
|
| 110 |
+
{errors.images_per_ad && (
|
| 111 |
+
<p className="mt-1 text-sm text-red-600">
|
| 112 |
+
{errors.images_per_ad.message}
|
| 113 |
+
</p>
|
| 114 |
+
)}
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div className="bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-xl p-4">
|
| 118 |
+
<p className="text-sm font-semibold text-gray-800">
|
| 119 |
+
<strong>Estimated:</strong> {count} ads × {imagesPerAd} variation(s) = {count * imagesPerAd} total variations
|
| 120 |
+
</p>
|
| 121 |
+
<p className="text-xs text-gray-600 mt-1">
|
| 122 |
+
This may take several minutes to complete
|
| 123 |
+
</p>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<Button
|
| 127 |
+
type="submit"
|
| 128 |
+
variant="primary"
|
| 129 |
+
size="lg"
|
| 130 |
+
isLoading={isLoading}
|
| 131 |
+
className="w-full"
|
| 132 |
+
>
|
| 133 |
+
Generate Batch
|
| 134 |
+
</Button>
|
| 135 |
+
</form>
|
| 136 |
+
</CardContent>
|
| 137 |
+
</Card>
|
| 138 |
+
);
|
| 139 |
+
};
|
frontend/components/generation/CorrectionModal.tsx
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { X, Wand2, Image as ImageIcon, CheckCircle2, AlertCircle, Loader2, Sparkles } from "lucide-react";
|
| 5 |
+
import { correctImage } from "@/lib/api/endpoints";
|
| 6 |
+
import type { ImageCorrectResponse, AdCreativeDB } from "@/types/api";
|
| 7 |
+
import { ProgressBar } from "@/components/ui/ProgressBar";
|
| 8 |
+
import { Button } from "@/components/ui/Button";
|
| 9 |
+
import { Input } from "@/components/ui/Input";
|
| 10 |
+
import { Card, CardContent } from "@/components/ui/Card";
|
| 11 |
+
|
| 12 |
+
interface CorrectionModalProps {
|
| 13 |
+
isOpen: boolean;
|
| 14 |
+
onClose: () => void;
|
| 15 |
+
adId: string;
|
| 16 |
+
ad?: AdCreativeDB | null;
|
| 17 |
+
onSuccess?: (result: ImageCorrectResponse) => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
type CorrectionStep = "idle" | "input" | "analyzing" | "correcting" | "regenerating" | "complete" | "error";
|
| 21 |
+
|
| 22 |
+
export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
| 23 |
+
isOpen,
|
| 24 |
+
onClose,
|
| 25 |
+
adId,
|
| 26 |
+
ad,
|
| 27 |
+
onSuccess,
|
| 28 |
+
}) => {
|
| 29 |
+
const [step, setStep] = useState<CorrectionStep>("idle");
|
| 30 |
+
const [progress, setProgress] = useState(0);
|
| 31 |
+
const [result, setResult] = useState<ImageCorrectResponse | null>(null);
|
| 32 |
+
const [error, setError] = useState<string | null>(null);
|
| 33 |
+
const [userInstructions, setUserInstructions] = useState("");
|
| 34 |
+
const [useAutoAnalyze, setUseAutoAnalyze] = useState(false);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (isOpen) {
|
| 38 |
+
setStep("input");
|
| 39 |
+
setProgress(0);
|
| 40 |
+
setResult(null);
|
| 41 |
+
setError(null);
|
| 42 |
+
setUserInstructions("");
|
| 43 |
+
setUseAutoAnalyze(false);
|
| 44 |
+
} else {
|
| 45 |
+
// Reset state when modal closes
|
| 46 |
+
setStep("idle");
|
| 47 |
+
setProgress(0);
|
| 48 |
+
setResult(null);
|
| 49 |
+
setError(null);
|
| 50 |
+
setUserInstructions("");
|
| 51 |
+
setUseAutoAnalyze(false);
|
| 52 |
+
}
|
| 53 |
+
}, [isOpen]);
|
| 54 |
+
|
| 55 |
+
const handleCorrection = async () => {
|
| 56 |
+
if (!userInstructions && !useAutoAnalyze) {
|
| 57 |
+
setError("Please specify what you want to correct or enable auto-analysis");
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
setStep("analyzing");
|
| 62 |
+
setProgress(0);
|
| 63 |
+
setError(null);
|
| 64 |
+
setResult(null);
|
| 65 |
+
|
| 66 |
+
try {
|
| 67 |
+
// Simulate progress updates
|
| 68 |
+
const progressInterval = setInterval(() => {
|
| 69 |
+
setProgress((prev) => {
|
| 70 |
+
if (prev < 90) {
|
| 71 |
+
return prev + 5;
|
| 72 |
+
}
|
| 73 |
+
return prev;
|
| 74 |
+
});
|
| 75 |
+
}, 500);
|
| 76 |
+
|
| 77 |
+
setStep("analyzing");
|
| 78 |
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
| 79 |
+
|
| 80 |
+
setStep("correcting");
|
| 81 |
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
| 82 |
+
|
| 83 |
+
setStep("regenerating");
|
| 84 |
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
| 85 |
+
|
| 86 |
+
// Actually perform the correction
|
| 87 |
+
const response = await correctImage({
|
| 88 |
+
image_id: adId,
|
| 89 |
+
user_instructions: userInstructions || undefined,
|
| 90 |
+
auto_analyze: useAutoAnalyze,
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
clearInterval(progressInterval);
|
| 94 |
+
setProgress(100);
|
| 95 |
+
|
| 96 |
+
if (response.status === "success") {
|
| 97 |
+
setStep("complete");
|
| 98 |
+
setResult(response);
|
| 99 |
+
onSuccess?.(response);
|
| 100 |
+
} else {
|
| 101 |
+
setStep("error");
|
| 102 |
+
setError(response.error || "Correction failed");
|
| 103 |
+
}
|
| 104 |
+
} catch (err: any) {
|
| 105 |
+
setStep("error");
|
| 106 |
+
setError(err.response?.data?.detail || err.message || "Failed to correct image");
|
| 107 |
+
setProgress(0);
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const getStepLabel = () => {
|
| 112 |
+
switch (step) {
|
| 113 |
+
case "input":
|
| 114 |
+
return "Specify Corrections";
|
| 115 |
+
case "analyzing":
|
| 116 |
+
return "Analyzing image...";
|
| 117 |
+
case "correcting":
|
| 118 |
+
return "Generating corrections...";
|
| 119 |
+
case "regenerating":
|
| 120 |
+
return "Regenerating with nano-banana-pro...";
|
| 121 |
+
case "complete":
|
| 122 |
+
return "Correction complete!";
|
| 123 |
+
case "error":
|
| 124 |
+
return "Error occurred";
|
| 125 |
+
default:
|
| 126 |
+
return "Starting correction...";
|
| 127 |
+
}
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const getStepIcon = () => {
|
| 131 |
+
switch (step) {
|
| 132 |
+
case "complete":
|
| 133 |
+
return <CheckCircle2 className="h-6 w-6 text-green-500" />;
|
| 134 |
+
case "error":
|
| 135 |
+
return <AlertCircle className="h-6 w-6 text-red-500" />;
|
| 136 |
+
default:
|
| 137 |
+
return <Loader2 className="h-6 w-6 text-blue-500 animate-spin" />;
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
if (!isOpen) return null;
|
| 142 |
+
|
| 143 |
+
return (
|
| 144 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
| 145 |
+
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
| 146 |
+
{/* Header */}
|
| 147 |
+
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 rounded-t-2xl z-10">
|
| 148 |
+
<div className="flex items-center justify-between">
|
| 149 |
+
<div className="flex items-center gap-3">
|
| 150 |
+
<div className="p-2 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-lg">
|
| 151 |
+
<Wand2 className="h-5 w-5 text-white" />
|
| 152 |
+
</div>
|
| 153 |
+
<div>
|
| 154 |
+
<h2 className="text-xl font-bold text-gray-900">Correct Image</h2>
|
| 155 |
+
<p className="text-sm text-gray-500">
|
| 156 |
+
{step === "input"
|
| 157 |
+
? "Specify what you want to correct"
|
| 158 |
+
: "Analyzing and correcting your ad creative"}
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
<button
|
| 163 |
+
onClick={onClose}
|
| 164 |
+
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
| 165 |
+
>
|
| 166 |
+
<X className="h-5 w-5 text-gray-500" />
|
| 167 |
+
</button>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
{/* Content */}
|
| 172 |
+
<div className="p-6 space-y-6">
|
| 173 |
+
{/* Input Step */}
|
| 174 |
+
{step === "input" && (
|
| 175 |
+
<div className="space-y-4">
|
| 176 |
+
<Card variant="glass">
|
| 177 |
+
<CardContent className="pt-6">
|
| 178 |
+
<div className="space-y-4">
|
| 179 |
+
<div>
|
| 180 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 181 |
+
What would you like to correct?
|
| 182 |
+
</label>
|
| 183 |
+
<Input
|
| 184 |
+
type="text"
|
| 185 |
+
placeholder="e.g., 'Fix spelling: change Save 50% to Save 60%' or 'Adjust colors to be brighter' or 'Change headline text to X'"
|
| 186 |
+
value={userInstructions}
|
| 187 |
+
onChange={(e) => setUserInstructions(e.target.value)}
|
| 188 |
+
className="w-full"
|
| 189 |
+
/>
|
| 190 |
+
<p className="text-xs text-gray-500 mt-2">
|
| 191 |
+
Be specific about what you want to change. Only the specified changes will be made.
|
| 192 |
+
</p>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<div className="flex items-center gap-3 pt-2 border-t border-gray-200">
|
| 196 |
+
<input
|
| 197 |
+
type="checkbox"
|
| 198 |
+
id="auto-analyze"
|
| 199 |
+
checked={useAutoAnalyze}
|
| 200 |
+
onChange={(e) => setUseAutoAnalyze(e.target.checked)}
|
| 201 |
+
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
| 202 |
+
/>
|
| 203 |
+
<label htmlFor="auto-analyze" className="text-sm text-gray-700 cursor-pointer">
|
| 204 |
+
Or let AI automatically analyze and suggest corrections
|
| 205 |
+
</label>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
{useAutoAnalyze && (
|
| 209 |
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
|
| 210 |
+
<p className="font-semibold mb-1">Auto-Analysis Mode</p>
|
| 211 |
+
<p>AI will analyze the image for spelling mistakes and visual issues, then suggest corrections.</p>
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
|
| 215 |
+
{userInstructions && (
|
| 216 |
+
<div className="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
|
| 217 |
+
<p className="font-semibold mb-1">Custom Correction Mode</p>
|
| 218 |
+
<p>Only the changes you specified will be made. The rest of the image will be preserved.</p>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
</CardContent>
|
| 223 |
+
</Card>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
{/* Progress Section */}
|
| 228 |
+
{step !== "input" && step !== "complete" && step !== "error" && (
|
| 229 |
+
<div className="space-y-4">
|
| 230 |
+
<div className="flex items-center gap-4">
|
| 231 |
+
{getStepIcon()}
|
| 232 |
+
<div className="flex-1">
|
| 233 |
+
<p className="font-semibold text-gray-900">{getStepLabel()}</p>
|
| 234 |
+
<ProgressBar progress={progress} showPercentage={true} className="mt-2" />
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
)}
|
| 239 |
+
|
| 240 |
+
{/* Error State */}
|
| 241 |
+
{step === "error" && (
|
| 242 |
+
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
| 243 |
+
<div className="flex items-start gap-3">
|
| 244 |
+
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5" />
|
| 245 |
+
<div className="flex-1">
|
| 246 |
+
<h3 className="font-semibold text-red-900 mb-1">Correction Failed</h3>
|
| 247 |
+
<p className="text-sm text-red-700">{error}</p>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
|
| 253 |
+
{/* Success State */}
|
| 254 |
+
{step === "complete" && result && (
|
| 255 |
+
<div className="space-y-4">
|
| 256 |
+
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
| 257 |
+
<div className="flex items-center gap-3">
|
| 258 |
+
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
| 259 |
+
<div>
|
| 260 |
+
<h3 className="font-semibold text-green-900">Correction Complete!</h3>
|
| 261 |
+
<p className="text-sm text-green-700">Your image has been corrected successfully</p>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
{result.corrected_image?.image_url && (
|
| 267 |
+
<div className="space-y-3">
|
| 268 |
+
<h3 className="font-semibold text-gray-900">Corrected Image</h3>
|
| 269 |
+
<img
|
| 270 |
+
src={result.corrected_image.image_url}
|
| 271 |
+
alt="Corrected"
|
| 272 |
+
className="w-full rounded-lg border border-gray-200"
|
| 273 |
+
/>
|
| 274 |
+
</div>
|
| 275 |
+
)}
|
| 276 |
+
|
| 277 |
+
{result.corrections && (
|
| 278 |
+
<div className="space-y-3">
|
| 279 |
+
{result.corrections.spelling_corrections.length > 0 && (
|
| 280 |
+
<div>
|
| 281 |
+
<h3 className="font-semibold text-gray-900 mb-2">Spelling Corrections</h3>
|
| 282 |
+
<div className="space-y-2">
|
| 283 |
+
{result.corrections.spelling_corrections.map((correction, idx) => (
|
| 284 |
+
<div
|
| 285 |
+
key={idx}
|
| 286 |
+
className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm"
|
| 287 |
+
>
|
| 288 |
+
<span className="font-medium text-red-600 line-through">
|
| 289 |
+
{correction.detected}
|
| 290 |
+
</span>{" "}
|
| 291 |
+
→{" "}
|
| 292 |
+
<span className="font-medium text-green-600">
|
| 293 |
+
{correction.corrected}
|
| 294 |
+
</span>
|
| 295 |
+
</div>
|
| 296 |
+
))}
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
)}
|
| 300 |
+
|
| 301 |
+
{result.corrections.visual_corrections.length > 0 && (
|
| 302 |
+
<div>
|
| 303 |
+
<h3 className="font-semibold text-gray-900 mb-2">Visual Improvements</h3>
|
| 304 |
+
<div className="space-y-2">
|
| 305 |
+
{result.corrections.visual_corrections.map((correction, idx) => (
|
| 306 |
+
<div
|
| 307 |
+
key={idx}
|
| 308 |
+
className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm"
|
| 309 |
+
>
|
| 310 |
+
<p className="font-medium text-gray-900">{correction.issue}</p>
|
| 311 |
+
<p className="text-gray-600 mt-1">{correction.suggestion}</p>
|
| 312 |
+
</div>
|
| 313 |
+
))}
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
)}
|
| 317 |
+
</div>
|
| 318 |
+
)}
|
| 319 |
+
</div>
|
| 320 |
+
)}
|
| 321 |
+
</div>
|
| 322 |
+
|
| 323 |
+
{/* Footer */}
|
| 324 |
+
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 rounded-b-2xl">
|
| 325 |
+
<div className="flex justify-end gap-3">
|
| 326 |
+
{step === "input" && (
|
| 327 |
+
<Button
|
| 328 |
+
onClick={handleCorrection}
|
| 329 |
+
variant="primary"
|
| 330 |
+
disabled={!userInstructions && !useAutoAnalyze}
|
| 331 |
+
>
|
| 332 |
+
<Sparkles className="h-4 w-4 mr-2" />
|
| 333 |
+
Start Correction
|
| 334 |
+
</Button>
|
| 335 |
+
)}
|
| 336 |
+
{step === "error" && (
|
| 337 |
+
<Button onClick={() => setStep("input")} variant="primary">
|
| 338 |
+
Try Again
|
| 339 |
+
</Button>
|
| 340 |
+
)}
|
| 341 |
+
<Button
|
| 342 |
+
onClick={onClose}
|
| 343 |
+
variant={step === "complete" ? "primary" : "secondary"}
|
| 344 |
+
>
|
| 345 |
+
{step === "complete" ? "Done" : "Close"}
|
| 346 |
+
</Button>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
);
|
| 352 |
+
};
|
frontend/components/generation/ExtensiveForm.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { useForm } from "react-hook-form";
|
| 5 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 6 |
+
import { z } from "zod";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
|
| 8 |
+
import { Select } from "@/components/ui/Select";
|
| 9 |
+
import { IMAGE_MODELS } from "@/lib/constants/models";
|
| 10 |
+
import type { Niche } from "@/types/api";
|
| 11 |
+
|
| 12 |
+
const extensiveSchema = z.object({
|
| 13 |
+
niche: z.enum(["home_insurance", "glp1"]),
|
| 14 |
+
target_audience: z.string().min(1, "Target audience is required"),
|
| 15 |
+
offer: z.string().min(1, "Offer is required"),
|
| 16 |
+
num_images: z.number().min(1).max(3),
|
| 17 |
+
num_strategies: z.number().min(1).max(10),
|
| 18 |
+
image_model: z.string().nullable().optional(),
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
type ExtensiveFormData = z.infer<typeof extensiveSchema>;
|
| 22 |
+
|
| 23 |
+
interface ExtensiveFormProps {
|
| 24 |
+
onSubmit: (data: {
|
| 25 |
+
niche: Niche;
|
| 26 |
+
target_audience: string;
|
| 27 |
+
offer: string;
|
| 28 |
+
num_images: number;
|
| 29 |
+
num_strategies: number;
|
| 30 |
+
image_model?: string | null;
|
| 31 |
+
}) => Promise<void>;
|
| 32 |
+
isLoading: boolean;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export const ExtensiveForm: React.FC<ExtensiveFormProps> = ({
|
| 36 |
+
onSubmit,
|
| 37 |
+
isLoading,
|
| 38 |
+
}) => {
|
| 39 |
+
const {
|
| 40 |
+
register,
|
| 41 |
+
handleSubmit,
|
| 42 |
+
formState: { errors },
|
| 43 |
+
watch,
|
| 44 |
+
} = useForm<ExtensiveFormData>({
|
| 45 |
+
resolver: zodResolver(extensiveSchema),
|
| 46 |
+
defaultValues: {
|
| 47 |
+
niche: "home_insurance" as Niche,
|
| 48 |
+
target_audience: "",
|
| 49 |
+
offer: "",
|
| 50 |
+
num_images: 1,
|
| 51 |
+
num_strategies: 5,
|
| 52 |
+
image_model: null,
|
| 53 |
+
},
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
const numImages = watch("num_images");
|
| 57 |
+
const numStrategies = watch("num_strategies");
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<Card variant="glass">
|
| 61 |
+
<CardHeader>
|
| 62 |
+
<CardTitle>Extensive Generation</CardTitle>
|
| 63 |
+
<CardDescription>
|
| 64 |
+
Researcher → Creative Director → Designer → Copywriter flow
|
| 65 |
+
</CardDescription>
|
| 66 |
+
</CardHeader>
|
| 67 |
+
<CardContent>
|
| 68 |
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
| 69 |
+
<Select
|
| 70 |
+
label="Niche"
|
| 71 |
+
options={[
|
| 72 |
+
{ value: "home_insurance", label: "Home Insurance" },
|
| 73 |
+
{ value: "glp1", label: "GLP-1" },
|
| 74 |
+
]}
|
| 75 |
+
error={errors.niche?.message}
|
| 76 |
+
{...register("niche")}
|
| 77 |
+
/>
|
| 78 |
+
|
| 79 |
+
<div>
|
| 80 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 81 |
+
Target Audience <span className="text-red-500">*</span>
|
| 82 |
+
</label>
|
| 83 |
+
<input
|
| 84 |
+
type="text"
|
| 85 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
|
| 86 |
+
placeholder="e.g., US people over 50+ age"
|
| 87 |
+
{...register("target_audience")}
|
| 88 |
+
/>
|
| 89 |
+
{errors.target_audience && (
|
| 90 |
+
<p className="text-red-500 text-xs mt-1">{errors.target_audience.message}</p>
|
| 91 |
+
)}
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div>
|
| 95 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 96 |
+
Offer <span className="text-red-500">*</span>
|
| 97 |
+
</label>
|
| 98 |
+
<input
|
| 99 |
+
type="text"
|
| 100 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-250"
|
| 101 |
+
placeholder="e.g., Don't overpay your insurance"
|
| 102 |
+
{...register("offer")}
|
| 103 |
+
/>
|
| 104 |
+
{errors.offer && (
|
| 105 |
+
<p className="text-red-500 text-xs mt-1">{errors.offer.message}</p>
|
| 106 |
+
)}
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<Select
|
| 110 |
+
label="Image Model"
|
| 111 |
+
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
| 112 |
+
error={errors.image_model?.message}
|
| 113 |
+
{...register("image_model")}
|
| 114 |
+
/>
|
| 115 |
+
|
| 116 |
+
<div>
|
| 117 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 118 |
+
Images per Strategy: <span className="text-blue-600 font-bold">{numImages}</span>
|
| 119 |
+
</label>
|
| 120 |
+
<input
|
| 121 |
+
type="range"
|
| 122 |
+
min="1"
|
| 123 |
+
max="3"
|
| 124 |
+
step="1"
|
| 125 |
+
className="w-full accent-blue-500"
|
| 126 |
+
{...register("num_images", { valueAsNumber: true })}
|
| 127 |
+
/>
|
| 128 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1 font-medium">
|
| 129 |
+
<span>1</span>
|
| 130 |
+
<span>3</span>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div>
|
| 135 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 136 |
+
Number of Strategies: <span className="text-blue-600 font-bold">{numStrategies}</span>
|
| 137 |
+
</label>
|
| 138 |
+
<input
|
| 139 |
+
type="range"
|
| 140 |
+
min="1"
|
| 141 |
+
max="10"
|
| 142 |
+
step="1"
|
| 143 |
+
className="w-full accent-blue-500"
|
| 144 |
+
{...register("num_strategies", { valueAsNumber: true })}
|
| 145 |
+
/>
|
| 146 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1 font-medium">
|
| 147 |
+
<span>1</span>
|
| 148 |
+
<span>10</span>
|
| 149 |
+
</div>
|
| 150 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 151 |
+
More strategies = more variety, but longer generation time
|
| 152 |
+
</p>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<button
|
| 156 |
+
type="submit"
|
| 157 |
+
disabled={isLoading}
|
| 158 |
+
className="w-full bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-bold py-4 px-6 rounded-xl hover:from-blue-600 hover:to-cyan-600 transition-all duration-300 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
| 159 |
+
>
|
| 160 |
+
{isLoading ? "Generating..." : "Generate with Extensive"}
|
| 161 |
+
</button>
|
| 162 |
+
</form>
|
| 163 |
+
</CardContent>
|
| 164 |
+
</Card>
|
| 165 |
+
);
|
| 166 |
+
};
|
frontend/components/generation/GenerationForm.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { useForm } from "react-hook-form";
|
| 5 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 6 |
+
import { generateAdSchema } from "@/lib/utils/validators";
|
| 7 |
+
import { Input } from "@/components/ui/Input";
|
| 8 |
+
import { Select } from "@/components/ui/Select";
|
| 9 |
+
import { Button } from "@/components/ui/Button";
|
| 10 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
|
| 11 |
+
import { IMAGE_MODELS } from "@/lib/constants/models";
|
| 12 |
+
import type { Niche } from "@/types/api";
|
| 13 |
+
|
| 14 |
+
interface GenerationFormProps {
|
| 15 |
+
onSubmit: (data: { niche: Niche; num_images: number; image_model?: string | null }) => Promise<void>;
|
| 16 |
+
isLoading: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const GenerationForm: React.FC<GenerationFormProps> = ({
|
| 20 |
+
onSubmit,
|
| 21 |
+
isLoading,
|
| 22 |
+
}) => {
|
| 23 |
+
const {
|
| 24 |
+
register,
|
| 25 |
+
handleSubmit,
|
| 26 |
+
formState: { errors },
|
| 27 |
+
watch,
|
| 28 |
+
} = useForm({
|
| 29 |
+
resolver: zodResolver(generateAdSchema),
|
| 30 |
+
defaultValues: {
|
| 31 |
+
niche: "home_insurance" as Niche,
|
| 32 |
+
num_images: 1,
|
| 33 |
+
image_model: null,
|
| 34 |
+
},
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const numImages = watch("num_images");
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<Card variant="glass">
|
| 41 |
+
<CardHeader>
|
| 42 |
+
<CardTitle>Generate Ad</CardTitle>
|
| 43 |
+
<CardDescription>
|
| 44 |
+
Create a new ad creative using randomized strategies
|
| 45 |
+
</CardDescription>
|
| 46 |
+
</CardHeader>
|
| 47 |
+
<CardContent>
|
| 48 |
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
| 49 |
+
<Select
|
| 50 |
+
label="Niche"
|
| 51 |
+
options={[
|
| 52 |
+
{ value: "home_insurance", label: "Home Insurance" },
|
| 53 |
+
{ value: "glp1", label: "GLP-1" },
|
| 54 |
+
]}
|
| 55 |
+
error={errors.niche?.message}
|
| 56 |
+
{...register("niche")}
|
| 57 |
+
/>
|
| 58 |
+
|
| 59 |
+
<Select
|
| 60 |
+
label="Image Model"
|
| 61 |
+
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
| 62 |
+
error={errors.image_model?.message}
|
| 63 |
+
{...register("image_model")}
|
| 64 |
+
/>
|
| 65 |
+
|
| 66 |
+
<div>
|
| 67 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 68 |
+
Number of Variations: <span className="text-blue-600 font-bold">{numImages}</span>
|
| 69 |
+
</label>
|
| 70 |
+
<input
|
| 71 |
+
type="range"
|
| 72 |
+
min="1"
|
| 73 |
+
max="10"
|
| 74 |
+
step="1"
|
| 75 |
+
className="w-full accent-blue-500"
|
| 76 |
+
{...register("num_images", { valueAsNumber: true })}
|
| 77 |
+
/>
|
| 78 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1 font-medium">
|
| 79 |
+
<span>1</span>
|
| 80 |
+
<span>10</span>
|
| 81 |
+
</div>
|
| 82 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 83 |
+
Each variation will have a unique image and slight copy variations
|
| 84 |
+
</p>
|
| 85 |
+
{errors.num_images && (
|
| 86 |
+
<p className="mt-1 text-sm text-red-600">
|
| 87 |
+
{errors.num_images.message}
|
| 88 |
+
</p>
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<Button
|
| 93 |
+
type="submit"
|
| 94 |
+
variant="primary"
|
| 95 |
+
size="lg"
|
| 96 |
+
isLoading={isLoading}
|
| 97 |
+
className="w-full"
|
| 98 |
+
>
|
| 99 |
+
Generate Ad
|
| 100 |
+
</Button>
|
| 101 |
+
</form>
|
| 102 |
+
</CardContent>
|
| 103 |
+
</Card>
|
| 104 |
+
);
|
| 105 |
+
};
|
frontend/components/generation/GenerationProgress.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { Card, CardContent } from "@/components/ui/Card";
|
| 5 |
+
import { ProgressBar } from "@/components/ui/ProgressBar";
|
| 6 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 7 |
+
import {
|
| 8 |
+
Sparkles,
|
| 9 |
+
Image as ImageIcon,
|
| 10 |
+
Database,
|
| 11 |
+
CheckCircle2,
|
| 12 |
+
AlertCircle,
|
| 13 |
+
Wand2,
|
| 14 |
+
Zap
|
| 15 |
+
} from "lucide-react";
|
| 16 |
+
import type { GenerationProgress } from "@/types";
|
| 17 |
+
|
| 18 |
+
interface GenerationProgressProps {
|
| 19 |
+
progress: GenerationProgress;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const STEPS = [
|
| 23 |
+
{ key: "copy", label: "Crafting Copy", icon: Sparkles, color: "from-blue-500 to-cyan-500", messages: [
|
| 24 |
+
"Brainstorming compelling headlines...",
|
| 25 |
+
"Writing persuasive ad copy...",
|
| 26 |
+
"Polishing the perfect message...",
|
| 27 |
+
"Adding psychological triggers...",
|
| 28 |
+
]},
|
| 29 |
+
{ key: "image", label: "Generating Images", icon: ImageIcon, color: "from-cyan-500 to-pink-500", messages: [
|
| 30 |
+
"Creating stunning visuals...",
|
| 31 |
+
"Bringing your vision to life...",
|
| 32 |
+
"Rendering high-quality images...",
|
| 33 |
+
"Adding creative flair...",
|
| 34 |
+
]},
|
| 35 |
+
{ key: "saving", label: "Saving", icon: Database, color: "from-pink-500 to-purple-500", messages: [
|
| 36 |
+
"Storing your creative...",
|
| 37 |
+
"Securing your masterpiece...",
|
| 38 |
+
"Almost done...",
|
| 39 |
+
]},
|
| 40 |
+
] as const;
|
| 41 |
+
|
| 42 |
+
export const GenerationProgressComponent: React.FC<GenerationProgressProps> = ({
|
| 43 |
+
progress,
|
| 44 |
+
}) => {
|
| 45 |
+
const stepProgress = {
|
| 46 |
+
idle: 0,
|
| 47 |
+
copy: 33,
|
| 48 |
+
image: 66,
|
| 49 |
+
saving: 90,
|
| 50 |
+
complete: 100,
|
| 51 |
+
error: 0,
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const currentProgress = progress.progress || stepProgress[progress.step];
|
| 55 |
+
const currentStepIndex = STEPS.findIndex(s => s.key === progress.step);
|
| 56 |
+
const isComplete = progress.step === "complete";
|
| 57 |
+
const isError = progress.step === "error";
|
| 58 |
+
|
| 59 |
+
// Get random message for current step
|
| 60 |
+
const getStepMessage = () => {
|
| 61 |
+
if (progress.message) return progress.message;
|
| 62 |
+
const step = STEPS.find(s => s.key === progress.step);
|
| 63 |
+
if (step && step.messages.length > 0) {
|
| 64 |
+
const randomIndex = Math.floor(Math.random() * step.messages.length);
|
| 65 |
+
return step.messages[randomIndex];
|
| 66 |
+
}
|
| 67 |
+
return "Processing...";
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<Card variant="glass" className="overflow-hidden">
|
| 72 |
+
<CardContent className="pt-6">
|
| 73 |
+
<div className="space-y-6">
|
| 74 |
+
{/* Header with animated icon */}
|
| 75 |
+
<div className="flex items-center justify-between">
|
| 76 |
+
<div className="flex items-center space-x-4">
|
| 77 |
+
{isComplete ? (
|
| 78 |
+
<div className="relative">
|
| 79 |
+
<div className="absolute inset-0 bg-green-500 rounded-full animate-ping opacity-75"></div>
|
| 80 |
+
<CheckCircle2 className="h-8 w-8 text-green-500 relative z-10" />
|
| 81 |
+
</div>
|
| 82 |
+
) : isError ? (
|
| 83 |
+
<AlertCircle className="h-8 w-8 text-red-500 animate-pulse" />
|
| 84 |
+
) : (
|
| 85 |
+
<div className="relative">
|
| 86 |
+
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full animate-ping opacity-20"></div>
|
| 87 |
+
<div className="relative bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full p-2">
|
| 88 |
+
<Wand2 className="h-5 w-5 text-white animate-spin-slow" />
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
)}
|
| 92 |
+
<div>
|
| 93 |
+
<h3 className="text-lg font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
|
| 94 |
+
{isComplete ? "Generation Complete!" : isError ? "Generation Failed" : "Creating Your Ad"}
|
| 95 |
+
</h3>
|
| 96 |
+
<p className="text-sm text-gray-600 mt-0.5">
|
| 97 |
+
{isComplete ? "Your ad is ready!" : isError ? "Something went wrong" : getStepMessage()}
|
| 98 |
+
</p>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
{progress.estimatedTimeRemaining && !isComplete && !isError && (
|
| 102 |
+
<div className="text-right">
|
| 103 |
+
<div className="flex items-center space-x-1 text-sm font-semibold text-gray-700">
|
| 104 |
+
<Zap className="h-4 w-4 text-yellow-500 animate-pulse" />
|
| 105 |
+
<span>~{Math.ceil(progress.estimatedTimeRemaining)}s</span>
|
| 106 |
+
</div>
|
| 107 |
+
<p className="text-xs text-gray-500">remaining</p>
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{/* Step Indicators */}
|
| 113 |
+
{!isComplete && !isError && (
|
| 114 |
+
<div className="flex items-center justify-between relative">
|
| 115 |
+
{/* Progress line */}
|
| 116 |
+
<div className="absolute top-5 left-0 right-0 h-0.5 bg-gray-200 -z-10">
|
| 117 |
+
<div
|
| 118 |
+
className="h-full bg-gradient-to-r from-blue-500 via-cyan-500 to-pink-500 transition-all duration-500 ease-out"
|
| 119 |
+
style={{ width: `${currentProgress}%` }}
|
| 120 |
+
/>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{STEPS.map((step, index) => {
|
| 124 |
+
const StepIcon = step.icon;
|
| 125 |
+
const isActive = progress.step === step.key;
|
| 126 |
+
const isCompleted = currentStepIndex > index;
|
| 127 |
+
const isUpcoming = currentStepIndex < index;
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<div key={step.key} className="flex flex-col items-center flex-1">
|
| 131 |
+
<div className={`relative w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
|
| 132 |
+
isActive
|
| 133 |
+
? `bg-gradient-to-r ${step.color} shadow-lg scale-110 animate-pulse`
|
| 134 |
+
: isCompleted
|
| 135 |
+
? "bg-gradient-to-r from-green-500 to-emerald-500 shadow-md"
|
| 136 |
+
: "bg-gray-200"
|
| 137 |
+
}`}>
|
| 138 |
+
{isActive ? (
|
| 139 |
+
<div className="text-white">
|
| 140 |
+
<LoadingSpinner size="sm" />
|
| 141 |
+
</div>
|
| 142 |
+
) : isCompleted ? (
|
| 143 |
+
<CheckCircle2 className="h-5 w-5 text-white" />
|
| 144 |
+
) : (
|
| 145 |
+
<StepIcon className={`h-5 w-5 ${isUpcoming ? "text-gray-400" : "text-white"}`} />
|
| 146 |
+
)}
|
| 147 |
+
{isActive && (
|
| 148 |
+
<div className={`absolute inset-0 rounded-full bg-gradient-to-r ${step.color} animate-ping opacity-75`}></div>
|
| 149 |
+
)}
|
| 150 |
+
</div>
|
| 151 |
+
<p className={`text-xs font-medium mt-2 text-center ${
|
| 152 |
+
isActive
|
| 153 |
+
? "text-gray-900 font-bold"
|
| 154 |
+
: isCompleted
|
| 155 |
+
? "text-green-600"
|
| 156 |
+
: "text-gray-400"
|
| 157 |
+
}`}>
|
| 158 |
+
{step.label}
|
| 159 |
+
</p>
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
})}
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
|
| 166 |
+
{/* Progress Bar */}
|
| 167 |
+
<div className="space-y-2">
|
| 168 |
+
<div className="flex justify-between items-center">
|
| 169 |
+
<span className="text-sm font-semibold text-gray-700">Overall Progress</span>
|
| 170 |
+
<span className="text-sm font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
|
| 171 |
+
{Math.round(currentProgress)}%
|
| 172 |
+
</span>
|
| 173 |
+
</div>
|
| 174 |
+
<ProgressBar
|
| 175 |
+
progress={currentProgress}
|
| 176 |
+
showPercentage={false}
|
| 177 |
+
/>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{/* Success State */}
|
| 181 |
+
{isComplete && (
|
| 182 |
+
<div className="mt-4 p-4 bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-xl animate-scale-in">
|
| 183 |
+
<div className="flex items-center space-x-3">
|
| 184 |
+
<CheckCircle2 className="h-6 w-6 text-green-600 flex-shrink-0" />
|
| 185 |
+
<div>
|
| 186 |
+
<p className="text-sm font-semibold text-green-900">
|
| 187 |
+
Ad generated successfully!
|
| 188 |
+
</p>
|
| 189 |
+
<p className="text-xs text-green-700 mt-0.5">
|
| 190 |
+
Your creative is ready to use
|
| 191 |
+
</p>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
{/* Error State */}
|
| 198 |
+
{isError && (
|
| 199 |
+
<div className="mt-4 p-4 bg-gradient-to-r from-red-50 to-pink-50 border-2 border-red-200 rounded-xl animate-scale-in">
|
| 200 |
+
<div className="flex items-center space-x-3">
|
| 201 |
+
<AlertCircle className="h-6 w-6 text-red-600 flex-shrink-0" />
|
| 202 |
+
<div>
|
| 203 |
+
<p className="text-sm font-semibold text-red-900">
|
| 204 |
+
Generation failed
|
| 205 |
+
</p>
|
| 206 |
+
<p className="text-xs text-red-700 mt-0.5">
|
| 207 |
+
{progress.message || "An error occurred. Please try again."}
|
| 208 |
+
</p>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
</div>
|
| 214 |
+
</CardContent>
|
| 215 |
+
</Card>
|
| 216 |
+
);
|
| 217 |
+
};
|
frontend/components/layout/ConditionalHeader.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { usePathname } from "next/navigation";
|
| 5 |
+
import { Header } from "./Header";
|
| 6 |
+
|
| 7 |
+
export const ConditionalHeader: React.FC = () => {
|
| 8 |
+
const pathname = usePathname();
|
| 9 |
+
const isLoginPage = pathname === "/login";
|
| 10 |
+
|
| 11 |
+
if (isLoginPage) {
|
| 12 |
+
return null;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
return <Header />;
|
| 16 |
+
};
|
frontend/components/layout/Header.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { usePathname, useRouter } from "next/navigation";
|
| 6 |
+
import { Home, Rocket, Grid, Layers, LogOut, User } from "lucide-react";
|
| 7 |
+
import { useAuthStore } from "@/store/authStore";
|
| 8 |
+
import { Button } from "@/components/ui/Button";
|
| 9 |
+
|
| 10 |
+
export const Header: React.FC = () => {
|
| 11 |
+
const pathname = usePathname();
|
| 12 |
+
const router = useRouter();
|
| 13 |
+
const { isAuthenticated, user, logout } = useAuthStore();
|
| 14 |
+
|
| 15 |
+
const navItems = [
|
| 16 |
+
{ href: "/", label: "Dashboard", icon: Home },
|
| 17 |
+
{ href: "/generate", label: "Generate", icon: Rocket },
|
| 18 |
+
{ href: "/gallery", label: "Gallery", icon: Grid },
|
| 19 |
+
{ href: "/matrix", label: "Matrix", icon: Layers },
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
const handleLogout = () => {
|
| 23 |
+
logout();
|
| 24 |
+
router.push("/login");
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<header className="sticky top-0 z-50 glass border-b border-white/20 backdrop-blur-xl transition-all duration-300">
|
| 29 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 30 |
+
<div className="flex justify-between items-center h-20">
|
| 31 |
+
<div className="flex items-center">
|
| 32 |
+
<Link href="/" className="flex items-center space-x-3 group">
|
| 33 |
+
<div className="relative">
|
| 34 |
+
<Rocket className="h-8 w-8 text-blue-500 group-hover:text-cyan-500 transition-all duration-300 group-hover:scale-110 group-hover:-translate-y-1" />
|
| 35 |
+
<div className="absolute inset-0 bg-blue-500/20 rounded-full blur-xl group-hover:bg-cyan-500/20 transition-colors duration-300"></div>
|
| 36 |
+
</div>
|
| 37 |
+
<span className="text-2xl font-bold gradient-text group-hover:scale-105 transition-transform duration-300">
|
| 38 |
+
Creative Breakthrough
|
| 39 |
+
</span>
|
| 40 |
+
</Link>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div className="flex items-center space-x-4">
|
| 44 |
+
<nav className="hidden md:flex space-x-2">
|
| 45 |
+
{navItems.map((item) => {
|
| 46 |
+
const Icon = item.icon;
|
| 47 |
+
const isActive = pathname === item.href ||
|
| 48 |
+
(item.href !== "/" && pathname?.startsWith(item.href));
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<Link
|
| 52 |
+
key={item.href}
|
| 53 |
+
href={item.href}
|
| 54 |
+
className={`relative flex items-center space-x-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300 ${
|
| 55 |
+
isActive
|
| 56 |
+
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg scale-105"
|
| 57 |
+
: "text-gray-700 hover:bg-white/50 hover:text-gray-900 hover:scale-105"
|
| 58 |
+
}`}
|
| 59 |
+
>
|
| 60 |
+
<Icon className={`h-4 w-4 transition-transform duration-300 ${isActive ? "scale-110" : ""}`} />
|
| 61 |
+
<span>{item.label}</span>
|
| 62 |
+
{isActive && (
|
| 63 |
+
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 opacity-50 blur-sm -z-10"></div>
|
| 64 |
+
)}
|
| 65 |
+
</Link>
|
| 66 |
+
);
|
| 67 |
+
})}
|
| 68 |
+
</nav>
|
| 69 |
+
|
| 70 |
+
{isAuthenticated ? (
|
| 71 |
+
<div className="flex items-center space-x-3">
|
| 72 |
+
<div className="hidden md:flex items-center space-x-2 px-4 py-2 rounded-xl bg-white/50 backdrop-blur-sm text-gray-700">
|
| 73 |
+
<User className="h-4 w-4" />
|
| 74 |
+
<span className="text-sm font-semibold">{user?.username}</span>
|
| 75 |
+
</div>
|
| 76 |
+
<Button
|
| 77 |
+
variant="outline"
|
| 78 |
+
size="sm"
|
| 79 |
+
onClick={handleLogout}
|
| 80 |
+
className="flex items-center space-x-2"
|
| 81 |
+
>
|
| 82 |
+
<LogOut className="h-4 w-4" />
|
| 83 |
+
<span className="hidden sm:inline">Logout</span>
|
| 84 |
+
</Button>
|
| 85 |
+
</div>
|
| 86 |
+
) : (
|
| 87 |
+
pathname !== "/login" && (
|
| 88 |
+
<Link href="/login">
|
| 89 |
+
<Button variant="primary" size="sm">
|
| 90 |
+
Login
|
| 91 |
+
</Button>
|
| 92 |
+
</Link>
|
| 93 |
+
)
|
| 94 |
+
)}
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</header>
|
| 99 |
+
);
|
| 100 |
+
};
|
frontend/components/matrix/AngleSelector.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 5 |
+
import { Input } from "@/components/ui/Input";
|
| 6 |
+
import { Select } from "@/components/ui/Select";
|
| 7 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 8 |
+
import { getAllAngles } from "@/lib/api/endpoints";
|
| 9 |
+
import { useMatrixStore } from "@/store/matrixStore";
|
| 10 |
+
import type { AngleInfo, AnglesResponse } from "@/types/api";
|
| 11 |
+
|
| 12 |
+
interface AngleSelectorProps {
|
| 13 |
+
onSelect?: (angle: AngleInfo) => void;
|
| 14 |
+
selectedAngle?: AngleInfo | null;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const AngleSelector: React.FC<AngleSelectorProps> = ({
|
| 18 |
+
onSelect,
|
| 19 |
+
selectedAngle,
|
| 20 |
+
}) => {
|
| 21 |
+
const { angles, isLoading, setAngles, setIsLoading } = useMatrixStore();
|
| 22 |
+
const [searchTerm, setSearchTerm] = useState("");
|
| 23 |
+
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
if (!angles) {
|
| 27 |
+
loadAngles();
|
| 28 |
+
}
|
| 29 |
+
}, []);
|
| 30 |
+
|
| 31 |
+
const loadAngles = async () => {
|
| 32 |
+
setIsLoading(true);
|
| 33 |
+
try {
|
| 34 |
+
const data = await getAllAngles();
|
| 35 |
+
setAngles(data);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error("Failed to load angles:", error);
|
| 38 |
+
} finally {
|
| 39 |
+
setIsLoading(false);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
if (isLoading && !angles) {
|
| 44 |
+
return (
|
| 45 |
+
<Card>
|
| 46 |
+
<CardContent className="pt-6">
|
| 47 |
+
<LoadingSpinner size="lg" />
|
| 48 |
+
</CardContent>
|
| 49 |
+
</Card>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (!angles) {
|
| 54 |
+
return (
|
| 55 |
+
<Card>
|
| 56 |
+
<CardContent className="pt-6">
|
| 57 |
+
<p className="text-gray-500">Failed to load angles</p>
|
| 58 |
+
</CardContent>
|
| 59 |
+
</Card>
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Filter angles
|
| 64 |
+
let filteredAngles: Array<{ category: string; angle: any }> = [];
|
| 65 |
+
|
| 66 |
+
Object.entries(angles.categories).forEach(([catKey, catData]) => {
|
| 67 |
+
if (selectedCategory && catKey !== selectedCategory) return;
|
| 68 |
+
|
| 69 |
+
catData.angles.forEach((angle) => {
|
| 70 |
+
if (
|
| 71 |
+
!searchTerm ||
|
| 72 |
+
angle.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 73 |
+
angle.trigger.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 74 |
+
angle.key.toLowerCase().includes(searchTerm.toLowerCase())
|
| 75 |
+
) {
|
| 76 |
+
filteredAngles.push({ category: catData.name, angle });
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
const categories = Object.entries(angles.categories).map(([key, data]) => ({
|
| 82 |
+
value: key,
|
| 83 |
+
label: data.name,
|
| 84 |
+
}));
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<Card variant="glass">
|
| 88 |
+
<CardHeader>
|
| 89 |
+
<CardTitle>Select Angle</CardTitle>
|
| 90 |
+
</CardHeader>
|
| 91 |
+
<CardContent className="space-y-4">
|
| 92 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 93 |
+
<Input
|
| 94 |
+
placeholder="Search angles..."
|
| 95 |
+
value={searchTerm}
|
| 96 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
| 97 |
+
/>
|
| 98 |
+
<Select
|
| 99 |
+
options={[{ value: "", label: "All Categories" }, ...categories]}
|
| 100 |
+
value={selectedCategory}
|
| 101 |
+
onChange={(e) => setSelectedCategory(e.target.value)}
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<div className="max-h-96 overflow-y-auto space-y-2">
|
| 106 |
+
{filteredAngles.length === 0 ? (
|
| 107 |
+
<p className="text-center text-gray-500 py-8">No angles found</p>
|
| 108 |
+
) : (
|
| 109 |
+
filteredAngles.map(({ category, angle }) => (
|
| 110 |
+
<div
|
| 111 |
+
key={angle.key}
|
| 112 |
+
onClick={() => onSelect?.(angle)}
|
| 113 |
+
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
| 114 |
+
selectedAngle?.key === angle.key
|
| 115 |
+
? "border-blue-500 bg-blue-50"
|
| 116 |
+
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
| 117 |
+
}`}
|
| 118 |
+
>
|
| 119 |
+
<div className="flex items-start justify-between">
|
| 120 |
+
<div className="flex-1">
|
| 121 |
+
<h4 className="font-semibold text-gray-900">{angle.name}</h4>
|
| 122 |
+
<p className="text-sm text-gray-600 mt-1">{angle.trigger}</p>
|
| 123 |
+
<p className="text-xs text-gray-500 mt-1">{category}</p>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
))
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
</CardContent>
|
| 131 |
+
</Card>
|
| 132 |
+
);
|
| 133 |
+
};
|
frontend/components/matrix/ConceptSelector.tsx
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
| 5 |
+
import { Input } from "@/components/ui/Input";
|
| 6 |
+
import { Select } from "@/components/ui/Select";
|
| 7 |
+
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
| 8 |
+
import { getAllConcepts, getCompatibleConcepts } from "@/lib/api/endpoints";
|
| 9 |
+
import { useMatrixStore } from "@/store/matrixStore";
|
| 10 |
+
import type { ConceptInfo, ConceptsResponse } from "@/types/api";
|
| 11 |
+
|
| 12 |
+
interface ConceptSelectorProps {
|
| 13 |
+
onSelect?: (concept: ConceptInfo) => void;
|
| 14 |
+
selectedConcept?: ConceptInfo | null;
|
| 15 |
+
angleKey?: string | null;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const ConceptSelector: React.FC<ConceptSelectorProps> = ({
|
| 19 |
+
onSelect,
|
| 20 |
+
selectedConcept,
|
| 21 |
+
angleKey,
|
| 22 |
+
}) => {
|
| 23 |
+
const { concepts, compatibleConcepts, isLoading, setConcepts, setCompatibleConcepts, setIsLoading } = useMatrixStore();
|
| 24 |
+
const [searchTerm, setSearchTerm] = useState("");
|
| 25 |
+
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
| 26 |
+
const [showCompatibleOnly, setShowCompatibleOnly] = useState(false);
|
| 27 |
+
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
if (!concepts) {
|
| 30 |
+
loadConcepts();
|
| 31 |
+
}
|
| 32 |
+
}, []);
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
if (angleKey && showCompatibleOnly) {
|
| 36 |
+
loadCompatibleConcepts(angleKey);
|
| 37 |
+
}
|
| 38 |
+
}, [angleKey, showCompatibleOnly]);
|
| 39 |
+
|
| 40 |
+
const loadConcepts = async () => {
|
| 41 |
+
setIsLoading(true);
|
| 42 |
+
try {
|
| 43 |
+
const data = await getAllConcepts();
|
| 44 |
+
setConcepts(data);
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error("Failed to load concepts:", error);
|
| 47 |
+
} finally {
|
| 48 |
+
setIsLoading(false);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const loadCompatibleConcepts = async (key: string) => {
|
| 53 |
+
try {
|
| 54 |
+
const data = await getCompatibleConcepts(key);
|
| 55 |
+
// Convert compatible concepts to full ConceptInfo by fetching from all concepts
|
| 56 |
+
if (concepts) {
|
| 57 |
+
const fullConcepts: ConceptInfo[] = [];
|
| 58 |
+
for (const compat of data.compatible_concepts) {
|
| 59 |
+
// Find full concept info from all concepts
|
| 60 |
+
let found = false;
|
| 61 |
+
for (const [catKey, cat] of Object.entries(concepts.categories)) {
|
| 62 |
+
const fullConcept = cat.concepts.find((c) => c.key === compat.key);
|
| 63 |
+
if (fullConcept) {
|
| 64 |
+
// Add category to the concept
|
| 65 |
+
fullConcepts.push({
|
| 66 |
+
...fullConcept,
|
| 67 |
+
category: cat.name,
|
| 68 |
+
});
|
| 69 |
+
found = true;
|
| 70 |
+
break;
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
// If not found, create a minimal version
|
| 74 |
+
if (!found) {
|
| 75 |
+
fullConcepts.push({
|
| 76 |
+
key: compat.key,
|
| 77 |
+
name: compat.name,
|
| 78 |
+
structure: compat.structure,
|
| 79 |
+
visual: "",
|
| 80 |
+
category: "",
|
| 81 |
+
});
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
setCompatibleConcepts(fullConcepts);
|
| 85 |
+
} else {
|
| 86 |
+
// Fallback to minimal concepts if full concepts not loaded
|
| 87 |
+
const minimalConcepts: ConceptInfo[] = data.compatible_concepts.map((c): ConceptInfo => ({
|
| 88 |
+
key: c.key,
|
| 89 |
+
name: c.name,
|
| 90 |
+
structure: c.structure,
|
| 91 |
+
visual: "",
|
| 92 |
+
category: "",
|
| 93 |
+
}));
|
| 94 |
+
setCompatibleConcepts(minimalConcepts);
|
| 95 |
+
}
|
| 96 |
+
} catch (error) {
|
| 97 |
+
console.error("Failed to load compatible concepts:", error);
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
if (isLoading && !concepts) {
|
| 102 |
+
return (
|
| 103 |
+
<Card>
|
| 104 |
+
<CardContent className="pt-6">
|
| 105 |
+
<LoadingSpinner size="lg" />
|
| 106 |
+
</CardContent>
|
| 107 |
+
</Card>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if (!concepts) {
|
| 112 |
+
return (
|
| 113 |
+
<Card>
|
| 114 |
+
<CardContent className="pt-6">
|
| 115 |
+
<p className="text-gray-500">Failed to load concepts</p>
|
| 116 |
+
</CardContent>
|
| 117 |
+
</Card>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Filter concepts
|
| 122 |
+
let filteredConcepts: Array<{ category: string; concept: any }> = [];
|
| 123 |
+
const conceptsToUse = showCompatibleOnly && compatibleConcepts.length > 0
|
| 124 |
+
? compatibleConcepts
|
| 125 |
+
: Object.values(concepts.categories).flatMap((cat) => cat.concepts);
|
| 126 |
+
|
| 127 |
+
Object.entries(concepts.categories).forEach(([catKey, catData]) => {
|
| 128 |
+
if (selectedCategory && catKey !== selectedCategory) return;
|
| 129 |
+
|
| 130 |
+
catData.concepts.forEach((concept) => {
|
| 131 |
+
if (
|
| 132 |
+
(!showCompatibleOnly || compatibleConcepts.some((c) => c.key === concept.key)) &&
|
| 133 |
+
(!searchTerm ||
|
| 134 |
+
concept.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 135 |
+
concept.structure.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 136 |
+
concept.key.toLowerCase().includes(searchTerm.toLowerCase()))
|
| 137 |
+
) {
|
| 138 |
+
filteredConcepts.push({ category: catData.name, concept });
|
| 139 |
+
}
|
| 140 |
+
});
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
const categories = Object.entries(concepts.categories).map(([key, data]) => ({
|
| 144 |
+
value: key,
|
| 145 |
+
label: data.name,
|
| 146 |
+
}));
|
| 147 |
+
|
| 148 |
+
return (
|
| 149 |
+
<Card variant="glass">
|
| 150 |
+
<CardHeader>
|
| 151 |
+
<CardTitle>Select Concept</CardTitle>
|
| 152 |
+
{angleKey && (
|
| 153 |
+
<div className="mt-2">
|
| 154 |
+
<label className="flex items-center space-x-2">
|
| 155 |
+
<input
|
| 156 |
+
type="checkbox"
|
| 157 |
+
checked={showCompatibleOnly}
|
| 158 |
+
onChange={(e) => setShowCompatibleOnly(e.target.checked)}
|
| 159 |
+
className="rounded"
|
| 160 |
+
/>
|
| 161 |
+
<span className="text-sm text-gray-600">Show compatible concepts only</span>
|
| 162 |
+
</label>
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
</CardHeader>
|
| 166 |
+
<CardContent className="space-y-4">
|
| 167 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 168 |
+
<Input
|
| 169 |
+
placeholder="Search concepts..."
|
| 170 |
+
value={searchTerm}
|
| 171 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
| 172 |
+
/>
|
| 173 |
+
<Select
|
| 174 |
+
options={[{ value: "", label: "All Categories" }, ...categories]}
|
| 175 |
+
value={selectedCategory}
|
| 176 |
+
onChange={(e) => setSelectedCategory(e.target.value)}
|
| 177 |
+
/>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<div className="max-h-96 overflow-y-auto space-y-2">
|
| 181 |
+
{filteredConcepts.length === 0 ? (
|
| 182 |
+
<p className="text-center text-gray-500 py-8">No concepts found</p>
|
| 183 |
+
) : (
|
| 184 |
+
filteredConcepts.map(({ category, concept }) => (
|
| 185 |
+
<div
|
| 186 |
+
key={concept.key}
|
| 187 |
+
onClick={() => onSelect?.(concept)}
|
| 188 |
+
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
| 189 |
+
selectedConcept?.key === concept.key
|
| 190 |
+
? "border-blue-500 bg-blue-50"
|
| 191 |
+
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
| 192 |
+
}`}
|
| 193 |
+
>
|
| 194 |
+
<div className="flex items-start justify-between">
|
| 195 |
+
<div className="flex-1">
|
| 196 |
+
<h4 className="font-semibold text-gray-900">{concept.name}</h4>
|
| 197 |
+
<p className="text-sm text-gray-600 mt-1">{concept.structure}</p>
|
| 198 |
+
<p className="text-xs text-gray-500 mt-1">{category}</p>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
))
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
</CardContent>
|
| 206 |
+
</Card>
|
| 207 |
+
);
|
| 208 |
+
};
|
frontend/components/matrix/TestingMatrixBuilder.tsx
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
|
| 6 |
+
import { Button } from "@/components/ui/Button";
|
| 7 |
+
import { Select } from "@/components/ui/Select";
|
| 8 |
+
import { ProgressBar } from "@/components/ui/ProgressBar";
|
| 9 |
+
import { generateTestingMatrix, generateMatrixAd } from "@/lib/api/endpoints";
|
| 10 |
+
import { exportAsJSON, exportAsCSV } from "@/lib/utils/export";
|
| 11 |
+
import { toast } from "react-hot-toast";
|
| 12 |
+
import { Download, FileJson, FileSpreadsheet, Rocket, CheckSquare, Square } from "lucide-react";
|
| 13 |
+
import { IMAGE_MODELS } from "@/lib/constants/models";
|
| 14 |
+
import type { TestingMatrixResponse, CombinationInfo } from "@/types/api";
|
| 15 |
+
import type { Niche } from "@/types/api";
|
| 16 |
+
|
| 17 |
+
interface TestingMatrixBuilderProps {
|
| 18 |
+
onMatrixGenerated?: (matrix: TestingMatrixResponse) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export const TestingMatrixBuilder: React.FC<TestingMatrixBuilderProps> = ({
|
| 22 |
+
onMatrixGenerated,
|
| 23 |
+
}) => {
|
| 24 |
+
const router = useRouter();
|
| 25 |
+
const [niche, setNiche] = useState<Niche>("home_insurance");
|
| 26 |
+
const [strategy, setStrategy] = useState<"balanced" | "top_performers" | "diverse">("balanced");
|
| 27 |
+
const [angleCount, setAngleCount] = useState(6);
|
| 28 |
+
const [conceptCount, setConceptCount] = useState(5);
|
| 29 |
+
const [matrix, setMatrix] = useState<TestingMatrixResponse | null>(null);
|
| 30 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 31 |
+
const [selectedCombinations, setSelectedCombinations] = useState<Set<string>>(new Set());
|
| 32 |
+
const [isGeneratingAds, setIsGeneratingAds] = useState(false);
|
| 33 |
+
const [generationProgress, setGenerationProgress] = useState({ current: 0, total: 0 });
|
| 34 |
+
const [numVariations, setNumVariations] = useState(1);
|
| 35 |
+
const [imageModel, setImageModel] = useState<string | null>(null);
|
| 36 |
+
|
| 37 |
+
const handleGenerate = async () => {
|
| 38 |
+
setIsGenerating(true);
|
| 39 |
+
try {
|
| 40 |
+
const result = await generateTestingMatrix({
|
| 41 |
+
niche,
|
| 42 |
+
angle_count: angleCount,
|
| 43 |
+
concept_count: conceptCount,
|
| 44 |
+
strategy,
|
| 45 |
+
});
|
| 46 |
+
setMatrix(result);
|
| 47 |
+
onMatrixGenerated?.(result);
|
| 48 |
+
toast.success(`Generated ${result.summary.total_combinations} combinations!`);
|
| 49 |
+
} catch (error: any) {
|
| 50 |
+
toast.error(error.message || "Failed to generate testing matrix");
|
| 51 |
+
} finally {
|
| 52 |
+
setIsGenerating(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const handleExportJSON = () => {
|
| 57 |
+
if (!matrix) return;
|
| 58 |
+
exportAsJSON(matrix, `testing-matrix-${niche}-${Date.now()}.json`);
|
| 59 |
+
toast.success("Matrix exported as JSON");
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const handleExportCSV = () => {
|
| 63 |
+
if (!matrix) return;
|
| 64 |
+
const csvData = matrix.combinations.map((combo) => ({
|
| 65 |
+
combination_id: combo.combination_id,
|
| 66 |
+
angle_key: combo.angle.key,
|
| 67 |
+
angle_name: combo.angle.name,
|
| 68 |
+
angle_trigger: combo.angle.trigger,
|
| 69 |
+
concept_key: combo.concept.key,
|
| 70 |
+
concept_name: combo.concept.name,
|
| 71 |
+
compatibility_score: combo.compatibility_score,
|
| 72 |
+
}));
|
| 73 |
+
exportAsCSV(csvData, `testing-matrix-${niche}-${Date.now()}.csv`);
|
| 74 |
+
toast.success("Matrix exported as CSV");
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const toggleCombination = (combinationId: string) => {
|
| 78 |
+
const newSelected = new Set(selectedCombinations);
|
| 79 |
+
if (newSelected.has(combinationId)) {
|
| 80 |
+
newSelected.delete(combinationId);
|
| 81 |
+
} else {
|
| 82 |
+
newSelected.add(combinationId);
|
| 83 |
+
}
|
| 84 |
+
setSelectedCombinations(newSelected);
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const selectAll = () => {
|
| 88 |
+
if (!matrix) return;
|
| 89 |
+
if (selectedCombinations.size === matrix.combinations.length) {
|
| 90 |
+
setSelectedCombinations(new Set());
|
| 91 |
+
} else {
|
| 92 |
+
setSelectedCombinations(new Set(matrix.combinations.map(c => c.combination_id)));
|
| 93 |
+
}
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const handleGenerateAds = async () => {
|
| 97 |
+
if (!matrix) return;
|
| 98 |
+
|
| 99 |
+
const combinationsToGenerate = selectedCombinations.size > 0
|
| 100 |
+
? matrix.combinations.filter(c => selectedCombinations.has(c.combination_id))
|
| 101 |
+
: matrix.combinations;
|
| 102 |
+
|
| 103 |
+
if (combinationsToGenerate.length === 0) {
|
| 104 |
+
toast.error("Please select at least one combination");
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
setIsGeneratingAds(true);
|
| 109 |
+
setGenerationProgress({ current: 0, total: combinationsToGenerate.length });
|
| 110 |
+
|
| 111 |
+
try {
|
| 112 |
+
const generatedAds = [];
|
| 113 |
+
|
| 114 |
+
for (let i = 0; i < combinationsToGenerate.length; i++) {
|
| 115 |
+
const combo = combinationsToGenerate[i];
|
| 116 |
+
setGenerationProgress({ current: i + 1, total: combinationsToGenerate.length });
|
| 117 |
+
|
| 118 |
+
try {
|
| 119 |
+
const result = await generateMatrixAd({
|
| 120 |
+
niche,
|
| 121 |
+
angle_key: combo.angle.key,
|
| 122 |
+
concept_key: combo.concept.key,
|
| 123 |
+
num_images: numVariations,
|
| 124 |
+
image_model: imageModel,
|
| 125 |
+
});
|
| 126 |
+
generatedAds.push(result);
|
| 127 |
+
toast.success(`Generated ad ${i + 1}/${combinationsToGenerate.length}`);
|
| 128 |
+
} catch (error: any) {
|
| 129 |
+
console.error(`Failed to generate ad for combination ${combo.combination_id}:`, error);
|
| 130 |
+
toast.error(`Failed to generate ad ${i + 1}/${combinationsToGenerate.length}`);
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
toast.success(`Successfully generated ${generatedAds.length} ads!`);
|
| 135 |
+
|
| 136 |
+
// Navigate to gallery to see the results
|
| 137 |
+
setTimeout(() => {
|
| 138 |
+
router.push("/gallery");
|
| 139 |
+
}, 1500);
|
| 140 |
+
} catch (error: any) {
|
| 141 |
+
toast.error(error.message || "Failed to generate ads");
|
| 142 |
+
} finally {
|
| 143 |
+
setIsGeneratingAds(false);
|
| 144 |
+
setGenerationProgress({ current: 0, total: 0 });
|
| 145 |
+
}
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
return (
|
| 149 |
+
<div className="space-y-6">
|
| 150 |
+
<Card variant="glass">
|
| 151 |
+
<CardHeader>
|
| 152 |
+
<CardTitle>Testing Matrix Builder</CardTitle>
|
| 153 |
+
<CardDescription>
|
| 154 |
+
Generate a systematic testing matrix for ad optimization
|
| 155 |
+
</CardDescription>
|
| 156 |
+
</CardHeader>
|
| 157 |
+
<CardContent className="space-y-6">
|
| 158 |
+
<Select
|
| 159 |
+
label="Niche"
|
| 160 |
+
options={[
|
| 161 |
+
{ value: "home_insurance", label: "Home Insurance" },
|
| 162 |
+
{ value: "glp1", label: "GLP-1" },
|
| 163 |
+
]}
|
| 164 |
+
value={niche}
|
| 165 |
+
onChange={(e) => setNiche(e.target.value as Niche)}
|
| 166 |
+
/>
|
| 167 |
+
|
| 168 |
+
<Select
|
| 169 |
+
label="Strategy"
|
| 170 |
+
options={[
|
| 171 |
+
{ value: "balanced", label: "Balanced - Mix of top performers and diverse" },
|
| 172 |
+
{ value: "top_performers", label: "Top Performers - Focus on proven winners" },
|
| 173 |
+
{ value: "diverse", label: "Diverse - Maximum variety" },
|
| 174 |
+
]}
|
| 175 |
+
value={strategy}
|
| 176 |
+
onChange={(e) => setStrategy(e.target.value as typeof strategy)}
|
| 177 |
+
/>
|
| 178 |
+
|
| 179 |
+
<div>
|
| 180 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 181 |
+
Number of Angles: {angleCount}
|
| 182 |
+
</label>
|
| 183 |
+
<input
|
| 184 |
+
type="range"
|
| 185 |
+
min="1"
|
| 186 |
+
max="10"
|
| 187 |
+
step="1"
|
| 188 |
+
className="w-full"
|
| 189 |
+
value={angleCount}
|
| 190 |
+
onChange={(e) => setAngleCount(Number(e.target.value))}
|
| 191 |
+
/>
|
| 192 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
| 193 |
+
<span>1</span>
|
| 194 |
+
<span>10</span>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div>
|
| 199 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 200 |
+
Concepts per Angle: {conceptCount}
|
| 201 |
+
</label>
|
| 202 |
+
<input
|
| 203 |
+
type="range"
|
| 204 |
+
min="1"
|
| 205 |
+
max="10"
|
| 206 |
+
step="1"
|
| 207 |
+
className="w-full"
|
| 208 |
+
value={conceptCount}
|
| 209 |
+
onChange={(e) => setConceptCount(Number(e.target.value))}
|
| 210 |
+
/>
|
| 211 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
| 212 |
+
<span>1</span>
|
| 213 |
+
<span>10</span>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
| 218 |
+
<p className="text-sm text-blue-800">
|
| 219 |
+
<strong>Total Combinations:</strong> {angleCount} × {conceptCount} = {angleCount * conceptCount}
|
| 220 |
+
</p>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<Button
|
| 224 |
+
variant="primary"
|
| 225 |
+
size="lg"
|
| 226 |
+
className="w-full"
|
| 227 |
+
onClick={handleGenerate}
|
| 228 |
+
isLoading={isGenerating}
|
| 229 |
+
>
|
| 230 |
+
Generate Testing Matrix
|
| 231 |
+
</Button>
|
| 232 |
+
</CardContent>
|
| 233 |
+
</Card>
|
| 234 |
+
|
| 235 |
+
{matrix && (
|
| 236 |
+
<>
|
| 237 |
+
<Card variant="glass">
|
| 238 |
+
<CardHeader>
|
| 239 |
+
<div className="flex items-center justify-between">
|
| 240 |
+
<div>
|
| 241 |
+
<CardTitle>Matrix Summary</CardTitle>
|
| 242 |
+
<CardDescription>
|
| 243 |
+
{matrix.summary.total_combinations} combinations ready for testing
|
| 244 |
+
</CardDescription>
|
| 245 |
+
</div>
|
| 246 |
+
<div className="flex space-x-2">
|
| 247 |
+
<Button variant="outline" size="sm" onClick={handleExportJSON}>
|
| 248 |
+
<FileJson className="h-4 w-4 mr-1" />
|
| 249 |
+
JSON
|
| 250 |
+
</Button>
|
| 251 |
+
<Button variant="outline" size="sm" onClick={handleExportCSV}>
|
| 252 |
+
<FileSpreadsheet className="h-4 w-4 mr-1" />
|
| 253 |
+
CSV
|
| 254 |
+
</Button>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</CardHeader>
|
| 258 |
+
<CardContent>
|
| 259 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
| 260 |
+
<div>
|
| 261 |
+
<p className="text-sm text-gray-600">Total Combinations</p>
|
| 262 |
+
<p className="text-2xl font-bold text-gray-900">{matrix.summary.total_combinations}</p>
|
| 263 |
+
</div>
|
| 264 |
+
<div>
|
| 265 |
+
<p className="text-sm text-gray-600">Unique Angles</p>
|
| 266 |
+
<p className="text-2xl font-bold text-gray-900">{matrix.summary.unique_angles}</p>
|
| 267 |
+
</div>
|
| 268 |
+
<div>
|
| 269 |
+
<p className="text-sm text-gray-600">Unique Concepts</p>
|
| 270 |
+
<p className="text-2xl font-bold text-gray-900">{matrix.summary.unique_concepts}</p>
|
| 271 |
+
</div>
|
| 272 |
+
<div>
|
| 273 |
+
<p className="text-sm text-gray-600">Avg Compatibility</p>
|
| 274 |
+
<p className="text-2xl font-bold text-gray-900">
|
| 275 |
+
{(matrix.summary.average_compatibility * 100).toFixed(0)}%
|
| 276 |
+
</p>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
{/* Generate Ads Section */}
|
| 281 |
+
<div className="bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-xl p-6 mb-6">
|
| 282 |
+
<div className="flex items-center justify-between mb-4">
|
| 283 |
+
<div>
|
| 284 |
+
<h3 className="text-lg font-bold text-gray-900 mb-1">Generate Ads from Matrix</h3>
|
| 285 |
+
<p className="text-sm text-gray-600">
|
| 286 |
+
Select combinations to generate ads, or generate all
|
| 287 |
+
</p>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<div className="mb-4">
|
| 292 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 293 |
+
Variations per Combination: {numVariations}
|
| 294 |
+
</label>
|
| 295 |
+
<input
|
| 296 |
+
type="range"
|
| 297 |
+
min="1"
|
| 298 |
+
max="3"
|
| 299 |
+
step="1"
|
| 300 |
+
className="w-full accent-blue-500"
|
| 301 |
+
value={numVariations}
|
| 302 |
+
onChange={(e) => setNumVariations(Number(e.target.value))}
|
| 303 |
+
/>
|
| 304 |
+
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
| 305 |
+
<span>1</span>
|
| 306 |
+
<span>3</span>
|
| 307 |
+
</div>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
<div className="mb-4">
|
| 311 |
+
<Select
|
| 312 |
+
label="Image Model"
|
| 313 |
+
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
| 314 |
+
value={imageModel || ""}
|
| 315 |
+
onChange={(e) => setImageModel(e.target.value || null)}
|
| 316 |
+
/>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div className="flex items-center justify-between mb-4">
|
| 320 |
+
<div className="flex items-center space-x-2">
|
| 321 |
+
<Button
|
| 322 |
+
variant="outline"
|
| 323 |
+
size="sm"
|
| 324 |
+
onClick={selectAll}
|
| 325 |
+
>
|
| 326 |
+
{selectedCombinations.size === matrix.combinations.length ? (
|
| 327 |
+
<>
|
| 328 |
+
<CheckSquare className="h-4 w-4 mr-1" />
|
| 329 |
+
Deselect All
|
| 330 |
+
</>
|
| 331 |
+
) : (
|
| 332 |
+
<>
|
| 333 |
+
<Square className="h-4 w-4 mr-1" />
|
| 334 |
+
Select All
|
| 335 |
+
</>
|
| 336 |
+
)}
|
| 337 |
+
</Button>
|
| 338 |
+
<span className="text-sm text-gray-600">
|
| 339 |
+
{selectedCombinations.size > 0
|
| 340 |
+
? `${selectedCombinations.size} selected`
|
| 341 |
+
: "All combinations will be generated"}
|
| 342 |
+
</span>
|
| 343 |
+
</div>
|
| 344 |
+
<Button
|
| 345 |
+
variant="primary"
|
| 346 |
+
size="lg"
|
| 347 |
+
onClick={handleGenerateAds}
|
| 348 |
+
isLoading={isGeneratingAds}
|
| 349 |
+
disabled={isGeneratingAds}
|
| 350 |
+
>
|
| 351 |
+
<Rocket className="h-5 w-5 mr-2" />
|
| 352 |
+
Generate Ads
|
| 353 |
+
</Button>
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
{isGeneratingAds && generationProgress.total > 0 && (
|
| 357 |
+
<div className="mt-4">
|
| 358 |
+
<div className="flex items-center justify-between mb-2">
|
| 359 |
+
<span className="text-sm font-medium text-gray-700">
|
| 360 |
+
Generating ads...
|
| 361 |
+
</span>
|
| 362 |
+
<span className="text-sm text-gray-600">
|
| 363 |
+
{generationProgress.current} / {generationProgress.total}
|
| 364 |
+
</span>
|
| 365 |
+
</div>
|
| 366 |
+
<ProgressBar
|
| 367 |
+
progress={(generationProgress.current / generationProgress.total) * 100}
|
| 368 |
+
/>
|
| 369 |
+
</div>
|
| 370 |
+
)}
|
| 371 |
+
|
| 372 |
+
<div className="bg-blue-100 border border-blue-300 rounded-lg p-3 mt-4">
|
| 373 |
+
<p className="text-xs text-blue-800">
|
| 374 |
+
<strong>Note:</strong> This will generate{" "}
|
| 375 |
+
{selectedCombinations.size > 0
|
| 376 |
+
? `${selectedCombinations.size} × ${numVariations} = ${selectedCombinations.size * numVariations}`
|
| 377 |
+
: `${matrix.combinations.length} × ${numVariations} = ${matrix.combinations.length * numVariations}`}{" "}
|
| 378 |
+
total ad variations. This may take several minutes.
|
| 379 |
+
</p>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
|
| 383 |
+
{/* Combinations List */}
|
| 384 |
+
<div className="max-h-96 overflow-y-auto space-y-2">
|
| 385 |
+
{matrix.combinations.map((combo) => {
|
| 386 |
+
const isSelected = selectedCombinations.has(combo.combination_id);
|
| 387 |
+
return (
|
| 388 |
+
<div
|
| 389 |
+
key={combo.combination_id}
|
| 390 |
+
onClick={() => toggleCombination(combo.combination_id)}
|
| 391 |
+
className={`p-3 border rounded-lg cursor-pointer transition-all ${
|
| 392 |
+
isSelected
|
| 393 |
+
? "border-blue-500 bg-blue-50 shadow-md"
|
| 394 |
+
: "border-gray-200 hover:bg-gray-50 hover:border-gray-300"
|
| 395 |
+
}`}
|
| 396 |
+
>
|
| 397 |
+
<div className="flex items-start justify-between">
|
| 398 |
+
<div className="flex items-start space-x-3 flex-1">
|
| 399 |
+
<div className="mt-0.5">
|
| 400 |
+
{isSelected ? (
|
| 401 |
+
<CheckSquare className="h-5 w-5 text-blue-600" />
|
| 402 |
+
) : (
|
| 403 |
+
<Square className="h-5 w-5 text-gray-400" />
|
| 404 |
+
)}
|
| 405 |
+
</div>
|
| 406 |
+
<div className="flex-1">
|
| 407 |
+
<div className="flex items-center space-x-2 mb-1">
|
| 408 |
+
<span className="text-sm font-semibold text-gray-900">
|
| 409 |
+
{combo.angle.name}
|
| 410 |
+
</span>
|
| 411 |
+
<span className="text-gray-400">×</span>
|
| 412 |
+
<span className="text-sm font-semibold text-gray-900">
|
| 413 |
+
{combo.concept.name}
|
| 414 |
+
</span>
|
| 415 |
+
</div>
|
| 416 |
+
<p className="text-xs text-gray-500">{combo.angle.trigger}</p>
|
| 417 |
+
<p className="text-xs text-gray-500 mt-1">{combo.concept.structure}</p>
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
<div className="ml-4 text-right">
|
| 421 |
+
<span className="text-xs font-medium text-blue-600">
|
| 422 |
+
{(combo.compatibility_score * 100).toFixed(0)}%
|
| 423 |
+
</span>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
);
|
| 428 |
+
})}
|
| 429 |
+
</div>
|
| 430 |
+
</CardContent>
|
| 431 |
+
</Card>
|
| 432 |
+
</>
|
| 433 |
+
)}
|
| 434 |
+
</div>
|
| 435 |
+
);
|
| 436 |
+
};
|
frontend/components/ui/Button.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { cn } from "../../lib/utils/cn";
|
| 3 |
+
|
| 4 |
+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
| 5 |
+
variant?: "primary" | "secondary" | "outline" | "ghost" | "danger";
|
| 6 |
+
size?: "sm" | "md" | "lg";
|
| 7 |
+
isLoading?: boolean;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const Button: React.FC<ButtonProps> = ({
|
| 11 |
+
children,
|
| 12 |
+
variant = "primary",
|
| 13 |
+
size = "md",
|
| 14 |
+
isLoading = false,
|
| 15 |
+
disabled,
|
| 16 |
+
className,
|
| 17 |
+
...props
|
| 18 |
+
}) => {
|
| 19 |
+
const baseStyles = "inline-flex items-center justify-center rounded-xl font-semibold transition-all duration-250 ease-out focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none relative overflow-hidden group";
|
| 20 |
+
|
| 21 |
+
const variants = {
|
| 22 |
+
primary: "bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:from-blue-600 hover:to-cyan-600 focus:ring-blue-500 shadow-lg hover:shadow-xl hover:scale-105 active:scale-100",
|
| 23 |
+
secondary: "bg-gradient-to-r from-orange-400 to-pink-500 text-white hover:from-orange-500 hover:to-pink-600 focus:ring-orange-500 shadow-lg hover:shadow-xl hover:scale-105 active:scale-100",
|
| 24 |
+
outline: "border-2 border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500 hover:border-gray-400 hover:scale-105 active:scale-100 bg-white/50 backdrop-blur-sm",
|
| 25 |
+
ghost: "text-gray-700 hover:bg-gray-100/80 focus:ring-gray-500 hover:scale-105 active:scale-100",
|
| 26 |
+
danger: "bg-gradient-to-r from-red-500 to-red-600 text-white hover:from-red-600 hover:to-red-700 focus:ring-red-500 shadow-lg hover:shadow-xl hover:scale-105 active:scale-100",
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const sizes = {
|
| 30 |
+
sm: "px-4 py-2 text-sm",
|
| 31 |
+
md: "px-6 py-3 text-base",
|
| 32 |
+
lg: "px-8 py-4 text-lg",
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<button
|
| 37 |
+
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
| 38 |
+
disabled={disabled || isLoading}
|
| 39 |
+
{...props}
|
| 40 |
+
>
|
| 41 |
+
{/* Shine effect on hover */}
|
| 42 |
+
<span className="absolute inset-0 -translate-x-full group-hover:translate-x-full transition-transform duration-700 bg-gradient-to-r from-transparent via-white/20 to-transparent"></span>
|
| 43 |
+
|
| 44 |
+
{isLoading ? (
|
| 45 |
+
<>
|
| 46 |
+
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 47 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 48 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 49 |
+
</svg>
|
| 50 |
+
Loading...
|
| 51 |
+
</>
|
| 52 |
+
) : (
|
| 53 |
+
<span className="relative z-10">{children}</span>
|
| 54 |
+
)}
|
| 55 |
+
</button>
|
| 56 |
+
);
|
| 57 |
+
};
|
frontend/components/ui/Card.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { cn } from "../../lib/utils/cn";
|
| 3 |
+
|
| 4 |
+
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
| 5 |
+
variant?: "default" | "outline" | "elevated" | "glass";
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export const Card: React.FC<CardProps> = ({
|
| 9 |
+
children,
|
| 10 |
+
variant = "default",
|
| 11 |
+
className,
|
| 12 |
+
...props
|
| 13 |
+
}) => {
|
| 14 |
+
const variants = {
|
| 15 |
+
default: "bg-white/80 backdrop-blur-sm border border-gray-200/50 shadow-md hover:shadow-lg transition-all duration-300",
|
| 16 |
+
outline: "bg-transparent border-2 border-gray-300/50 hover:border-gray-400 transition-all duration-300",
|
| 17 |
+
elevated: "bg-white shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1",
|
| 18 |
+
glass: "glass border border-white/20 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1",
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div
|
| 23 |
+
className={cn("rounded-2xl p-6 hover-lift", variants[variant], className)}
|
| 24 |
+
{...props}
|
| 25 |
+
>
|
| 26 |
+
{children}
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
export const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
| 32 |
+
children,
|
| 33 |
+
className,
|
| 34 |
+
...props
|
| 35 |
+
}) => (
|
| 36 |
+
<div className={cn("mb-6", className)} {...props}>
|
| 37 |
+
{children}
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
export const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
|
| 42 |
+
children,
|
| 43 |
+
className,
|
| 44 |
+
...props
|
| 45 |
+
}) => (
|
| 46 |
+
<h3 className={cn("text-2xl font-bold gradient-text", className)} {...props}>
|
| 47 |
+
{children}
|
| 48 |
+
</h3>
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
export const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
|
| 52 |
+
children,
|
| 53 |
+
className,
|
| 54 |
+
...props
|
| 55 |
+
}) => (
|
| 56 |
+
<p className={cn("text-sm text-gray-600 mt-2", className)} {...props}>
|
| 57 |
+
{children}
|
| 58 |
+
</p>
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
export const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
| 62 |
+
children,
|
| 63 |
+
className,
|
| 64 |
+
...props
|
| 65 |
+
}) => (
|
| 66 |
+
<div className={cn("", className)} {...props}>
|
| 67 |
+
{children}
|
| 68 |
+
</div>
|
| 69 |
+
);
|
| 70 |
+
|
| 71 |
+
export const CardFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
| 72 |
+
children,
|
| 73 |
+
className,
|
| 74 |
+
...props
|
| 75 |
+
}) => (
|
| 76 |
+
<div className={cn("mt-6 flex items-center justify-between", className)} {...props}>
|
| 77 |
+
{children}
|
| 78 |
+
</div>
|
| 79 |
+
);
|
frontend/components/ui/Input.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
import { cn } from "../../lib/utils/cn";
|
| 3 |
+
|
| 4 |
+
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
| 5 |
+
label?: string;
|
| 6 |
+
error?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const Input: React.FC<InputProps> = ({
|
| 10 |
+
label,
|
| 11 |
+
error,
|
| 12 |
+
className,
|
| 13 |
+
...props
|
| 14 |
+
}) => {
|
| 15 |
+
const [focused, setFocused] = useState(false);
|
| 16 |
+
const hasValue = props.value !== undefined && props.value !== "";
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="w-full">
|
| 20 |
+
{label && (
|
| 21 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 22 |
+
{label}
|
| 23 |
+
</label>
|
| 24 |
+
)}
|
| 25 |
+
<div className="relative">
|
| 26 |
+
<input
|
| 27 |
+
className={cn(
|
| 28 |
+
"w-full px-4 py-3 rounded-xl border-2 transition-all duration-250 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
| 29 |
+
"bg-white/80 backdrop-blur-sm",
|
| 30 |
+
error
|
| 31 |
+
? "border-red-400 focus:border-red-500 focus:ring-red-500"
|
| 32 |
+
: focused || hasValue
|
| 33 |
+
? "border-blue-400 focus:border-blue-500 focus:ring-blue-500"
|
| 34 |
+
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
|
| 35 |
+
"hover:border-gray-400",
|
| 36 |
+
className
|
| 37 |
+
)}
|
| 38 |
+
onFocus={() => setFocused(true)}
|
| 39 |
+
onBlur={() => setFocused(false)}
|
| 40 |
+
{...props}
|
| 41 |
+
/>
|
| 42 |
+
{(focused || hasValue) && !error && (
|
| 43 |
+
<div className="absolute inset-0 rounded-xl border-2 border-blue-500 pointer-events-none animate-pulse-glow opacity-50"></div>
|
| 44 |
+
)}
|
| 45 |
+
</div>
|
| 46 |
+
{error && (
|
| 47 |
+
<p className="mt-2 text-sm text-red-600 font-medium animate-slide-in">{error}</p>
|
| 48 |
+
)}
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
};
|
frontend/components/ui/LoadingSkeleton.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { cn } from "../../lib/utils/cn";
|
| 3 |
+
|
| 4 |
+
interface LoadingSkeletonProps {
|
| 5 |
+
className?: string;
|
| 6 |
+
variant?: "text" | "circular" | "rectangular";
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
|
| 10 |
+
className,
|
| 11 |
+
variant = "rectangular",
|
| 12 |
+
}) => {
|
| 13 |
+
const baseStyles = "animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-shimmer";
|
| 14 |
+
|
| 15 |
+
const variants = {
|
| 16 |
+
text: "h-4 rounded",
|
| 17 |
+
circular: "rounded-full aspect-square",
|
| 18 |
+
rectangular: "rounded-xl",
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className={cn(baseStyles, variants[variant], className)} />
|
| 23 |
+
);
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
// Add shimmer animation to globals.css
|
| 27 |
+
const shimmerKeyframes = `
|
| 28 |
+
@keyframes shimmer {
|
| 29 |
+
0% {
|
| 30 |
+
background-position: -200% 0;
|
| 31 |
+
}
|
| 32 |
+
100% {
|
| 33 |
+
background-position: 200% 0;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
`;
|
frontend/components/ui/LoadingSpinner.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { cn } from "../../lib/utils/cn";
|
| 3 |
+
|
| 4 |
+
interface LoadingSpinnerProps {
|
| 5 |
+
size?: "sm" | "md" | "lg";
|
| 6 |
+
className?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
| 10 |
+
size = "md",
|
| 11 |
+
className,
|
| 12 |
+
}) => {
|
| 13 |
+
const sizes = {
|
| 14 |
+
sm: "h-4 w-4",
|
| 15 |
+
md: "h-8 w-8",
|
| 16 |
+
lg: "h-12 w-12",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className={cn("flex items-center justify-center relative", className)}>
|
| 21 |
+
{/* Outer glow */}
|
| 22 |
+
<div className={cn("absolute rounded-full bg-gradient-to-r from-blue-500 to-cyan-500 opacity-20 animate-ping", sizes[size])}></div>
|
| 23 |
+
|
| 24 |
+
{/* Main spinner */}
|
| 25 |
+
<svg
|
| 26 |
+
className={cn("animate-spin relative z-10", sizes[size])}
|
| 27 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 28 |
+
fill="none"
|
| 29 |
+
viewBox="0 0 24 24"
|
| 30 |
+
>
|
| 31 |
+
<defs>
|
| 32 |
+
<linearGradient id="spinner-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 33 |
+
<stop offset="0%" stopColor="#3b82f6" />
|
| 34 |
+
<stop offset="50%" stopColor="#06b6d4" />
|
| 35 |
+
<stop offset="100%" stopColor="#ec4899" />
|
| 36 |
+
</linearGradient>
|
| 37 |
+
</defs>
|
| 38 |
+
<circle
|
| 39 |
+
className="opacity-25"
|
| 40 |
+
cx="12"
|
| 41 |
+
cy="12"
|
| 42 |
+
r="10"
|
| 43 |
+
stroke="currentColor"
|
| 44 |
+
strokeWidth="4"
|
| 45 |
+
></circle>
|
| 46 |
+
<path
|
| 47 |
+
className="opacity-75"
|
| 48 |
+
fill="url(#spinner-gradient)"
|
| 49 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
| 50 |
+
></path>
|
| 51 |
+
</svg>
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
};
|