Commit
·
addcf34
1
Parent(s):
b8b7791
Update ad generation features and enhance user input options
Browse files- Renamed project references from Ad Generator Lite to PsyAdGenesis, reflecting the new branding.
- Added optional fields for target audience and offer in ad generation requests to improve customization.
- Enhanced the GeneratePage and BatchForm components to include input fields for target audience and offer.
- Updated API endpoints to handle new parameters for ad generation and batch processing.
- Improved error handling and validation for new input fields to ensure robust user experience.
- Enhanced the ad creative generation logic to incorporate user-defined target audience and offer in the output.
- Ad_Generator_Lite.postman_collection.json +4 -4
- README.md +5 -2
- data/glp1.py +308 -0
- frontend/README.md +3 -3
- frontend/app/gallery/[id]/page.tsx +220 -59
- frontend/app/gallery/page.tsx +33 -12
- frontend/app/generate/page.tsx +231 -73
- frontend/app/page.tsx +1 -4
- frontend/components/gallery/AdCard.tsx +27 -0
- frontend/components/gallery/FilterBar.tsx +3 -3
- frontend/components/gallery/GalleryGrid.tsx +3 -0
- frontend/components/generation/AdPreview.tsx +47 -19
- frontend/components/generation/BatchForm.tsx +33 -1
- frontend/components/generation/CorrectionModal.tsx +67 -23
- frontend/components/generation/EditCopyModal.tsx +334 -0
- frontend/components/generation/ExtensiveForm.tsx +6 -6
- frontend/components/generation/GenerationForm.tsx +35 -3
- frontend/components/generation/GenerationProgress.tsx +2 -1
- frontend/lib/api/endpoints.ts +35 -4
- frontend/lib/utils/export.ts +10 -5
- frontend/lib/utils/validators.ts +6 -0
- frontend/store/galleryStore.ts +1 -0
- frontend/types/api.ts +14 -2
- main.py +303 -50
- services/database.py +83 -0
- services/generator.py +100 -115
Ad_Generator_Lite.postman_collection.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
{
|
| 2 |
"info": {
|
| 3 |
-
"_postman_id": "
|
| 4 |
-
"name": "
|
| 5 |
-
"description": "Complete API collection for
|
| 6 |
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
| 7 |
-
"_exporter_id": "
|
| 8 |
},
|
| 9 |
"item": [
|
| 10 |
{
|
|
|
|
| 1 |
{
|
| 2 |
"info": {
|
| 3 |
+
"_postman_id": "psyadgenesis-collection",
|
| 4 |
+
"name": "PsyAdGenesis API",
|
| 5 |
+
"description": "Complete API collection for PsyAdGenesis - Design ads that stop the scroll. Generate high-converting ad creatives for Home Insurance and GLP-1 niches",
|
| 6 |
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
| 7 |
+
"_exporter_id": "psyadgenesis"
|
| 8 |
},
|
| 9 |
"item": [
|
| 10 |
{
|
README.md
CHANGED
|
@@ -18,7 +18,7 @@ Generate high-converting ad creatives for Home Insurance and GLP-1 niches using
|
|
| 18 |
## Features
|
| 19 |
|
| 20 |
- **Multiple Generation Modes**:
|
| 21 |
-
- Standard generation
|
| 22 |
- Batch generation for multiple ads
|
| 23 |
- Angle × Concept matrix system (100 angles × 100 concepts)
|
| 24 |
- Extensive generation with researcher → creative director → designer → copywriter flow
|
|
@@ -189,11 +189,14 @@ https://your-username-psyadgenesis.hf.space
|
|
| 189 |
|
| 190 |
## Matrix System
|
| 191 |
|
| 192 |
-
The app
|
| 193 |
- **100 Angles**: Psychological triggers (10 categories)
|
| 194 |
- **100 Concepts**: Visual approaches (10 categories)
|
| 195 |
- **10,000 possible combinations**
|
| 196 |
|
|
|
|
|
|
|
|
|
|
| 197 |
Formula: 1 Offer → 5-8 Angles → 3-5 Concepts per angle
|
| 198 |
|
| 199 |
## License
|
|
|
|
| 18 |
## Features
|
| 19 |
|
| 20 |
- **Multiple Generation Modes**:
|
| 21 |
+
- Standard generation using random angle × concept combinations
|
| 22 |
- Batch generation for multiple ads
|
| 23 |
- Angle × Concept matrix system (100 angles × 100 concepts)
|
| 24 |
- Extensive generation with researcher → creative director → designer → copywriter flow
|
|
|
|
| 189 |
|
| 190 |
## Matrix System
|
| 191 |
|
| 192 |
+
The app uses a systematic Angle × Concept matrix for all ad generation:
|
| 193 |
- **100 Angles**: Psychological triggers (10 categories)
|
| 194 |
- **100 Concepts**: Visual approaches (10 categories)
|
| 195 |
- **10,000 possible combinations**
|
| 196 |
|
| 197 |
+
**Standard Generation**: Randomly selects an angle × concept combination for each ad
|
| 198 |
+
**Matrix Generation**: Allows explicit selection of specific angle × concept combinations
|
| 199 |
+
|
| 200 |
Formula: 1 Offer → 5-8 Angles → 3-5 Concepts per angle
|
| 201 |
|
| 202 |
## License
|
data/glp1.py
CHANGED
|
@@ -666,6 +666,314 @@ CTAS = [
|
|
| 666 |
"See Real Results",
|
| 667 |
]
|
| 668 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
|
| 670 |
def get_niche_data():
|
| 671 |
"""Return all GLP-1 data for the generator."""
|
|
|
|
| 666 |
"See Real Results",
|
| 667 |
]
|
| 668 |
|
| 669 |
+
# ============================================================================
|
| 670 |
+
# SECTION 3: VISUAL LIBRARY - Category Entry Points & Psychological Moments
|
| 671 |
+
# Based on DirectMeds Marketing Brief: 7 Ws, CEPs, Life-Force 8, Stage of Awareness
|
| 672 |
+
# ============================================================================
|
| 673 |
+
|
| 674 |
+
# High-Priority Category Entry Points (CEPs)
|
| 675 |
+
DIET_FATIGUE_VISUALS = [
|
| 676 |
+
"exhausted person looking at calendar, Monday marked",
|
| 677 |
+
"pile of diet books and unused meal prep containers",
|
| 678 |
+
"frustrated person deleting calorie tracking app",
|
| 679 |
+
"empty gym at 5am, abandoned exercise equipment",
|
| 680 |
+
"kitchen counter with half-finished meal prep",
|
| 681 |
+
"person crumpling up diet plan, defeated expression",
|
| 682 |
+
"calendar showing Monday starts and Friday failures",
|
| 683 |
+
"discarded diet supplements and meal replacement bars",
|
| 684 |
+
"person standing at fridge late night, exhausted",
|
| 685 |
+
"workout clothes hanging unused in closet",
|
| 686 |
+
]
|
| 687 |
+
|
| 688 |
+
CLOTHES_BODY_AWARENESS_VISUALS = [
|
| 689 |
+
"person standing in closet, three pairs of jeans on floor",
|
| 690 |
+
"mirror reflection, person trying on old goal-weight clothes",
|
| 691 |
+
"tight jeans with tag showing larger size",
|
| 692 |
+
"full-length mirror, person examining side profile",
|
| 693 |
+
"closet with 'goal' clothes on separate hanger",
|
| 694 |
+
"person looking down at clothes that don't fit",
|
| 695 |
+
"dressing room mirror, person holding up too-small clothing",
|
| 696 |
+
"closet full of clothes with only a few that fit",
|
| 697 |
+
"person avoiding full-length mirror in bedroom",
|
| 698 |
+
"baggy clothes hanging to hide body shape",
|
| 699 |
+
]
|
| 700 |
+
|
| 701 |
+
HEALTH_WAKE_UP_CALLS_VISUALS = [
|
| 702 |
+
"person reading lab results with concerned expression",
|
| 703 |
+
"doctor pointing at health chart during consultation",
|
| 704 |
+
"blood pressure monitor showing elevated numbers",
|
| 705 |
+
"person holding prescription slip, thoughtful expression",
|
| 706 |
+
"health screening results with warning indicators",
|
| 707 |
+
"person looking at scale reading with worried face",
|
| 708 |
+
"medical chart with circled high numbers",
|
| 709 |
+
"person in doctor's office reviewing test results",
|
| 710 |
+
"health app showing concerning metrics",
|
| 711 |
+
"person receiving health warning notification on phone",
|
| 712 |
+
]
|
| 713 |
+
|
| 714 |
+
MENTAL_LOAD_FOOD_NOISE_VISUALS = [
|
| 715 |
+
"person staring at fridge, overwhelmed by food choices",
|
| 716 |
+
"kitchen with multiple diet plans and tracking apps open",
|
| 717 |
+
"person scrolling through food tracking apps late at night",
|
| 718 |
+
"notepad with food thoughts and meal planning stress",
|
| 719 |
+
"person at grocery store looking overwhelmed by options",
|
| 720 |
+
"multiple calorie tracking apps on phone screen",
|
| 721 |
+
"person with hand on forehead, thinking about food",
|
| 722 |
+
"kitchen counter cluttered with meal planning materials",
|
| 723 |
+
"person researching food constantly on phone",
|
| 724 |
+
"mental exhaustion from constant diet decision-making",
|
| 725 |
+
]
|
| 726 |
+
|
| 727 |
+
# Life-Force 8 Desires
|
| 728 |
+
SURVIVAL_LIFE_EXTENSION_VISUALS = [
|
| 729 |
+
"person looking at family photos, wanting to be healthier",
|
| 730 |
+
"health chart showing reduced risk after weight loss",
|
| 731 |
+
"person exercising with improved energy and mobility",
|
| 732 |
+
"doctor explaining health benefits of weight management",
|
| 733 |
+
"person feeling stronger and more capable in daily life",
|
| 734 |
+
"family members looking relieved at improved health",
|
| 735 |
+
"person reviewing long-term health benefits",
|
| 736 |
+
"active lifestyle showing increased longevity potential",
|
| 737 |
+
"person with improved lab results and health metrics",
|
| 738 |
+
"future-focused visualization of healthy aging",
|
| 739 |
+
]
|
| 740 |
+
|
| 741 |
+
FREEDOM_FROM_FEAR_PAIN_VISUALS = [
|
| 742 |
+
"person looking relieved after doctor's positive report",
|
| 743 |
+
"calm expression after receiving health reassurance",
|
| 744 |
+
"person feeling confident about future health outcomes",
|
| 745 |
+
"relief from constant health anxiety",
|
| 746 |
+
"peaceful moment after health scare resolution",
|
| 747 |
+
"person feeling secure about health trajectory",
|
| 748 |
+
"worried expression transforming to calm relief",
|
| 749 |
+
"person feeling protected from health risks",
|
| 750 |
+
"anxiety-free moment after taking health action",
|
| 751 |
+
"person sleeping peacefully, no longer worried about health",
|
| 752 |
+
]
|
| 753 |
+
|
| 754 |
+
SEXUAL_COMPANIONSHIP_VISUALS = [
|
| 755 |
+
"confident person smiling, feeling attractive",
|
| 756 |
+
"person feeling good in their own skin",
|
| 757 |
+
"couple showing affection, both feeling confident",
|
| 758 |
+
"person with renewed self-esteem and body confidence",
|
| 759 |
+
"intimate moment showing increased confidence",
|
| 760 |
+
"person looking in mirror with self-love",
|
| 761 |
+
"couple enjoying active lifestyle together",
|
| 762 |
+
"person feeling attractive and desirable",
|
| 763 |
+
"confidence boost from improved body image",
|
| 764 |
+
"person radiating self-confidence and attractiveness",
|
| 765 |
+
]
|
| 766 |
+
|
| 767 |
+
COMFORTABLE_LIVING_VISUALS = [
|
| 768 |
+
"person moving easily and comfortably through daily activities",
|
| 769 |
+
"comfortable seating, person feeling at ease in their body",
|
| 770 |
+
"person walking confidently without physical discomfort",
|
| 771 |
+
"daily tasks performed with ease and comfort",
|
| 772 |
+
"person feeling comfortable in public settings",
|
| 773 |
+
"relaxed moment showing physical comfort",
|
| 774 |
+
"person enjoying activities without physical limitations",
|
| 775 |
+
"comfortable mobility in everyday situations",
|
| 776 |
+
"person feeling at ease in their own skin",
|
| 777 |
+
"daily life with improved physical comfort and ease",
|
| 778 |
+
]
|
| 779 |
+
|
| 780 |
+
SUPERIORITY_WINNING_VISUALS = [
|
| 781 |
+
"person celebrating weight loss success story",
|
| 782 |
+
"transformation reveal to friends and family",
|
| 783 |
+
"person feeling like a success story",
|
| 784 |
+
"achievement moment showing weight loss victory",
|
| 785 |
+
"person being recognized for their transformation",
|
| 786 |
+
"success story shared on social media",
|
| 787 |
+
"person feeling like they've won and succeeded",
|
| 788 |
+
"achievement badge or milestone celebration",
|
| 789 |
+
"person feeling superior and accomplished",
|
| 790 |
+
"social proof moment showing transformation success",
|
| 791 |
+
]
|
| 792 |
+
|
| 793 |
+
CARE_PROTECTION_LOVED_ONES_VISUALS = [
|
| 794 |
+
"parent with children, wanting to stay healthy for them",
|
| 795 |
+
"person motivated by family love and responsibility",
|
| 796 |
+
"elderly relative looking proud of health improvements",
|
| 797 |
+
"family gathering, person wanting to be present",
|
| 798 |
+
"person thinking about being there for grandchildren",
|
| 799 |
+
"family photo showing importance of being healthy",
|
| 800 |
+
"person making health decisions for loved ones",
|
| 801 |
+
"family support showing in health journey",
|
| 802 |
+
"person feeling responsibility to stay healthy",
|
| 803 |
+
"intergenerational moment showing health importance",
|
| 804 |
+
]
|
| 805 |
+
|
| 806 |
+
SOCIAL_APPROVAL_VISUALS = [
|
| 807 |
+
"person revealing transformation to amazed friends",
|
| 808 |
+
"social media post showing positive reactions",
|
| 809 |
+
"compliments from others on transformation",
|
| 810 |
+
"person receiving recognition and approval",
|
| 811 |
+
"social gathering with positive attention",
|
| 812 |
+
"transformation reveal moment with shocked reactions",
|
| 813 |
+
"person feeling accepted and approved of",
|
| 814 |
+
"social validation from weight loss success",
|
| 815 |
+
"person receiving praise and positive feedback",
|
| 816 |
+
"recognition moment showing social approval",
|
| 817 |
+
]
|
| 818 |
+
|
| 819 |
+
# 7 Ws-Based Moments
|
| 820 |
+
WHY_MOMENT_VISUALS = [
|
| 821 |
+
"person stepping on scale, seeing disappointing number",
|
| 822 |
+
"unflattering photo that triggers desire to change",
|
| 823 |
+
"clothes that don't fit triggering frustration",
|
| 824 |
+
"doctor's warning creating urgency to act",
|
| 825 |
+
"lab results showing concerning health metrics",
|
| 826 |
+
"mirror reflection showing unwanted change",
|
| 827 |
+
"photo comparison showing weight gain over time",
|
| 828 |
+
"health scare moment creating motivation",
|
| 829 |
+
"social event where person feels uncomfortable",
|
| 830 |
+
"realization moment triggering desire for change",
|
| 831 |
+
]
|
| 832 |
+
|
| 833 |
+
WHEN_MOMENT_VISUALS = [
|
| 834 |
+
"person stepping on scale in morning",
|
| 835 |
+
"person seeing unflattering photo from recent event",
|
| 836 |
+
"person trying on clothes that used to fit",
|
| 837 |
+
"person receiving doctor's warning or lab results",
|
| 838 |
+
"new year resolution moment of determination",
|
| 839 |
+
"summer approaching creating urgency to change",
|
| 840 |
+
"person packing for trip, clothes don't fit",
|
| 841 |
+
"person reviewing photos from recent vacation",
|
| 842 |
+
"seasonal change triggering health focus",
|
| 843 |
+
"milestone event creating motivation to change",
|
| 844 |
+
]
|
| 845 |
+
|
| 846 |
+
WHERE_MOMENT_VISUALS = [
|
| 847 |
+
"person in front of mirror at home",
|
| 848 |
+
"person in closet trying on clothes",
|
| 849 |
+
"person at gym feeling discouraged",
|
| 850 |
+
"person at doctor's office reviewing health",
|
| 851 |
+
"person on couch scrolling social media",
|
| 852 |
+
"person in grocery store feeling overwhelmed",
|
| 853 |
+
"person at social event feeling self-conscious",
|
| 854 |
+
"person packing suitcase, clothes don't fit",
|
| 855 |
+
"person in bathroom with scale",
|
| 856 |
+
"person in car researching solutions on phone",
|
| 857 |
+
]
|
| 858 |
+
|
| 859 |
+
WITH_WHOM_VISUALS = [
|
| 860 |
+
"person alone researching weight loss solutions",
|
| 861 |
+
"person with partner discussing health goals",
|
| 862 |
+
"person with friends talking about weight loss",
|
| 863 |
+
"person with doctor receiving health guidance",
|
| 864 |
+
"person with other parents discussing health",
|
| 865 |
+
"person with coworkers comparing routines",
|
| 866 |
+
"person in online community sharing experiences",
|
| 867 |
+
"person with family supporting health journey",
|
| 868 |
+
"person with healthcare provider discussing options",
|
| 869 |
+
"person with accountability partner or support group",
|
| 870 |
+
]
|
| 871 |
+
|
| 872 |
+
WITH_WHAT_ACTIVITY_VISUALS = [
|
| 873 |
+
"person dieting, attempting another reset",
|
| 874 |
+
"person exercising inconsistently, feeling frustrated",
|
| 875 |
+
"person tracking calories or macros obsessively",
|
| 876 |
+
"person scrolling social media for solutions",
|
| 877 |
+
"person reading reviews or forum discussions",
|
| 878 |
+
"person watching transformation videos",
|
| 879 |
+
"person meal prepping with difficulty",
|
| 880 |
+
"person trying supplements that don't work",
|
| 881 |
+
"person researching prescription options",
|
| 882 |
+
"person comparing different weight loss programs",
|
| 883 |
+
]
|
| 884 |
+
|
| 885 |
+
WHILE_WEARING_USING_VISUALS = [
|
| 886 |
+
"person wearing tight or uncomfortable clothing",
|
| 887 |
+
"person trying on old goal-weight clothes",
|
| 888 |
+
"person in gym wear, feeling discouraged",
|
| 889 |
+
"person in pajamas researching late at night",
|
| 890 |
+
"person using smartwatch tracking activity",
|
| 891 |
+
"person using food tracking app on phone",
|
| 892 |
+
"person standing on bathroom scale",
|
| 893 |
+
"person taking mirror selfie, not satisfied",
|
| 894 |
+
"person wearing clothes that hide body",
|
| 895 |
+
"person using health monitoring devices",
|
| 896 |
+
]
|
| 897 |
+
|
| 898 |
+
WHILE_FEELING_VISUALS = [
|
| 899 |
+
"person showing frustrated expression",
|
| 900 |
+
"person looking defeated and tired",
|
| 901 |
+
"person feeling hopeful but skeptical",
|
| 902 |
+
"person looking embarrassed or self-conscious",
|
| 903 |
+
"person feeling motivated but overwhelmed",
|
| 904 |
+
"person showing anxiety about health",
|
| 905 |
+
"person tired of starting over repeatedly",
|
| 906 |
+
"person ready for real help and solutions",
|
| 907 |
+
"person feeling relieved to find legitimate option",
|
| 908 |
+
"person feeling determined and ready to change",
|
| 909 |
+
]
|
| 910 |
+
|
| 911 |
+
# Stage of Awareness
|
| 912 |
+
UNAWARE_VISUALS = [
|
| 913 |
+
"educational moment introducing health possibilities",
|
| 914 |
+
"person discovering new health perspectives",
|
| 915 |
+
"curiosity creation about weight loss options",
|
| 916 |
+
"person learning about health risks",
|
| 917 |
+
"awareness-building moment about weight and health",
|
| 918 |
+
"person opening eyes to new possibilities",
|
| 919 |
+
"educational content sparking interest",
|
| 920 |
+
"person becoming aware of health connections",
|
| 921 |
+
"learning moment about weight loss science",
|
| 922 |
+
"person discovering weight affects health",
|
| 923 |
+
]
|
| 924 |
+
|
| 925 |
+
PROBLEM_AWARE_VISUALS = [
|
| 926 |
+
"person recognizing weight as a problem",
|
| 927 |
+
"person acknowledging health concerns",
|
| 928 |
+
"person realizing need for solution",
|
| 929 |
+
"person actively looking for answers",
|
| 930 |
+
"person recognizing impact of weight on life",
|
| 931 |
+
"person admitting frustration with situation",
|
| 932 |
+
"person aware of problem but seeking solution",
|
| 933 |
+
"person recognizing need for change",
|
| 934 |
+
"person acknowledging weight loss struggles",
|
| 935 |
+
"person aware of health implications",
|
| 936 |
+
]
|
| 937 |
+
|
| 938 |
+
SOLUTION_AWARE_VISUALS = [
|
| 939 |
+
"person discovering weight loss solutions",
|
| 940 |
+
"person comparing different solution options",
|
| 941 |
+
"person learning about prescription options",
|
| 942 |
+
"person researching effective methods",
|
| 943 |
+
"person exploring available solutions",
|
| 944 |
+
"person evaluating different approaches",
|
| 945 |
+
"person discovering GLP-1 medications",
|
| 946 |
+
"person learning about medical weight loss",
|
| 947 |
+
"person comparing solution effectiveness",
|
| 948 |
+
"person discovering legitimate options",
|
| 949 |
+
]
|
| 950 |
+
|
| 951 |
+
PRODUCT_AWARE_VISUALS = [
|
| 952 |
+
"person considering GLP-1 prescription option",
|
| 953 |
+
"person evaluating product benefits and value",
|
| 954 |
+
"person reviewing product information",
|
| 955 |
+
"person comparing products and providers",
|
| 956 |
+
"person looking at pricing and options",
|
| 957 |
+
"person considering specific medication",
|
| 958 |
+
"person reviewing provider credentials",
|
| 959 |
+
"person evaluating product effectiveness",
|
| 960 |
+
"person considering commitment and cost",
|
| 961 |
+
"person near decision point about product",
|
| 962 |
+
]
|
| 963 |
+
|
| 964 |
+
MOST_AWARE_VISUALS = [
|
| 965 |
+
"person ready to purchase, finalizing decision",
|
| 966 |
+
"person completing qualification or quiz",
|
| 967 |
+
"person ready to start transformation journey",
|
| 968 |
+
"person feeling urgency to begin",
|
| 969 |
+
"person ready to take action",
|
| 970 |
+
"person committing to health journey",
|
| 971 |
+
"person ready for prescription and start",
|
| 972 |
+
"person feeling confident in decision",
|
| 973 |
+
"person ready to make purchase",
|
| 974 |
+
"person at final step before starting",
|
| 975 |
+
]
|
| 976 |
+
|
| 977 |
|
| 978 |
def get_niche_data():
|
| 979 |
"""Return all GLP-1 data for the generator."""
|
frontend/README.md
CHANGED
|
@@ -92,9 +92,9 @@ The frontend integrates with the FastAPI backend. All API endpoints are defined
|
|
| 92 |
- Quick action buttons
|
| 93 |
|
| 94 |
### Generation
|
| 95 |
-
- **Single**: Generate one ad with configurable image count
|
| 96 |
-
- **Batch**: Generate multiple ads (1-20) with 1-3 images each
|
| 97 |
-
- **Matrix**: Select specific angle and concept combinations
|
| 98 |
|
| 99 |
### Gallery
|
| 100 |
- Grid view of all ads
|
|
|
|
| 92 |
- Quick action buttons
|
| 93 |
|
| 94 |
### Generation
|
| 95 |
+
- **Single**: Generate one ad using a random angle × concept combination with configurable image count
|
| 96 |
+
- **Batch**: Generate multiple ads (1-20) with 1-3 images each, each using random angle × concept combinations
|
| 97 |
+
- **Matrix**: Select specific angle and concept combinations for targeted generation
|
| 98 |
|
| 99 |
### Gallery
|
| 100 |
- Grid view of all ads
|
frontend/app/gallery/[id]/page.tsx
CHANGED
|
@@ -10,9 +10,10 @@ 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();
|
|
@@ -26,6 +27,14 @@ export default function AdDetailPage() {
|
|
| 26 |
const [allAds, setAllAds] = useState<AdCreativeDB[]>([]);
|
| 27 |
const [currentIndex, setCurrentIndex] = useState<number>(-1);
|
| 28 |
const [showCorrectionModal, setShowCorrectionModal] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
useEffect(() => {
|
| 31 |
loadAd();
|
|
@@ -34,8 +43,19 @@ export default function AdDetailPage() {
|
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
if (ad) {
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
setImageError(false);
|
| 40 |
const index = allAds.findIndex((a) => a.id === ad.id);
|
| 41 |
setCurrentIndex(index);
|
|
@@ -43,7 +63,7 @@ export default function AdDetailPage() {
|
|
| 43 |
setImageSrc(null);
|
| 44 |
setImageError(false);
|
| 45 |
}
|
| 46 |
-
}, [ad, allAds]);
|
| 47 |
|
| 48 |
const navigateToPrevious = useCallback(() => {
|
| 49 |
if (currentIndex > 0 && allAds.length > 0) {
|
|
@@ -113,16 +133,35 @@ export default function AdDetailPage() {
|
|
| 113 |
};
|
| 114 |
|
| 115 |
const handleDownloadImage = async () => {
|
| 116 |
-
if (!ad
|
| 117 |
toast.error("No image available");
|
| 118 |
return;
|
| 119 |
}
|
|
|
|
| 120 |
try {
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
} catch (error) {
|
| 127 |
toast.error("Failed to download image");
|
| 128 |
}
|
|
@@ -275,13 +314,35 @@ export default function AdDetailPage() {
|
|
| 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">
|
|
@@ -338,50 +399,76 @@ export default function AdDetailPage() {
|
|
| 338 |
|
| 339 |
{/* Right - Ad Copy */}
|
| 340 |
<div className="space-y-5">
|
| 341 |
-
{/*
|
| 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 |
-
<
|
| 345 |
-
|
| 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={() =>
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
>
|
| 359 |
-
<
|
| 360 |
</Button>
|
| 361 |
-
<
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
</div>
|
| 365 |
</div>
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
{/* Description */}
|
| 369 |
{ad.description && (
|
| 370 |
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-violet-500">
|
| 371 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 372 |
<h3 className="text-xs font-bold text-violet-600 uppercase tracking-wider">Description</h3>
|
| 373 |
-
<div className="
|
| 374 |
<Button
|
| 375 |
variant="ghost"
|
| 376 |
size="sm"
|
| 377 |
-
onClick={() =>
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
>
|
| 380 |
-
<
|
| 381 |
</Button>
|
| 382 |
-
<
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
<p className="text-gray-700 leading-relaxed">{ad.description}</p>
|
|
@@ -393,21 +480,62 @@ export default function AdDetailPage() {
|
|
| 393 |
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-amber-500">
|
| 394 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 395 |
<h3 className="text-xs font-bold text-amber-600 uppercase tracking-wider">Body Story</h3>
|
| 396 |
-
<div className="
|
| 397 |
<Button
|
| 398 |
variant="ghost"
|
| 399 |
size="sm"
|
| 400 |
-
onClick={() =>
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
>
|
| 403 |
-
<
|
| 404 |
</Button>
|
| 405 |
-
<
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
</div>
|
| 409 |
</div>
|
| 410 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
</div>
|
| 412 |
)}
|
| 413 |
|
|
@@ -416,18 +544,33 @@ export default function AdDetailPage() {
|
|
| 416 |
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-2xl shadow-md p-6 border border-emerald-200">
|
| 417 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 418 |
<h3 className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Call to Action</h3>
|
| 419 |
-
<div className="
|
| 420 |
<Button
|
| 421 |
variant="ghost"
|
| 422 |
size="sm"
|
| 423 |
-
onClick={() =>
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
>
|
| 426 |
-
<
|
| 427 |
</Button>
|
| 428 |
-
<
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
</div>
|
| 432 |
</div>
|
| 433 |
<p className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">{ad.cta}</p>
|
|
@@ -472,18 +615,36 @@ export default function AdDetailPage() {
|
|
| 472 |
onClose={() => setShowCorrectionModal(false)}
|
| 473 |
adId={adId}
|
| 474 |
ad={ad}
|
| 475 |
-
onSuccess={(result: ImageCorrectResponse) => {
|
| 476 |
-
//
|
| 477 |
-
if (result.
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
|
|
|
| 484 |
}
|
| 485 |
}}
|
| 486 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
</div>
|
| 488 |
);
|
| 489 |
}
|
|
|
|
| 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, History, RotateCcw, ChevronDown, ChevronUp, Edit3 } from "lucide-react";
|
| 14 |
import type { AdCreativeDB, ImageCorrectResponse } from "@/types/api";
|
| 15 |
import { CorrectionModal } from "@/components/generation/CorrectionModal";
|
| 16 |
+
import { EditCopyModal } from "@/components/generation/EditCopyModal";
|
| 17 |
|
| 18 |
export default function AdDetailPage() {
|
| 19 |
const params = useParams();
|
|
|
|
| 27 |
const [allAds, setAllAds] = useState<AdCreativeDB[]>([]);
|
| 28 |
const [currentIndex, setCurrentIndex] = useState<number>(-1);
|
| 29 |
const [showCorrectionModal, setShowCorrectionModal] = useState(false);
|
| 30 |
+
const [showingOriginal, setShowingOriginal] = useState(false);
|
| 31 |
+
const [isBodyStoryExpanded, setIsBodyStoryExpanded] = useState(false);
|
| 32 |
+
const [editModal, setEditModal] = useState<{
|
| 33 |
+
isOpen: boolean;
|
| 34 |
+
field: "title" | "headline" | "primary_text" | "description" | "body_story" | "cta";
|
| 35 |
+
fieldLabel: string;
|
| 36 |
+
currentValue: string;
|
| 37 |
+
} | null>(null);
|
| 38 |
|
| 39 |
useEffect(() => {
|
| 40 |
loadAd();
|
|
|
|
| 43 |
|
| 44 |
useEffect(() => {
|
| 45 |
if (ad) {
|
| 46 |
+
// Check if we should show original image
|
| 47 |
+
if (showingOriginal && ad.metadata) {
|
| 48 |
+
// Prefer original_r2_url if available, otherwise use original_image_url
|
| 49 |
+
const originalUrl = ad.metadata.original_r2_url || ad.metadata.original_image_url;
|
| 50 |
+
if (originalUrl) {
|
| 51 |
+
setImageSrc(originalUrl);
|
| 52 |
+
} else {
|
| 53 |
+
setImageSrc(null);
|
| 54 |
+
}
|
| 55 |
+
} else {
|
| 56 |
+
const { primary, fallback } = getImageUrlFallback(ad.r2_url || ad.image_url, ad.image_filename);
|
| 57 |
+
setImageSrc(primary || fallback);
|
| 58 |
+
}
|
| 59 |
setImageError(false);
|
| 60 |
const index = allAds.findIndex((a) => a.id === ad.id);
|
| 61 |
setCurrentIndex(index);
|
|
|
|
| 63 |
setImageSrc(null);
|
| 64 |
setImageError(false);
|
| 65 |
}
|
| 66 |
+
}, [ad, allAds, showingOriginal]);
|
| 67 |
|
| 68 |
const navigateToPrevious = useCallback(() => {
|
| 69 |
if (currentIndex > 0 && allAds.length > 0) {
|
|
|
|
| 133 |
};
|
| 134 |
|
| 135 |
const handleDownloadImage = async () => {
|
| 136 |
+
if (!ad) {
|
| 137 |
toast.error("No image available");
|
| 138 |
return;
|
| 139 |
}
|
| 140 |
+
|
| 141 |
try {
|
| 142 |
+
let imageUrl: string | null = null;
|
| 143 |
+
let filename: string | null = null;
|
| 144 |
+
|
| 145 |
+
// Download the currently displayed image (original or corrected)
|
| 146 |
+
if (showingOriginal && ad.metadata) {
|
| 147 |
+
// Download original image
|
| 148 |
+
imageUrl = ad.metadata.original_r2_url || ad.metadata.original_image_url || null;
|
| 149 |
+
filename = ad.metadata.original_image_filename || `original-${ad.id}.png`;
|
| 150 |
+
} else {
|
| 151 |
+
// Download corrected/current image
|
| 152 |
+
const { primary } = getImageUrlFallback(ad.r2_url || ad.image_url, ad.image_filename);
|
| 153 |
+
imageUrl = primary || null;
|
| 154 |
+
filename = ad.image_filename || `ad-${ad.id}.png`;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
if (!imageUrl && !ad.id) {
|
| 158 |
+
toast.error("No image available");
|
| 159 |
+
return;
|
| 160 |
}
|
| 161 |
+
|
| 162 |
+
// Use proxy endpoint with ad ID to avoid CORS issues
|
| 163 |
+
await downloadImage(imageUrl, filename, ad.id);
|
| 164 |
+
toast.success(`Image downloaded${showingOriginal ? " (original)" : ""}`);
|
| 165 |
} catch (error) {
|
| 166 |
toast.error("Failed to download image");
|
| 167 |
}
|
|
|
|
| 314 |
{/* Left - Image */}
|
| 315 |
<div className="space-y-4">
|
| 316 |
{imageSrc ? (
|
| 317 |
+
<div className="bg-white rounded-2xl shadow-lg shadow-blue-100/50 overflow-hidden ring-1 ring-blue-100 relative">
|
| 318 |
<img
|
| 319 |
src={imageSrc}
|
| 320 |
alt={ad.headline}
|
| 321 |
className="w-full h-auto"
|
| 322 |
onError={handleImageError}
|
| 323 |
/>
|
| 324 |
+
{/* Toggle button for viewing original image - only show if original image exists */}
|
| 325 |
+
{(ad.metadata?.original_image_url || ad.metadata?.original_r2_url) && (
|
| 326 |
+
<div className="absolute top-4 right-4 z-10">
|
| 327 |
+
<button
|
| 328 |
+
onClick={() => setShowingOriginal(!showingOriginal)}
|
| 329 |
+
className="group relative bg-white/80 backdrop-blur-lg hover:bg-white/95 shadow-lg hover:shadow-xl border border-white/50 rounded-full px-4 py-2.5 transition-all duration-300 flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900"
|
| 330 |
+
title={showingOriginal ? "Show current version" : "Show original version"}
|
| 331 |
+
>
|
| 332 |
+
<div className="relative">
|
| 333 |
+
{showingOriginal ? (
|
| 334 |
+
<RotateCcw className="h-4 w-4 text-blue-600 group-hover:text-blue-700 transition-colors" />
|
| 335 |
+
) : (
|
| 336 |
+
<History className="h-4 w-4 text-amber-600 group-hover:text-amber-700 transition-colors" />
|
| 337 |
+
)}
|
| 338 |
+
</div>
|
| 339 |
+
<span className="text-xs font-semibold">
|
| 340 |
+
{showingOriginal ? "Current" : "Original"}
|
| 341 |
+
</span>
|
| 342 |
+
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-blue-500/0 via-blue-500/0 to-blue-500/0 group-hover:from-blue-500/5 group-hover:via-blue-500/10 group-hover:to-blue-500/5 transition-all duration-300 -z-10" />
|
| 343 |
+
</button>
|
| 344 |
+
</div>
|
| 345 |
+
)}
|
| 346 |
</div>
|
| 347 |
) : (
|
| 348 |
<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">
|
|
|
|
| 399 |
|
| 400 |
{/* Right - Ad Copy */}
|
| 401 |
<div className="space-y-5">
|
| 402 |
+
{/* Title */}
|
| 403 |
<div className="bg-white rounded-2xl shadow-lg shadow-blue-100/30 p-6 border-l-4 border-blue-500">
|
| 404 |
+
<div className="flex items-start justify-between gap-4 mb-3">
|
| 405 |
+
<h3 className="text-xs font-bold text-blue-600 uppercase tracking-wider">Title</h3>
|
| 406 |
+
<div className="flex items-center gap-1">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
<Button
|
| 408 |
variant="ghost"
|
| 409 |
size="sm"
|
| 410 |
+
onClick={() => setEditModal({
|
| 411 |
+
isOpen: true,
|
| 412 |
+
field: "headline",
|
| 413 |
+
fieldLabel: "Title",
|
| 414 |
+
currentValue: ad.headline,
|
| 415 |
+
})}
|
| 416 |
+
className="h-6 w-6 p-0 text-blue-500 hover:bg-blue-50"
|
| 417 |
>
|
| 418 |
+
<Edit3 className="h-3 w-3" />
|
| 419 |
</Button>
|
| 420 |
+
<div className="relative group">
|
| 421 |
+
<Button
|
| 422 |
+
variant="ghost"
|
| 423 |
+
size="sm"
|
| 424 |
+
onClick={() => handleCopyText(ad.headline, "Title")}
|
| 425 |
+
className="text-blue-500 hover:bg-blue-50"
|
| 426 |
+
>
|
| 427 |
+
<Copy className="h-4 w-4" />
|
| 428 |
+
</Button>
|
| 429 |
+
<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">
|
| 430 |
+
Copy Title
|
| 431 |
+
</span>
|
| 432 |
+
</div>
|
| 433 |
</div>
|
| 434 |
</div>
|
| 435 |
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent leading-tight">
|
| 436 |
+
{ad.headline}
|
| 437 |
+
</h1>
|
| 438 |
+
</div>
|
| 439 |
|
| 440 |
{/* Description */}
|
| 441 |
{ad.description && (
|
| 442 |
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-violet-500">
|
| 443 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 444 |
<h3 className="text-xs font-bold text-violet-600 uppercase tracking-wider">Description</h3>
|
| 445 |
+
<div className="flex items-center gap-1">
|
| 446 |
<Button
|
| 447 |
variant="ghost"
|
| 448 |
size="sm"
|
| 449 |
+
onClick={() => setEditModal({
|
| 450 |
+
isOpen: true,
|
| 451 |
+
field: "description",
|
| 452 |
+
fieldLabel: "Description",
|
| 453 |
+
currentValue: ad.description || "",
|
| 454 |
+
})}
|
| 455 |
+
className="h-6 w-6 p-0 text-violet-500 hover:bg-violet-50"
|
| 456 |
>
|
| 457 |
+
<Edit3 className="h-3 w-3" />
|
| 458 |
</Button>
|
| 459 |
+
<div className="relative group">
|
| 460 |
+
<Button
|
| 461 |
+
variant="ghost"
|
| 462 |
+
size="sm"
|
| 463 |
+
onClick={() => handleCopyText(ad.description!, "Description")}
|
| 464 |
+
className="text-violet-500 hover:bg-violet-50"
|
| 465 |
+
>
|
| 466 |
+
<Copy className="h-4 w-4" />
|
| 467 |
+
</Button>
|
| 468 |
+
<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">
|
| 469 |
+
Copy Description
|
| 470 |
+
</span>
|
| 471 |
+
</div>
|
| 472 |
</div>
|
| 473 |
</div>
|
| 474 |
<p className="text-gray-700 leading-relaxed">{ad.description}</p>
|
|
|
|
| 480 |
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-amber-500">
|
| 481 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 482 |
<h3 className="text-xs font-bold text-amber-600 uppercase tracking-wider">Body Story</h3>
|
| 483 |
+
<div className="flex items-center gap-1">
|
| 484 |
<Button
|
| 485 |
variant="ghost"
|
| 486 |
size="sm"
|
| 487 |
+
onClick={() => setEditModal({
|
| 488 |
+
isOpen: true,
|
| 489 |
+
field: "body_story",
|
| 490 |
+
fieldLabel: "Body Story",
|
| 491 |
+
currentValue: ad.body_story || "",
|
| 492 |
+
})}
|
| 493 |
+
className="h-6 w-6 p-0 text-amber-500 hover:bg-amber-50"
|
| 494 |
>
|
| 495 |
+
<Edit3 className="h-3 w-3" />
|
| 496 |
</Button>
|
| 497 |
+
<div className="relative group">
|
| 498 |
+
<Button
|
| 499 |
+
variant="ghost"
|
| 500 |
+
size="sm"
|
| 501 |
+
onClick={() => handleCopyText(ad.body_story!, "Body Story")}
|
| 502 |
+
className="text-amber-500 hover:bg-amber-50"
|
| 503 |
+
>
|
| 504 |
+
<Copy className="h-4 w-4" />
|
| 505 |
+
</Button>
|
| 506 |
+
<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">
|
| 507 |
+
Copy Story
|
| 508 |
+
</span>
|
| 509 |
+
</div>
|
| 510 |
</div>
|
| 511 |
</div>
|
| 512 |
+
<div className="relative">
|
| 513 |
+
<p
|
| 514 |
+
className={`text-gray-700 whitespace-pre-line leading-relaxed transition-all duration-300 ${
|
| 515 |
+
!isBodyStoryExpanded ? 'line-clamp-3' : ''
|
| 516 |
+
}`}
|
| 517 |
+
>
|
| 518 |
+
{ad.body_story}
|
| 519 |
+
</p>
|
| 520 |
+
{ad.body_story.split('\n').length > 3 || ad.body_story.length > 200 ? (
|
| 521 |
+
<button
|
| 522 |
+
onClick={() => setIsBodyStoryExpanded(!isBodyStoryExpanded)}
|
| 523 |
+
className="mt-2 flex items-center gap-1 text-amber-600 hover:text-amber-700 text-sm font-medium transition-colors"
|
| 524 |
+
>
|
| 525 |
+
{isBodyStoryExpanded ? (
|
| 526 |
+
<>
|
| 527 |
+
<span>Show less</span>
|
| 528 |
+
<ChevronUp className="h-4 w-4" />
|
| 529 |
+
</>
|
| 530 |
+
) : (
|
| 531 |
+
<>
|
| 532 |
+
<span>Read more</span>
|
| 533 |
+
<ChevronDown className="h-4 w-4" />
|
| 534 |
+
</>
|
| 535 |
+
)}
|
| 536 |
+
</button>
|
| 537 |
+
) : null}
|
| 538 |
+
</div>
|
| 539 |
</div>
|
| 540 |
)}
|
| 541 |
|
|
|
|
| 544 |
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-2xl shadow-md p-6 border border-emerald-200">
|
| 545 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 546 |
<h3 className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Call to Action</h3>
|
| 547 |
+
<div className="flex items-center gap-1">
|
| 548 |
<Button
|
| 549 |
variant="ghost"
|
| 550 |
size="sm"
|
| 551 |
+
onClick={() => setEditModal({
|
| 552 |
+
isOpen: true,
|
| 553 |
+
field: "cta",
|
| 554 |
+
fieldLabel: "Call to Action",
|
| 555 |
+
currentValue: ad.cta || "",
|
| 556 |
+
})}
|
| 557 |
+
className="h-6 w-6 p-0 text-emerald-600 hover:bg-emerald-100"
|
| 558 |
>
|
| 559 |
+
<Edit3 className="h-3 w-3" />
|
| 560 |
</Button>
|
| 561 |
+
<div className="relative group">
|
| 562 |
+
<Button
|
| 563 |
+
variant="ghost"
|
| 564 |
+
size="sm"
|
| 565 |
+
onClick={() => handleCopyText(ad.cta!, "CTA")}
|
| 566 |
+
className="text-emerald-600 hover:bg-emerald-100"
|
| 567 |
+
>
|
| 568 |
+
<Copy className="h-4 w-4" />
|
| 569 |
+
</Button>
|
| 570 |
+
<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">
|
| 571 |
+
Copy CTA
|
| 572 |
+
</span>
|
| 573 |
+
</div>
|
| 574 |
</div>
|
| 575 |
</div>
|
| 576 |
<p className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">{ad.cta}</p>
|
|
|
|
| 615 |
onClose={() => setShowCorrectionModal(false)}
|
| 616 |
adId={adId}
|
| 617 |
ad={ad}
|
| 618 |
+
onSuccess={async (result: ImageCorrectResponse) => {
|
| 619 |
+
// Reload the ad to get updated image and metadata
|
| 620 |
+
if (result.status === "success") {
|
| 621 |
+
toast.success("Image corrected successfully!");
|
| 622 |
+
// Wait a moment for database to update, then reload
|
| 623 |
+
setTimeout(async () => {
|
| 624 |
+
await loadAd(); // Reload to get updated ad with new image and metadata
|
| 625 |
+
loadAllAds(); // Reload gallery list
|
| 626 |
+
setShowingOriginal(false); // Reset to show current (corrected) image
|
| 627 |
+
}, 500);
|
| 628 |
}
|
| 629 |
}}
|
| 630 |
/>
|
| 631 |
+
|
| 632 |
+
{/* Edit Copy Modal */}
|
| 633 |
+
{editModal && ad && (
|
| 634 |
+
<EditCopyModal
|
| 635 |
+
isOpen={editModal.isOpen}
|
| 636 |
+
onClose={() => setEditModal(null)}
|
| 637 |
+
adId={adId}
|
| 638 |
+
ad={ad}
|
| 639 |
+
field={editModal.field}
|
| 640 |
+
fieldLabel={editModal.fieldLabel}
|
| 641 |
+
currentValue={editModal.currentValue}
|
| 642 |
+
onSuccess={async () => {
|
| 643 |
+
await loadAd();
|
| 644 |
+
setEditModal(null);
|
| 645 |
+
}}
|
| 646 |
+
/>
|
| 647 |
+
)}
|
| 648 |
</div>
|
| 649 |
);
|
| 650 |
}
|
frontend/app/gallery/page.tsx
CHANGED
|
@@ -98,6 +98,14 @@ export default function GalleryPage() {
|
|
| 98 |
// In a production app, you'd want server-side search for accurate totals
|
| 99 |
const total = filters.search ? filteredAds.length : response.total;
|
| 100 |
setAds(filteredAds, total);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
} catch (error: any) {
|
| 102 |
toast.error("Failed to load ads");
|
| 103 |
console.error(error);
|
|
@@ -110,6 +118,20 @@ export default function GalleryPage() {
|
|
| 110 |
loadAds();
|
| 111 |
}, [loadAds]);
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
const handleBulkDelete = async () => {
|
| 114 |
if (selectedAds.length === 0) return;
|
| 115 |
|
|
@@ -172,32 +194,31 @@ export default function GalleryPage() {
|
|
| 172 |
</>
|
| 173 |
) : (
|
| 174 |
ads.length > 0 && (
|
| 175 |
-
<
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
| 181 |
)
|
| 182 |
)}
|
| 183 |
</div>
|
| 184 |
|
| 185 |
{/* Sort Controls */}
|
| 186 |
{sortOptions && (
|
| 187 |
-
<
|
| 188 |
-
variant="outline"
|
| 189 |
-
size="sm"
|
| 190 |
onClick={() => setSortOptions({
|
| 191 |
field: "created_at",
|
| 192 |
direction: sortOptions.direction === "desc" ? "asc" : "desc"
|
| 193 |
})}
|
| 194 |
-
className="flex items-center gap-2"
|
| 195 |
>
|
| 196 |
<ArrowUpDown className="h-4 w-4" />
|
| 197 |
-
<span
|
| 198 |
{sortOptions.direction === "desc" ? "Newest First" : "Oldest First"}
|
| 199 |
</span>
|
| 200 |
-
</
|
| 201 |
)}
|
| 202 |
</div>
|
| 203 |
</div>
|
|
|
|
| 98 |
// In a production app, you'd want server-side search for accurate totals
|
| 99 |
const total = filters.search ? filteredAds.length : response.total;
|
| 100 |
setAds(filteredAds, total);
|
| 101 |
+
|
| 102 |
+
// Clear selections for ads that are no longer in the current list
|
| 103 |
+
const currentAdIds = new Set(filteredAds.map(ad => ad.id));
|
| 104 |
+
const validSelections = selectedAds.filter(id => currentAdIds.has(id));
|
| 105 |
+
if (validSelections.length !== selectedAds.length) {
|
| 106 |
+
// Only update if there's a difference to avoid unnecessary re-renders
|
| 107 |
+
clearSelection();
|
| 108 |
+
}
|
| 109 |
} catch (error: any) {
|
| 110 |
toast.error("Failed to load ads");
|
| 111 |
console.error(error);
|
|
|
|
| 118 |
loadAds();
|
| 119 |
}, [loadAds]);
|
| 120 |
|
| 121 |
+
// Clear stale selections when component mounts (selections for ads not in current list)
|
| 122 |
+
useEffect(() => {
|
| 123 |
+
if (selectedAds.length > 0 && ads.length > 0) {
|
| 124 |
+
const currentAdIds = new Set(ads.map(ad => ad.id));
|
| 125 |
+
const hasStaleSelections = selectedAds.some(id => !currentAdIds.has(id));
|
| 126 |
+
if (hasStaleSelections) {
|
| 127 |
+
clearSelection();
|
| 128 |
+
}
|
| 129 |
+
} else if (selectedAds.length > 0 && ads.length === 0) {
|
| 130 |
+
// If there are selections but no ads loaded yet, clear them
|
| 131 |
+
clearSelection();
|
| 132 |
+
}
|
| 133 |
+
}, [ads, selectedAds, clearSelection]);
|
| 134 |
+
|
| 135 |
const handleBulkDelete = async () => {
|
| 136 |
if (selectedAds.length === 0) return;
|
| 137 |
|
|
|
|
| 194 |
</>
|
| 195 |
) : (
|
| 196 |
ads.length > 0 && (
|
| 197 |
+
<button
|
| 198 |
+
onClick={selectAll}
|
| 199 |
+
className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 transition-colors duration-200"
|
| 200 |
+
>
|
| 201 |
+
<CheckSquare className="h-4 w-4" />
|
| 202 |
+
<span>Select All</span>
|
| 203 |
+
</button>
|
| 204 |
)
|
| 205 |
)}
|
| 206 |
</div>
|
| 207 |
|
| 208 |
{/* Sort Controls */}
|
| 209 |
{sortOptions && (
|
| 210 |
+
<button
|
|
|
|
|
|
|
| 211 |
onClick={() => setSortOptions({
|
| 212 |
field: "created_at",
|
| 213 |
direction: sortOptions.direction === "desc" ? "asc" : "desc"
|
| 214 |
})}
|
| 215 |
+
className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 transition-colors duration-200"
|
| 216 |
>
|
| 217 |
<ArrowUpDown className="h-4 w-4" />
|
| 218 |
+
<span>
|
| 219 |
{sortOptions.direction === "desc" ? "Newest First" : "Oldest First"}
|
| 220 |
</span>
|
| 221 |
+
</button>
|
| 222 |
)}
|
| 223 |
</div>
|
| 224 |
</div>
|
frontend/app/generate/page.tsx
CHANGED
|
@@ -27,6 +27,8 @@ export default function GeneratePage() {
|
|
| 27 |
const [niche, setNiche] = useState<Niche>("home_insurance");
|
| 28 |
const [numImages, setNumImages] = useState(1);
|
| 29 |
const [imageModel, setImageModel] = useState<string | null>(null);
|
|
|
|
|
|
|
| 30 |
const [batchResults, setBatchResults] = useState<GenerateResponse[]>([]);
|
| 31 |
const [currentBatchIndex, setCurrentBatchIndex] = useState(0);
|
| 32 |
const [batchProgress, setBatchProgress] = useState(0);
|
|
@@ -73,57 +75,120 @@ export default function GeneratePage() {
|
|
| 73 |
}
|
| 74 |
}, [progress.step, currentGeneration]);
|
| 75 |
|
| 76 |
-
const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null }) => {
|
| 77 |
reset();
|
| 78 |
setIsGenerating(true);
|
| 79 |
setGenerationStartTime(Date.now());
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
| 95 |
});
|
| 96 |
-
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
setProgress({
|
| 104 |
-
step: "
|
| 105 |
-
progress:
|
| 106 |
-
message: "
|
| 107 |
});
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
};
|
| 129 |
|
|
@@ -136,43 +201,106 @@ export default function GeneratePage() {
|
|
| 136 |
reset();
|
| 137 |
setIsGenerating(true);
|
| 138 |
setGenerationStartTime(Date.now());
|
| 139 |
-
setProgress({
|
| 140 |
-
step: "copy",
|
| 141 |
-
progress: 10,
|
| 142 |
-
message: "Generating ad with selected angle and concept...",
|
| 143 |
-
});
|
| 144 |
-
|
| 145 |
-
try {
|
| 146 |
-
const result = await generateMatrixAd({
|
| 147 |
-
niche,
|
| 148 |
-
angle_key: selectedAngle.key,
|
| 149 |
-
concept_key: selectedConcept.key,
|
| 150 |
-
num_images: numImages,
|
| 151 |
-
image_model: imageModel,
|
| 152 |
-
});
|
| 153 |
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
setProgress({
|
| 156 |
-
step: "
|
| 157 |
-
progress:
|
| 158 |
-
message:
|
| 159 |
});
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
setProgress({
|
| 165 |
-
step: "
|
| 166 |
-
progress:
|
| 167 |
-
message:
|
| 168 |
});
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
}
|
| 173 |
};
|
| 174 |
|
| 175 |
-
const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null }) => {
|
| 176 |
setBatchResults([]);
|
| 177 |
setIsGenerating(true);
|
| 178 |
setGenerationStartTime(Date.now());
|
|
@@ -205,7 +333,11 @@ export default function GeneratePage() {
|
|
| 205 |
}, progressInterval);
|
| 206 |
|
| 207 |
try {
|
| 208 |
-
const result = await generateBatch(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
clearInterval(progressIntervalId);
|
| 210 |
setBatchResults(result.ads);
|
| 211 |
setCurrentBatchIndex(data.count - 1); // Set to last ad
|
|
@@ -233,8 +365,8 @@ export default function GeneratePage() {
|
|
| 233 |
|
| 234 |
const handleExtensiveGenerate = async (data: {
|
| 235 |
niche: Niche;
|
| 236 |
-
target_audience
|
| 237 |
-
offer
|
| 238 |
num_images: number;
|
| 239 |
num_strategies: number;
|
| 240 |
image_model?: string | null;
|
|
@@ -526,6 +658,32 @@ export default function GeneratePage() {
|
|
| 526 |
</select>
|
| 527 |
</div>
|
| 528 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
<div>
|
| 530 |
<label className="block text-sm font-semibold text-gray-700 mb-2">Image Model</label>
|
| 531 |
<select
|
|
@@ -543,7 +701,7 @@ export default function GeneratePage() {
|
|
| 543 |
|
| 544 |
<div>
|
| 545 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 546 |
-
Number of
|
| 547 |
</label>
|
| 548 |
<input
|
| 549 |
type="range"
|
|
@@ -559,7 +717,7 @@ export default function GeneratePage() {
|
|
| 559 |
<span>5</span>
|
| 560 |
</div>
|
| 561 |
<p className="text-xs text-gray-500 mt-1">
|
| 562 |
-
|
| 563 |
</p>
|
| 564 |
</div>
|
| 565 |
</CardContent>
|
|
@@ -610,7 +768,7 @@ export default function GeneratePage() {
|
|
| 610 |
|
| 611 |
{/* Right Column - Preview */}
|
| 612 |
<div className="lg:col-span-2">
|
| 613 |
-
{
|
| 614 |
<div className="space-y-6 animate-fade-in">
|
| 615 |
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg p-6 border border-gray-200">
|
| 616 |
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
|
|
| 27 |
const [niche, setNiche] = useState<Niche>("home_insurance");
|
| 28 |
const [numImages, setNumImages] = useState(1);
|
| 29 |
const [imageModel, setImageModel] = useState<string | null>(null);
|
| 30 |
+
const [targetAudience, setTargetAudience] = useState("");
|
| 31 |
+
const [offer, setOffer] = useState("");
|
| 32 |
const [batchResults, setBatchResults] = useState<GenerateResponse[]>([]);
|
| 33 |
const [currentBatchIndex, setCurrentBatchIndex] = useState(0);
|
| 34 |
const [batchProgress, setBatchProgress] = useState(0);
|
|
|
|
| 75 |
}
|
| 76 |
}, [progress.step, currentGeneration]);
|
| 77 |
|
| 78 |
+
const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null; target_audience?: string; offer?: string }) => {
|
| 79 |
reset();
|
| 80 |
setIsGenerating(true);
|
| 81 |
setGenerationStartTime(Date.now());
|
| 82 |
+
|
| 83 |
+
// If num_images > 1, generate batch of ads
|
| 84 |
+
if (data.num_images > 1) {
|
| 85 |
+
setBatchCount(data.num_images);
|
| 86 |
+
setBatchResults([]);
|
| 87 |
+
setCurrentBatchIndex(0);
|
| 88 |
+
setBatchProgress(0);
|
| 89 |
+
|
| 90 |
+
setProgress({
|
| 91 |
+
step: "image",
|
| 92 |
+
progress: 0,
|
| 93 |
+
message: `Generating ${data.num_images} ads in batch...`,
|
| 94 |
+
});
|
| 95 |
|
| 96 |
+
try {
|
| 97 |
+
// Use batch generation for multiple ads with standard method
|
| 98 |
+
const batchResponse = await generateBatch({
|
| 99 |
+
niche: data.niche,
|
| 100 |
+
count: data.num_images,
|
| 101 |
+
images_per_ad: 1, // Each ad gets 1 image
|
| 102 |
+
image_model: data.image_model,
|
| 103 |
+
method: "standard", // Use standard method only
|
| 104 |
+
target_audience: data.target_audience,
|
| 105 |
+
offer: data.offer,
|
| 106 |
});
|
| 107 |
+
const results = batchResponse.ads;
|
| 108 |
|
| 109 |
+
setBatchResults(results);
|
| 110 |
+
setBatchProgress(100);
|
| 111 |
+
setProgress({
|
| 112 |
+
step: "complete",
|
| 113 |
+
progress: 100,
|
| 114 |
+
message: `Generated ${results.length} ads successfully!`,
|
| 115 |
+
});
|
| 116 |
|
| 117 |
+
// Show first result
|
| 118 |
+
if (results.length > 0) {
|
| 119 |
+
setCurrentGeneration(results[0] as GenerateResponse);
|
| 120 |
+
}
|
| 121 |
|
| 122 |
+
toast.success(`Generated ${results.length} ads successfully!`);
|
| 123 |
+
} catch (error: any) {
|
| 124 |
+
setError(error.message || "Failed to generate ads");
|
| 125 |
+
setProgress({
|
| 126 |
+
step: "error",
|
| 127 |
+
progress: 0,
|
| 128 |
+
message: error.message || "An error occurred",
|
| 129 |
+
});
|
| 130 |
+
toast.error(error.message || "Failed to generate ads");
|
| 131 |
+
} finally {
|
| 132 |
+
setIsGenerating(false);
|
| 133 |
+
}
|
| 134 |
+
} else {
|
| 135 |
+
// Single ad generation - clear batch results
|
| 136 |
+
setBatchResults([]);
|
| 137 |
+
setCurrentBatchIndex(0);
|
| 138 |
+
|
| 139 |
setProgress({
|
| 140 |
+
step: "copy",
|
| 141 |
+
progress: 10,
|
| 142 |
+
message: "Generating ad copy...",
|
| 143 |
});
|
| 144 |
|
| 145 |
+
try {
|
| 146 |
+
// Simulate progress updates
|
| 147 |
+
let currentProgress = 20;
|
| 148 |
+
const progressInterval = setInterval(() => {
|
| 149 |
+
currentProgress = Math.min(90, currentProgress + 5);
|
| 150 |
+
setProgress({
|
| 151 |
+
step: currentProgress < 50 ? "copy" : "image",
|
| 152 |
+
progress: currentProgress,
|
| 153 |
+
message: currentProgress < 50 ? "Generating ad copy..." : "Generating images...",
|
| 154 |
+
});
|
| 155 |
+
}, 1000);
|
| 156 |
+
|
| 157 |
+
// Generate single ad with 1 image
|
| 158 |
+
const result = await generateAd({
|
| 159 |
+
...data,
|
| 160 |
+
num_images: 1,
|
| 161 |
+
target_audience: data.target_audience,
|
| 162 |
+
offer: data.offer,
|
| 163 |
+
});
|
| 164 |
|
| 165 |
+
clearInterval(progressInterval);
|
| 166 |
+
|
| 167 |
+
setProgress({
|
| 168 |
+
step: "saving",
|
| 169 |
+
progress: 90,
|
| 170 |
+
message: "Saving to database...",
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
setCurrentGeneration(result);
|
| 174 |
+
setProgress({
|
| 175 |
+
step: "complete",
|
| 176 |
+
progress: 100,
|
| 177 |
+
message: "Ad generated successfully!",
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
toast.success("Ad generated successfully!");
|
| 181 |
+
} catch (error: any) {
|
| 182 |
+
setError(error.message || "Failed to generate ad");
|
| 183 |
+
setProgress({
|
| 184 |
+
step: "error",
|
| 185 |
+
progress: 0,
|
| 186 |
+
message: error.message || "An error occurred",
|
| 187 |
+
});
|
| 188 |
+
toast.error(error.message || "Failed to generate ad");
|
| 189 |
+
} finally {
|
| 190 |
+
setIsGenerating(false);
|
| 191 |
+
}
|
| 192 |
}
|
| 193 |
};
|
| 194 |
|
|
|
|
| 201 |
reset();
|
| 202 |
setIsGenerating(true);
|
| 203 |
setGenerationStartTime(Date.now());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
+
// If numImages > 1, generate batch of ads using matrix method
|
| 206 |
+
if (numImages > 1) {
|
| 207 |
+
setBatchCount(numImages);
|
| 208 |
+
setBatchResults([]);
|
| 209 |
+
setCurrentBatchIndex(0);
|
| 210 |
+
setBatchProgress(0);
|
| 211 |
+
|
| 212 |
setProgress({
|
| 213 |
+
step: "image",
|
| 214 |
+
progress: 0,
|
| 215 |
+
message: `Generating ${numImages} ads with matrix method...`,
|
| 216 |
});
|
| 217 |
|
| 218 |
+
try {
|
| 219 |
+
// Generate batch using matrix method
|
| 220 |
+
const results = await Promise.all(
|
| 221 |
+
Array.from({ length: numImages }, (_, i) =>
|
| 222 |
+
generateMatrixAd({
|
| 223 |
+
niche,
|
| 224 |
+
angle_key: selectedAngle.key,
|
| 225 |
+
concept_key: selectedConcept.key,
|
| 226 |
+
num_images: 1, // Each ad gets 1 image
|
| 227 |
+
image_model: imageModel,
|
| 228 |
+
target_audience: targetAudience || undefined,
|
| 229 |
+
offer: offer || undefined,
|
| 230 |
+
})
|
| 231 |
+
)
|
| 232 |
+
);
|
| 233 |
+
|
| 234 |
+
setBatchResults(results as unknown as GenerateResponse[]);
|
| 235 |
+
setBatchProgress(100);
|
| 236 |
+
setProgress({
|
| 237 |
+
step: "complete",
|
| 238 |
+
progress: 100,
|
| 239 |
+
message: `Generated ${results.length} ads successfully!`,
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
// Show first result
|
| 243 |
+
if (results.length > 0) {
|
| 244 |
+
setCurrentGeneration(results[0]);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
toast.success(`Generated ${results.length} ads successfully!`);
|
| 248 |
+
} catch (error: any) {
|
| 249 |
+
setError(error.message || "Failed to generate ads");
|
| 250 |
+
setProgress({
|
| 251 |
+
step: "error",
|
| 252 |
+
progress: 0,
|
| 253 |
+
message: error.message || "An error occurred",
|
| 254 |
+
});
|
| 255 |
+
toast.error(error.message || "Failed to generate ads");
|
| 256 |
+
} finally {
|
| 257 |
+
setIsGenerating(false);
|
| 258 |
+
}
|
| 259 |
+
} else {
|
| 260 |
+
// Single ad generation - clear batch results
|
| 261 |
+
setBatchResults([]);
|
| 262 |
+
setCurrentBatchIndex(0);
|
| 263 |
+
|
| 264 |
setProgress({
|
| 265 |
+
step: "copy",
|
| 266 |
+
progress: 10,
|
| 267 |
+
message: "Generating ad with selected angle and concept...",
|
| 268 |
});
|
| 269 |
+
|
| 270 |
+
try {
|
| 271 |
+
const result = await generateMatrixAd({
|
| 272 |
+
niche,
|
| 273 |
+
angle_key: selectedAngle.key,
|
| 274 |
+
concept_key: selectedConcept.key,
|
| 275 |
+
num_images: 1, // Single image
|
| 276 |
+
image_model: imageModel,
|
| 277 |
+
target_audience: targetAudience || undefined,
|
| 278 |
+
offer: offer || undefined,
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
setCurrentGeneration(result);
|
| 282 |
+
setProgress({
|
| 283 |
+
step: "complete",
|
| 284 |
+
progress: 100,
|
| 285 |
+
message: "Ad generated successfully!",
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
toast.success("Ad generated successfully!");
|
| 289 |
+
} catch (error: any) {
|
| 290 |
+
setError(error.message || "Failed to generate ad");
|
| 291 |
+
setProgress({
|
| 292 |
+
step: "error",
|
| 293 |
+
progress: 0,
|
| 294 |
+
message: error.message || "An error occurred",
|
| 295 |
+
});
|
| 296 |
+
toast.error(error.message || "Failed to generate ad");
|
| 297 |
+
} finally {
|
| 298 |
+
setIsGenerating(false);
|
| 299 |
+
}
|
| 300 |
}
|
| 301 |
};
|
| 302 |
|
| 303 |
+
const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null; target_audience?: string; offer?: string }) => {
|
| 304 |
setBatchResults([]);
|
| 305 |
setIsGenerating(true);
|
| 306 |
setGenerationStartTime(Date.now());
|
|
|
|
| 333 |
}, progressInterval);
|
| 334 |
|
| 335 |
try {
|
| 336 |
+
const result = await generateBatch({
|
| 337 |
+
...data,
|
| 338 |
+
target_audience: data.target_audience,
|
| 339 |
+
offer: data.offer,
|
| 340 |
+
});
|
| 341 |
clearInterval(progressIntervalId);
|
| 342 |
setBatchResults(result.ads);
|
| 343 |
setCurrentBatchIndex(data.count - 1); // Set to last ad
|
|
|
|
| 365 |
|
| 366 |
const handleExtensiveGenerate = async (data: {
|
| 367 |
niche: Niche;
|
| 368 |
+
target_audience?: string;
|
| 369 |
+
offer?: string;
|
| 370 |
num_images: number;
|
| 371 |
num_strategies: number;
|
| 372 |
image_model?: string | null;
|
|
|
|
| 658 |
</select>
|
| 659 |
</div>
|
| 660 |
|
| 661 |
+
<div>
|
| 662 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 663 |
+
Target Audience
|
| 664 |
+
</label>
|
| 665 |
+
<input
|
| 666 |
+
type="text"
|
| 667 |
+
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"
|
| 668 |
+
placeholder="e.g., US people over 50+ age"
|
| 669 |
+
value={targetAudience}
|
| 670 |
+
onChange={(e) => setTargetAudience(e.target.value)}
|
| 671 |
+
/>
|
| 672 |
+
</div>
|
| 673 |
+
|
| 674 |
+
<div>
|
| 675 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 676 |
+
Offer
|
| 677 |
+
</label>
|
| 678 |
+
<input
|
| 679 |
+
type="text"
|
| 680 |
+
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"
|
| 681 |
+
placeholder="e.g., Don't overpay your insurance"
|
| 682 |
+
value={offer}
|
| 683 |
+
onChange={(e) => setOffer(e.target.value)}
|
| 684 |
+
/>
|
| 685 |
+
</div>
|
| 686 |
+
|
| 687 |
<div>
|
| 688 |
<label className="block text-sm font-semibold text-gray-700 mb-2">Image Model</label>
|
| 689 |
<select
|
|
|
|
| 701 |
|
| 702 |
<div>
|
| 703 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 704 |
+
Number of Images: <span className="text-blue-600 font-bold">{numImages}</span>
|
| 705 |
</label>
|
| 706 |
<input
|
| 707 |
type="range"
|
|
|
|
| 717 |
<span>5</span>
|
| 718 |
</div>
|
| 719 |
<p className="text-xs text-gray-500 mt-1">
|
| 720 |
+
Generate multiple images for the same ad copy using the same method
|
| 721 |
</p>
|
| 722 |
</div>
|
| 723 |
</CardContent>
|
|
|
|
| 768 |
|
| 769 |
{/* Right Column - Preview */}
|
| 770 |
<div className="lg:col-span-2">
|
| 771 |
+
{batchResults.length > 0 ? (
|
| 772 |
<div className="space-y-6 animate-fade-in">
|
| 773 |
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg p-6 border border-gray-200">
|
| 774 |
<div className="flex items-center justify-between flex-wrap gap-4">
|
frontend/app/page.tsx
CHANGED
|
@@ -150,7 +150,7 @@ export default function Dashboard() {
|
|
| 150 |
return (
|
| 151 |
<div className="min-h-screen pb-12">
|
| 152 |
{/* Hero Section */}
|
| 153 |
-
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-
|
| 154 |
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 155 |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 156 |
<div className="text-center animate-fade-in">
|
|
@@ -160,9 +160,6 @@ export default function Dashboard() {
|
|
| 160 |
<p className="text-xl text-gray-600 max-w-2xl mx-auto font-medium">
|
| 161 |
Design ads that stop the scroll.
|
| 162 |
</p>
|
| 163 |
-
<p className="text-lg text-gray-500 max-w-2xl mx-auto mt-2">
|
| 164 |
-
Generate high-converting ad creatives for Home Insurance and GLP-1 niches with AI-powered generation
|
| 165 |
-
</p>
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
</div>
|
|
|
|
| 150 |
return (
|
| 151 |
<div className="min-h-screen pb-12">
|
| 152 |
{/* Hero Section */}
|
| 153 |
+
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-cyan-50 to-pink-50 py-8 mb-8">
|
| 154 |
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
| 155 |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
| 156 |
<div className="text-center animate-fade-in">
|
|
|
|
| 160 |
<p className="text-xl text-gray-600 max-w-2xl mx-auto font-medium">
|
| 161 |
Design ads that stop the scroll.
|
| 162 |
</p>
|
|
|
|
|
|
|
|
|
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
</div>
|
frontend/components/gallery/AdCard.tsx
CHANGED
|
@@ -5,12 +5,14 @@ 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 |
// Reusable gradient badge component
|
|
@@ -24,6 +26,7 @@ export const AdCard: React.FC<AdCardProps> = memo(({
|
|
| 24 |
ad,
|
| 25 |
isSelected = false,
|
| 26 |
onSelect,
|
|
|
|
| 27 |
}) => {
|
| 28 |
const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename);
|
| 29 |
const [imageSrc, setImageSrc] = useState<string | null>(primary || fallback);
|
|
@@ -74,6 +77,29 @@ export const AdCard: React.FC<AdCardProps> = memo(({
|
|
| 74 |
</div>
|
| 75 |
)}
|
| 76 |
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
)}
|
| 79 |
<div className="p-5 flex-1 flex flex-col">
|
|
@@ -113,6 +139,7 @@ export const AdCard: React.FC<AdCardProps> = memo(({
|
|
| 113 |
return (
|
| 114 |
prevProps.ad.id === nextProps.ad.id &&
|
| 115 |
prevProps.isSelected === nextProps.isSelected &&
|
|
|
|
| 116 |
prevProps.ad.image_url === nextProps.ad.image_url
|
| 117 |
);
|
| 118 |
});
|
|
|
|
| 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 { CheckSquare, Square } from "lucide-react";
|
| 9 |
import type { AdCreativeDB } from "@/types/api";
|
| 10 |
|
| 11 |
interface AdCardProps {
|
| 12 |
ad: AdCreativeDB;
|
| 13 |
isSelected?: boolean;
|
| 14 |
onSelect?: (adId: string) => void;
|
| 15 |
+
hasAnySelection?: boolean;
|
| 16 |
}
|
| 17 |
|
| 18 |
// Reusable gradient badge component
|
|
|
|
| 26 |
ad,
|
| 27 |
isSelected = false,
|
| 28 |
onSelect,
|
| 29 |
+
hasAnySelection = false,
|
| 30 |
}) => {
|
| 31 |
const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename);
|
| 32 |
const [imageSrc, setImageSrc] = useState<string | null>(primary || fallback);
|
|
|
|
| 77 |
</div>
|
| 78 |
)}
|
| 79 |
<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>
|
| 80 |
+
{/* Selection checkbox - show on hover when nothing selected, always show when something is selected */}
|
| 81 |
+
<div className="absolute top-3 left-3 z-10">
|
| 82 |
+
<button
|
| 83 |
+
className={`flex items-center justify-center w-6 h-6 rounded-md border-2 transition-all duration-200 ${
|
| 84 |
+
isSelected
|
| 85 |
+
? "bg-blue-500 border-blue-500 text-white shadow-lg"
|
| 86 |
+
: hasAnySelection
|
| 87 |
+
? "bg-white/90 backdrop-blur-sm border-gray-300 hover:border-blue-400 hover:bg-white shadow-md"
|
| 88 |
+
: "bg-white/90 backdrop-blur-sm border-gray-300 hover:border-blue-400 hover:bg-white shadow-md opacity-0 group-hover:opacity-100"
|
| 89 |
+
}`}
|
| 90 |
+
onClick={(e) => {
|
| 91 |
+
e.stopPropagation();
|
| 92 |
+
e.preventDefault();
|
| 93 |
+
onSelect?.(ad.id);
|
| 94 |
+
}}
|
| 95 |
+
>
|
| 96 |
+
{isSelected ? (
|
| 97 |
+
<CheckSquare className="h-4 w-4" />
|
| 98 |
+
) : (
|
| 99 |
+
<Square className="h-4 w-4 text-gray-400" />
|
| 100 |
+
)}
|
| 101 |
+
</button>
|
| 102 |
+
</div>
|
| 103 |
</div>
|
| 104 |
)}
|
| 105 |
<div className="p-5 flex-1 flex flex-col">
|
|
|
|
| 139 |
return (
|
| 140 |
prevProps.ad.id === nextProps.ad.id &&
|
| 141 |
prevProps.isSelected === nextProps.isSelected &&
|
| 142 |
+
prevProps.hasAnySelection === nextProps.hasAnySelection &&
|
| 143 |
prevProps.ad.image_url === nextProps.ad.image_url
|
| 144 |
);
|
| 145 |
});
|
frontend/components/gallery/FilterBar.tsx
CHANGED
|
@@ -35,9 +35,9 @@ export const FilterBar: React.FC<FilterBarProps> = ({
|
|
| 35 |
|
| 36 |
return (
|
| 37 |
<Card variant="glass">
|
| 38 |
-
<div className="p-
|
| 39 |
<div className="flex items-center justify-between">
|
| 40 |
-
<h3 className="text-
|
| 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" />
|
|
@@ -46,7 +46,7 @@ export const FilterBar: React.FC<FilterBarProps> = ({
|
|
| 46 |
)}
|
| 47 |
</div>
|
| 48 |
|
| 49 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-
|
| 50 |
<Input
|
| 51 |
placeholder="Search by headline or title..."
|
| 52 |
value={filters.search || ""}
|
|
|
|
| 35 |
|
| 36 |
return (
|
| 37 |
<Card variant="glass">
|
| 38 |
+
<div className="p-3 space-y-3">
|
| 39 |
<div className="flex items-center justify-between">
|
| 40 |
+
<h3 className="text-base 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" />
|
|
|
|
| 46 |
)}
|
| 47 |
</div>
|
| 48 |
|
| 49 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
| 50 |
<Input
|
| 51 |
placeholder="Search by headline or title..."
|
| 52 |
value={filters.search || ""}
|
frontend/components/gallery/GalleryGrid.tsx
CHANGED
|
@@ -35,6 +35,8 @@ export const GalleryGrid: React.FC<GalleryGridProps> = ({
|
|
| 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) => (
|
|
@@ -43,6 +45,7 @@ export const GalleryGrid: React.FC<GalleryGridProps> = ({
|
|
| 43 |
ad={ad}
|
| 44 |
isSelected={selectedAds.includes(ad.id)}
|
| 45 |
onSelect={onAdSelect}
|
|
|
|
| 46 |
/>
|
| 47 |
))}
|
| 48 |
</div>
|
|
|
|
| 35 |
);
|
| 36 |
}
|
| 37 |
|
| 38 |
+
const hasAnySelection = selectedAds.length > 0;
|
| 39 |
+
|
| 40 |
return (
|
| 41 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
| 42 |
{ads.map((ad) => (
|
|
|
|
| 45 |
ad={ad}
|
| 46 |
isSelected={selectedAds.includes(ad.id)}
|
| 47 |
onSelect={onAdSelect}
|
| 48 |
+
hasAnySelection={hasAnySelection}
|
| 49 |
/>
|
| 50 |
))}
|
| 51 |
</div>
|
frontend/components/generation/AdPreview.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 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, formatRelativeDate } from "@/lib/utils/formatters";
|
| 8 |
import { toast } from "react-hot-toast";
|
|
@@ -14,6 +14,7 @@ interface AdPreviewProps {
|
|
| 14 |
|
| 15 |
export const AdPreview: React.FC<AdPreviewProps> = ({ ad }) => {
|
| 16 |
const [imageErrors, setImageErrors] = useState<Record<number, boolean>>({});
|
|
|
|
| 17 |
|
| 18 |
// Debug: Log image details to verify uniqueness
|
| 19 |
React.useEffect(() => {
|
|
@@ -32,17 +33,16 @@ export const AdPreview: React.FC<AdPreviewProps> = ({ ad }) => {
|
|
| 32 |
}, [ad.id, ad.images?.length, ad.created_at]); // Use stable dependencies: id, length, and timestamp
|
| 33 |
|
| 34 |
const handleDownloadImage = async (imageUrl: string | null | undefined, filename: string | null | undefined) => {
|
| 35 |
-
if (!imageUrl && !filename) {
|
| 36 |
toast.error("No image URL available");
|
| 37 |
return;
|
| 38 |
}
|
| 39 |
|
| 40 |
try {
|
| 41 |
const url = getImageUrl(imageUrl, filename);
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
}
|
| 46 |
} catch (error) {
|
| 47 |
toast.error("Failed to download image");
|
| 48 |
}
|
|
@@ -292,21 +292,49 @@ export const AdPreview: React.FC<AdPreviewProps> = ({ ad }) => {
|
|
| 292 |
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-amber-500">
|
| 293 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 294 |
<h3 className="text-xs font-bold text-amber-600 uppercase tracking-wider">Body Story</h3>
|
| 295 |
-
<div className="
|
| 296 |
-
<
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
</div>
|
| 311 |
)}
|
| 312 |
|
|
|
|
| 2 |
|
| 3 |
import React, { useState } from "react";
|
| 4 |
import { Button } from "@/components/ui/Button";
|
| 5 |
+
import { Download, Copy, ChevronDown, ChevronUp } from "lucide-react";
|
| 6 |
import { downloadImage, copyToClipboard } from "@/lib/utils/export";
|
| 7 |
import { getImageUrl, getImageUrlFallback, formatRelativeDate } from "@/lib/utils/formatters";
|
| 8 |
import { toast } from "react-hot-toast";
|
|
|
|
| 14 |
|
| 15 |
export const AdPreview: React.FC<AdPreviewProps> = ({ ad }) => {
|
| 16 |
const [imageErrors, setImageErrors] = useState<Record<number, boolean>>({});
|
| 17 |
+
const [isBodyStoryExpanded, setIsBodyStoryExpanded] = useState(false);
|
| 18 |
|
| 19 |
// Debug: Log image details to verify uniqueness
|
| 20 |
React.useEffect(() => {
|
|
|
|
| 33 |
}, [ad.id, ad.images?.length, ad.created_at]); // Use stable dependencies: id, length, and timestamp
|
| 34 |
|
| 35 |
const handleDownloadImage = async (imageUrl: string | null | undefined, filename: string | null | undefined) => {
|
| 36 |
+
if (!imageUrl && !filename && !ad.id) {
|
| 37 |
toast.error("No image URL available");
|
| 38 |
return;
|
| 39 |
}
|
| 40 |
|
| 41 |
try {
|
| 42 |
const url = getImageUrl(imageUrl, filename);
|
| 43 |
+
// Use proxy endpoint with ad ID to avoid CORS issues
|
| 44 |
+
await downloadImage(url, filename || `ad-${ad.id}.png`, ad.id);
|
| 45 |
+
toast.success("Image downloaded");
|
|
|
|
| 46 |
} catch (error) {
|
| 47 |
toast.error("Failed to download image");
|
| 48 |
}
|
|
|
|
| 292 |
<div className="bg-white rounded-2xl shadow-md p-6 border-l-4 border-amber-500">
|
| 293 |
<div className="flex items-start justify-between gap-4 mb-3">
|
| 294 |
<h3 className="text-xs font-bold text-amber-600 uppercase tracking-wider">Body Story</h3>
|
| 295 |
+
<div className="flex items-center gap-2">
|
| 296 |
+
<div className="relative group">
|
| 297 |
+
<Button
|
| 298 |
+
variant="ghost"
|
| 299 |
+
size="sm"
|
| 300 |
+
onClick={() => handleCopyText(ad.body_story!, "Body Story")}
|
| 301 |
+
className="text-amber-500 hover:bg-amber-50"
|
| 302 |
+
>
|
| 303 |
+
<Copy className="h-4 w-4" />
|
| 304 |
+
</Button>
|
| 305 |
+
<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">
|
| 306 |
+
Copy Story
|
| 307 |
+
</span>
|
| 308 |
+
</div>
|
| 309 |
</div>
|
| 310 |
</div>
|
| 311 |
+
<div className="relative">
|
| 312 |
+
<p
|
| 313 |
+
className={`text-gray-700 whitespace-pre-line leading-relaxed transition-all duration-300 ${
|
| 314 |
+
!isBodyStoryExpanded ? 'line-clamp-3' : ''
|
| 315 |
+
}`}
|
| 316 |
+
>
|
| 317 |
+
{ad.body_story}
|
| 318 |
+
</p>
|
| 319 |
+
{ad.body_story.split('\n').length > 3 || ad.body_story.length > 200 ? (
|
| 320 |
+
<button
|
| 321 |
+
onClick={() => setIsBodyStoryExpanded(!isBodyStoryExpanded)}
|
| 322 |
+
className="mt-2 flex items-center gap-1 text-amber-600 hover:text-amber-700 text-sm font-medium transition-colors"
|
| 323 |
+
>
|
| 324 |
+
{isBodyStoryExpanded ? (
|
| 325 |
+
<>
|
| 326 |
+
<span>Show less</span>
|
| 327 |
+
<ChevronUp className="h-4 w-4" />
|
| 328 |
+
</>
|
| 329 |
+
) : (
|
| 330 |
+
<>
|
| 331 |
+
<span>Read more</span>
|
| 332 |
+
<ChevronDown className="h-4 w-4" />
|
| 333 |
+
</>
|
| 334 |
+
)}
|
| 335 |
+
</button>
|
| 336 |
+
) : null}
|
| 337 |
+
</div>
|
| 338 |
</div>
|
| 339 |
)}
|
| 340 |
|
frontend/components/generation/BatchForm.tsx
CHANGED
|
@@ -12,7 +12,7 @@ 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 |
|
|
@@ -32,6 +32,8 @@ export const BatchForm: React.FC<BatchFormProps> = ({
|
|
| 32 |
count: 5,
|
| 33 |
images_per_ad: 1,
|
| 34 |
image_model: null,
|
|
|
|
|
|
|
| 35 |
},
|
| 36 |
});
|
| 37 |
|
|
@@ -58,6 +60,36 @@ export const BatchForm: React.FC<BatchFormProps> = ({
|
|
| 58 |
{...register("niche")}
|
| 59 |
/>
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<Select
|
| 62 |
label="Image Model"
|
| 63 |
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
|
|
|
| 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; target_audience?: string; offer?: string }) => Promise<void>;
|
| 16 |
isLoading: boolean;
|
| 17 |
}
|
| 18 |
|
|
|
|
| 32 |
count: 5,
|
| 33 |
images_per_ad: 1,
|
| 34 |
image_model: null,
|
| 35 |
+
target_audience: "",
|
| 36 |
+
offer: "",
|
| 37 |
},
|
| 38 |
});
|
| 39 |
|
|
|
|
| 60 |
{...register("niche")}
|
| 61 |
/>
|
| 62 |
|
| 63 |
+
<div>
|
| 64 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 65 |
+
Target Audience
|
| 66 |
+
</label>
|
| 67 |
+
<input
|
| 68 |
+
type="text"
|
| 69 |
+
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"
|
| 70 |
+
placeholder="e.g., US people over 50+ age"
|
| 71 |
+
{...register("target_audience")}
|
| 72 |
+
/>
|
| 73 |
+
{errors.target_audience && (
|
| 74 |
+
<p className="text-red-500 text-xs mt-1">{errors.target_audience.message}</p>
|
| 75 |
+
)}
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div>
|
| 79 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 80 |
+
Offer
|
| 81 |
+
</label>
|
| 82 |
+
<input
|
| 83 |
+
type="text"
|
| 84 |
+
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"
|
| 85 |
+
placeholder="e.g., Don't overpay your insurance"
|
| 86 |
+
{...register("offer")}
|
| 87 |
+
/>
|
| 88 |
+
{errors.offer && (
|
| 89 |
+
<p className="text-red-500 text-xs mt-1">{errors.offer.message}</p>
|
| 90 |
+
)}
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
<Select
|
| 94 |
label="Image Model"
|
| 95 |
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
frontend/components/generation/CorrectionModal.tsx
CHANGED
|
@@ -96,7 +96,8 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 96 |
if (response.status === "success") {
|
| 97 |
setStep("complete");
|
| 98 |
setResult(response);
|
| 99 |
-
onSuccess
|
|
|
|
| 100 |
} else {
|
| 101 |
setStep("error");
|
| 102 |
setError(response.error || "Correction failed");
|
|
@@ -211,13 +212,6 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 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>
|
|
@@ -274,46 +268,90 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 274 |
</div>
|
| 275 |
)}
|
| 276 |
|
|
|
|
| 277 |
{result.corrections && (
|
| 278 |
-
<div className="space-y-
|
| 279 |
-
|
|
|
|
|
|
|
| 280 |
<div>
|
| 281 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
</div>
|
| 296 |
))}
|
| 297 |
</div>
|
| 298 |
</div>
|
| 299 |
)}
|
| 300 |
|
| 301 |
-
{result.corrections.visual_corrections.length > 0 && (
|
| 302 |
<div>
|
| 303 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
</div>
|
| 313 |
))}
|
| 314 |
</div>
|
| 315 |
</div>
|
| 316 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
</div>
|
| 318 |
)}
|
| 319 |
</div>
|
|
@@ -339,7 +377,13 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
|
|
| 339 |
</Button>
|
| 340 |
)}
|
| 341 |
<Button
|
| 342 |
-
onClick={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
variant={step === "complete" ? "primary" : "secondary"}
|
| 344 |
>
|
| 345 |
{step === "complete" ? "Done" : "Close"}
|
|
|
|
| 96 |
if (response.status === "success") {
|
| 97 |
setStep("complete");
|
| 98 |
setResult(response);
|
| 99 |
+
// Don't call onSuccess immediately - let user see the corrections first
|
| 100 |
+
// onSuccess will be called when user clicks "Done"
|
| 101 |
} else {
|
| 102 |
setStep("error");
|
| 103 |
setError(response.error || "Correction failed");
|
|
|
|
| 212 |
<p>AI will analyze the image for spelling mistakes and visual issues, then suggest corrections.</p>
|
| 213 |
</div>
|
| 214 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
</div>
|
| 216 |
</CardContent>
|
| 217 |
</Card>
|
|
|
|
| 268 |
</div>
|
| 269 |
)}
|
| 270 |
|
| 271 |
+
{/* Show corrections details */}
|
| 272 |
{result.corrections && (
|
| 273 |
+
<div className="space-y-4">
|
| 274 |
+
<h3 className="font-semibold text-gray-900 text-lg">Correction Details</h3>
|
| 275 |
+
|
| 276 |
+
{result.corrections.spelling_corrections && result.corrections.spelling_corrections.length > 0 && (
|
| 277 |
<div>
|
| 278 |
+
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
| 279 |
+
<span className="text-yellow-600">✏️</span>
|
| 280 |
+
Spelling Corrections
|
| 281 |
+
</h4>
|
| 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 |
+
<div className="flex items-center gap-2">
|
| 289 |
+
<span className="font-medium text-red-600 line-through">
|
| 290 |
+
{correction.detected}
|
| 291 |
+
</span>
|
| 292 |
+
<span className="text-gray-400">→</span>
|
| 293 |
+
<span className="font-medium text-green-600">
|
| 294 |
+
{correction.corrected}
|
| 295 |
+
</span>
|
| 296 |
+
</div>
|
| 297 |
+
{correction.context && (
|
| 298 |
+
<p className="text-xs text-gray-500 mt-1 italic">{correction.context}</p>
|
| 299 |
+
)}
|
| 300 |
</div>
|
| 301 |
))}
|
| 302 |
</div>
|
| 303 |
</div>
|
| 304 |
)}
|
| 305 |
|
| 306 |
+
{result.corrections.visual_corrections && result.corrections.visual_corrections.length > 0 && (
|
| 307 |
<div>
|
| 308 |
+
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
| 309 |
+
<span className="text-blue-600">🎨</span>
|
| 310 |
+
Visual Improvements
|
| 311 |
+
</h4>
|
| 312 |
<div className="space-y-2">
|
| 313 |
{result.corrections.visual_corrections.map((correction, idx) => (
|
| 314 |
<div
|
| 315 |
key={idx}
|
| 316 |
className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm"
|
| 317 |
>
|
| 318 |
+
<div className="flex items-start justify-between gap-2">
|
| 319 |
+
<div className="flex-1">
|
| 320 |
+
<p className="font-medium text-gray-900">{correction.issue}</p>
|
| 321 |
+
<p className="text-gray-600 mt-1">{correction.suggestion}</p>
|
| 322 |
+
</div>
|
| 323 |
+
{correction.priority && (
|
| 324 |
+
<span className={`text-xs px-2 py-1 rounded ${
|
| 325 |
+
correction.priority === "high" ? "bg-red-100 text-red-700" :
|
| 326 |
+
correction.priority === "medium" ? "bg-yellow-100 text-yellow-700" :
|
| 327 |
+
"bg-gray-100 text-gray-700"
|
| 328 |
+
}`}>
|
| 329 |
+
{correction.priority}
|
| 330 |
+
</span>
|
| 331 |
+
)}
|
| 332 |
+
</div>
|
| 333 |
</div>
|
| 334 |
))}
|
| 335 |
</div>
|
| 336 |
</div>
|
| 337 |
)}
|
| 338 |
+
|
| 339 |
+
{result.corrections.corrected_prompt && (
|
| 340 |
+
<div>
|
| 341 |
+
<h4 className="font-semibold text-gray-900 mb-2">Corrected Prompt</h4>
|
| 342 |
+
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm">
|
| 343 |
+
<p className="text-gray-700">{result.corrections.corrected_prompt}</p>
|
| 344 |
+
</div>
|
| 345 |
+
</div>
|
| 346 |
+
)}
|
| 347 |
+
|
| 348 |
+
{/* Show message if no corrections were made */}
|
| 349 |
+
{(!result.corrections.spelling_corrections || result.corrections.spelling_corrections.length === 0) &&
|
| 350 |
+
(!result.corrections.visual_corrections || result.corrections.visual_corrections.length === 0) && (
|
| 351 |
+
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm text-gray-600">
|
| 352 |
+
<p>No specific corrections were identified. The image was regenerated based on your instructions.</p>
|
| 353 |
+
</div>
|
| 354 |
+
)}
|
| 355 |
</div>
|
| 356 |
)}
|
| 357 |
</div>
|
|
|
|
| 377 |
</Button>
|
| 378 |
)}
|
| 379 |
<Button
|
| 380 |
+
onClick={() => {
|
| 381 |
+
if (step === "complete" && result) {
|
| 382 |
+
// Call onSuccess when user clicks "Done" to reload the ad
|
| 383 |
+
onSuccess?.(result);
|
| 384 |
+
}
|
| 385 |
+
onClose();
|
| 386 |
+
}}
|
| 387 |
variant={step === "complete" ? "primary" : "secondary"}
|
| 388 |
>
|
| 389 |
{step === "complete" ? "Done" : "Close"}
|
frontend/components/generation/EditCopyModal.tsx
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { X, Wand2, Edit3, Sparkles, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
| 5 |
+
import { editAdCopy } from "@/lib/api/endpoints";
|
| 6 |
+
import type { AdCreativeDB } from "@/types/api";
|
| 7 |
+
import { Button } from "@/components/ui/Button";
|
| 8 |
+
import { Input } from "@/components/ui/Input";
|
| 9 |
+
import { Card, CardContent } from "@/components/ui/Card";
|
| 10 |
+
|
| 11 |
+
interface EditCopyModalProps {
|
| 12 |
+
isOpen: boolean;
|
| 13 |
+
onClose: () => void;
|
| 14 |
+
adId: string;
|
| 15 |
+
ad?: AdCreativeDB | null;
|
| 16 |
+
field: "title" | "headline" | "primary_text" | "description" | "body_story" | "cta";
|
| 17 |
+
fieldLabel: string;
|
| 18 |
+
currentValue: string;
|
| 19 |
+
onSuccess?: () => void;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type EditMode = "manual" | "ai";
|
| 23 |
+
|
| 24 |
+
export const EditCopyModal: React.FC<EditCopyModalProps> = ({
|
| 25 |
+
isOpen,
|
| 26 |
+
onClose,
|
| 27 |
+
adId,
|
| 28 |
+
ad,
|
| 29 |
+
field,
|
| 30 |
+
fieldLabel,
|
| 31 |
+
currentValue,
|
| 32 |
+
onSuccess,
|
| 33 |
+
}) => {
|
| 34 |
+
const [mode, setMode] = useState<EditMode>("manual");
|
| 35 |
+
const [editedValue, setEditedValue] = useState(currentValue);
|
| 36 |
+
const [aiSuggestion, setAiSuggestion] = useState("");
|
| 37 |
+
const [userSuggestion, setUserSuggestion] = useState("");
|
| 38 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 39 |
+
const [error, setError] = useState<string | null>(null);
|
| 40 |
+
const [success, setSuccess] = useState(false);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
if (isOpen) {
|
| 44 |
+
setEditedValue(currentValue);
|
| 45 |
+
setAiSuggestion("");
|
| 46 |
+
setUserSuggestion("");
|
| 47 |
+
setError(null);
|
| 48 |
+
setSuccess(false);
|
| 49 |
+
setMode("manual");
|
| 50 |
+
}
|
| 51 |
+
}, [isOpen, currentValue]);
|
| 52 |
+
|
| 53 |
+
const handleManualSave = async () => {
|
| 54 |
+
if (!editedValue.trim()) {
|
| 55 |
+
setError("This field cannot be empty");
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
setIsLoading(true);
|
| 60 |
+
setError(null);
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
await editAdCopy({
|
| 64 |
+
ad_id: adId,
|
| 65 |
+
field,
|
| 66 |
+
value: editedValue,
|
| 67 |
+
mode: "manual",
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
setSuccess(true);
|
| 71 |
+
setTimeout(() => {
|
| 72 |
+
onSuccess?.();
|
| 73 |
+
onClose();
|
| 74 |
+
}, 1000);
|
| 75 |
+
} catch (err: any) {
|
| 76 |
+
setError(err.message || "Failed to update. Please try again.");
|
| 77 |
+
} finally {
|
| 78 |
+
setIsLoading(false);
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
const handleAIGenerate = async () => {
|
| 83 |
+
setIsLoading(true);
|
| 84 |
+
setError(null);
|
| 85 |
+
|
| 86 |
+
try {
|
| 87 |
+
const result = await editAdCopy({
|
| 88 |
+
ad_id: adId,
|
| 89 |
+
field,
|
| 90 |
+
value: currentValue,
|
| 91 |
+
mode: "ai",
|
| 92 |
+
user_suggestion: userSuggestion || undefined,
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
setAiSuggestion(result.edited_value || "");
|
| 96 |
+
} catch (err: any) {
|
| 97 |
+
setError(err.message || "Failed to generate AI suggestion. Please try again.");
|
| 98 |
+
} finally {
|
| 99 |
+
setIsLoading(false);
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const handleAIApply = async () => {
|
| 104 |
+
if (!aiSuggestion.trim()) {
|
| 105 |
+
setError("No AI suggestion available");
|
| 106 |
+
return;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
setIsLoading(true);
|
| 110 |
+
setError(null);
|
| 111 |
+
|
| 112 |
+
try {
|
| 113 |
+
await editAdCopy({
|
| 114 |
+
ad_id: adId,
|
| 115 |
+
field,
|
| 116 |
+
value: aiSuggestion,
|
| 117 |
+
mode: "manual",
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
setSuccess(true);
|
| 121 |
+
setTimeout(() => {
|
| 122 |
+
onSuccess?.();
|
| 123 |
+
onClose();
|
| 124 |
+
}, 1000);
|
| 125 |
+
} catch (err: any) {
|
| 126 |
+
setError(err.message || "Failed to apply AI suggestion. Please try again.");
|
| 127 |
+
} finally {
|
| 128 |
+
setIsLoading(false);
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
if (!isOpen) return null;
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
|
| 136 |
+
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl border-2 border-blue-200 animate-scale-in">
|
| 137 |
+
<CardContent className="p-6">
|
| 138 |
+
{/* Header */}
|
| 139 |
+
<div className="flex items-center justify-between mb-6">
|
| 140 |
+
<div className="flex items-center gap-3">
|
| 141 |
+
<div className="p-2 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-lg">
|
| 142 |
+
<Edit3 className="h-5 w-5 text-white" />
|
| 143 |
+
</div>
|
| 144 |
+
<div>
|
| 145 |
+
<h2 className="text-xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
|
| 146 |
+
Edit {fieldLabel}
|
| 147 |
+
</h2>
|
| 148 |
+
<p className="text-sm text-gray-500">Update your ad copy</p>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
<button
|
| 152 |
+
onClick={onClose}
|
| 153 |
+
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
| 154 |
+
>
|
| 155 |
+
<X className="h-5 w-5 text-gray-500" />
|
| 156 |
+
</button>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
{/* Mode Toggle */}
|
| 160 |
+
<div className="flex gap-2 mb-6 p-1 bg-gray-100 rounded-lg">
|
| 161 |
+
<button
|
| 162 |
+
onClick={() => setMode("manual")}
|
| 163 |
+
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
| 164 |
+
mode === "manual"
|
| 165 |
+
? "bg-white text-blue-600 shadow-sm"
|
| 166 |
+
: "text-gray-600 hover:text-gray-900"
|
| 167 |
+
}`}
|
| 168 |
+
>
|
| 169 |
+
<Edit3 className="h-4 w-4" />
|
| 170 |
+
Manual Edit
|
| 171 |
+
</button>
|
| 172 |
+
<button
|
| 173 |
+
onClick={() => setMode("ai")}
|
| 174 |
+
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
| 175 |
+
mode === "ai"
|
| 176 |
+
? "bg-white text-blue-600 shadow-sm"
|
| 177 |
+
: "text-gray-600 hover:text-gray-900"
|
| 178 |
+
}`}
|
| 179 |
+
>
|
| 180 |
+
<Wand2 className="h-4 w-4" />
|
| 181 |
+
AI Edit
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* Manual Edit Mode */}
|
| 186 |
+
{mode === "manual" && (
|
| 187 |
+
<div className="space-y-4">
|
| 188 |
+
<div>
|
| 189 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 190 |
+
{fieldLabel}
|
| 191 |
+
</label>
|
| 192 |
+
<textarea
|
| 193 |
+
value={editedValue}
|
| 194 |
+
onChange={(e) => setEditedValue(e.target.value)}
|
| 195 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all resize-none"
|
| 196 |
+
rows={field === "body_story" ? 8 : field === "description" ? 4 : 3}
|
| 197 |
+
placeholder={`Enter your ${fieldLabel.toLowerCase()}...`}
|
| 198 |
+
/>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
{error && (
|
| 202 |
+
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
| 203 |
+
<AlertCircle className="h-4 w-4" />
|
| 204 |
+
<span className="text-sm">{error}</span>
|
| 205 |
+
</div>
|
| 206 |
+
)}
|
| 207 |
+
|
| 208 |
+
{success && (
|
| 209 |
+
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-lg text-green-600">
|
| 210 |
+
<CheckCircle2 className="h-4 w-4" />
|
| 211 |
+
<span className="text-sm">Successfully updated!</span>
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
|
| 215 |
+
<div className="flex gap-3">
|
| 216 |
+
<Button
|
| 217 |
+
onClick={handleManualSave}
|
| 218 |
+
isLoading={isLoading}
|
| 219 |
+
variant="primary"
|
| 220 |
+
className="flex-1"
|
| 221 |
+
>
|
| 222 |
+
Save Changes
|
| 223 |
+
</Button>
|
| 224 |
+
<Button
|
| 225 |
+
onClick={onClose}
|
| 226 |
+
variant="ghost"
|
| 227 |
+
disabled={isLoading}
|
| 228 |
+
>
|
| 229 |
+
Cancel
|
| 230 |
+
</Button>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
|
| 235 |
+
{/* AI Edit Mode */}
|
| 236 |
+
{mode === "ai" && (
|
| 237 |
+
<div className="space-y-4">
|
| 238 |
+
<div>
|
| 239 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 240 |
+
Your Suggestion (Optional)
|
| 241 |
+
</label>
|
| 242 |
+
<textarea
|
| 243 |
+
value={userSuggestion}
|
| 244 |
+
onChange={(e) => setUserSuggestion(e.target.value)}
|
| 245 |
+
className="w-full px-4 py-3 rounded-xl border-2 border-gray-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all resize-none"
|
| 246 |
+
rows={3}
|
| 247 |
+
placeholder="e.g., Make it more emotional, Add urgency, Make it shorter, Focus on benefits..."
|
| 248 |
+
/>
|
| 249 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 250 |
+
Tell AI how you'd like the {fieldLabel.toLowerCase()} improved
|
| 251 |
+
</p>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
<div>
|
| 255 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 256 |
+
Current {fieldLabel}
|
| 257 |
+
</label>
|
| 258 |
+
<div className="px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 text-gray-700">
|
| 259 |
+
{currentValue || <span className="text-gray-400">No content</span>}
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<Button
|
| 264 |
+
onClick={handleAIGenerate}
|
| 265 |
+
isLoading={isLoading}
|
| 266 |
+
variant="primary"
|
| 267 |
+
className="w-full"
|
| 268 |
+
disabled={isLoading}
|
| 269 |
+
>
|
| 270 |
+
<Wand2 className="h-4 w-4 mr-2" />
|
| 271 |
+
{isLoading ? "Generating..." : "Generate AI Suggestion"}
|
| 272 |
+
</Button>
|
| 273 |
+
|
| 274 |
+
{aiSuggestion && (
|
| 275 |
+
<div className="space-y-3">
|
| 276 |
+
<div>
|
| 277 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 278 |
+
AI Suggestion
|
| 279 |
+
</label>
|
| 280 |
+
<div className="px-4 py-3 rounded-xl border-2 border-blue-200 bg-blue-50 text-gray-700 whitespace-pre-line">
|
| 281 |
+
{aiSuggestion}
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
{error && (
|
| 286 |
+
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
| 287 |
+
<AlertCircle className="h-4 w-4" />
|
| 288 |
+
<span className="text-sm">{error}</span>
|
| 289 |
+
</div>
|
| 290 |
+
)}
|
| 291 |
+
|
| 292 |
+
{success && (
|
| 293 |
+
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-lg text-green-600">
|
| 294 |
+
<CheckCircle2 className="h-4 w-4" />
|
| 295 |
+
<span className="text-sm">Successfully updated!</span>
|
| 296 |
+
</div>
|
| 297 |
+
)}
|
| 298 |
+
|
| 299 |
+
<div className="flex gap-3">
|
| 300 |
+
<Button
|
| 301 |
+
onClick={handleAIApply}
|
| 302 |
+
isLoading={isLoading}
|
| 303 |
+
variant="primary"
|
| 304 |
+
className="flex-1"
|
| 305 |
+
>
|
| 306 |
+
Apply AI Suggestion
|
| 307 |
+
</Button>
|
| 308 |
+
<Button
|
| 309 |
+
onClick={() => {
|
| 310 |
+
setAiSuggestion("");
|
| 311 |
+
setUserSuggestion("");
|
| 312 |
+
}}
|
| 313 |
+
variant="ghost"
|
| 314 |
+
disabled={isLoading}
|
| 315 |
+
>
|
| 316 |
+
Regenerate
|
| 317 |
+
</Button>
|
| 318 |
+
<Button
|
| 319 |
+
onClick={onClose}
|
| 320 |
+
variant="ghost"
|
| 321 |
+
disabled={isLoading}
|
| 322 |
+
>
|
| 323 |
+
Cancel
|
| 324 |
+
</Button>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
)}
|
| 328 |
+
</div>
|
| 329 |
+
)}
|
| 330 |
+
</CardContent>
|
| 331 |
+
</Card>
|
| 332 |
+
</div>
|
| 333 |
+
);
|
| 334 |
+
};
|
frontend/components/generation/ExtensiveForm.tsx
CHANGED
|
@@ -11,8 +11,8 @@ import type { Niche } from "@/types/api";
|
|
| 11 |
|
| 12 |
const extensiveSchema = z.object({
|
| 13 |
niche: z.enum(["home_insurance", "glp1"]),
|
| 14 |
-
target_audience: z.string().
|
| 15 |
-
offer: z.string().
|
| 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(),
|
|
@@ -23,8 +23,8 @@ type ExtensiveFormData = z.infer<typeof extensiveSchema>;
|
|
| 23 |
interface ExtensiveFormProps {
|
| 24 |
onSubmit: (data: {
|
| 25 |
niche: Niche;
|
| 26 |
-
target_audience
|
| 27 |
-
offer
|
| 28 |
num_images: number;
|
| 29 |
num_strategies: number;
|
| 30 |
image_model?: string | null;
|
|
@@ -78,7 +78,7 @@ export const ExtensiveForm: React.FC<ExtensiveFormProps> = ({
|
|
| 78 |
|
| 79 |
<div>
|
| 80 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 81 |
-
Target Audience
|
| 82 |
</label>
|
| 83 |
<input
|
| 84 |
type="text"
|
|
@@ -93,7 +93,7 @@ export const ExtensiveForm: React.FC<ExtensiveFormProps> = ({
|
|
| 93 |
|
| 94 |
<div>
|
| 95 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 96 |
-
Offer
|
| 97 |
</label>
|
| 98 |
<input
|
| 99 |
type="text"
|
|
|
|
| 11 |
|
| 12 |
const extensiveSchema = z.object({
|
| 13 |
niche: z.enum(["home_insurance", "glp1"]),
|
| 14 |
+
target_audience: z.string().optional(),
|
| 15 |
+
offer: z.string().optional(),
|
| 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(),
|
|
|
|
| 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;
|
|
|
|
| 78 |
|
| 79 |
<div>
|
| 80 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 81 |
+
Target Audience
|
| 82 |
</label>
|
| 83 |
<input
|
| 84 |
type="text"
|
|
|
|
| 93 |
|
| 94 |
<div>
|
| 95 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 96 |
+
Offer
|
| 97 |
</label>
|
| 98 |
<input
|
| 99 |
type="text"
|
frontend/components/generation/GenerationForm.tsx
CHANGED
|
@@ -12,7 +12,7 @@ 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 |
|
|
@@ -31,6 +31,8 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
|
|
| 31 |
niche: "home_insurance" as Niche,
|
| 32 |
num_images: 1,
|
| 33 |
image_model: null,
|
|
|
|
|
|
|
| 34 |
},
|
| 35 |
});
|
| 36 |
|
|
@@ -56,6 +58,36 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
|
|
| 56 |
{...register("niche")}
|
| 57 |
/>
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
<Select
|
| 60 |
label="Image Model"
|
| 61 |
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
|
@@ -65,7 +97,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
|
|
| 65 |
|
| 66 |
<div>
|
| 67 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 68 |
-
Number of
|
| 69 |
</label>
|
| 70 |
<input
|
| 71 |
type="range"
|
|
@@ -80,7 +112,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
|
|
| 80 |
<span>10</span>
|
| 81 |
</div>
|
| 82 |
<p className="text-xs text-gray-500 mt-1">
|
| 83 |
-
|
| 84 |
</p>
|
| 85 |
{errors.num_images && (
|
| 86 |
<p className="mt-1 text-sm text-red-600">
|
|
|
|
| 12 |
import type { Niche } from "@/types/api";
|
| 13 |
|
| 14 |
interface GenerationFormProps {
|
| 15 |
+
onSubmit: (data: { niche: Niche; num_images: number; image_model?: string | null; target_audience?: string; offer?: string }) => Promise<void>;
|
| 16 |
isLoading: boolean;
|
| 17 |
}
|
| 18 |
|
|
|
|
| 31 |
niche: "home_insurance" as Niche,
|
| 32 |
num_images: 1,
|
| 33 |
image_model: null,
|
| 34 |
+
target_audience: "",
|
| 35 |
+
offer: "",
|
| 36 |
},
|
| 37 |
});
|
| 38 |
|
|
|
|
| 58 |
{...register("niche")}
|
| 59 |
/>
|
| 60 |
|
| 61 |
+
<div>
|
| 62 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 63 |
+
Target Audience
|
| 64 |
+
</label>
|
| 65 |
+
<input
|
| 66 |
+
type="text"
|
| 67 |
+
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"
|
| 68 |
+
placeholder="e.g., US people over 50+ age"
|
| 69 |
+
{...register("target_audience")}
|
| 70 |
+
/>
|
| 71 |
+
{errors.target_audience && (
|
| 72 |
+
<p className="text-red-500 text-xs mt-1">{errors.target_audience.message}</p>
|
| 73 |
+
)}
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div>
|
| 77 |
+
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 78 |
+
Offer
|
| 79 |
+
</label>
|
| 80 |
+
<input
|
| 81 |
+
type="text"
|
| 82 |
+
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"
|
| 83 |
+
placeholder="e.g., Don't overpay your insurance"
|
| 84 |
+
{...register("offer")}
|
| 85 |
+
/>
|
| 86 |
+
{errors.offer && (
|
| 87 |
+
<p className="text-red-500 text-xs mt-1">{errors.offer.message}</p>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
<Select
|
| 92 |
label="Image Model"
|
| 93 |
options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
|
|
|
|
| 97 |
|
| 98 |
<div>
|
| 99 |
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
| 100 |
+
Number of Images: <span className="text-blue-600 font-bold">{numImages}</span>
|
| 101 |
</label>
|
| 102 |
<input
|
| 103 |
type="range"
|
|
|
|
| 112 |
<span>10</span>
|
| 113 |
</div>
|
| 114 |
<p className="text-xs text-gray-500 mt-1">
|
| 115 |
+
Generate multiple images for the same ad copy using the same method
|
| 116 |
</p>
|
| 117 |
{errors.num_images && (
|
| 118 |
<p className="mt-1 text-sm text-red-600">
|
frontend/components/generation/GenerationProgress.tsx
CHANGED
|
@@ -75,7 +75,8 @@ export const GenerationProgressComponent: React.FC<GenerationProgressProps> = ({
|
|
| 75 |
error: 0,
|
| 76 |
};
|
| 77 |
|
| 78 |
-
|
|
|
|
| 79 |
const currentStepIndex = STEPS.findIndex(s => s.key === progress.step);
|
| 80 |
const isComplete = progress.step === "complete";
|
| 81 |
const isError = progress.step === "error";
|
|
|
|
| 75 |
error: 0,
|
| 76 |
};
|
| 77 |
|
| 78 |
+
// Use nullish coalescing (??) instead of || to properly handle 0 as a valid progress value
|
| 79 |
+
const currentProgress = progress.progress ?? stepProgress[progress.step];
|
| 80 |
const currentStepIndex = STEPS.findIndex(s => s.key === progress.step);
|
| 81 |
const isComplete = progress.step === "complete";
|
| 82 |
const isError = progress.step === "error";
|
frontend/lib/api/endpoints.ts
CHANGED
|
@@ -35,6 +35,8 @@ export const generateAd = async (params: {
|
|
| 35 |
niche: Niche;
|
| 36 |
num_images: number;
|
| 37 |
image_model?: string | null;
|
|
|
|
|
|
|
| 38 |
}): Promise<GenerateResponse> => {
|
| 39 |
const response = await apiClient.post<GenerateResponse>("/generate", params);
|
| 40 |
return response.data;
|
|
@@ -45,6 +47,9 @@ export const generateBatch = async (params: {
|
|
| 45 |
count: number;
|
| 46 |
images_per_ad: number;
|
| 47 |
image_model?: string | null;
|
|
|
|
|
|
|
|
|
|
| 48 |
}): Promise<BatchResponse> => {
|
| 49 |
const response = await apiClient.post<BatchResponse>("/generate/batch", params);
|
| 50 |
return response.data;
|
|
@@ -57,6 +62,8 @@ export const generateMatrixAd = async (params: {
|
|
| 57 |
concept_key?: string | null;
|
| 58 |
num_images: number;
|
| 59 |
image_model?: string | null;
|
|
|
|
|
|
|
| 60 |
}): Promise<MatrixGenerateResponse> => {
|
| 61 |
const response = await apiClient.post<MatrixGenerateResponse>("/matrix/generate", params);
|
| 62 |
return response.data;
|
|
@@ -65,8 +72,8 @@ export const generateMatrixAd = async (params: {
|
|
| 65 |
// Extensive Endpoint
|
| 66 |
export const generateExtensiveAd = async (params: {
|
| 67 |
niche: Niche;
|
| 68 |
-
target_audience
|
| 69 |
-
offer
|
| 70 |
num_images: number;
|
| 71 |
image_model?: string | null;
|
| 72 |
num_strategies: number;
|
|
@@ -74,10 +81,10 @@ export const generateExtensiveAd = async (params: {
|
|
| 74 |
// Ensure required parameters are always sent
|
| 75 |
const requestParams = {
|
| 76 |
niche: params.niche,
|
| 77 |
-
target_audience: params.target_audience,
|
| 78 |
-
offer: params.offer,
|
| 79 |
num_images: params.num_images || 1,
|
| 80 |
num_strategies: params.num_strategies || 1,
|
|
|
|
|
|
|
| 81 |
...(params.image_model && { image_model: params.image_model }),
|
| 82 |
};
|
| 83 |
const response = await apiClient.post<GenerateResponse>("/extensive/generate", requestParams);
|
|
@@ -169,3 +176,27 @@ export const login = async (username: string, password: string): Promise<LoginRe
|
|
| 169 |
});
|
| 170 |
return response.data;
|
| 171 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
niche: Niche;
|
| 36 |
num_images: number;
|
| 37 |
image_model?: string | null;
|
| 38 |
+
target_audience?: string;
|
| 39 |
+
offer?: string;
|
| 40 |
}): Promise<GenerateResponse> => {
|
| 41 |
const response = await apiClient.post<GenerateResponse>("/generate", params);
|
| 42 |
return response.data;
|
|
|
|
| 47 |
count: number;
|
| 48 |
images_per_ad: number;
|
| 49 |
image_model?: string | null;
|
| 50 |
+
method?: "standard" | "matrix" | null;
|
| 51 |
+
target_audience?: string;
|
| 52 |
+
offer?: string;
|
| 53 |
}): Promise<BatchResponse> => {
|
| 54 |
const response = await apiClient.post<BatchResponse>("/generate/batch", params);
|
| 55 |
return response.data;
|
|
|
|
| 62 |
concept_key?: string | null;
|
| 63 |
num_images: number;
|
| 64 |
image_model?: string | null;
|
| 65 |
+
target_audience?: string;
|
| 66 |
+
offer?: string;
|
| 67 |
}): Promise<MatrixGenerateResponse> => {
|
| 68 |
const response = await apiClient.post<MatrixGenerateResponse>("/matrix/generate", params);
|
| 69 |
return response.data;
|
|
|
|
| 72 |
// Extensive Endpoint
|
| 73 |
export const generateExtensiveAd = async (params: {
|
| 74 |
niche: Niche;
|
| 75 |
+
target_audience?: string;
|
| 76 |
+
offer?: string;
|
| 77 |
num_images: number;
|
| 78 |
image_model?: string | null;
|
| 79 |
num_strategies: number;
|
|
|
|
| 81 |
// Ensure required parameters are always sent
|
| 82 |
const requestParams = {
|
| 83 |
niche: params.niche,
|
|
|
|
|
|
|
| 84 |
num_images: params.num_images || 1,
|
| 85 |
num_strategies: params.num_strategies || 1,
|
| 86 |
+
...(params.target_audience && { target_audience: params.target_audience }),
|
| 87 |
+
...(params.offer && { offer: params.offer }),
|
| 88 |
...(params.image_model && { image_model: params.image_model }),
|
| 89 |
};
|
| 90 |
const response = await apiClient.post<GenerateResponse>("/extensive/generate", requestParams);
|
|
|
|
| 176 |
});
|
| 177 |
return response.data;
|
| 178 |
};
|
| 179 |
+
|
| 180 |
+
// Edit Ad Copy Endpoint
|
| 181 |
+
export const editAdCopy = async (params: {
|
| 182 |
+
ad_id: string;
|
| 183 |
+
field: "title" | "headline" | "primary_text" | "description" | "body_story" | "cta";
|
| 184 |
+
value: string;
|
| 185 |
+
mode: "manual" | "ai";
|
| 186 |
+
user_suggestion?: string;
|
| 187 |
+
}): Promise<{ edited_value: string; success: boolean }> => {
|
| 188 |
+
const response = await apiClient.post<{ edited_value: string; success: boolean }>("/db/ad/edit", params);
|
| 189 |
+
return response.data;
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
// Download Image Endpoint (proxy to avoid CORS)
|
| 193 |
+
export const downloadImageProxy = async (params: {
|
| 194 |
+
image_url?: string;
|
| 195 |
+
image_id?: string;
|
| 196 |
+
}): Promise<Blob> => {
|
| 197 |
+
const response = await apiClient.get("/api/download-image", {
|
| 198 |
+
params,
|
| 199 |
+
responseType: "blob",
|
| 200 |
+
});
|
| 201 |
+
return response.data as Blob;
|
| 202 |
+
};
|
frontend/lib/utils/export.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
| 1 |
// Export utilities for downloading/exporting ads
|
|
|
|
| 2 |
|
| 3 |
-
export const downloadImage = async (url: string | null | undefined, filename: string | null | undefined): Promise<void> => {
|
| 4 |
-
if (!url) {
|
| 5 |
-
throw new Error("No image URL provided");
|
| 6 |
}
|
| 7 |
|
| 8 |
try {
|
| 9 |
-
|
| 10 |
-
const blob = await
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
const blobUrl = window.URL.createObjectURL(blob);
|
| 12 |
const link = document.createElement("a");
|
| 13 |
link.href = blobUrl;
|
|
|
|
| 1 |
// Export utilities for downloading/exporting ads
|
| 2 |
+
import { downloadImageProxy } from "@/lib/api/endpoints";
|
| 3 |
|
| 4 |
+
export const downloadImage = async (url: string | null | undefined, filename: string | null | undefined, adId?: string): Promise<void> => {
|
| 5 |
+
if (!url && !adId) {
|
| 6 |
+
throw new Error("No image URL or ad ID provided");
|
| 7 |
}
|
| 8 |
|
| 9 |
try {
|
| 10 |
+
// Use proxy endpoint to avoid CORS issues
|
| 11 |
+
const blob = await downloadImageProxy({
|
| 12 |
+
image_url: url || undefined,
|
| 13 |
+
image_id: adId || undefined,
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
const blobUrl = window.URL.createObjectURL(blob);
|
| 17 |
const link = document.createElement("a");
|
| 18 |
link.href = blobUrl;
|
frontend/lib/utils/validators.ts
CHANGED
|
@@ -4,6 +4,8 @@ export const generateAdSchema = z.object({
|
|
| 4 |
niche: z.enum(["home_insurance", "glp1"]),
|
| 5 |
num_images: z.number().min(1).max(10),
|
| 6 |
image_model: z.string().optional().nullable(),
|
|
|
|
|
|
|
| 7 |
});
|
| 8 |
|
| 9 |
export const generateBatchSchema = z.object({
|
|
@@ -11,6 +13,8 @@ export const generateBatchSchema = z.object({
|
|
| 11 |
count: z.number().min(1).max(20),
|
| 12 |
images_per_ad: z.number().min(1).max(3),
|
| 13 |
image_model: z.string().optional().nullable(),
|
|
|
|
|
|
|
| 14 |
});
|
| 15 |
|
| 16 |
export const generateMatrixSchema = z.object({
|
|
@@ -19,6 +23,8 @@ export const generateMatrixSchema = z.object({
|
|
| 19 |
concept_key: z.string().optional().nullable(),
|
| 20 |
num_images: z.number().min(1).max(5),
|
| 21 |
image_model: z.string().optional().nullable(),
|
|
|
|
|
|
|
| 22 |
});
|
| 23 |
|
| 24 |
export const testingMatrixSchema = z.object({
|
|
|
|
| 4 |
niche: z.enum(["home_insurance", "glp1"]),
|
| 5 |
num_images: z.number().min(1).max(10),
|
| 6 |
image_model: z.string().optional().nullable(),
|
| 7 |
+
target_audience: z.string().optional(),
|
| 8 |
+
offer: z.string().optional(),
|
| 9 |
});
|
| 10 |
|
| 11 |
export const generateBatchSchema = z.object({
|
|
|
|
| 13 |
count: z.number().min(1).max(20),
|
| 14 |
images_per_ad: z.number().min(1).max(3),
|
| 15 |
image_model: z.string().optional().nullable(),
|
| 16 |
+
target_audience: z.string().optional(),
|
| 17 |
+
offer: z.string().optional(),
|
| 18 |
});
|
| 19 |
|
| 20 |
export const generateMatrixSchema = z.object({
|
|
|
|
| 23 |
concept_key: z.string().optional().nullable(),
|
| 24 |
num_images: z.number().min(1).max(5),
|
| 25 |
image_model: z.string().optional().nullable(),
|
| 26 |
+
target_audience: z.string().optional(),
|
| 27 |
+
offer: z.string().optional(),
|
| 28 |
});
|
| 29 |
|
| 30 |
export const testingMatrixSchema = z.object({
|
frontend/store/galleryStore.ts
CHANGED
|
@@ -48,6 +48,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|
| 48 |
setFilters: (filters) => set((state) => ({
|
| 49 |
filters: { ...state.filters, ...filters },
|
| 50 |
offset: 0, // Reset to first page when filters change
|
|
|
|
| 51 |
})),
|
| 52 |
|
| 53 |
setSortOptions: (sort) => set({ sortOptions: sort, offset: 0 }), // Reset to first page when sort changes
|
|
|
|
| 48 |
setFilters: (filters) => set((state) => ({
|
| 49 |
filters: { ...state.filters, ...filters },
|
| 50 |
offset: 0, // Reset to first page when filters change
|
| 51 |
+
selectedAds: [], // Clear selections when filters change
|
| 52 |
})),
|
| 53 |
|
| 54 |
setSortOptions: (sort) => set({ sortOptions: sort, offset: 0 }), // Reset to first page when sort changes
|
frontend/types/api.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface GenerateResponse {
|
|
| 27 |
id: string;
|
| 28 |
niche: string;
|
| 29 |
created_at: string;
|
| 30 |
-
title
|
| 31 |
headline: string;
|
| 32 |
primary_text: string;
|
| 33 |
description: string;
|
|
@@ -72,7 +72,7 @@ export interface MatrixGenerateResponse {
|
|
| 72 |
id: string;
|
| 73 |
niche: string;
|
| 74 |
created_at: string;
|
| 75 |
-
title
|
| 76 |
headline: string;
|
| 77 |
primary_text: string;
|
| 78 |
description: string;
|
|
@@ -183,6 +183,18 @@ export interface AdCreativeDB {
|
|
| 183 |
concept_name?: string | null;
|
| 184 |
generation_method?: string | null;
|
| 185 |
created_at?: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
|
| 188 |
export interface AdsListResponse {
|
|
|
|
| 27 |
id: string;
|
| 28 |
niche: string;
|
| 29 |
created_at: string;
|
| 30 |
+
title?: string | null;
|
| 31 |
headline: string;
|
| 32 |
primary_text: string;
|
| 33 |
description: string;
|
|
|
|
| 72 |
id: string;
|
| 73 |
niche: string;
|
| 74 |
created_at: string;
|
| 75 |
+
title?: string | null;
|
| 76 |
headline: string;
|
| 77 |
primary_text: string;
|
| 78 |
description: string;
|
|
|
|
| 183 |
concept_name?: string | null;
|
| 184 |
generation_method?: string | null;
|
| 185 |
created_at?: string | null;
|
| 186 |
+
metadata?: {
|
| 187 |
+
original_image_url?: string | null;
|
| 188 |
+
original_r2_url?: string | null;
|
| 189 |
+
original_image_filename?: string | null;
|
| 190 |
+
original_image_model?: string | null;
|
| 191 |
+
original_image_prompt?: string | null;
|
| 192 |
+
is_corrected?: boolean;
|
| 193 |
+
correction_date?: string | null;
|
| 194 |
+
corrections?: CorrectionData | null;
|
| 195 |
+
[key: string]: any;
|
| 196 |
+
} | null;
|
| 197 |
+
r2_url?: string | null;
|
| 198 |
}
|
| 199 |
|
| 200 |
export interface AdsListResponse {
|
main.py
CHANGED
|
@@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
|
|
| 11 |
from fastapi.responses import FileResponse, StreamingResponse, Response as FastAPIResponse
|
| 12 |
from pydantic import BaseModel, Field
|
| 13 |
from typing import Optional, List, Literal, Any, Dict
|
|
|
|
| 14 |
import os
|
| 15 |
import logging
|
| 16 |
import time
|
|
@@ -117,6 +118,14 @@ class GenerateRequest(BaseModel):
|
|
| 117 |
default=None,
|
| 118 |
description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')"
|
| 119 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
class GenerateBatchRequest(BaseModel):
|
|
@@ -140,6 +149,18 @@ class GenerateBatchRequest(BaseModel):
|
|
| 140 |
default=None,
|
| 141 |
description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')"
|
| 142 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
class ImageResult(BaseModel):
|
|
@@ -170,11 +191,11 @@ class GenerateResponse(BaseModel):
|
|
| 170 |
id: str
|
| 171 |
niche: str
|
| 172 |
created_at: str
|
| 173 |
-
title: str = Field(description="Short punchy ad title (3-5 words)")
|
| 174 |
headline: str
|
| 175 |
primary_text: str
|
| 176 |
description: str
|
| 177 |
-
body_story: str = Field(description="Compelling
|
| 178 |
cta: str
|
| 179 |
psychological_angle: str
|
| 180 |
why_it_works: Optional[str] = None
|
|
@@ -212,6 +233,14 @@ class MatrixGenerateRequest(BaseModel):
|
|
| 212 |
default=None,
|
| 213 |
description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')"
|
| 214 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
class MatrixBatchRequest(BaseModel):
|
|
@@ -270,11 +299,11 @@ class MatrixGenerateResponse(BaseModel):
|
|
| 270 |
id: str
|
| 271 |
niche: str
|
| 272 |
created_at: str
|
| 273 |
-
title: str = Field(description="Short punchy ad title (3-5 words)")
|
| 274 |
headline: str
|
| 275 |
primary_text: str
|
| 276 |
description: str
|
| 277 |
-
body_story: str = Field(description="Compelling
|
| 278 |
cta: str
|
| 279 |
psychological_angle: str
|
| 280 |
why_it_works: Optional[str] = None
|
|
@@ -482,6 +511,7 @@ async def generate_batch(
|
|
| 482 |
images_per_ad=request.images_per_ad,
|
| 483 |
image_model=request.image_model,
|
| 484 |
username=username, # Pass current user
|
|
|
|
| 485 |
)
|
| 486 |
return {
|
| 487 |
"count": len(results),
|
|
@@ -500,6 +530,82 @@ async def get_image(filename: str):
|
|
| 500 |
return FileResponse(filepath)
|
| 501 |
|
| 502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
# =============================================================================
|
| 504 |
# IMAGE CORRECTION ENDPOINTS
|
| 505 |
# =============================================================================
|
|
@@ -676,55 +782,76 @@ async def correct_image(
|
|
| 676 |
"corrected_prompt": corrected_img.get("corrected_prompt"),
|
| 677 |
}
|
| 678 |
|
| 679 |
-
#
|
| 680 |
if result.get("status") == "success" and result.get("_db_metadata"):
|
| 681 |
db_metadata = result["_db_metadata"]
|
| 682 |
try:
|
| 683 |
-
api_logger.info("
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
)
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
|
|
|
|
|
|
| 719 |
if "corrected_image" not in response_data:
|
| 720 |
response_data["corrected_image"] = {}
|
| 721 |
-
response_data["corrected_image"]["ad_id"] =
|
| 722 |
else:
|
| 723 |
-
api_logger.warning("Failed to
|
| 724 |
except Exception as e:
|
| 725 |
-
api_logger.error(f"Failed to
|
| 726 |
-
api_logger.exception("Database
|
| 727 |
-
# Don't fail the request if database
|
| 728 |
|
| 729 |
total_api_time = time.time() - api_start_time
|
| 730 |
api_logger.info("=" * 80)
|
|
@@ -980,11 +1107,13 @@ class ExtensiveGenerateRequest(BaseModel):
|
|
| 980 |
niche: Literal["home_insurance", "glp1"] = Field(
|
| 981 |
description="Target niche"
|
| 982 |
)
|
| 983 |
-
target_audience: str = Field(
|
| 984 |
-
|
|
|
|
| 985 |
)
|
| 986 |
-
offer: str = Field(
|
| 987 |
-
|
|
|
|
| 988 |
)
|
| 989 |
num_images: int = Field(
|
| 990 |
default=1,
|
|
@@ -1169,6 +1298,7 @@ async def get_stored_ad(ad_id: str):
|
|
| 1169 |
"image_filename": ad.get("image_filename"),
|
| 1170 |
"image_model": ad.get("image_model"),
|
| 1171 |
"image_seed": ad.get("image_seed"),
|
|
|
|
| 1172 |
"angle_key": ad.get("angle_key"),
|
| 1173 |
"angle_name": ad.get("angle_name"),
|
| 1174 |
"angle_trigger": ad.get("angle_trigger"),
|
|
@@ -1200,6 +1330,120 @@ async def delete_stored_ad(ad_id: str, username: str = Depends(get_current_user)
|
|
| 1200 |
return {"success": True, "deleted_id": ad_id}
|
| 1201 |
|
| 1202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1203 |
# Frontend proxy - forward non-API requests to Next.js
|
| 1204 |
# This must be LAST so it doesn't intercept API routes
|
| 1205 |
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
|
|
@@ -1211,7 +1455,7 @@ async def frontend_proxy(path: str, request: StarletteRequest):
|
|
| 1211 |
# Exact API-only routes (never frontend)
|
| 1212 |
# Note: /health has its own explicit route, so not listed here
|
| 1213 |
api_only_routes = [
|
| 1214 |
-
"auth/login", "api/correct", "db/stats", "db/ads",
|
| 1215 |
"strategies", "extensive/generate"
|
| 1216 |
]
|
| 1217 |
|
|
@@ -1227,6 +1471,11 @@ async def frontend_proxy(path: str, request: StarletteRequest):
|
|
| 1227 |
"matrix/compatible", "db/ad"
|
| 1228 |
]
|
| 1229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1230 |
# Check if this is an API-only route
|
| 1231 |
if any(path == route or path.startswith(f"{route}/") for route in api_only_routes):
|
| 1232 |
raise HTTPException(status_code=404, detail="API endpoint not found")
|
|
@@ -1235,6 +1484,10 @@ async def frontend_proxy(path: str, request: StarletteRequest):
|
|
| 1235 |
if request.method == "POST" and any(path == route or path.startswith(f"{route}/") for route in api_post_routes):
|
| 1236 |
raise HTTPException(status_code=404, detail="API endpoint not found")
|
| 1237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
# Check if path starts with image serving routes
|
| 1239 |
if path.startswith("image/") or path.startswith("images/"):
|
| 1240 |
raise HTTPException(status_code=404, detail="API endpoint not found")
|
|
|
|
| 11 |
from fastapi.responses import FileResponse, StreamingResponse, Response as FastAPIResponse
|
| 12 |
from pydantic import BaseModel, Field
|
| 13 |
from typing import Optional, List, Literal, Any, Dict
|
| 14 |
+
from datetime import datetime
|
| 15 |
import os
|
| 16 |
import logging
|
| 17 |
import time
|
|
|
|
| 118 |
default=None,
|
| 119 |
description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')"
|
| 120 |
)
|
| 121 |
+
target_audience: Optional[str] = Field(
|
| 122 |
+
default=None,
|
| 123 |
+
description="Optional target audience description (e.g., 'US people over 50+ age')"
|
| 124 |
+
)
|
| 125 |
+
offer: Optional[str] = Field(
|
| 126 |
+
default=None,
|
| 127 |
+
description="Optional offer to run (e.g., 'Don't overpay your insurance')"
|
| 128 |
+
)
|
| 129 |
|
| 130 |
|
| 131 |
class GenerateBatchRequest(BaseModel):
|
|
|
|
| 149 |
default=None,
|
| 150 |
description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')"
|
| 151 |
)
|
| 152 |
+
method: Optional[Literal["standard", "matrix"]] = Field(
|
| 153 |
+
default=None,
|
| 154 |
+
description="Generation method: 'standard' for standard method only, 'matrix' for matrix method only, None for mixed (50/50)"
|
| 155 |
+
)
|
| 156 |
+
target_audience: Optional[str] = Field(
|
| 157 |
+
default=None,
|
| 158 |
+
description="Optional target audience description (e.g., 'US people over 50+ age')"
|
| 159 |
+
)
|
| 160 |
+
offer: Optional[str] = Field(
|
| 161 |
+
default=None,
|
| 162 |
+
description="Optional offer to run (e.g., 'Don't overpay your insurance')"
|
| 163 |
+
)
|
| 164 |
|
| 165 |
|
| 166 |
class ImageResult(BaseModel):
|
|
|
|
| 191 |
id: str
|
| 192 |
niche: str
|
| 193 |
created_at: str
|
| 194 |
+
title: Optional[str] = Field(default=None, description="Short punchy ad title (3-5 words) - only for extensive flow")
|
| 195 |
headline: str
|
| 196 |
primary_text: str
|
| 197 |
description: str
|
| 198 |
+
body_story: str = Field(description="Compelling 8-12 sentence story that hooks emotionally")
|
| 199 |
cta: str
|
| 200 |
psychological_angle: str
|
| 201 |
why_it_works: Optional[str] = None
|
|
|
|
| 233 |
default=None,
|
| 234 |
description="Image generation model to use (e.g., 'z-image-turbo', 'nano-banana', 'nano-banana-pro', 'imagen-4-ultra', 'recraft-v3', 'ideogram-v3', 'photon', 'seedream-3')"
|
| 235 |
)
|
| 236 |
+
target_audience: Optional[str] = Field(
|
| 237 |
+
default=None,
|
| 238 |
+
description="Optional target audience description (e.g., 'US people over 50+ age')"
|
| 239 |
+
)
|
| 240 |
+
offer: Optional[str] = Field(
|
| 241 |
+
default=None,
|
| 242 |
+
description="Optional offer to run (e.g., 'Don't overpay your insurance')"
|
| 243 |
+
)
|
| 244 |
|
| 245 |
|
| 246 |
class MatrixBatchRequest(BaseModel):
|
|
|
|
| 299 |
id: str
|
| 300 |
niche: str
|
| 301 |
created_at: str
|
| 302 |
+
title: Optional[str] = Field(default=None, description="Short punchy ad title (3-5 words) - not used in matrix flow")
|
| 303 |
headline: str
|
| 304 |
primary_text: str
|
| 305 |
description: str
|
| 306 |
+
body_story: str = Field(description="Compelling 8-12 sentence story that hooks emotionally")
|
| 307 |
cta: str
|
| 308 |
psychological_angle: str
|
| 309 |
why_it_works: Optional[str] = None
|
|
|
|
| 511 |
images_per_ad=request.images_per_ad,
|
| 512 |
image_model=request.image_model,
|
| 513 |
username=username, # Pass current user
|
| 514 |
+
method=request.method, # Pass method parameter
|
| 515 |
)
|
| 516 |
return {
|
| 517 |
"count": len(results),
|
|
|
|
| 530 |
return FileResponse(filepath)
|
| 531 |
|
| 532 |
|
| 533 |
+
@app.get("/api/download-image")
|
| 534 |
+
async def download_image_proxy(
|
| 535 |
+
image_url: Optional[str] = None,
|
| 536 |
+
image_id: Optional[str] = None,
|
| 537 |
+
username: str = Depends(get_current_user)
|
| 538 |
+
):
|
| 539 |
+
"""
|
| 540 |
+
Proxy endpoint to download images, avoiding CORS issues.
|
| 541 |
+
Can fetch from external URLs (R2, Replicate) or local files.
|
| 542 |
+
"""
|
| 543 |
+
import httpx
|
| 544 |
+
|
| 545 |
+
filename = None
|
| 546 |
+
|
| 547 |
+
# If image_id is provided, verify ownership
|
| 548 |
+
if image_id:
|
| 549 |
+
ad = await db_service.get_ad_creative(image_id)
|
| 550 |
+
if not ad:
|
| 551 |
+
raise HTTPException(status_code=404, detail="Ad not found")
|
| 552 |
+
|
| 553 |
+
# Verify ownership
|
| 554 |
+
if ad.get("username") != username:
|
| 555 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 556 |
+
|
| 557 |
+
# Only use ad's image URL if image_url was not explicitly provided
|
| 558 |
+
# This allows downloading original images from metadata
|
| 559 |
+
if not image_url:
|
| 560 |
+
image_url = ad.get("r2_url") or ad.get("image_url")
|
| 561 |
+
filename = ad.get("image_filename")
|
| 562 |
+
else:
|
| 563 |
+
# If image_url is provided, try to get filename from metadata if it's an original image
|
| 564 |
+
metadata = ad.get("metadata", {})
|
| 565 |
+
if metadata.get("original_r2_url") == image_url or metadata.get("original_image_url") == image_url:
|
| 566 |
+
filename = metadata.get("original_image_filename")
|
| 567 |
+
|
| 568 |
+
if not image_url:
|
| 569 |
+
raise HTTPException(status_code=400, detail="No image URL provided")
|
| 570 |
+
|
| 571 |
+
try:
|
| 572 |
+
# Check if it's a local file
|
| 573 |
+
if not image_url.startswith(("http://", "https://")):
|
| 574 |
+
# Local file
|
| 575 |
+
filepath = os.path.join(settings.output_dir, image_url)
|
| 576 |
+
if os.path.exists(filepath):
|
| 577 |
+
return FileResponse(filepath, filename=filename or os.path.basename(filepath))
|
| 578 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 579 |
+
|
| 580 |
+
# External URL - fetch and proxy
|
| 581 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 582 |
+
response = await client.get(image_url)
|
| 583 |
+
response.raise_for_status()
|
| 584 |
+
|
| 585 |
+
# Determine content type
|
| 586 |
+
content_type = response.headers.get("content-type", "image/png")
|
| 587 |
+
|
| 588 |
+
# Get filename from URL or use provided filename
|
| 589 |
+
if not filename:
|
| 590 |
+
# Try to extract from URL
|
| 591 |
+
filename = image_url.split("/")[-1].split("?")[0]
|
| 592 |
+
if not filename or "." not in filename:
|
| 593 |
+
filename = "image.png"
|
| 594 |
+
|
| 595 |
+
return FastAPIResponse(
|
| 596 |
+
content=response.content,
|
| 597 |
+
media_type=content_type,
|
| 598 |
+
headers={
|
| 599 |
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
| 600 |
+
"Cache-Control": "public, max-age=3600",
|
| 601 |
+
}
|
| 602 |
+
)
|
| 603 |
+
except httpx.HTTPError as e:
|
| 604 |
+
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {str(e)}")
|
| 605 |
+
except Exception as e:
|
| 606 |
+
raise HTTPException(status_code=500, detail=f"Error downloading image: {str(e)}")
|
| 607 |
+
|
| 608 |
+
|
| 609 |
# =============================================================================
|
| 610 |
# IMAGE CORRECTION ENDPOINTS
|
| 611 |
# =============================================================================
|
|
|
|
| 782 |
"corrected_prompt": corrected_img.get("corrected_prompt"),
|
| 783 |
}
|
| 784 |
|
| 785 |
+
# Update original ad with corrected image (instead of creating new one)
|
| 786 |
if result.get("status") == "success" and result.get("_db_metadata"):
|
| 787 |
db_metadata = result["_db_metadata"]
|
| 788 |
try:
|
| 789 |
+
api_logger.info("Updating original ad with corrected image...")
|
| 790 |
+
|
| 791 |
+
# Store old image data in metadata before updating
|
| 792 |
+
old_image_url = ad.get("r2_url") or ad.get("image_url")
|
| 793 |
+
old_r2_url = ad.get("r2_url")
|
| 794 |
+
old_image_filename = ad.get("image_filename")
|
| 795 |
+
old_image_model = ad.get("image_model")
|
| 796 |
+
old_image_prompt = ad.get("image_prompt")
|
| 797 |
+
|
| 798 |
+
# Prepare metadata with old image info and corrections
|
| 799 |
+
# Only include fields that have values
|
| 800 |
+
correction_metadata = {
|
| 801 |
+
"is_corrected": True,
|
| 802 |
+
"correction_date": datetime.utcnow().isoformat() + "Z",
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
# Add original image data only if it exists
|
| 806 |
+
if old_image_url:
|
| 807 |
+
correction_metadata["original_image_url"] = old_image_url
|
| 808 |
+
if old_r2_url:
|
| 809 |
+
correction_metadata["original_r2_url"] = old_r2_url
|
| 810 |
+
if old_image_filename:
|
| 811 |
+
correction_metadata["original_image_filename"] = old_image_filename
|
| 812 |
+
if old_image_model:
|
| 813 |
+
correction_metadata["original_image_model"] = old_image_model
|
| 814 |
+
if old_image_prompt:
|
| 815 |
+
correction_metadata["original_image_prompt"] = old_image_prompt
|
| 816 |
+
if result.get("corrections"):
|
| 817 |
+
correction_metadata["corrections"] = result.get("corrections")
|
| 818 |
+
|
| 819 |
+
api_logger.info(f"Prepared correction metadata: {correction_metadata}")
|
| 820 |
+
api_logger.info(f"Old image URL: {old_image_url}")
|
| 821 |
+
api_logger.info(f"Old R2 URL: {old_r2_url}")
|
| 822 |
+
|
| 823 |
+
# Update the original ad with corrected image
|
| 824 |
+
# Also update r2_url if available
|
| 825 |
+
update_kwargs = {
|
| 826 |
+
"image_url": db_metadata.get("image_url"),
|
| 827 |
+
"image_filename": db_metadata.get("filename"),
|
| 828 |
+
"image_model": db_metadata.get("model_used"),
|
| 829 |
+
"image_prompt": db_metadata.get("corrected_prompt"),
|
| 830 |
+
}
|
| 831 |
+
if db_metadata.get("r2_url"):
|
| 832 |
+
update_kwargs["r2_url"] = db_metadata.get("r2_url")
|
| 833 |
+
|
| 834 |
+
api_logger.info(f"Updating ad {request.image_id} with metadata: {correction_metadata}")
|
| 835 |
+
update_success = await db_service.update_ad_creative(
|
| 836 |
+
ad_id=request.image_id,
|
| 837 |
+
username=username,
|
| 838 |
+
metadata=correction_metadata,
|
| 839 |
+
**update_kwargs
|
| 840 |
)
|
| 841 |
+
api_logger.info(f"Update success: {update_success}")
|
| 842 |
+
|
| 843 |
+
if update_success:
|
| 844 |
+
api_logger.info(f"✓ Original ad updated with corrected image (ID: {request.image_id})")
|
| 845 |
+
# Add the updated ad ID to the response
|
| 846 |
if "corrected_image" not in response_data:
|
| 847 |
response_data["corrected_image"] = {}
|
| 848 |
+
response_data["corrected_image"]["ad_id"] = request.image_id
|
| 849 |
else:
|
| 850 |
+
api_logger.warning("Failed to update ad with corrected image (update returned False)")
|
| 851 |
except Exception as e:
|
| 852 |
+
api_logger.error(f"Failed to update ad with corrected image: {e}")
|
| 853 |
+
api_logger.exception("Database update error details:")
|
| 854 |
+
# Don't fail the request if database update fails
|
| 855 |
|
| 856 |
total_api_time = time.time() - api_start_time
|
| 857 |
api_logger.info("=" * 80)
|
|
|
|
| 1107 |
niche: Literal["home_insurance", "glp1"] = Field(
|
| 1108 |
description="Target niche"
|
| 1109 |
)
|
| 1110 |
+
target_audience: Optional[str] = Field(
|
| 1111 |
+
default=None,
|
| 1112 |
+
description="Optional target audience description (e.g., 'US people over 50+ age')"
|
| 1113 |
)
|
| 1114 |
+
offer: Optional[str] = Field(
|
| 1115 |
+
default=None,
|
| 1116 |
+
description="Optional offer to run (e.g., 'Don't overpay your insurance')"
|
| 1117 |
)
|
| 1118 |
num_images: int = Field(
|
| 1119 |
default=1,
|
|
|
|
| 1298 |
"image_filename": ad.get("image_filename"),
|
| 1299 |
"image_model": ad.get("image_model"),
|
| 1300 |
"image_seed": ad.get("image_seed"),
|
| 1301 |
+
"r2_url": ad.get("r2_url"), # Include r2_url in response
|
| 1302 |
"angle_key": ad.get("angle_key"),
|
| 1303 |
"angle_name": ad.get("angle_name"),
|
| 1304 |
"angle_trigger": ad.get("angle_trigger"),
|
|
|
|
| 1330 |
return {"success": True, "deleted_id": ad_id}
|
| 1331 |
|
| 1332 |
|
| 1333 |
+
class EditAdCopyRequest(BaseModel):
|
| 1334 |
+
"""Request for editing ad copy."""
|
| 1335 |
+
ad_id: str = Field(description="ID of the ad to edit")
|
| 1336 |
+
field: Literal["title", "headline", "primary_text", "description", "body_story", "cta"] = Field(
|
| 1337 |
+
description="Field to edit"
|
| 1338 |
+
)
|
| 1339 |
+
value: str = Field(description="New value for the field (for manual edit) or current value (for AI edit)")
|
| 1340 |
+
mode: Literal["manual", "ai"] = Field(description="Edit mode: manual or AI")
|
| 1341 |
+
user_suggestion: Optional[str] = Field(
|
| 1342 |
+
default=None,
|
| 1343 |
+
description="User suggestion for AI editing (optional)"
|
| 1344 |
+
)
|
| 1345 |
+
|
| 1346 |
+
|
| 1347 |
+
@app.post("/db/ad/edit")
|
| 1348 |
+
async def edit_ad_copy(
|
| 1349 |
+
request: EditAdCopyRequest,
|
| 1350 |
+
username: str = Depends(get_current_user)
|
| 1351 |
+
):
|
| 1352 |
+
"""
|
| 1353 |
+
Edit ad copy fields with manual or AI assistance.
|
| 1354 |
+
|
| 1355 |
+
Requires authentication. Users can only edit their own ads.
|
| 1356 |
+
|
| 1357 |
+
Modes:
|
| 1358 |
+
- manual: Directly update the field with the provided value
|
| 1359 |
+
- ai: Generate an improved version using AI, optionally with user suggestions
|
| 1360 |
+
"""
|
| 1361 |
+
from services.llm import LLMService
|
| 1362 |
+
|
| 1363 |
+
# Verify ad exists and belongs to user
|
| 1364 |
+
ad = await db_service.get_ad_creative(request.ad_id)
|
| 1365 |
+
if not ad:
|
| 1366 |
+
raise HTTPException(status_code=404, detail=f"Ad '{request.ad_id}' not found")
|
| 1367 |
+
|
| 1368 |
+
if ad.get("username") != username:
|
| 1369 |
+
raise HTTPException(status_code=403, detail="You can only edit your own ads")
|
| 1370 |
+
|
| 1371 |
+
if request.mode == "manual":
|
| 1372 |
+
# Direct update
|
| 1373 |
+
update_data = {request.field: request.value}
|
| 1374 |
+
success = await db_service.update_ad_creative(
|
| 1375 |
+
ad_id=request.ad_id,
|
| 1376 |
+
username=username,
|
| 1377 |
+
**update_data
|
| 1378 |
+
)
|
| 1379 |
+
|
| 1380 |
+
if not success:
|
| 1381 |
+
raise HTTPException(status_code=500, detail="Failed to update ad")
|
| 1382 |
+
|
| 1383 |
+
return {
|
| 1384 |
+
"edited_value": request.value,
|
| 1385 |
+
"success": True
|
| 1386 |
+
}
|
| 1387 |
+
|
| 1388 |
+
else: # AI mode
|
| 1389 |
+
# Generate improved version using AI
|
| 1390 |
+
llm_service = LLMService()
|
| 1391 |
+
|
| 1392 |
+
# Build context for AI
|
| 1393 |
+
field_labels = {
|
| 1394 |
+
"title": "title",
|
| 1395 |
+
"headline": "headline",
|
| 1396 |
+
"primary_text": "primary text",
|
| 1397 |
+
"description": "description",
|
| 1398 |
+
"body_story": "body story",
|
| 1399 |
+
"cta": "call to action"
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
field_label = field_labels.get(request.field, request.field)
|
| 1403 |
+
current_value = request.value
|
| 1404 |
+
niche = ad.get("niche", "general")
|
| 1405 |
+
|
| 1406 |
+
# Build prompt
|
| 1407 |
+
system_prompt = f"""You are an expert copywriter specializing in high-converting ad copy for {niche.replace('_', ' ')}.
|
| 1408 |
+
Your task is to improve the {field_label} while maintaining its core message and emotional impact.
|
| 1409 |
+
Keep the same tone and style, but make it more compelling, clear, and effective."""
|
| 1410 |
+
|
| 1411 |
+
user_prompt = f"""Current {field_label}:
|
| 1412 |
+
{current_value}
|
| 1413 |
+
|
| 1414 |
+
"""
|
| 1415 |
+
|
| 1416 |
+
if request.user_suggestion:
|
| 1417 |
+
user_prompt += f"""User's suggestion: {request.user_suggestion}
|
| 1418 |
+
|
| 1419 |
+
"""
|
| 1420 |
+
|
| 1421 |
+
user_prompt += f"""Please provide an improved version of this {field_label} that:
|
| 1422 |
+
1. Maintains the core message and emotional impact
|
| 1423 |
+
2. Is more compelling and engaging
|
| 1424 |
+
3. Follows best practices for {field_label} in ad copy
|
| 1425 |
+
4. {"Incorporates the user's suggestion" if request.user_suggestion else "Is optimized for conversion"}
|
| 1426 |
+
|
| 1427 |
+
Return ONLY the improved {field_label} text, without any explanations or additional text."""
|
| 1428 |
+
|
| 1429 |
+
try:
|
| 1430 |
+
edited_value = await llm_service.generate(
|
| 1431 |
+
prompt=user_prompt,
|
| 1432 |
+
system_prompt=system_prompt,
|
| 1433 |
+
temperature=0.7
|
| 1434 |
+
)
|
| 1435 |
+
|
| 1436 |
+
# Clean up the response (remove quotes if wrapped)
|
| 1437 |
+
edited_value = edited_value.strip().strip('"').strip("'")
|
| 1438 |
+
|
| 1439 |
+
return {
|
| 1440 |
+
"edited_value": edited_value,
|
| 1441 |
+
"success": True
|
| 1442 |
+
}
|
| 1443 |
+
except Exception as e:
|
| 1444 |
+
raise HTTPException(status_code=500, detail=f"Failed to generate AI edit: {str(e)}")
|
| 1445 |
+
|
| 1446 |
+
|
| 1447 |
# Frontend proxy - forward non-API requests to Next.js
|
| 1448 |
# This must be LAST so it doesn't intercept API routes
|
| 1449 |
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
|
|
|
|
| 1455 |
# Exact API-only routes (never frontend)
|
| 1456 |
# Note: /health has its own explicit route, so not listed here
|
| 1457 |
api_only_routes = [
|
| 1458 |
+
"auth/login", "api/correct", "api/download-image", "db/stats", "db/ads",
|
| 1459 |
"strategies", "extensive/generate"
|
| 1460 |
]
|
| 1461 |
|
|
|
|
| 1471 |
"matrix/compatible", "db/ad"
|
| 1472 |
]
|
| 1473 |
|
| 1474 |
+
# Routes that are API for POST
|
| 1475 |
+
api_post_routes_additional = [
|
| 1476 |
+
"db/ad/edit"
|
| 1477 |
+
]
|
| 1478 |
+
|
| 1479 |
# Check if this is an API-only route
|
| 1480 |
if any(path == route or path.startswith(f"{route}/") for route in api_only_routes):
|
| 1481 |
raise HTTPException(status_code=404, detail="API endpoint not found")
|
|
|
|
| 1484 |
if request.method == "POST" and any(path == route or path.startswith(f"{route}/") for route in api_post_routes):
|
| 1485 |
raise HTTPException(status_code=404, detail="API endpoint not found")
|
| 1486 |
|
| 1487 |
+
# Check additional POST routes
|
| 1488 |
+
if request.method == "POST" and any(path == route or path.startswith(f"{route}/") for route in api_post_routes_additional):
|
| 1489 |
+
raise HTTPException(status_code=404, detail="API endpoint not found")
|
| 1490 |
+
|
| 1491 |
# Check if path starts with image serving routes
|
| 1492 |
if path.startswith("image/") or path.startswith("images/"):
|
| 1493 |
raise HTTPException(status_code=404, detail="API endpoint not found")
|
services/database.py
CHANGED
|
@@ -237,6 +237,89 @@ class DatabaseService:
|
|
| 237 |
print(f"Failed to list ad creatives: {e}")
|
| 238 |
return [], 0
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
async def delete_ad_creative(self, ad_id: str, username: Optional[str] = None) -> bool:
|
| 241 |
"""
|
| 242 |
Delete an ad creative by ID.
|
|
|
|
| 237 |
print(f"Failed to list ad creatives: {e}")
|
| 238 |
return [], 0
|
| 239 |
|
| 240 |
+
async def update_ad_creative(
|
| 241 |
+
self,
|
| 242 |
+
ad_id: str,
|
| 243 |
+
username: Optional[str] = None,
|
| 244 |
+
image_url: Optional[str] = None,
|
| 245 |
+
image_filename: Optional[str] = None,
|
| 246 |
+
image_model: Optional[str] = None,
|
| 247 |
+
image_prompt: Optional[str] = None,
|
| 248 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 249 |
+
**kwargs
|
| 250 |
+
) -> bool:
|
| 251 |
+
"""
|
| 252 |
+
Update an ad creative by ID.
|
| 253 |
+
If username is provided, only updates if the ad belongs to that user.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
ad_id: ID of the ad to update
|
| 257 |
+
username: Optional username to verify ownership
|
| 258 |
+
image_url: New image URL
|
| 259 |
+
image_filename: New image filename
|
| 260 |
+
image_model: New image model
|
| 261 |
+
image_prompt: New image prompt
|
| 262 |
+
metadata: Metadata dict (will be merged with existing metadata)
|
| 263 |
+
**kwargs: Additional fields to update
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
True if update was successful, False otherwise
|
| 267 |
+
"""
|
| 268 |
+
if self.collection is None:
|
| 269 |
+
return False
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
# Try to convert to ObjectId if it's a valid ObjectId string
|
| 273 |
+
try:
|
| 274 |
+
object_id = ObjectId(ad_id)
|
| 275 |
+
except:
|
| 276 |
+
# If not a valid ObjectId, try as string
|
| 277 |
+
object_id = ad_id
|
| 278 |
+
|
| 279 |
+
query = {"_id": object_id}
|
| 280 |
+
if username:
|
| 281 |
+
query["username"] = username
|
| 282 |
+
|
| 283 |
+
# Build update document
|
| 284 |
+
update_doc = {"updated_at": datetime.utcnow()}
|
| 285 |
+
|
| 286 |
+
if image_url is not None:
|
| 287 |
+
update_doc["image_url"] = image_url
|
| 288 |
+
if image_filename is not None:
|
| 289 |
+
update_doc["image_filename"] = image_filename
|
| 290 |
+
if image_model is not None:
|
| 291 |
+
update_doc["image_model"] = image_model
|
| 292 |
+
if image_prompt is not None:
|
| 293 |
+
update_doc["image_prompt"] = image_prompt
|
| 294 |
+
|
| 295 |
+
# Add any additional fields from kwargs
|
| 296 |
+
for key, value in kwargs.items():
|
| 297 |
+
if value is not None:
|
| 298 |
+
update_doc[key] = value
|
| 299 |
+
|
| 300 |
+
# Handle metadata merge
|
| 301 |
+
if metadata is not None:
|
| 302 |
+
# Get existing ad to merge metadata
|
| 303 |
+
existing_ad = await self.collection.find_one(query)
|
| 304 |
+
if existing_ad:
|
| 305 |
+
existing_metadata = existing_ad.get("metadata") or {}
|
| 306 |
+
# Merge metadata (new values override old ones)
|
| 307 |
+
merged_metadata = {**existing_metadata, **metadata}
|
| 308 |
+
update_doc["metadata"] = merged_metadata
|
| 309 |
+
print(f"Debug: Merging metadata. Existing: {existing_metadata}, New: {metadata}, Merged: {merged_metadata}")
|
| 310 |
+
else:
|
| 311 |
+
update_doc["metadata"] = metadata
|
| 312 |
+
print(f"Debug: Setting new metadata (no existing ad found): {metadata}")
|
| 313 |
+
|
| 314 |
+
result = await self.collection.update_one(
|
| 315 |
+
query,
|
| 316 |
+
{"$set": update_doc}
|
| 317 |
+
)
|
| 318 |
+
return result.modified_count > 0
|
| 319 |
+
except Exception as e:
|
| 320 |
+
print(f"Failed to update ad creative: {e}")
|
| 321 |
+
return False
|
| 322 |
+
|
| 323 |
async def delete_ad_creative(self, ad_id: str, username: Optional[str] = None) -> bool:
|
| 324 |
"""
|
| 325 |
Delete an ad creative by ID.
|
services/generator.py
CHANGED
|
@@ -71,72 +71,6 @@ NICHE_DATA = {
|
|
| 71 |
# Note: Frameworks are now loaded from data/frameworks.py
|
| 72 |
# This provides comprehensive framework data with hooks, visual styles, and niche-specific recommendations
|
| 73 |
|
| 74 |
-
# =============================================================================
|
| 75 |
-
# WINNING AD FORMATS (from high-converting creative analysis)
|
| 76 |
-
# =============================================================================
|
| 77 |
-
|
| 78 |
-
# Format types that bypass ad-blindness
|
| 79 |
-
WINNING_AD_FORMATS = [
|
| 80 |
-
{
|
| 81 |
-
"name": "accusation_opener",
|
| 82 |
-
"description": "Direct accusation that triggers loss aversion immediately",
|
| 83 |
-
"headline_pattern": "[ACCUSATION]?",
|
| 84 |
-
"examples": ["OVERPAYING?", "Still Underinsured?", "Wasting Money?"],
|
| 85 |
-
"visual_style": "person with money/bills, documentary candid shot",
|
| 86 |
-
},
|
| 87 |
-
{
|
| 88 |
-
"name": "curiosity_gap",
|
| 89 |
-
"description": "Open loop that demands click to close",
|
| 90 |
-
"headline_pattern": "[Group] are [action] and doing THIS instead",
|
| 91 |
-
"examples": [
|
| 92 |
-
"Seniors Are Ditching Their Home Insurance & Doing This Instead",
|
| 93 |
-
"Thousands of homeowners are dropping their home insurance after THIS",
|
| 94 |
-
],
|
| 95 |
-
"visual_style": "candid documentary photo, real person, everyday setting",
|
| 96 |
-
},
|
| 97 |
-
{
|
| 98 |
-
"name": "specific_price_anchor",
|
| 99 |
-
"description": "Oddly specific price creates believability",
|
| 100 |
-
"headline_pattern": "[Product] for as low as $XX.XX/month",
|
| 101 |
-
"examples": ["Home Insurance for as low as $97.33/month", "$43/month"],
|
| 102 |
-
"visual_style": "clean typography focus, price dominant, age selector buttons",
|
| 103 |
-
},
|
| 104 |
-
{
|
| 105 |
-
"name": "before_after_proof",
|
| 106 |
-
"description": "Social proof with specific numbers",
|
| 107 |
-
"headline_pattern": "WAS: $X,XXX → NOW: $XXX",
|
| 108 |
-
"examples": ["WAS: $1,701 → NOW: $583"],
|
| 109 |
-
"visual_style": "real person holding document with circled numbers",
|
| 110 |
-
},
|
| 111 |
-
{
|
| 112 |
-
"name": "quiz_interactive",
|
| 113 |
-
"description": "Interactive quiz format that drives engagement",
|
| 114 |
-
"headline_pattern": "[Personal Question]?",
|
| 115 |
-
"examples": ["What Year Was Your House Built?", "Tap your age to calculate"],
|
| 116 |
-
"visual_style": "notes app screenshot, dark mode UI, checkbox options",
|
| 117 |
-
},
|
| 118 |
-
{
|
| 119 |
-
"name": "authority_transfer",
|
| 120 |
-
"description": "Transfer trust from government/institution",
|
| 121 |
-
"headline_pattern": "[Authority] + [Benefit] for [Group]",
|
| 122 |
-
"examples": ["State Farm Brings Welfare!", "Sponsored by the US government"],
|
| 123 |
-
"visual_style": "government seal, official document aesthetic, institutional",
|
| 124 |
-
},
|
| 125 |
-
{
|
| 126 |
-
"name": "identity_targeting",
|
| 127 |
-
"description": "Direct demographic callout for self-selection",
|
| 128 |
-
"headline_pattern": "[Demographic] Won't Have To Pay More Than $XX",
|
| 129 |
-
"examples": ["Seniors Won't Have To Pay More Than $49 A Month"],
|
| 130 |
-
"visual_style": "real seniors, portrait photography, relatable faces",
|
| 131 |
-
},
|
| 132 |
-
{
|
| 133 |
-
"name": "insider_secret",
|
| 134 |
-
"description": "Exclusivity and hidden knowledge framing",
|
| 135 |
-
"headline_pattern": "The [Adjective] Way To [Benefit]",
|
| 136 |
-
"examples": ["The Easiest Way To Cut Home Insurance Bills"],
|
| 137 |
-
"visual_style": "person revealing information, document proof, testimonial style",
|
| 138 |
-
},
|
| 139 |
-
]
|
| 140 |
|
| 141 |
# Age brackets for identity targeting (proven high-CTR pattern)
|
| 142 |
AGE_BRACKETS = [
|
|
@@ -384,8 +318,32 @@ class AdGenerator:
|
|
| 384 |
"seasonal": home_insurance.SEASONAL_VISUALS,
|
| 385 |
}
|
| 386 |
elif niche == "glp1":
|
| 387 |
-
|
| 388 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
return {}
|
| 390 |
|
| 391 |
def _select_visuals_from_library(self, niche: str, strategy_name: str, count: int = 2) -> List[str]:
|
|
@@ -546,10 +504,6 @@ NICHE-SPECIFIC REQUIREMENTS (GLP-1 / WEIGHT LOSS):
|
|
| 546 |
}
|
| 547 |
return {}
|
| 548 |
|
| 549 |
-
def _select_ad_format(self) -> Dict[str, Any]:
|
| 550 |
-
"""Randomly select a winning ad format."""
|
| 551 |
-
return random.choice(WINNING_AD_FORMATS)
|
| 552 |
-
|
| 553 |
def _get_framework_container_compatibility(self, framework_key: str) -> List[str]:
|
| 554 |
"""
|
| 555 |
Get container types that are most compatible with a framework.
|
|
@@ -637,20 +591,21 @@ NICHE-SPECIFIC REQUIREMENTS (GLP-1 / WEIGHT LOSS):
|
|
| 637 |
trigger_data: Dict[str, Any] = None,
|
| 638 |
trigger_combination: Dict[str, Any] = None,
|
| 639 |
power_words: List[str] = None,
|
|
|
|
|
|
|
| 640 |
) -> str:
|
| 641 |
"""
|
| 642 |
Build professional LLM prompt for ad copy generation.
|
| 643 |
-
Uses
|
| 644 |
"""
|
| 645 |
strategy_names = [s["name"] for s in strategies]
|
| 646 |
strategy_descriptions = [f"- {s['name']}: {s['description']}" for s in strategies]
|
| 647 |
cta = random.choice(niche_data["ctas"])
|
| 648 |
niche_guidance = self._get_niche_specific_guidance(niche)
|
| 649 |
|
| 650 |
-
# Select
|
| 651 |
# Use framework-aware container selection (improvement)
|
| 652 |
container_strategy = "framework_aware" if random.random() < 0.6 else random.choice(["native", "ugc", "alert", "balanced"])
|
| 653 |
-
ad_format = self._select_ad_format()
|
| 654 |
container = self._select_container(prefer_native=True, strategy=container_strategy, framework_key=framework_data.get("key"))
|
| 655 |
price_guidance = self._generate_specific_price(niche)
|
| 656 |
niche_numbers = self._generate_niche_numbers(niche)
|
|
@@ -668,7 +623,7 @@ You may include specific numbers if they enhance the ad's believability and fit
|
|
| 668 |
- Target Age Bracket: {age_bracket['label']}
|
| 669 |
|
| 670 |
DECISION: You decide whether to include these numbers based on:
|
| 671 |
-
- The
|
| 672 |
- The psychological strategy (some strategies benefit from specificity, others from emotional appeal)
|
| 673 |
- The overall message flow
|
| 674 |
|
|
@@ -684,7 +639,7 @@ You may include specific prices/numbers if they enhance the ad's believability a
|
|
| 684 |
- Target Age Bracket: {age_bracket['label']}
|
| 685 |
|
| 686 |
DECISION: You decide whether to include prices/numbers based on:
|
| 687 |
-
- The
|
| 688 |
- The psychological strategy (some strategies need specificity, others work better emotionally)
|
| 689 |
- The overall message flow and what feels most authentic
|
| 690 |
|
|
@@ -774,12 +729,16 @@ FRAMEWORK HOOK EXAMPLES: {', '.join(framework_hooks[:3]) if framework_hooks else
|
|
| 774 |
CREATIVE DIRECTION: {creative_direction}
|
| 775 |
CALL-TO-ACTION: {cta}
|
| 776 |
|
| 777 |
-
===
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
|
| 784 |
=== CONTAINER FORMAT (Native-Looking Ad) ===
|
| 785 |
CONTAINER TYPE: {container['name']}
|
|
@@ -813,21 +772,22 @@ FRAMEWORK HOOK EXAMPLES: {', '.join(framework_hooks[:5]) if framework_hooks else
|
|
| 813 |
|
| 814 |
{headline_formulas}
|
| 815 |
=== YOUR MISSION ===
|
| 816 |
-
Create a SCROLL-STOPPING Facebook ad for {niche.replace("_", " ").upper()} using the
|
| 817 |
|
| 818 |
=== OUTPUT REQUIREMENTS ===
|
| 819 |
|
| 820 |
1. HEADLINE (The "Arrest")
|
| 821 |
-
-
|
| 822 |
- MAXIMUM 10 words
|
| 823 |
- Must create INSTANT pattern interrupt
|
| 824 |
-
- You decide whether to include specific numbers/prices based on the
|
| 825 |
- Include demographic targeting where appropriate
|
| 826 |
- NO generic phrases - be SPECIFIC and EMOTIONAL
|
| 827 |
|
| 828 |
2. PRIMARY TEXT (The "Agitation")
|
| 829 |
- 2-3 punchy sentences that AMPLIFY the emotional hook
|
| 830 |
-
-
|
|
|
|
| 831 |
- Reference the demographic appropriately
|
| 832 |
- Create urgency
|
| 833 |
- Make them FEEL the pain or desire
|
|
@@ -838,14 +798,15 @@ Create a SCROLL-STOPPING Facebook ad for {niche.replace("_", " ").upper()} using
|
|
| 838 |
- Create action urgency
|
| 839 |
|
| 840 |
4. IMAGE BRIEF (CRITICAL - must match {container['name']} container style)
|
|
|
|
| 841 |
- Describe the scene for the {container['name']} container format ONLY
|
| 842 |
- Visual guidance: {get_container_visual_guidance(container.get('key', ''))}
|
| 843 |
- The image should look like ORGANIC CONTENT, not an ad
|
| 844 |
- Include: setting, subjects, props, mood
|
| 845 |
- Follow container authenticity tips: {', '.join(container.get('authenticity_tips', [])[:2])}
|
| 846 |
- CRITICAL: Use ONLY {container['name']} format - DO NOT mix with other container types (no WhatsApp + memo, no iMessage + document)
|
| 847 |
-
- {"If chat container: Include 2-4 readable, coherent messages related to
|
| 848 |
-
- {"If document container: Include readable, properly formatted text related to
|
| 849 |
- FOR GLP-1: Use VARIETY - show different visual types: quiz interfaces, doctor/medical settings, person on scale, mirror reflections, lifestyle moments, confidence scenes, testimonial portraits, celebrity references, or before/after (only when strategy calls for it) use diverse visual concepts.
|
| 850 |
- FOR HOME INSURANCE: Show person with document, savings proof, home setting
|
| 851 |
|
|
@@ -867,17 +828,15 @@ Create a SCROLL-STOPPING Facebook ad for {niche.replace("_", " ").upper()} using
|
|
| 867 |
|
| 868 |
=== OUTPUT FORMAT (JSON) ===
|
| 869 |
{{
|
| 870 |
-
"
|
| 871 |
-
"headline": "Your pattern-matching headline using {ad_format['name']} format",
|
| 872 |
"primary_text": "Your 2-3 sentence emotional amplification with specific numbers",
|
| 873 |
"description": "Your one powerful sentence",
|
| 874 |
-
"body_story": "A compelling
|
| 875 |
-
"image_brief": "Detailed description matching {container['name']} container style - organic content feel",
|
| 876 |
"cta": "{cta}",
|
| 877 |
-
"ad_format_used": "{ad_format['name']}",
|
| 878 |
"container_used": "{container['name']}",
|
| 879 |
"container_key": "{container.get('key', '')}",
|
| 880 |
-
"psychological_angle": "Primary psychological trigger being used",
|
| 881 |
"why_it_works": "Brief explanation of the psychological mechanism"
|
| 882 |
}}
|
| 883 |
|
|
@@ -908,7 +867,6 @@ Generate the ad copy now for {niche.replace("_", " ").upper()}. Make it look lik
|
|
| 908 |
psychological_angle = ad_copy.get("psychological_angle", "")
|
| 909 |
container_key = ad_copy.get("container_key", "")
|
| 910 |
container_name = ad_copy.get("container_used", "Standard Ad")
|
| 911 |
-
ad_format = ad_copy.get("ad_format_used", "curiosity_gap")
|
| 912 |
price_anchor = ad_copy.get("price_anchor", "$97")
|
| 913 |
|
| 914 |
# Get container data if key is available
|
|
@@ -1019,7 +977,7 @@ REQUIREMENTS:
|
|
| 1019 |
{"=== TEXT REQUIREMENTS FOR CHAT CONTAINERS ===" if is_chat_container else ""}
|
| 1020 |
{"CRITICAL: All text in chat bubbles MUST be:" if is_chat_container else ""}
|
| 1021 |
{"- READABLE and COHERENT (not gibberish, not placeholder text)" if is_chat_container else ""}
|
| 1022 |
-
{"- Realistic conversation text related to
|
| 1023 |
{"- Proper spelling and grammar" if is_chat_container else ""}
|
| 1024 |
{"- Natural message flow (2-4 messages max)" if is_chat_container else ""}
|
| 1025 |
{"- Use the headline or a variation as one of the messages" if is_chat_container else ""}
|
|
@@ -1028,7 +986,7 @@ REQUIREMENTS:
|
|
| 1028 |
{"=== TEXT REQUIREMENTS FOR DOCUMENT CONTAINERS ===" if is_document_container else ""}
|
| 1029 |
{"CRITICAL: All text in documents MUST be:" if is_document_container else ""}
|
| 1030 |
{"- READABLE and COHERENT" if is_document_container else ""}
|
| 1031 |
-
{"- Related to
|
| 1032 |
{"- Proper formatting (title, body text, etc.)" if is_document_container else ""}
|
| 1033 |
{"- NO gibberish or placeholder text" if is_document_container else ""}
|
| 1034 |
"""
|
|
@@ -1053,7 +1011,7 @@ REQUIREMENTS:
|
|
| 1053 |
|
| 1054 |
CRITICAL REMINDERS:
|
| 1055 |
- Use ONLY {container.get('name', 'Standard Ad')} format - NO mixing with other containers
|
| 1056 |
-
- If using chat container: All text MUST be readable, coherent, and related to
|
| 1057 |
- If using document container: All text MUST be readable and properly formatted
|
| 1058 |
- NO gibberish, placeholder text, or random characters
|
| 1059 |
- NO decorative borders, frames, or boxes
|
|
@@ -1431,6 +1389,11 @@ CRITICAL REQUIREMENTS:
|
|
| 1431 |
# Get niche visual guidance
|
| 1432 |
niche_visual_guidance_data = get_niche_visual_guidance(niche)
|
| 1433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1434 |
# Generate ad copy via LLM with professional prompt
|
| 1435 |
copy_prompt = self._build_copy_prompt(
|
| 1436 |
niche=niche,
|
|
@@ -1444,6 +1407,8 @@ CRITICAL REQUIREMENTS:
|
|
| 1444 |
trigger_data=trigger_data,
|
| 1445 |
trigger_combination=trigger_combination,
|
| 1446 |
power_words=power_words,
|
|
|
|
|
|
|
| 1447 |
)
|
| 1448 |
|
| 1449 |
ad_copy = await llm_service.generate_json(
|
|
@@ -1569,7 +1534,7 @@ CRITICAL REQUIREMENTS:
|
|
| 1569 |
try:
|
| 1570 |
db_id = await db_service.save_ad_creative(
|
| 1571 |
niche=niche,
|
| 1572 |
-
title=
|
| 1573 |
headline=ad_copy.get("headline", ""),
|
| 1574 |
primary_text=ad_copy.get("primary_text", ""),
|
| 1575 |
description=ad_copy.get("description", ""),
|
|
@@ -1584,6 +1549,15 @@ CRITICAL REQUIREMENTS:
|
|
| 1584 |
image_seed=first_image.get("seed"),
|
| 1585 |
image_prompt=first_image.get("image_prompt"), # Save the final refined prompt
|
| 1586 |
generation_method="standard",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1587 |
metadata=metadata,
|
| 1588 |
)
|
| 1589 |
if db_id:
|
|
@@ -1599,7 +1573,6 @@ CRITICAL REQUIREMENTS:
|
|
| 1599 |
"created_at": datetime.now().isoformat(),
|
| 1600 |
|
| 1601 |
# Ad copy
|
| 1602 |
-
"title": ad_copy.get("title", ""),
|
| 1603 |
"headline": ad_copy.get("headline", ""),
|
| 1604 |
"primary_text": ad_copy.get("primary_text", ""),
|
| 1605 |
"description": ad_copy.get("description", ""),
|
|
@@ -1690,7 +1663,6 @@ CONCEPT: {concept['name']}
|
|
| 1690 |
"schema": {
|
| 1691 |
"type": "object",
|
| 1692 |
"properties": {
|
| 1693 |
-
"title": {"type": "string"},
|
| 1694 |
"headline": {"type": "string"},
|
| 1695 |
"primary_text": {"type": "string"},
|
| 1696 |
"description": {"type": "string"},
|
|
@@ -1700,7 +1672,7 @@ CONCEPT: {concept['name']}
|
|
| 1700 |
"psychological_angle": {"type": "string"},
|
| 1701 |
"why_it_works": {"type": "string"},
|
| 1702 |
},
|
| 1703 |
-
"required": ["
|
| 1704 |
},
|
| 1705 |
},
|
| 1706 |
}
|
|
@@ -1817,7 +1789,7 @@ CONCEPT: {concept['name']}
|
|
| 1817 |
try:
|
| 1818 |
db_id = await db_service.save_ad_creative(
|
| 1819 |
niche=niche,
|
| 1820 |
-
title=
|
| 1821 |
headline=ad_copy.get("headline", ""),
|
| 1822 |
primary_text=ad_copy.get("primary_text", ""),
|
| 1823 |
description=ad_copy.get("description", ""),
|
|
@@ -1853,7 +1825,6 @@ CONCEPT: {concept['name']}
|
|
| 1853 |
"id": ad_id,
|
| 1854 |
"niche": niche,
|
| 1855 |
"created_at": datetime.now().isoformat(),
|
| 1856 |
-
"title": ad_copy.get("title", ""),
|
| 1857 |
"headline": ad_copy.get("headline", ""),
|
| 1858 |
"primary_text": ad_copy.get("primary_text", ""),
|
| 1859 |
"description": ad_copy.get("description", ""),
|
|
@@ -1885,8 +1856,8 @@ CONCEPT: {concept['name']}
|
|
| 1885 |
async def generate_ad_extensive(
|
| 1886 |
self,
|
| 1887 |
niche: str,
|
| 1888 |
-
target_audience: str,
|
| 1889 |
-
offer: str,
|
| 1890 |
num_images: int = 1,
|
| 1891 |
image_model: Optional[str] = None,
|
| 1892 |
num_strategies: int = 5,
|
|
@@ -1897,8 +1868,8 @@ CONCEPT: {concept['name']}
|
|
| 1897 |
|
| 1898 |
Args:
|
| 1899 |
niche: Target niche (home_insurance or glp1)
|
| 1900 |
-
target_audience:
|
| 1901 |
-
offer:
|
| 1902 |
num_images: Number of images to generate per strategy
|
| 1903 |
image_model: Image generation model to use
|
| 1904 |
num_strategies: Number of creative strategies to generate
|
|
@@ -1916,6 +1887,12 @@ CONCEPT: {concept['name']}
|
|
| 1916 |
}
|
| 1917 |
niche_display = niche_map.get(niche, niche.title())
|
| 1918 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1919 |
# Step 1: Researcher
|
| 1920 |
print("🔍 Step 1: Researching psychology triggers, angles, and concepts...")
|
| 1921 |
researcher_output = third_flow_service.researcher(
|
|
@@ -2244,11 +2221,10 @@ Create a scroll-stopping ad that:
|
|
| 2244 |
|
| 2245 |
=== OUTPUT (JSON) ===
|
| 2246 |
{{
|
| 2247 |
-
"title": "Short punchy title (3-5 words) - the campaign/ad name",
|
| 2248 |
"headline": "10 words max, triggers {angle.get('trigger')}. You decide whether to include specific numbers based on what enhances the message.",
|
| 2249 |
"primary_text": "2-3 emotional sentences. You decide whether to include specific numbers based on what enhances believability and fits the strategy.",
|
| 2250 |
"description": "One powerful sentence, 10 words max",
|
| 2251 |
-
"body_story": "A compelling
|
| 2252 |
"image_brief": "Detailed scene description following '{concept.get('name')}' concept: {concept.get('structure')}",
|
| 2253 |
"cta": "{cta}",
|
| 2254 |
"psychological_angle": "{angle.get('name')}",
|
|
@@ -2360,16 +2336,19 @@ Create a scroll-stopping ad image with "{headline}" prominently displayed."""
|
|
| 2360 |
images_per_ad: int = 1,
|
| 2361 |
image_model: Optional[str] = None,
|
| 2362 |
username: Optional[str] = None, # Username of the user generating the ads
|
|
|
|
| 2363 |
) -> List[Dict[str, Any]]:
|
| 2364 |
"""
|
| 2365 |
Generate multiple ad creatives - PARALLELIZED.
|
| 2366 |
-
Uses variety: 50% standard generation, 50% matrix generation.
|
| 2367 |
Uses semaphore to limit concurrent operations and prevent resource exhaustion.
|
| 2368 |
|
| 2369 |
Args:
|
| 2370 |
niche: Target niche
|
| 2371 |
count: Number of ads to generate
|
| 2372 |
-
images_per_ad: Images per ad
|
|
|
|
|
|
|
|
|
|
| 2373 |
|
| 2374 |
Returns:
|
| 2375 |
List of ad results (all normalized to GenerateResponse format)
|
|
@@ -2381,8 +2360,14 @@ Create a scroll-stopping ad image with "{headline}" prominently displayed."""
|
|
| 2381 |
"""Helper function to generate a single ad with semaphore control."""
|
| 2382 |
async with semaphore:
|
| 2383 |
try:
|
| 2384 |
-
# Use
|
| 2385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2386 |
|
| 2387 |
if use_matrix:
|
| 2388 |
# Use angle × concept matrix approach
|
|
|
|
| 71 |
# Note: Frameworks are now loaded from data/frameworks.py
|
| 72 |
# This provides comprehensive framework data with hooks, visual styles, and niche-specific recommendations
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# Age brackets for identity targeting (proven high-CTR pattern)
|
| 76 |
AGE_BRACKETS = [
|
|
|
|
| 318 |
"seasonal": home_insurance.SEASONAL_VISUALS,
|
| 319 |
}
|
| 320 |
elif niche == "glp1":
|
| 321 |
+
from data import glp1
|
| 322 |
+
return {
|
| 323 |
+
"diet_fatigue": glp1.DIET_FATIGUE_VISUALS,
|
| 324 |
+
"clothes_body_awareness": glp1.CLOTHES_BODY_AWARENESS_VISUALS,
|
| 325 |
+
"health_wake_up_calls": glp1.HEALTH_WAKE_UP_CALLS_VISUALS,
|
| 326 |
+
"mental_load_food_noise": glp1.MENTAL_LOAD_FOOD_NOISE_VISUALS,
|
| 327 |
+
"survival_life_extension": glp1.SURVIVAL_LIFE_EXTENSION_VISUALS,
|
| 328 |
+
"freedom_from_fear_pain": glp1.FREEDOM_FROM_FEAR_PAIN_VISUALS,
|
| 329 |
+
"sexual_companionship": glp1.SEXUAL_COMPANIONSHIP_VISUALS,
|
| 330 |
+
"comfortable_living": glp1.COMFORTABLE_LIVING_VISUALS,
|
| 331 |
+
"superiority_winning": glp1.SUPERIORITY_WINNING_VISUALS,
|
| 332 |
+
"care_protection_loved_ones": glp1.CARE_PROTECTION_LOVED_ONES_VISUALS,
|
| 333 |
+
"social_approval": glp1.SOCIAL_APPROVAL_VISUALS,
|
| 334 |
+
"why_moment": glp1.WHY_MOMENT_VISUALS,
|
| 335 |
+
"when_moment": glp1.WHEN_MOMENT_VISUALS,
|
| 336 |
+
"where_moment": glp1.WHERE_MOMENT_VISUALS,
|
| 337 |
+
"with_whom": glp1.WITH_WHOM_VISUALS,
|
| 338 |
+
"with_what_activity": glp1.WITH_WHAT_ACTIVITY_VISUALS,
|
| 339 |
+
"while_wearing_using": glp1.WHILE_WEARING_USING_VISUALS,
|
| 340 |
+
"while_feeling": glp1.WHILE_FEELING_VISUALS,
|
| 341 |
+
"unaware": glp1.UNAWARE_VISUALS,
|
| 342 |
+
"problem_aware": glp1.PROBLEM_AWARE_VISUALS,
|
| 343 |
+
"solution_aware": glp1.SOLUTION_AWARE_VISUALS,
|
| 344 |
+
"product_aware": glp1.PRODUCT_AWARE_VISUALS,
|
| 345 |
+
"most_aware": glp1.MOST_AWARE_VISUALS,
|
| 346 |
+
}
|
| 347 |
return {}
|
| 348 |
|
| 349 |
def _select_visuals_from_library(self, niche: str, strategy_name: str, count: int = 2) -> List[str]:
|
|
|
|
| 504 |
}
|
| 505 |
return {}
|
| 506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
def _get_framework_container_compatibility(self, framework_key: str) -> List[str]:
|
| 508 |
"""
|
| 509 |
Get container types that are most compatible with a framework.
|
|
|
|
| 591 |
trigger_data: Dict[str, Any] = None,
|
| 592 |
trigger_combination: Dict[str, Any] = None,
|
| 593 |
power_words: List[str] = None,
|
| 594 |
+
angle: Dict[str, Any] = None,
|
| 595 |
+
concept: Dict[str, Any] = None,
|
| 596 |
) -> str:
|
| 597 |
"""
|
| 598 |
Build professional LLM prompt for ad copy generation.
|
| 599 |
+
Uses angle × concept matrix approach for psychological targeting.
|
| 600 |
"""
|
| 601 |
strategy_names = [s["name"] for s in strategies]
|
| 602 |
strategy_descriptions = [f"- {s['name']}: {s['description']}" for s in strategies]
|
| 603 |
cta = random.choice(niche_data["ctas"])
|
| 604 |
niche_guidance = self._get_niche_specific_guidance(niche)
|
| 605 |
|
| 606 |
+
# Select container (native-looking format)
|
| 607 |
# Use framework-aware container selection (improvement)
|
| 608 |
container_strategy = "framework_aware" if random.random() < 0.6 else random.choice(["native", "ugc", "alert", "balanced"])
|
|
|
|
| 609 |
container = self._select_container(prefer_native=True, strategy=container_strategy, framework_key=framework_data.get("key"))
|
| 610 |
price_guidance = self._generate_specific_price(niche)
|
| 611 |
niche_numbers = self._generate_niche_numbers(niche)
|
|
|
|
| 623 |
- Target Age Bracket: {age_bracket['label']}
|
| 624 |
|
| 625 |
DECISION: You decide whether to include these numbers based on:
|
| 626 |
+
- The psychological angle (some angles work better with numbers, others without)
|
| 627 |
- The psychological strategy (some strategies benefit from specificity, others from emotional appeal)
|
| 628 |
- The overall message flow
|
| 629 |
|
|
|
|
| 639 |
- Target Age Bracket: {age_bracket['label']}
|
| 640 |
|
| 641 |
DECISION: You decide whether to include prices/numbers based on:
|
| 642 |
+
- The psychological angle (some angles benefit from prices, others may not)
|
| 643 |
- The psychological strategy (some strategies need specificity, others work better emotionally)
|
| 644 |
- The overall message flow and what feels most authentic
|
| 645 |
|
|
|
|
| 729 |
CREATIVE DIRECTION: {creative_direction}
|
| 730 |
CALL-TO-ACTION: {cta}
|
| 731 |
|
| 732 |
+
=== ANGLE × CONCEPT FRAMEWORK ===
|
| 733 |
+
ANGLE: {angle.get('name') if angle else 'N/A'}
|
| 734 |
+
- Psychological Trigger: {angle.get('trigger') if angle else 'N/A'}
|
| 735 |
+
- Example Hook: "{angle.get('example') if angle else 'N/A'}"
|
| 736 |
+
- This angle answers WHY they should care
|
| 737 |
+
|
| 738 |
+
CONCEPT: {concept.get('name') if concept else 'N/A'}
|
| 739 |
+
- Visual Structure: {concept.get('structure') if concept else 'N/A'}
|
| 740 |
+
- Visual Guidance: {concept.get('visual') if concept else 'N/A'}
|
| 741 |
+
- This concept defines HOW to show it visually
|
| 742 |
|
| 743 |
=== CONTAINER FORMAT (Native-Looking Ad) ===
|
| 744 |
CONTAINER TYPE: {container['name']}
|
|
|
|
| 772 |
|
| 773 |
{headline_formulas}
|
| 774 |
=== YOUR MISSION ===
|
| 775 |
+
Create a SCROLL-STOPPING Facebook ad for {niche.replace("_", " ").upper()} using the "{angle.get('name') if angle else 'psychological'}" angle and "{concept.get('name') if concept else 'visual'}" concept that looks like organic content, not advertising.
|
| 776 |
|
| 777 |
=== OUTPUT REQUIREMENTS ===
|
| 778 |
|
| 779 |
1. HEADLINE (The "Arrest")
|
| 780 |
+
- Use the "{angle.get('name') if angle else 'psychological'}" angle to trigger {angle.get('trigger') if angle else 'emotion'}
|
| 781 |
- MAXIMUM 10 words
|
| 782 |
- Must create INSTANT pattern interrupt
|
| 783 |
+
- You decide whether to include specific numbers/prices based on the angle and what will be most effective. If including prices, make them oddly specific (e.g., "$97.33" not "$100") for believability.
|
| 784 |
- Include demographic targeting where appropriate
|
| 785 |
- NO generic phrases - be SPECIFIC and EMOTIONAL
|
| 786 |
|
| 787 |
2. PRIMARY TEXT (The "Agitation")
|
| 788 |
- 2-3 punchy sentences that AMPLIFY the emotional hook
|
| 789 |
+
- Use the "{angle.get('name') if angle else 'psychological'}" angle to deepen emotional connection
|
| 790 |
+
- You decide whether to include specific numbers/prices based on the angle and strategy. If including, make them oddly specific for believability.
|
| 791 |
- Reference the demographic appropriately
|
| 792 |
- Create urgency
|
| 793 |
- Make them FEEL the pain or desire
|
|
|
|
| 798 |
- Create action urgency
|
| 799 |
|
| 800 |
4. IMAGE BRIEF (CRITICAL - must match {container['name']} container style)
|
| 801 |
+
- Follow the "{concept.get('name') if concept else 'visual'}" concept: {concept.get('structure') if concept else 'authentic visual'}
|
| 802 |
- Describe the scene for the {container['name']} container format ONLY
|
| 803 |
- Visual guidance: {get_container_visual_guidance(container.get('key', ''))}
|
| 804 |
- The image should look like ORGANIC CONTENT, not an ad
|
| 805 |
- Include: setting, subjects, props, mood
|
| 806 |
- Follow container authenticity tips: {', '.join(container.get('authenticity_tips', [])[:2])}
|
| 807 |
- CRITICAL: Use ONLY {container['name']} format - DO NOT mix with other container types (no WhatsApp + memo, no iMessage + document)
|
| 808 |
+
- {f"If chat container: Include 2-4 readable, coherent messages related to {niche.replace('_', ' ').title()}. Use the headline or a variation as one message." if container.get('key') in ['imessage', 'whatsapp', 'sms', 'reddit_post', 'social_post'] else ""}
|
| 809 |
+
- {f"If document container: Include readable, properly formatted text related to {niche.replace('_', ' ').title()}." if container.get('key') in ['memo', 'email_notification', 'browser_alert'] else ""}
|
| 810 |
- FOR GLP-1: Use VARIETY - show different visual types: quiz interfaces, doctor/medical settings, person on scale, mirror reflections, lifestyle moments, confidence scenes, testimonial portraits, celebrity references, or before/after (only when strategy calls for it) use diverse visual concepts.
|
| 811 |
- FOR HOME INSURANCE: Show person with document, savings proof, home setting
|
| 812 |
|
|
|
|
| 828 |
|
| 829 |
=== OUTPUT FORMAT (JSON) ===
|
| 830 |
{{
|
| 831 |
+
"headline": "Your headline using the {angle.get('name') if angle else 'psychological'} angle to trigger {angle.get('trigger') if angle else 'emotion'}",
|
|
|
|
| 832 |
"primary_text": "Your 2-3 sentence emotional amplification with specific numbers",
|
| 833 |
"description": "Your one powerful sentence",
|
| 834 |
+
"body_story": "A compelling 8-12 sentence STORY that hooks the reader emotionally. Start with a relatable situation or pain point. Build tension gradually. Show the transformation with vivid details. End with hope and a soft CTA. Write in first or second person for intimacy. Make it engaging and detailed enough to fully capture the reader's attention.",
|
| 835 |
+
"image_brief": "Detailed description following '{concept.get('name') if concept else 'visual'}' concept and matching {container['name']} container style - organic content feel",
|
| 836 |
"cta": "{cta}",
|
|
|
|
| 837 |
"container_used": "{container['name']}",
|
| 838 |
"container_key": "{container.get('key', '')}",
|
| 839 |
+
"psychological_angle": "{angle.get('name') if angle else 'Primary psychological trigger being used'}",
|
| 840 |
"why_it_works": "Brief explanation of the psychological mechanism"
|
| 841 |
}}
|
| 842 |
|
|
|
|
| 867 |
psychological_angle = ad_copy.get("psychological_angle", "")
|
| 868 |
container_key = ad_copy.get("container_key", "")
|
| 869 |
container_name = ad_copy.get("container_used", "Standard Ad")
|
|
|
|
| 870 |
price_anchor = ad_copy.get("price_anchor", "$97")
|
| 871 |
|
| 872 |
# Get container data if key is available
|
|
|
|
| 977 |
{"=== TEXT REQUIREMENTS FOR CHAT CONTAINERS ===" if is_chat_container else ""}
|
| 978 |
{"CRITICAL: All text in chat bubbles MUST be:" if is_chat_container else ""}
|
| 979 |
{"- READABLE and COHERENT (not gibberish, not placeholder text)" if is_chat_container else ""}
|
| 980 |
+
{f"- Realistic conversation text related to {niche.replace('_', ' ').title()}" if is_chat_container else ""}
|
| 981 |
{"- Proper spelling and grammar" if is_chat_container else ""}
|
| 982 |
{"- Natural message flow (2-4 messages max)" if is_chat_container else ""}
|
| 983 |
{"- Use the headline or a variation as one of the messages" if is_chat_container else ""}
|
|
|
|
| 986 |
{"=== TEXT REQUIREMENTS FOR DOCUMENT CONTAINERS ===" if is_document_container else ""}
|
| 987 |
{"CRITICAL: All text in documents MUST be:" if is_document_container else ""}
|
| 988 |
{"- READABLE and COHERENT" if is_document_container else ""}
|
| 989 |
+
{f"- Related to {niche.replace('_', ' ').title()} topic" if is_document_container else ""}
|
| 990 |
{"- Proper formatting (title, body text, etc.)" if is_document_container else ""}
|
| 991 |
{"- NO gibberish or placeholder text" if is_document_container else ""}
|
| 992 |
"""
|
|
|
|
| 1011 |
|
| 1012 |
CRITICAL REMINDERS:
|
| 1013 |
- Use ONLY {container.get('name', 'Standard Ad')} format - NO mixing with other containers
|
| 1014 |
+
- If using chat container: All text MUST be readable, coherent, and related to the {niche.replace('_', ' ').title()} niche
|
| 1015 |
- If using document container: All text MUST be readable and properly formatted
|
| 1016 |
- NO gibberish, placeholder text, or random characters
|
| 1017 |
- NO decorative borders, frames, or boxes
|
|
|
|
| 1389 |
# Get niche visual guidance
|
| 1390 |
niche_visual_guidance_data = get_niche_visual_guidance(niche)
|
| 1391 |
|
| 1392 |
+
# Get random angle × concept combination (like matrix generation)
|
| 1393 |
+
combination = matrix_service.generate_single_combination(niche)
|
| 1394 |
+
angle = combination["angle"]
|
| 1395 |
+
concept = combination["concept"]
|
| 1396 |
+
|
| 1397 |
# Generate ad copy via LLM with professional prompt
|
| 1398 |
copy_prompt = self._build_copy_prompt(
|
| 1399 |
niche=niche,
|
|
|
|
| 1407 |
trigger_data=trigger_data,
|
| 1408 |
trigger_combination=trigger_combination,
|
| 1409 |
power_words=power_words,
|
| 1410 |
+
angle=angle,
|
| 1411 |
+
concept=concept,
|
| 1412 |
)
|
| 1413 |
|
| 1414 |
ad_copy = await llm_service.generate_json(
|
|
|
|
| 1534 |
try:
|
| 1535 |
db_id = await db_service.save_ad_creative(
|
| 1536 |
niche=niche,
|
| 1537 |
+
title=None, # No title for standard flow
|
| 1538 |
headline=ad_copy.get("headline", ""),
|
| 1539 |
primary_text=ad_copy.get("primary_text", ""),
|
| 1540 |
description=ad_copy.get("description", ""),
|
|
|
|
| 1549 |
image_seed=first_image.get("seed"),
|
| 1550 |
image_prompt=first_image.get("image_prompt"), # Save the final refined prompt
|
| 1551 |
generation_method="standard",
|
| 1552 |
+
angle_key=angle.get("key"),
|
| 1553 |
+
angle_name=angle.get("name"),
|
| 1554 |
+
angle_trigger=angle.get("trigger"),
|
| 1555 |
+
angle_category=angle.get("category"),
|
| 1556 |
+
concept_key=concept.get("key"),
|
| 1557 |
+
concept_name=concept.get("name"),
|
| 1558 |
+
concept_structure=concept.get("structure"),
|
| 1559 |
+
concept_visual=concept.get("visual"),
|
| 1560 |
+
concept_category=concept.get("category"),
|
| 1561 |
metadata=metadata,
|
| 1562 |
)
|
| 1563 |
if db_id:
|
|
|
|
| 1573 |
"created_at": datetime.now().isoformat(),
|
| 1574 |
|
| 1575 |
# Ad copy
|
|
|
|
| 1576 |
"headline": ad_copy.get("headline", ""),
|
| 1577 |
"primary_text": ad_copy.get("primary_text", ""),
|
| 1578 |
"description": ad_copy.get("description", ""),
|
|
|
|
| 1663 |
"schema": {
|
| 1664 |
"type": "object",
|
| 1665 |
"properties": {
|
|
|
|
| 1666 |
"headline": {"type": "string"},
|
| 1667 |
"primary_text": {"type": "string"},
|
| 1668 |
"description": {"type": "string"},
|
|
|
|
| 1672 |
"psychological_angle": {"type": "string"},
|
| 1673 |
"why_it_works": {"type": "string"},
|
| 1674 |
},
|
| 1675 |
+
"required": ["headline", "primary_text", "description", "body_story", "image_brief", "cta"],
|
| 1676 |
},
|
| 1677 |
},
|
| 1678 |
}
|
|
|
|
| 1789 |
try:
|
| 1790 |
db_id = await db_service.save_ad_creative(
|
| 1791 |
niche=niche,
|
| 1792 |
+
title=None, # No title for matrix flow
|
| 1793 |
headline=ad_copy.get("headline", ""),
|
| 1794 |
primary_text=ad_copy.get("primary_text", ""),
|
| 1795 |
description=ad_copy.get("description", ""),
|
|
|
|
| 1825 |
"id": ad_id,
|
| 1826 |
"niche": niche,
|
| 1827 |
"created_at": datetime.now().isoformat(),
|
|
|
|
| 1828 |
"headline": ad_copy.get("headline", ""),
|
| 1829 |
"primary_text": ad_copy.get("primary_text", ""),
|
| 1830 |
"description": ad_copy.get("description", ""),
|
|
|
|
| 1856 |
async def generate_ad_extensive(
|
| 1857 |
self,
|
| 1858 |
niche: str,
|
| 1859 |
+
target_audience: Optional[str] = None,
|
| 1860 |
+
offer: Optional[str] = None,
|
| 1861 |
num_images: int = 1,
|
| 1862 |
image_model: Optional[str] = None,
|
| 1863 |
num_strategies: int = 5,
|
|
|
|
| 1868 |
|
| 1869 |
Args:
|
| 1870 |
niche: Target niche (home_insurance or glp1)
|
| 1871 |
+
target_audience: Optional target audience description
|
| 1872 |
+
offer: Optional offer to run
|
| 1873 |
num_images: Number of images to generate per strategy
|
| 1874 |
image_model: Image generation model to use
|
| 1875 |
num_strategies: Number of creative strategies to generate
|
|
|
|
| 1887 |
}
|
| 1888 |
niche_display = niche_map.get(niche, niche.title())
|
| 1889 |
|
| 1890 |
+
# Provide defaults if target_audience or offer are not provided
|
| 1891 |
+
if not target_audience:
|
| 1892 |
+
target_audience = f"People interested in {niche_display}"
|
| 1893 |
+
if not offer:
|
| 1894 |
+
offer = f"Get the best {niche_display} solution"
|
| 1895 |
+
|
| 1896 |
# Step 1: Researcher
|
| 1897 |
print("🔍 Step 1: Researching psychology triggers, angles, and concepts...")
|
| 1898 |
researcher_output = third_flow_service.researcher(
|
|
|
|
| 2221 |
|
| 2222 |
=== OUTPUT (JSON) ===
|
| 2223 |
{{
|
|
|
|
| 2224 |
"headline": "10 words max, triggers {angle.get('trigger')}. You decide whether to include specific numbers based on what enhances the message.",
|
| 2225 |
"primary_text": "2-3 emotional sentences. You decide whether to include specific numbers based on what enhances believability and fits the strategy.",
|
| 2226 |
"description": "One powerful sentence, 10 words max",
|
| 2227 |
+
"body_story": "A compelling 8-12 sentence STORY. Start with relatable pain/situation. Build tension gradually with vivid details. Show transformation with specific examples. End with hope and emotional connection. Write in first/second person. Make it engaging and detailed enough to fully capture the reader's attention.",
|
| 2228 |
"image_brief": "Detailed scene description following '{concept.get('name')}' concept: {concept.get('structure')}",
|
| 2229 |
"cta": "{cta}",
|
| 2230 |
"psychological_angle": "{angle.get('name')}",
|
|
|
|
| 2336 |
images_per_ad: int = 1,
|
| 2337 |
image_model: Optional[str] = None,
|
| 2338 |
username: Optional[str] = None, # Username of the user generating the ads
|
| 2339 |
+
method: Optional[str] = None, # "standard", "matrix", or None (mixed)
|
| 2340 |
) -> List[Dict[str, Any]]:
|
| 2341 |
"""
|
| 2342 |
Generate multiple ad creatives - PARALLELIZED.
|
|
|
|
| 2343 |
Uses semaphore to limit concurrent operations and prevent resource exhaustion.
|
| 2344 |
|
| 2345 |
Args:
|
| 2346 |
niche: Target niche
|
| 2347 |
count: Number of ads to generate
|
| 2348 |
+
images_per_ad: Images per ad (typically 1 for batch)
|
| 2349 |
+
image_model: Image model to use
|
| 2350 |
+
username: Username of the user generating the ads
|
| 2351 |
+
method: Generation method - "standard", "matrix", or None (mixed 50/50)
|
| 2352 |
|
| 2353 |
Returns:
|
| 2354 |
List of ad results (all normalized to GenerateResponse format)
|
|
|
|
| 2360 |
"""Helper function to generate a single ad with semaphore control."""
|
| 2361 |
async with semaphore:
|
| 2362 |
try:
|
| 2363 |
+
# Use method parameter to determine generation type
|
| 2364 |
+
if method == "matrix":
|
| 2365 |
+
use_matrix = True
|
| 2366 |
+
elif method == "standard":
|
| 2367 |
+
use_matrix = False
|
| 2368 |
+
else:
|
| 2369 |
+
# Default: 50% standard, 50% matrix (ensures all resources used)
|
| 2370 |
+
use_matrix = random.random() < 0.5
|
| 2371 |
|
| 2372 |
if use_matrix:
|
| 2373 |
# Use angle × concept matrix approach
|