sushilideaclan01 commited on
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 CHANGED
@@ -1,10 +1,10 @@
1
  {
2
  "info": {
3
- "_postman_id": "ad-generator-lite-collection",
4
- "name": "Ad Generator Lite API",
5
- "description": "Complete API collection for Ad Generator Lite - 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": "ad-generator-lite"
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 with randomization
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 includes a systematic Angle × Concept matrix:
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
- const { primary, fallback } = getImageUrlFallback(ad.image_url, ad.image_filename);
38
- setImageSrc(primary || fallback);
 
 
 
 
 
 
 
 
 
 
 
39
  setImageError(false);
40
  const index = allAds.findIndex((a) => a.id === ad.id);
41
  setCurrentIndex(index);
@@ -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?.image_url && !ad?.image_filename) {
117
  toast.error("No image available");
118
  return;
119
  }
 
120
  try {
121
- const imageUrl = getImageUrl(ad.image_url, ad.image_filename);
122
- if (imageUrl) {
123
- await downloadImage(imageUrl, ad.image_filename || `ad-${ad.id}.png`);
124
- toast.success("Image downloaded");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
 
 
 
 
126
  } catch (error) {
127
  toast.error("Failed to download image");
128
  }
@@ -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
- {/* Headline */}
342
  <div className="bg-white rounded-2xl shadow-lg shadow-blue-100/30 p-6 border-l-4 border-blue-500">
343
- <div className="flex items-start justify-between gap-4">
344
- <div className="flex-1">
345
- {ad.title && (
346
- <p className="text-sm text-blue-600 font-medium mb-1">{ad.title}</p>
347
- )}
348
- <h1 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent leading-tight">
349
- {ad.headline}
350
- </h1>
351
- </div>
352
- <div className="relative group shrink-0">
353
  <Button
354
  variant="ghost"
355
  size="sm"
356
- onClick={() => handleCopyText(ad.headline, "Headline")}
357
- className="text-blue-500 hover:bg-blue-50"
 
 
 
 
 
358
  >
359
- <Copy className="h-4 w-4" />
360
  </Button>
361
- <span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-blue-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
362
- Copy Headline
363
- </span>
 
 
 
 
 
 
 
 
 
 
364
  </div>
365
  </div>
366
- </div>
 
 
 
367
 
368
  {/* 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="relative group">
374
  <Button
375
  variant="ghost"
376
  size="sm"
377
- onClick={() => handleCopyText(ad.description!, "Description")}
378
- className="text-violet-500 hover:bg-violet-50"
 
 
 
 
 
379
  >
380
- <Copy className="h-4 w-4" />
381
  </Button>
382
- <span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-violet-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
383
- Copy Description
384
- </span>
 
 
 
 
 
 
 
 
 
 
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="relative group">
397
  <Button
398
  variant="ghost"
399
  size="sm"
400
- onClick={() => handleCopyText(ad.body_story!, "Body Story")}
401
- className="text-amber-500 hover:bg-amber-50"
 
 
 
 
 
402
  >
403
- <Copy className="h-4 w-4" />
404
  </Button>
405
- <span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-amber-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
406
- Copy Story
407
- </span>
 
 
 
 
 
 
 
 
 
 
408
  </div>
409
  </div>
410
- <p className="text-gray-700 whitespace-pre-line leading-relaxed">{ad.body_story}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="relative group">
420
  <Button
421
  variant="ghost"
422
  size="sm"
423
- onClick={() => handleCopyText(ad.cta!, "CTA")}
424
- className="text-emerald-600 hover:bg-emerald-100"
 
 
 
 
 
425
  >
426
- <Copy className="h-4 w-4" />
427
  </Button>
428
- <span className="absolute -bottom-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-emerald-600 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
429
- Copy CTA
430
- </span>
 
 
 
 
 
 
 
 
 
 
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
- // Update the displayed image if correction was successful
477
- if (result.corrected_image?.image_url) {
478
- setImageSrc(result.corrected_image.image_url);
479
- }
480
- // If a new ad was created, optionally navigate to it or reload the gallery
481
- if (result.corrected_image?.ad_id) {
482
- toast.success("Corrected image saved to gallery!");
483
- // Optionally: router.push(`/gallery/${result.corrected_image.ad_id}`);
 
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
- <Button variant="outline" size="sm" onClick={selectAll}>
176
- <div className="flex items-center gap-2">
177
- <CheckSquare className="h-4 w-4" />
178
- <span>Select All</span>
179
- </div>
180
- </Button>
 
181
  )
182
  )}
183
  </div>
184
 
185
  {/* Sort Controls */}
186
  {sortOptions && (
187
- <Button
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 className="text-sm">
198
  {sortOptions.direction === "desc" ? "Newest First" : "Oldest First"}
199
  </span>
200
- </Button>
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
- setProgress({
81
- step: "copy",
82
- progress: 10,
83
- message: "Generating ad copy...",
84
- });
 
 
 
 
 
 
 
 
85
 
86
- try {
87
- // Simulate progress updates
88
- let currentProgress = 20;
89
- const progressInterval = setInterval(() => {
90
- currentProgress = Math.min(90, currentProgress + 5);
91
- setProgress({
92
- step: currentProgress < 50 ? "copy" : "image",
93
- progress: currentProgress,
94
- message: currentProgress < 50 ? "Generating ad copy..." : "Generating images...",
 
95
  });
96
- }, 1000);
97
 
98
- // Generate ad
99
- const result = await generateAd(data);
 
 
 
 
 
100
 
101
- clearInterval(progressInterval);
 
 
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  setProgress({
104
- step: "saving",
105
- progress: 90,
106
- message: "Saving to database...",
107
  });
108
 
109
- setCurrentGeneration(result);
110
- setProgress({
111
- step: "complete",
112
- progress: 100,
113
- message: "Ad generated successfully!",
114
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- toast.success("Ad generated successfully!");
117
- } catch (error: any) {
118
- setError(error.message || "Failed to generate ad");
119
- setProgress({
120
- step: "error",
121
- progress: 0,
122
- message: error.message || "An error occurred",
123
- });
124
- toast.error(error.message || "Failed to generate ad");
125
- } finally {
126
- setIsGenerating(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- setCurrentGeneration(result);
 
 
 
 
 
 
155
  setProgress({
156
- step: "complete",
157
- progress: 100,
158
- message: "Ad generated successfully!",
159
  });
160
 
161
- toast.success("Ad generated successfully!");
162
- } catch (error: any) {
163
- setError(error.message || "Failed to generate ad");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  setProgress({
165
- step: "error",
166
- progress: 0,
167
- message: error.message || "An error occurred",
168
  });
169
- toast.error(error.message || "Failed to generate ad");
170
- } finally {
171
- setIsGenerating(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(data);
 
 
 
 
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: string;
237
- offer: string;
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 Variations: <span className="text-blue-600 font-bold">{numImages}</span>
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
- Each variation will have a unique image and slight copy variations
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
- {mode === "batch" && batchResults.length > 0 ? (
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-16 mb-12">
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-6 space-y-4">
39
  <div className="flex items-center justify-between">
40
- <h3 className="text-lg font-bold gradient-text">Filters</h3>
41
  {hasActiveFilters && (
42
  <Button variant="ghost" size="sm" onClick={clearFilters} className="group">
43
  <X className="h-4 w-4 mr-1 group-hover:rotate-90 transition-transform duration-300" />
@@ -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-4">
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
- if (url) {
43
- await downloadImage(url, filename || `ad-${ad.id}.png`);
44
- toast.success("Image downloaded");
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="relative group">
296
- <Button
297
- variant="ghost"
298
- size="sm"
299
- onClick={() => handleCopyText(ad.body_story!, "Body Story")}
300
- className="text-amber-500 hover:bg-amber-50"
301
- >
302
- <Copy className="h-4 w-4" />
303
- </Button>
304
- <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">
305
- Copy Story
306
- </span>
 
 
307
  </div>
308
  </div>
309
- <p className="text-gray-700 whitespace-pre-line leading-relaxed">{ad.body_story}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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?.(response);
 
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-3">
279
- {result.corrections.spelling_corrections.length > 0 && (
 
 
280
  <div>
281
- <h3 className="font-semibold text-gray-900 mb-2">Spelling Corrections</h3>
 
 
 
282
  <div className="space-y-2">
283
  {result.corrections.spelling_corrections.map((correction, idx) => (
284
  <div
285
  key={idx}
286
  className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm"
287
  >
288
- <span className="font-medium text-red-600 line-through">
289
- {correction.detected}
290
- </span>{" "}
291
- →{" "}
292
- <span className="font-medium text-green-600">
293
- {correction.corrected}
294
- </span>
 
 
 
 
 
295
  </div>
296
  ))}
297
  </div>
298
  </div>
299
  )}
300
 
301
- {result.corrections.visual_corrections.length > 0 && (
302
  <div>
303
- <h3 className="font-semibold text-gray-900 mb-2">Visual Improvements</h3>
 
 
 
304
  <div className="space-y-2">
305
  {result.corrections.visual_corrections.map((correction, idx) => (
306
  <div
307
  key={idx}
308
  className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm"
309
  >
310
- <p className="font-medium text-gray-900">{correction.issue}</p>
311
- <p className="text-gray-600 mt-1">{correction.suggestion}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  </div>
313
  ))}
314
  </div>
315
  </div>
316
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  </div>
318
  )}
319
  </div>
@@ -339,7 +377,13 @@ export const CorrectionModal: React.FC<CorrectionModalProps> = ({
339
  </Button>
340
  )}
341
  <Button
342
- onClick={onClose}
 
 
 
 
 
 
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().min(1, "Target audience is required"),
15
- offer: z.string().min(1, "Offer is required"),
16
  num_images: z.number().min(1).max(3),
17
  num_strategies: z.number().min(1).max(10),
18
  image_model: z.string().nullable().optional(),
@@ -23,8 +23,8 @@ type ExtensiveFormData = z.infer<typeof extensiveSchema>;
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,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 <span className="text-red-500">*</span>
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 <span className="text-red-500">*</span>
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 Variations: <span className="text-blue-600 font-bold">{numImages}</span>
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
- Each variation will have a unique image and slight copy variations
84
  </p>
85
  {errors.num_images && (
86
  <p className="mt-1 text-sm text-red-600">
 
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
- const currentProgress = progress.progress || stepProgress[progress.step];
 
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: string;
69
- offer: string;
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
- const response = await fetch(url);
10
- const blob = await response.blob();
 
 
 
 
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: string;
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: string;
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 4-6 sentence story that hooks emotionally")
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 4-6 sentence story that hooks emotionally")
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
- # Save corrected image to database as a new ad creative
680
  if result.get("status") == "success" and result.get("_db_metadata"):
681
  db_metadata = result["_db_metadata"]
682
  try:
683
- api_logger.info("Saving corrected image to database as new ad creative...")
684
- # Save as a new ad creative with same copy but corrected image
685
- corrected_ad_id = await db_service.save_ad_creative(
686
- niche=ad.get("niche", ""),
687
- title=ad.get("title", ""),
688
- headline=ad.get("headline", ""),
689
- primary_text=ad.get("primary_text", ""),
690
- description=ad.get("description", ""),
691
- body_story=ad.get("body_story", ""),
692
- cta=ad.get("cta", ""),
693
- psychological_angle=ad.get("psychological_angle", ""),
694
- why_it_works=ad.get("why_it_works", ""),
695
- username=username, # Pass current user
696
- image_url=db_metadata.get("image_url"),
697
- image_filename=db_metadata.get("filename"),
698
- image_model=db_metadata.get("model_used"),
699
- image_prompt=db_metadata.get("corrected_prompt"),
700
- angle_key=ad.get("angle_key"),
701
- angle_name=ad.get("angle_name"),
702
- angle_trigger=ad.get("angle_trigger"),
703
- angle_category=ad.get("angle_category"),
704
- concept_key=ad.get("concept_key"),
705
- concept_name=ad.get("concept_name"),
706
- concept_structure=ad.get("concept_structure"),
707
- concept_visual=ad.get("concept_visual"),
708
- concept_category=ad.get("concept_category"),
709
- generation_method="correction",
710
- metadata={
711
- "original_ad_id": request.image_id,
712
- "corrections": result.get("corrections"),
713
- "is_corrected": True,
714
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  )
716
- if corrected_ad_id:
717
- api_logger.info(f"✓ Corrected image saved to database with ID: {corrected_ad_id}")
718
- # Add the new ad ID to the response
 
 
719
  if "corrected_image" not in response_data:
720
  response_data["corrected_image"] = {}
721
- response_data["corrected_image"]["ad_id"] = corrected_ad_id
722
  else:
723
- api_logger.warning("Failed to save corrected image to database (no ID returned)")
724
  except Exception as e:
725
- api_logger.error(f"Failed to save corrected image to database: {e}")
726
- api_logger.exception("Database save error details:")
727
- # Don't fail the request if database save fails
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
- description="Target audience description (e.g., 'US people over 50+ age')"
 
985
  )
986
- offer: str = Field(
987
- description="Offer to run (e.g., 'Don't overpay your insurance')"
 
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
- # Add GLP-1 visual library if available
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 winning ad patterns from high-converting creatives analysis.
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 winning format and container (native-looking format)
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 ad format (some formats work better with numbers, others without)
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 ad format (e.g., "specific_price_anchor" format benefits from prices, "curiosity_gap" may not)
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
- === WINNING AD FORMAT TO USE ===
778
- FORMAT: {ad_format['name']}
779
- DESCRIPTION: {ad_format['description']}
780
- PATTERN: {ad_format['headline_pattern']}
781
- EXAMPLES: {', '.join(ad_format['examples'])}
782
- VISUAL STYLE: {ad_format['visual_style']}
 
 
 
 
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 format "{ad_format['name']}" that looks like organic content, not advertising.
817
 
818
  === OUTPUT REQUIREMENTS ===
819
 
820
  1. HEADLINE (The "Arrest")
821
- - Follow the {ad_format['name']} pattern
822
  - MAXIMUM 10 words
823
  - Must create INSTANT pattern interrupt
824
- - You decide whether to include specific numbers/prices based on the ad format and what will be most effective. If including prices, make them oddly specific (e.g., "$97.33" not "$100") for believability.
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
- - You decide whether to include specific numbers/prices based on the ad format and strategy. If including, make them oddly specific for believability.
 
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 home insurance. Use the headline or a variation as one message." if container.get('key') in ['imessage', 'whatsapp', 'sms', 'reddit_post', 'social_post'] else ""}
848
- - {"If document container: Include readable, properly formatted text related to home insurance." if container.get('key') in ['memo', 'email_notification', 'browser_alert'] else ""}
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
- "title": "Short, punchy ad title (3-5 words max) - think of it as the campaign name",
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 4-6 sentence STORY that hooks the reader emotionally. Start with a relatable situation or pain point. Build tension. Show the transformation. End with hope and a soft CTA. Write in first or second person for intimacy.",
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 home insurance" if is_chat_container else ""}
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 home insurance topic" if is_document_container else ""}
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 home insurance
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=ad_copy.get("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": ["title", "headline", "primary_text", "description", "body_story", "image_brief", "cta"],
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=ad_copy.get("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: Target audience description
1901
- offer: Offer to run
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 4-6 sentence STORY. Start with relatable pain/situation. Build tension. Show transformation. End with hope. Write in first/second person.",
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 variety: 50% standard, 50% matrix (ensures all resources used)
2385
- use_matrix = random.random() < 0.5 # 50% chance to use matrix
 
 
 
 
 
 
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