sushilideaclan01 commited on
Commit
b317cd0
·
1 Parent(s): 6b000fc

Refactor ad creative generation and analysis: Updated the analyze_product and generate_ad_creatives functions to accept target audience segments for tailored analysis and creative generation. Enhanced the frontend to include a multi-select dropdown for target audiences, improving user experience. Adjusted styles for the new dropdown component and ensured proper handling of product image URLs for better ad results.

Browse files
backend/app/analysis.py CHANGED
@@ -4,15 +4,40 @@ Deep marketing analysis of product data for ad creative generation.
4
 
5
  import json
6
 
7
- from app.llm import call_llm, extract_json
8
 
9
 
10
- def analyze_product(product_data: dict) -> dict:
11
  """
12
  Deep marketing analysis of the product.
13
  Returns structured dict for generating highly targeted ad creative.
 
14
  """
15
  price = product_data.get("price", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  prompt = f"""
17
  You are an elite Indian DTC jewelry performance strategist.
18
 
@@ -29,6 +54,7 @@ You think ONLY in:
29
  Your job:
30
  Perform a DEEP conversion-focused analysis of THIS specific product
31
  that will directly power high-performing STATIC ad creatives.
 
32
 
33
  ━━━━━━━━━━━━━━━━━━
34
  CRITICAL PERFORMANCE RULES
@@ -40,7 +66,7 @@ CRITICAL PERFORMANCE RULES
40
  - Visual direction
41
  - Hook creation
42
  - Or targeting decision.
43
- 4. Assume this is being sold via Meta ads to global urban buyers.
44
  5. If product data lacks detail, infer logically based on category norms.
45
  6. Think in micro-audiences, not broad demographics.
46
  7. This analysis must help generate scroll-stopping static creatives.
@@ -61,11 +87,9 @@ OUTPUT REQUIREMENTS
61
 
62
  - Exactly 6 emotional triggers
63
  - Exactly 5 ad angles
64
- - Exactly 5 best_ad_formats
65
  - Exactly 4 shooting_angles
66
  - Exactly 3 color_worlds
67
  - Exactly 4 unique_selling_points
68
- - Exactly 3 improvement_suggestions
69
  - 3 tagline_options
70
 
71
  Price rules:
@@ -78,13 +102,12 @@ RETURN THIS EXACT JSON STRUCTURE
78
  ━━━━━━━━━━━━━━━━━━
79
 
80
  {{
 
81
  "positioning": "Clear one-line positioning statement (who + outcome + differentiation)",
82
  "tagline_options": ["Option 1", "Option 2", "Option 3"],
83
  "ideal_customer": {{
84
  "age_range": "",
85
  "lifestyle": "specific, not generic",
86
- "style_identity": "how she sees herself",
87
- "income_level": "lower middle / mid / upper mid etc",
88
  "platform": "primary buying platform",
89
  "pain_points": ["Pain 1", "Pain 2", "Pain 3"]
90
  }},
@@ -102,22 +125,36 @@ RETURN THIS EXACT JSON STRUCTURE
102
  {{ "angle_name": "", "hook": "", "why_it_works": "" }},
103
  {{ "angle_name": "", "hook": "", "why_it_works": "" }}
104
  ],
105
- "best_ad_formats": ["format1", "format2", "format3", "format4", "format5"],
106
  "shooting_angles": ["Angle 1", "Angle 2", "Angle 3", "Angle 4"],
107
  "color_worlds": ["Direction 1", "Direction 2", "Direction 3"],
108
  "copy_direction": {{
109
  "headline_style": "e.g. bold serif uppercase + elegant script",
110
- "tone": "clear tonal direction",
111
- "do": ["Do 1", "Do 2", "Do 3"],
112
- "dont": ["Don't 1", "Don't 2", "Don't 3"]
113
  }},
114
- "competitor_tier": "budget / mid / premium",
115
- "unique_selling_points": ["USP 1", "USP 2", "USP 3", "USP 4"],
116
- "improvement_suggestions": ["Improvement 1", "Improvement 2", "Improvement 3"]
117
  }}
118
 
119
  Return ONLY valid JSON. No markdown. No commentary.
120
  """
121
 
122
- raw = call_llm(prompt, temperature=0.7)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  return extract_json(raw)
 
4
 
5
  import json
6
 
7
+ from app.llm import call_llm, call_llm_vision, extract_json
8
 
9
 
10
+ def analyze_product(product_data: dict, target_audience: list[str] | None = None) -> dict:
11
  """
12
  Deep marketing analysis of the product.
13
  Returns structured dict for generating highly targeted ad creative.
14
+ When target_audience is provided, analysis is tailored to those segments.
15
  """
16
  price = product_data.get("price", "")
17
+ audience_block = ""
18
+ if target_audience and len(target_audience) > 0:
19
+ audience_list = ", ".join(target_audience)
20
+ audience_block = f"""
21
+ ━━━━━━━━━━━━━━━━━━
22
+ TARGET AUDIENCES (PRIORITY — MUST DRIVE THIS ANALYSIS)
23
+ ━━━━━━━━━━━━━━━━━━
24
+
25
+ The user has selected these specific audience segments. Your ENTIRE analysis MUST be tailored to these audiences.
26
+
27
+ - positioning: Reframe for these segments (who they are, what they want, how this product fits).
28
+ - ideal_customer: Age, lifestyle, platform, and pain_points must align with the selected segments—not generic "urban women" or "25-35".
29
+ - emotional_triggers: Choose triggers that resonate with these specific segments.
30
+ - ad_angles: Each angle must speak directly to one or more of these audiences; hooks and "why it works" must reference them.
31
+ - tagline_options: Lines that appeal to these segments.
32
+ - price_analysis: Frame value and perception for these buyers (e.g. COD buyers, ₹1.5k–₹3k, festive, self-gifters).
33
+ - shooting_angles, color_worlds, copy_direction: Align with how these audiences discover and consume content.
34
+
35
+ Selected segments (use these, do not substitute generic demographics):
36
+ {audience_list}
37
+
38
+ Do NOT produce a generic analysis. Every section must reflect the selected segments.
39
+ """
40
+
41
  prompt = f"""
42
  You are an elite Indian DTC jewelry performance strategist.
43
 
 
54
  Your job:
55
  Perform a DEEP conversion-focused analysis of THIS specific product
56
  that will directly power high-performing STATIC ad creatives.
57
+ {audience_block}
58
 
59
  ━━━━━━━━━━━━━━━━━━
60
  CRITICAL PERFORMANCE RULES
 
66
  - Visual direction
67
  - Hook creation
68
  - Or targeting decision.
69
+ 4. Assume this is being sold via Meta ads to global urban buyers (unless target audiences specify otherwise).
70
  5. If product data lacks detail, infer logically based on category norms.
71
  6. Think in micro-audiences, not broad demographics.
72
  7. This analysis must help generate scroll-stopping static creatives.
 
87
 
88
  - Exactly 6 emotional triggers
89
  - Exactly 5 ad angles
 
90
  - Exactly 4 shooting_angles
91
  - Exactly 3 color_worlds
92
  - Exactly 4 unique_selling_points
 
93
  - 3 tagline_options
94
 
95
  Price rules:
 
102
  ━━━━━━━━━━━━━━━━━━
103
 
104
  {{
105
+ "product_visual_notes": "When product images were provided: 2-3 short paragraphs. (1) How it looks when worn: describe how the piece sits on the wearer (ear/neck/wrist), styling context, how it frames the face or body, movement or drape. (2) Shape, size & proportions: describe shape (e.g. dangler, stud, length), scale and proportions, drop length or size of elements if visible, key design details (chains, stones, motifs). (3) Material, finish & color: metal type, finish (matte/shiny/brushed), color as seen, stones or accents. Omit or empty string if no images.",
106
  "positioning": "Clear one-line positioning statement (who + outcome + differentiation)",
107
  "tagline_options": ["Option 1", "Option 2", "Option 3"],
108
  "ideal_customer": {{
109
  "age_range": "",
110
  "lifestyle": "specific, not generic",
 
 
111
  "platform": "primary buying platform",
112
  "pain_points": ["Pain 1", "Pain 2", "Pain 3"]
113
  }},
 
125
  {{ "angle_name": "", "hook": "", "why_it_works": "" }},
126
  {{ "angle_name": "", "hook": "", "why_it_works": "" }}
127
  ],
 
128
  "shooting_angles": ["Angle 1", "Angle 2", "Angle 3", "Angle 4"],
129
  "color_worlds": ["Direction 1", "Direction 2", "Direction 3"],
130
  "copy_direction": {{
131
  "headline_style": "e.g. bold serif uppercase + elegant script",
132
+ "tone": "clear tonal direction"
 
 
133
  }},
134
+ "unique_selling_points": ["USP 1", "USP 2", "USP 3", "USP 4"]
 
 
135
  }}
136
 
137
  Return ONLY valid JSON. No markdown. No commentary.
138
  """
139
 
140
+ # First 3 product image URLs for vision (comma-split, strip, filter valid)
141
+ image_urls = [
142
+ u.strip()
143
+ for u in (product_data.get("product_images") or "").split(",")
144
+ if (u and u.strip().startswith("http"))
145
+ ][:3]
146
+
147
+ if image_urls:
148
+ vision_note = "\n\nYou are also provided with product images in this message. Describe the product in detail: (1) how it looks when worn—how it sits on the wearer, styling, frame of face/body, movement; (2) shape, size, and proportions—shape type, scale, drop length, key design details; (3) material, finish, and color as seen. Put this in product_visual_notes as 2-3 short paragraphs. Use these observations to inform your visual direction, shooting_angles, color_worlds, and overall creative recommendations."
149
+ content: list[dict] = [{"type": "text", "text": prompt + vision_note}]
150
+ for url in image_urls:
151
+ content.append({"type": "image_url", "image_url": {"url": url}})
152
+ raw = call_llm_vision(
153
+ messages=[{"role": "user", "content": content}],
154
+ model="gpt-4o-mini",
155
+ temperature=0.7,
156
+ )
157
+ else:
158
+ raw = call_llm(prompt, temperature=0.7)
159
+
160
  return extract_json(raw)
backend/app/creatives.py CHANGED
@@ -12,7 +12,7 @@ def _upcoming_festival_from_web() -> str:
12
  """Web search for upcoming Indian festival; on failure tell LLM to use date-relevant festival."""
13
  today = date.today()
14
  try:
15
- from duckduckgo_search import DDGS
16
 
17
  query = f"upcoming Indian festivals {today.strftime('%B %Y')} next festival dates"
18
  with DDGS() as ddgs:
@@ -26,18 +26,31 @@ def _upcoming_festival_from_web() -> str:
26
  return f"Use the upcoming Indian festival relevant to {today.strftime('%B %Y')} (e.g. Diwali, Holi)."
27
 
28
 
29
- def generate_ad_creatives(product_data: dict, analysis: dict) -> dict:
 
 
30
  """Generate 20 ad creative packages (17 product-focused, 3 no-product)."""
31
  price = product_data.get("price", "")
32
  product_name = product_data.get("product_name", "")
33
  today_str = date.today().strftime("%d %B %Y")
34
  festival_context = _upcoming_festival_from_web()
35
 
 
 
 
 
 
 
 
 
 
 
 
36
  prompt = f"""
37
  You are an elite, performance-driven DTC jewelry ad creative director.
38
 
39
  Your job is to generate EXACTLY 20 ad creatives.
40
-
41
  Today's date: {today_str}. For the "Current Festival" creative, use this upcoming/current festival: {festival_context}. Use it for headlines, copy, and scene using a hyper-specifc festival related elements.
42
 
43
  ━━━━━━━━━━━━━━━━━━
@@ -47,7 +60,7 @@ OUTPUT DISTRIBUTION (STRICT)
47
  PRODUCT-FOCUSED (17 total): Dark Moody ×2, Flat Lay ×1, Social Proof ×1, Witty Copy-Led ×1, Lifestyle (tight crop, realistic skin) ×1, Texture Contrast ×1, Shadow Play ×1, Gift Reveal ×1, Current Festival ×1, Price Confidence ×1, One-Word Dominance ×1, Macro Extreme Closeup ×1, Minimal ×1, Heirloom Depth ×1, Power Suit ×1, Scarcity Drop ×1.
48
  Set category to the frame name. Invent headlines, body copy, and scene prompts that fit each frame and the product—no prescribed lines.
49
 
50
- NO-PRODUCT (3 total): Instagram DM screenshot ×1, WhatsApp chat style ×1, Customer review card ×1.
51
 
52
  ━━━━━━━━━━━━━━━━━━
53
  PRODUCT-FOCUSED RULES
@@ -59,7 +72,7 @@ PRODUCT-FOCUSED RULES
59
  NO-PRODUCT RULES
60
  ━━━━━━━━━━━━━━━━━━
61
 
62
- - DO NOT show product. Image prompt should describe UI layout, screenshot style, card design, or typography layout only.
63
 
64
  ━━━━━━━━━━━━━━━━━━
65
  PRICE RULE
@@ -67,13 +80,19 @@ PRICE RULE
67
 
68
  Include price in EXACTLY 7 creatives. For those 7: price_original = final price + ₹500, price_final = {price}. All other creatives: includes_price = false. For those 7, set price_original_style to one of: strikethrough | slant | faded | small | double (vary across creatives; match style to concept). CRITICAL: Only the original (higher) price is styled with strikethrough/slant/etc.; the final (selling) price must NEVER be shown with strikethrough—it is the main price customers pay.
69
 
 
 
 
 
 
 
70
  ━━━━━━━━━━━━━━━━━━
71
  SCENE PROMPT RULES
72
  ━━━━━━━━━━━━━━━━━━
73
 
74
  For PRODUCT creatives: Describe ONLY background, lighting, framing, scene. NEVER describe the jewelry itself. End EVERY scene_prompt with: "Ensure the jewelry is the dominant focal point in sharp focus. Preserve the product exactly as shown. Square 1:1, 1080x1080px."
75
 
76
- For NO-PRODUCT creatives: Describe UI layout, typography, screenshot realism, or card structure only. Do NOT include the mandatory jewelry sentence.
77
 
78
  ━━━━━━━━━━━━━━━━━━
79
  PRODUCT DATA
@@ -109,11 +128,12 @@ Return ONLY valid JSON. No markdown. No extra text.
109
  "headline_script": "Elegant script line",
110
  "body": "1–2 lines",
111
  "product_label": "{product_name}",
112
- "price_original": "higher/compare-at price, only if includes_price=true; this one gets the strikethrough style",
113
- "price_final": "actual selling price, only if includes_price=true; never strikethrough this",
114
  "price_original_style": "strikethrough|slant|faded|small if includes_price (applies to price_original only)",
115
  "cta": "CTA"
116
  }},
 
117
  "layout_notes": "how layout reinforces message"
118
  }}
119
  ]
 
12
  """Web search for upcoming Indian festival; on failure tell LLM to use date-relevant festival."""
13
  today = date.today()
14
  try:
15
+ from ddgs import DDGS
16
 
17
  query = f"upcoming Indian festivals {today.strftime('%B %Y')} next festival dates"
18
  with DDGS() as ddgs:
 
26
  return f"Use the upcoming Indian festival relevant to {today.strftime('%B %Y')} (e.g. Diwali, Holi)."
27
 
28
 
29
+ def generate_ad_creatives(
30
+ product_data: dict, analysis: dict, target_audience: list[str] | None = None
31
+ ) -> dict:
32
  """Generate 20 ad creative packages (17 product-focused, 3 no-product)."""
33
  price = product_data.get("price", "")
34
  product_name = product_data.get("product_name", "")
35
  today_str = date.today().strftime("%d %B %Y")
36
  festival_context = _upcoming_festival_from_web()
37
 
38
+ audience_block = ""
39
+ if target_audience and len(target_audience) > 0:
40
+ audience_list = ", ".join(target_audience)
41
+ audience_block = f"""
42
+ TARGET AUDIENCES (tailor copy and concepts to these segments):
43
+ {audience_list}
44
+
45
+ Headlines, body copy, scene prompts, and CTA must speak directly to these audiences. Match tone, occasions, and value framing to the selected segments (e.g. festive buyers, self-gifters, COD/UPI, specific age/lifestyle). Do not use generic "urban women" or one-size-fits-all copy when specific segments are provided.
46
+
47
+ """
48
+
49
  prompt = f"""
50
  You are an elite, performance-driven DTC jewelry ad creative director.
51
 
52
  Your job is to generate EXACTLY 20 ad creatives.
53
+ {audience_block}
54
  Today's date: {today_str}. For the "Current Festival" creative, use this upcoming/current festival: {festival_context}. Use it for headlines, copy, and scene using a hyper-specifc festival related elements.
55
 
56
  ━━━━━━━━━━━━━━━━━━
 
60
  PRODUCT-FOCUSED (17 total): Dark Moody ×2, Flat Lay ×1, Social Proof ×1, Witty Copy-Led ×1, Lifestyle (tight crop, realistic skin) ×1, Texture Contrast ×1, Shadow Play ×1, Gift Reveal ×1, Current Festival ×1, Price Confidence ×1, One-Word Dominance ×1, Macro Extreme Closeup ×1, Minimal ×1, Heirloom Depth ×1, Power Suit ×1, Scarcity Drop ×1.
61
  Set category to the frame name. Invent headlines, body copy, and scene prompts that fit each frame and the product—no prescribed lines.
62
 
63
+ NO-PRODUCT (3 total): Instagram DM screenshot ×1, WhatsApp chat style ×1, Twitter/X post style ×1.
64
 
65
  ━━━━━━━━━━━━━━━━━━
66
  PRODUCT-FOCUSED RULES
 
72
  NO-PRODUCT RULES
73
  ━━━━━━━━━━━━━━━━━━
74
 
75
+ - DO NOT show product. Image prompt should describe UI layout, screenshot style, card design, Twitter/X post layout (handle, post text, engagement UI), or typography layout only.
76
 
77
  ━━━━━━━━━━━━━━━━━━
78
  PRICE RULE
 
80
 
81
  Include price in EXACTLY 7 creatives. For those 7: price_original = final price + ₹500, price_final = {price}. All other creatives: includes_price = false. For those 7, set price_original_style to one of: strikethrough | slant | faded | small | double (vary across creatives; match style to concept). CRITICAL: Only the original (higher) price is styled with strikethrough/slant/etc.; the final (selling) price must NEVER be shown with strikethrough—it is the main price customers pay.
82
 
83
+ ━━━━━━━━━━━━━━━━━━
84
+ CTA POSITION
85
+ ━━━━━━━━━━━━━━━━━━
86
+
87
+ Vary CTA placement across creatives. Set "cta_position" to exactly one of: bottom_center | bottom_left | bottom_right | top_center | top_left | top_right. Do not put every CTA in bottom_center—use corners and top positions too so we get layout variety.
88
+
89
  ━━━━━━━━━━━━━━━━━━
90
  SCENE PROMPT RULES
91
  ━━━━━━━━━━━━━━━━━━
92
 
93
  For PRODUCT creatives: Describe ONLY background, lighting, framing, scene. NEVER describe the jewelry itself. End EVERY scene_prompt with: "Ensure the jewelry is the dominant focal point in sharp focus. Preserve the product exactly as shown. Square 1:1, 1080x1080px."
94
 
95
+ For NO-PRODUCT creatives: Describe UI layout, typography, screenshot realism, card structure, or Twitter/X post layout (tweet card, handle, text, likes/retweets) only. Do NOT include the mandatory jewelry sentence.
96
 
97
  ━━━━━━━━━━━━━━━━━━
98
  PRODUCT DATA
 
128
  "headline_script": "Elegant script line",
129
  "body": "1–2 lines",
130
  "product_label": "{product_name}",
131
+ "price_original": "higher/compare-at price, same currency as price_final (₹ or Rs); strikethrough style",
132
+ "price_final": "selling price only, plain text (e.g. ₹2,499). Same currency as price_original. Never include strikethrough, Unicode strikethrough, or any decoration in this value.",
133
  "price_original_style": "strikethrough|slant|faded|small if includes_price (applies to price_original only)",
134
  "cta": "CTA"
135
  }},
136
+ "cta_position": "bottom_center | bottom_left | bottom_right | top_center | top_left | top_right (vary across creatives)",
137
  "layout_notes": "how layout reinforces message"
138
  }}
139
  ]
backend/app/llm.py CHANGED
@@ -69,3 +69,33 @@ def call_llm(
69
  err_msg = str(last_error)
70
  raise RuntimeError(f"LLM call failed after {max_retries} attempts: {err_msg}") from last_error
71
  return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  err_msg = str(last_error)
70
  raise RuntimeError(f"LLM call failed after {max_retries} attempts: {err_msg}") from last_error
71
  return ""
72
+
73
+
74
+ def call_llm_vision(
75
+ messages: list[dict],
76
+ model: str = "gpt-4o-mini",
77
+ temperature: float = 0.85,
78
+ max_retries: int = 3,
79
+ retry_delay: float = 2.0,
80
+ ) -> str:
81
+ """Calls the OpenAI Chat API with messages that may include image_url content parts.
82
+ messages: list of message dicts, e.g. [{"role": "user", "content": [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {"url": "https://..."}}]}].
83
+ Returns the assistant's text content."""
84
+ client = get_client()
85
+ for attempt in range(1, max_retries + 1):
86
+ try:
87
+ response = client.chat.completions.create(
88
+ model=model,
89
+ messages=messages,
90
+ temperature=temperature,
91
+ )
92
+ content = response.choices[0].message.content
93
+ return (content or "").strip()
94
+ except Exception as e:
95
+ last_error = e
96
+ if attempt < max_retries:
97
+ time.sleep(retry_delay * attempt)
98
+ else:
99
+ err_msg = str(last_error)
100
+ raise RuntimeError(f"LLM call failed after {max_retries} attempts: {err_msg}") from last_error
101
+ return ""
backend/app/main.py CHANGED
@@ -94,13 +94,15 @@ class LoginRequest(BaseModel):
94
 
95
  class RunPipelineRequest(BaseModel):
96
  url: str # Amalfa product page URL
 
97
 
98
 
99
  class GenerateAdsRequest(BaseModel):
100
  """Each creative is the full creative JSON (id, concept_name, creative_type, scene_prompt, ad_copy, etc.). Sent as the full "image prompt" (whole JSON) to the model."""
101
  creatives: list[dict] # full creative object per item; must have "id" and "scene_prompt"
102
  model_key: str = "nano-banana"
103
- product_image_url: str | None = None # from scraped product (default reference)
 
104
  reference_image_url: str | None = None # user's own image URL (overrides product)
105
  product_name: str | None = None # for gallery metadata
106
 
@@ -155,11 +157,11 @@ def run_pipeline(body: RunPipelineRequest, _user: dict = Depends(get_current_use
155
  except Exception as e:
156
  raise HTTPException(status_code=400, detail=f"Scrape failed: {str(e)}")
157
  try:
158
- analysis = analyze_product(product_data)
159
  except Exception as e:
160
  raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
161
  try:
162
- creatives = generate_ad_creatives(product_data, analysis)
163
  except Exception as e:
164
  raise HTTPException(status_code=500, detail=f"Creatives generation failed: {str(e)}")
165
  return {
@@ -182,7 +184,7 @@ def _sse_message(obj: dict) -> str:
182
  return f"data: {json.dumps(obj)}\n\n"
183
 
184
 
185
- def _stream_pipeline(url: str):
186
  """Yield SSE events as pipeline runs: step updates then scrape_done, analysis_done, creatives_done."""
187
  if not os.environ.get("OPENAI_API_KEY"):
188
  yield _sse_message({"event": "error", "message": "OPENAI_API_KEY is not set"})
@@ -193,11 +195,11 @@ def _stream_pipeline(url: str):
193
  yield _sse_message({"event": "scrape_done", "data": product_data})
194
 
195
  yield _sse_message({"event": "step", "step": "analysis", "message": "Running marketing analysis…"})
196
- analysis = analyze_product(product_data)
197
  yield _sse_message({"event": "analysis_done", "data": analysis})
198
 
199
  yield _sse_message({"event": "step", "step": "creatives", "message": "Generating ad creatives…"})
200
- creatives = generate_ad_creatives(product_data, analysis)
201
  yield _sse_message({"event": "creatives_done", "data": creatives.get("ad_creatives", [])})
202
 
203
  yield _sse_message({"event": "done"})
@@ -212,7 +214,7 @@ def run_pipeline_stream(body: RunPipelineRequest, _user: dict = Depends(get_curr
212
  if not url:
213
  raise HTTPException(status_code=400, detail="URL is required")
214
  return StreamingResponse(
215
- _stream_pipeline(url),
216
  media_type="text/event-stream",
217
  headers={
218
  "Cache-Control": "no-cache",
@@ -261,14 +263,20 @@ async def _save_creative_to_r2(replicate_url: str, creative_id: int, concept_nam
261
 
262
 
263
  def _build_reference_urls(
264
- reference_url: str | None,
 
265
  model_key: str,
266
  request: Request | None,
267
  ) -> list[str]:
268
- """Build list of reference image URLs. For nano-banana: product/reference + Amalfa logo in every creative."""
269
- urls = []
270
- if reference_url:
271
- urls.append(reference_url)
 
 
 
 
 
272
  if model_key in REFERENCE_IMAGE_MODELS and LOGO_PATH.exists() and request is not None:
273
  base = _reference_image_base_url(request)
274
  logo_url = f"{base.rstrip('/')}/api/logo"
@@ -276,6 +284,30 @@ def _build_reference_urls(
276
  return urls
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  def _delete_reference_files_after_use(reference_urls: list[str]) -> None:
280
  """Remove uploaded reference images from disk after generation (do not store after use)."""
281
  for url in reference_urls or []:
@@ -308,7 +340,11 @@ async def generate_ads(
308
  raise HTTPException(status_code=400, detail=f"Unknown model: {body.model_key}")
309
 
310
  ref_single = (body.reference_image_url or body.product_image_url or "").strip() or None
311
- reference_urls = _build_reference_urls(ref_single, body.model_key, request)
 
 
 
 
312
  sem = asyncio.Semaphore(GENERATE_ADS_CONCURRENCY)
313
  username = (_user or {}).get("username", "")
314
 
@@ -339,6 +375,7 @@ async def generate_ads(
339
  if sp and not sp.endswith("."):
340
  sp += "."
341
  payload["scene_prompt"] = f"{sp} Include the Amalfa logo visibly in the composition."
 
342
  prompt_for_model = json.dumps(payload, indent=2)
343
  async with sem:
344
  url, err = await generate_image(
@@ -384,7 +421,11 @@ async def _stream_generate_ads(
384
  yield _sse_message({"event": "error", "message": f"Unknown model: {body.model_key}"})
385
  return
386
  ref_single = (body.reference_image_url or body.product_image_url or "").strip() or None
387
- reference_urls = _build_reference_urls(ref_single, body.model_key, request)
 
 
 
 
388
  sem = asyncio.Semaphore(GENERATE_ADS_CONCURRENCY)
389
 
390
  async def process_one(item: dict):
@@ -407,13 +448,14 @@ async def _stream_generate_ads(
407
  ad_copy = dict(payload["ad_copy"])
408
  ad_copy.pop("body", None)
409
  payload["ad_copy"] = ad_copy
410
- # For multi-reference models: instruct to include Amalfa logo in the image
411
  if body.model_key in REFERENCE_IMAGE_MODELS and len(reference_urls) > 1:
412
  payload = dict(payload)
413
  sp = (payload.get("scene_prompt") or "").strip()
414
  if sp and not sp.endswith("."):
415
  sp += "."
416
  payload["scene_prompt"] = f"{sp} Include the Amalfa logo visibly in the composition."
 
417
  prompt_for_model = json.dumps(payload, indent=2)
418
  async with sem:
419
  url, err = await generate_image(
 
94
 
95
  class RunPipelineRequest(BaseModel):
96
  url: str # Amalfa product page URL
97
+ target_audience: list[str] | None = None # Optional audience segments for analysis/creatives (multi-select)
98
 
99
 
100
  class GenerateAdsRequest(BaseModel):
101
  """Each creative is the full creative JSON (id, concept_name, creative_type, scene_prompt, ad_copy, etc.). Sent as the full "image prompt" (whole JSON) to the model."""
102
  creatives: list[dict] # full creative object per item; must have "id" and "scene_prompt"
103
  model_key: str = "nano-banana"
104
+ product_image_url: str | None = None # from scraped product (single reference; used when product_image_urls not provided)
105
+ product_image_urls: list[str] | None = None # up to 3 product reference images (better results when provided)
106
  reference_image_url: str | None = None # user's own image URL (overrides product)
107
  product_name: str | None = None # for gallery metadata
108
 
 
157
  except Exception as e:
158
  raise HTTPException(status_code=400, detail=f"Scrape failed: {str(e)}")
159
  try:
160
+ analysis = analyze_product(product_data, target_audience=body.target_audience)
161
  except Exception as e:
162
  raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
163
  try:
164
+ creatives = generate_ad_creatives(product_data, analysis, target_audience=body.target_audience)
165
  except Exception as e:
166
  raise HTTPException(status_code=500, detail=f"Creatives generation failed: {str(e)}")
167
  return {
 
184
  return f"data: {json.dumps(obj)}\n\n"
185
 
186
 
187
+ def _stream_pipeline(url: str, target_audience: list[str] | None = None):
188
  """Yield SSE events as pipeline runs: step updates then scrape_done, analysis_done, creatives_done."""
189
  if not os.environ.get("OPENAI_API_KEY"):
190
  yield _sse_message({"event": "error", "message": "OPENAI_API_KEY is not set"})
 
195
  yield _sse_message({"event": "scrape_done", "data": product_data})
196
 
197
  yield _sse_message({"event": "step", "step": "analysis", "message": "Running marketing analysis…"})
198
+ analysis = analyze_product(product_data, target_audience=target_audience)
199
  yield _sse_message({"event": "analysis_done", "data": analysis})
200
 
201
  yield _sse_message({"event": "step", "step": "creatives", "message": "Generating ad creatives…"})
202
+ creatives = generate_ad_creatives(product_data, analysis, target_audience=target_audience)
203
  yield _sse_message({"event": "creatives_done", "data": creatives.get("ad_creatives", [])})
204
 
205
  yield _sse_message({"event": "done"})
 
214
  if not url:
215
  raise HTTPException(status_code=400, detail="URL is required")
216
  return StreamingResponse(
217
+ _stream_pipeline(url, target_audience=body.target_audience),
218
  media_type="text/event-stream",
219
  headers={
220
  "Cache-Control": "no-cache",
 
263
 
264
 
265
  def _build_reference_urls(
266
+ product_image_urls: list[str] | None,
267
+ single_reference_url: str | None,
268
  model_key: str,
269
  request: Request | None,
270
  ) -> list[str]:
271
+ """Build list of reference image URLs (up to 3 product images, then logo). Sending 3 refs gives better results."""
272
+ urls: list[str] = []
273
+ if product_image_urls:
274
+ for u in product_image_urls[:3]:
275
+ u = (u or "").strip()
276
+ if u.startswith("http") and u not in urls:
277
+ urls.append(u)
278
+ if not urls and single_reference_url:
279
+ urls.append(single_reference_url)
280
  if model_key in REFERENCE_IMAGE_MODELS and LOGO_PATH.exists() and request is not None:
281
  base = _reference_image_base_url(request)
282
  logo_url = f"{base.rstrip('/')}/api/logo"
 
284
  return urls
285
 
286
 
287
+ CTA_POSITION_VALUES = frozenset({
288
+ "bottom_center", "bottom_left", "bottom_right",
289
+ "top_center", "top_left", "top_right",
290
+ })
291
+
292
+
293
+ def _inject_cta_position_into_scene(payload: dict) -> None:
294
+ """If payload has cta_position, append a short instruction to scene_prompt so the image model places CTA there."""
295
+ pos = (payload.get("cta_position") or "").strip()
296
+ if pos not in CTA_POSITION_VALUES:
297
+ return
298
+ sp = (payload.get("scene_prompt") or "").strip()
299
+ if not sp:
300
+ return
301
+ if sp.endswith("."):
302
+ sp += " "
303
+ else:
304
+ sp += ". "
305
+ # Human-readable for the model, e.g. "bottom left", "top right"
306
+ pos_readable = pos.replace("_", " ")
307
+ sp += f"Place the CTA button at {pos_readable}."
308
+ payload["scene_prompt"] = sp
309
+
310
+
311
  def _delete_reference_files_after_use(reference_urls: list[str]) -> None:
312
  """Remove uploaded reference images from disk after generation (do not store after use)."""
313
  for url in reference_urls or []:
 
340
  raise HTTPException(status_code=400, detail=f"Unknown model: {body.model_key}")
341
 
342
  ref_single = (body.reference_image_url or body.product_image_url or "").strip() or None
343
+ if body.reference_image_url:
344
+ product_urls = None
345
+ else:
346
+ product_urls = [u.strip() for u in (body.product_image_urls or []) if (u and u.strip().startswith("http"))][:3]
347
+ reference_urls = _build_reference_urls(product_urls, ref_single, body.model_key, request)
348
  sem = asyncio.Semaphore(GENERATE_ADS_CONCURRENCY)
349
  username = (_user or {}).get("username", "")
350
 
 
375
  if sp and not sp.endswith("."):
376
  sp += "."
377
  payload["scene_prompt"] = f"{sp} Include the Amalfa logo visibly in the composition."
378
+ _inject_cta_position_into_scene(payload)
379
  prompt_for_model = json.dumps(payload, indent=2)
380
  async with sem:
381
  url, err = await generate_image(
 
421
  yield _sse_message({"event": "error", "message": f"Unknown model: {body.model_key}"})
422
  return
423
  ref_single = (body.reference_image_url or body.product_image_url or "").strip() or None
424
+ if body.reference_image_url:
425
+ product_urls = None
426
+ else:
427
+ product_urls = [u.strip() for u in (body.product_image_urls or []) if (u and u.strip().startswith("http"))][:3]
428
+ reference_urls = _build_reference_urls(product_urls, ref_single, body.model_key, request)
429
  sem = asyncio.Semaphore(GENERATE_ADS_CONCURRENCY)
430
 
431
  async def process_one(item: dict):
 
448
  ad_copy = dict(payload["ad_copy"])
449
  ad_copy.pop("body", None)
450
  payload["ad_copy"] = ad_copy
451
+ # For multi-reference models: instruct to include Amalfa logo when we have multiple refs
452
  if body.model_key in REFERENCE_IMAGE_MODELS and len(reference_urls) > 1:
453
  payload = dict(payload)
454
  sp = (payload.get("scene_prompt") or "").strip()
455
  if sp and not sp.endswith("."):
456
  sp += "."
457
  payload["scene_prompt"] = f"{sp} Include the Amalfa logo visibly in the composition."
458
+ _inject_cta_position_into_scene(payload)
459
  prompt_for_model = json.dumps(payload, indent=2)
460
  async with sem:
461
  url, err = await generate_image(
backend/app/r2.py CHANGED
@@ -11,8 +11,14 @@ from typing import Optional
11
  # Presigned URL expiry: 7 days (max for sigv4 is 7 days)
12
  PRESIGNED_EXPIRY = 7 * 24 * 3600
13
 
 
 
 
14
 
15
  def _get_client():
 
 
 
16
  import boto3
17
  endpoint = os.environ.get("R2_ENDPOINT", "").strip()
18
  bucket = os.environ.get("R2_BUCKET_NAME", "").strip()
@@ -27,7 +33,8 @@ def _get_client():
27
  aws_secret_access_key=secret,
28
  region_name="auto",
29
  )
30
- return client, bucket
 
31
 
32
 
33
  def upload_creative_image(
@@ -95,7 +102,11 @@ def get_object(r2_key: str) -> Optional[bytes]:
95
  return None
96
  try:
97
  resp = client.get_object(Bucket=bucket, Key=r2_key)
98
- return resp["Body"].read()
 
 
 
 
99
  except Exception:
100
  return None
101
 
 
11
  # Presigned URL expiry: 7 days (max for sigv4 is 7 days)
12
  PRESIGNED_EXPIRY = 7 * 24 * 3600
13
 
14
+ # Reuse a single boto3 client to avoid creating new connections every call (prevents ResourceWarning for unclosed SSL sockets)
15
+ _client_cache: Optional[tuple] = None # (client, bucket) or None
16
+
17
 
18
  def _get_client():
19
+ global _client_cache
20
+ if _client_cache is not None:
21
+ return _client_cache
22
  import boto3
23
  endpoint = os.environ.get("R2_ENDPOINT", "").strip()
24
  bucket = os.environ.get("R2_BUCKET_NAME", "").strip()
 
33
  aws_secret_access_key=secret,
34
  region_name="auto",
35
  )
36
+ _client_cache = (client, bucket)
37
+ return _client_cache
38
 
39
 
40
  def upload_creative_image(
 
102
  return None
103
  try:
104
  resp = client.get_object(Bucket=bucket, Key=r2_key)
105
+ body = resp["Body"]
106
+ try:
107
+ return body.read()
108
+ finally:
109
+ body.close()
110
  except Exception:
111
  return None
112
 
backend/app/replicate_image.py CHANGED
@@ -14,7 +14,7 @@ import replicate
14
  from replicate.exceptions import ReplicateError
15
 
16
  # Replicate model registry: image-to-image models only (reference image support)
17
- REFERENCE_IMAGE_MODELS = {"nano-banana", "nano-banana-pro"}
18
 
19
  MODEL_REGISTRY: dict[str, dict[str, Any]] = {
20
  "nano-banana": {
@@ -27,6 +27,14 @@ MODEL_REGISTRY: dict[str, dict[str, Any]] = {
27
  "param_name": "aspect_ratio",
28
  "uses_dimensions": False,
29
  },
 
 
 
 
 
 
 
 
30
  # "flux-2-max": {
31
  # "id": "black-forest-labs/flux-2-max",
32
  # "param_name": "aspect_ratio",
@@ -169,7 +177,11 @@ def generate_image_sync(
169
  input_data = {"prompt": prompt, "seed": seed}
170
  urls = [u for u in (reference_image_urls or []) if u and isinstance(u, str) and u.strip()]
171
  if urls and model_key in REFERENCE_IMAGE_MODELS:
172
- input_data["image_input"] = urls
 
 
 
 
173
  if cfg.get("uses_dimensions"):
174
  input_data["width"] = width
175
  input_data["height"] = height
 
14
  from replicate.exceptions import ReplicateError
15
 
16
  # Replicate model registry: image-to-image models only (reference image support)
17
+ REFERENCE_IMAGE_MODELS = {"nano-banana", "nano-banana-pro", "grok-imagine"}
18
 
19
  MODEL_REGISTRY: dict[str, dict[str, Any]] = {
20
  "nano-banana": {
 
27
  "param_name": "aspect_ratio",
28
  "uses_dimensions": False,
29
  },
30
+ "grok-imagine": {
31
+ "id": "xai/grok-imagine-image",
32
+ "param_name": "aspect_ratio",
33
+ "uses_dimensions": False,
34
+ # Replicate expects single "image" (uri) for editing; not "image_input" list
35
+ "reference_key": "image",
36
+ "reference_single": True,
37
+ },
38
  # "flux-2-max": {
39
  # "id": "black-forest-labs/flux-2-max",
40
  # "param_name": "aspect_ratio",
 
177
  input_data = {"prompt": prompt, "seed": seed}
178
  urls = [u for u in (reference_image_urls or []) if u and isinstance(u, str) and u.strip()]
179
  if urls and model_key in REFERENCE_IMAGE_MODELS:
180
+ ref_key = cfg.get("reference_key") or "image_input"
181
+ if cfg.get("reference_single"):
182
+ input_data[ref_key] = urls[0]
183
+ else:
184
+ input_data[ref_key] = urls
185
  if cfg.get("uses_dimensions"):
186
  input_data["width"] = width
187
  input_data["height"] = height
backend/app/scraper.py CHANGED
@@ -75,7 +75,7 @@ def scrape_product(url: str) -> dict[str, Any]:
75
  product["price"] = str(data["offers"][0].get("price", ""))
76
  if data.get("image"):
77
  imgs = data["image"] if isinstance(data["image"], list) else [data["image"]]
78
- product["product_images"] = ", ".join(str(u).strip() for u in imgs[:10] if u)
79
  if product["product_name"] and product["price"]:
80
  break
81
  except (json.JSONDecodeError, TypeError):
@@ -119,15 +119,36 @@ def scrape_product(url: str) -> dict[str, Any]:
119
  desc_el.get_text() if hasattr(desc_el, "get_text") else (desc_el.get("content") or "")
120
  )
121
 
122
- if not product["product_images"]:
123
- seen = set()
124
- for img in soup.select("img[src*='cdn.shopify'], img[data-src*='shopify'], img[src*='amalfa']")[:20]:
125
- if len(seen) >= 10:
126
- break
127
- src = (img.get("data-src") or img.get("src") or "").split("?")[0].strip()
128
- if src and src.startswith("http") and src not in seen:
129
- seen.add(src)
130
- product["product_images"] = (product["product_images"] + ", " + src).strip(", ")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  path = (parsed.path or "").lower()
133
  if "earring" in path:
@@ -144,4 +165,11 @@ def scrape_product(url: str) -> dict[str, Any]:
144
  if not product["category"]:
145
  product["category"] = "Jewellery"
146
 
 
 
 
 
 
 
 
147
  return product
 
75
  product["price"] = str(data["offers"][0].get("price", ""))
76
  if data.get("image"):
77
  imgs = data["image"] if isinstance(data["image"], list) else [data["image"]]
78
+ product["product_images"] = ", ".join(str(u).strip() for u in imgs[:9] if u)
79
  if product["product_name"] and product["price"]:
80
  break
81
  except (json.JSONDecodeError, TypeError):
 
119
  desc_el.get_text() if hasattr(desc_el, "get_text") else (desc_el.get("content") or "")
120
  )
121
 
122
+ # Shopify product JSON has the full images list (primary source for product images)
123
+ path_parts = (parsed.path or "").strip("/").split("/")
124
+ if path_parts and path_parts[0] == "products" and len(path_parts) >= 2:
125
+ handle = path_parts[1]
126
+ product_json_url = f"{parsed.scheme}://{parsed.netloc}/products/{handle}.json"
127
+ try:
128
+ r = requests.get(product_json_url, headers={**headers, "Accept": "application/json"}, timeout=10)
129
+ if r.ok:
130
+ data = r.json()
131
+ # Shopify Ajax API: root is the product object, or wrapped as {"product": {...}}
132
+ prod = data.get("product") if isinstance(data.get("product"), dict) else data
133
+ if isinstance(prod, dict):
134
+ images = prod.get("images")
135
+ if isinstance(images, list) and len(images) >= 1:
136
+ urls = []
137
+ for img in images[:9]:
138
+ u = None
139
+ if isinstance(img, dict) and img.get("src"):
140
+ u = (img.get("src") or "").strip()
141
+ elif isinstance(img, str) and img.strip():
142
+ u = img.strip()
143
+ if u:
144
+ if u.startswith("//"):
145
+ u = "https:" + u
146
+ if u.startswith("http") and u not in urls:
147
+ urls.append(u)
148
+ if urls:
149
+ product["product_images"] = ", ".join(urls)
150
+ except (requests.RequestException, ValueError, KeyError):
151
+ pass
152
 
153
  path = (parsed.path or "").lower()
154
  if "earring" in path:
 
165
  if not product["category"]:
166
  product["category"] = "Jewellery"
167
 
168
+ # Log scraped data for verification (especially product images)
169
+ _images = [u.strip() for u in (product.get("product_images") or "").split(",") if u.strip()]
170
+ print(
171
+ "[scraper] product_name=%r category=%r | product_images count=%d | urls=%s"
172
+ % (product.get("product_name"), product.get("category"), len(_images), _images)
173
+ )
174
+
175
  return product
backend/data/gallery/admin.json CHANGED
@@ -736,5 +736,626 @@
736
  "creative_id": 9,
737
  "product_name": "Blue Necklace for Women \u2013 Sapphire Diamond V-Curve Design - Amalfa",
738
  "created_at": "2026-02-23T12:19:17.750724+00:00"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  }
740
  ]
 
736
  "creative_id": 9,
737
  "product_name": "Blue Necklace for Women \u2013 Sapphire Diamond V-Curve Design - Amalfa",
738
  "created_at": "2026-02-23T12:19:17.750724+00:00"
739
+ },
740
+ {
741
+ "id": "6e745b4d-bb2a-4adb-b237-bcd19328fdd7",
742
+ "username": "admin",
743
+ "r2_key": "creatives/14-Minimalist-Approach-4ea7a20c.png",
744
+ "concept_name": "Minimalist Approach",
745
+ "creative_id": 14,
746
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
747
+ "created_at": "2026-02-24T09:42:03.929805+00:00"
748
+ },
749
+ {
750
+ "id": "7bede04f-d8a7-4048-b864-a9e65264d484",
751
+ "username": "admin",
752
+ "r2_key": "creatives/1-Dark-Moody-Glam-1a1e3e30.png",
753
+ "concept_name": "Dark Moody Glam",
754
+ "creative_id": 1,
755
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
756
+ "created_at": "2026-02-24T09:42:03.961672+00:00"
757
+ },
758
+ {
759
+ "id": "f17febdf-5a55-42f2-8b9e-887b63fdbb0a",
760
+ "username": "admin",
761
+ "r2_key": "creatives/3-Flat-Lay-Elegance-6e16b88d.png",
762
+ "concept_name": "Flat Lay Elegance",
763
+ "creative_id": 3,
764
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
765
+ "created_at": "2026-02-24T09:42:06.589406+00:00"
766
+ },
767
+ {
768
+ "id": "bea60965-1cf8-468d-babe-33c39244eb2a",
769
+ "username": "admin",
770
+ "r2_key": "creatives/15-Heirloom-Depth-c3ba5bc3.png",
771
+ "concept_name": "Heirloom Depth",
772
+ "creative_id": 15,
773
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
774
+ "created_at": "2026-02-24T09:42:18.774940+00:00"
775
+ },
776
+ {
777
+ "id": "64695c6f-5c92-448c-a0b5-be55cb727eb7",
778
+ "username": "admin",
779
+ "r2_key": "creatives/4-Social-Proof-Spotlight-57e68f76.png",
780
+ "concept_name": "Social Proof Spotlight",
781
+ "creative_id": 4,
782
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
783
+ "created_at": "2026-02-24T09:42:19.087703+00:00"
784
+ },
785
+ {
786
+ "id": "a5f3a6b6-eb33-4665-be2e-5bab1c207653",
787
+ "username": "admin",
788
+ "r2_key": "creatives/9-Gift-Reveal-Surprise-62e4246b.png",
789
+ "concept_name": "Gift Reveal Surprise",
790
+ "creative_id": 9,
791
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
792
+ "created_at": "2026-02-24T09:42:20.148585+00:00"
793
+ },
794
+ {
795
+ "id": "7a0b2e2b-96b2-475b-ad75-a494d5903496",
796
+ "username": "admin",
797
+ "r2_key": "creatives/10-Current-Festival-Glam-7c19e717.png",
798
+ "concept_name": "Current Festival Glam",
799
+ "creative_id": 10,
800
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
801
+ "created_at": "2026-02-24T09:42:29.236596+00:00"
802
+ },
803
+ {
804
+ "id": "ea3698fe-c531-4274-b521-544c08d9883e",
805
+ "username": "admin",
806
+ "r2_key": "creatives/5-Witty-Copy-Led-Charm-37943b47.png",
807
+ "concept_name": "Witty Copy-Led Charm",
808
+ "creative_id": 5,
809
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
810
+ "created_at": "2026-02-24T09:42:30.671569+00:00"
811
+ },
812
+ {
813
+ "id": "7e7f691a-b63d-4652-9917-c0c6458fa118",
814
+ "username": "admin",
815
+ "r2_key": "creatives/16-Power-Suit-Chic-2760bbfe.png",
816
+ "concept_name": "Power Suit Chic",
817
+ "creative_id": 16,
818
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
819
+ "created_at": "2026-02-24T09:42:40.696472+00:00"
820
+ },
821
+ {
822
+ "id": "2859010d-0523-498e-8edb-41598c3ffb8e",
823
+ "username": "admin",
824
+ "r2_key": "creatives/11-Price-Confidence-High-0989c0bd.png",
825
+ "concept_name": "Price Confidence High",
826
+ "creative_id": 11,
827
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
828
+ "created_at": "2026-02-24T09:42:41.085659+00:00"
829
+ },
830
+ {
831
+ "id": "4bd85468-a91f-4d6d-b0e7-9f747239ceb9",
832
+ "username": "admin",
833
+ "r2_key": "creatives/17-Scarcity-Drop-0e221b5a.png",
834
+ "concept_name": "Scarcity Drop",
835
+ "creative_id": 17,
836
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
837
+ "created_at": "2026-02-24T09:42:46.229732+00:00"
838
+ },
839
+ {
840
+ "id": "2bc0a8c7-b1a9-497a-8279-131f5fcdf76c",
841
+ "username": "admin",
842
+ "r2_key": "creatives/6-Lifestyle-Chic-85afdf64.png",
843
+ "concept_name": "Lifestyle Chic",
844
+ "creative_id": 6,
845
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
846
+ "created_at": "2026-02-24T09:42:51.349440+00:00"
847
+ },
848
+ {
849
+ "id": "3b1d7789-7b37-4475-89ec-16edcaaf1edf",
850
+ "username": "admin",
851
+ "r2_key": "creatives/12-One-Word-Dominance-3f9cdd6d.png",
852
+ "concept_name": "One-Word Dominance",
853
+ "creative_id": 12,
854
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
855
+ "created_at": "2026-02-24T09:42:54.209877+00:00"
856
+ },
857
+ {
858
+ "id": "745bb348-6366-4497-923d-c806447a8957",
859
+ "username": "admin",
860
+ "r2_key": "creatives/7-Texture-Contrast-91cc606f.png",
861
+ "concept_name": "Texture Contrast",
862
+ "creative_id": 7,
863
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
864
+ "created_at": "2026-02-24T09:43:01.462742+00:00"
865
+ },
866
+ {
867
+ "id": "508a8cf1-6b25-43d7-80fa-0e72cb0d8271",
868
+ "username": "admin",
869
+ "r2_key": "creatives/13-Macro-Extreme-Closeup-651eb75e.png",
870
+ "concept_name": "Macro Extreme Closeup",
871
+ "creative_id": 13,
872
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
873
+ "created_at": "2026-02-24T09:43:05.123687+00:00"
874
+ },
875
+ {
876
+ "id": "f6a66f16-b8a3-403b-94fb-3196b2d5a3a4",
877
+ "username": "admin",
878
+ "r2_key": "creatives/2-Dark-Moody-Social-Vibe-6d2d3de7.png",
879
+ "concept_name": "Dark Moody Social Vibe",
880
+ "creative_id": 2,
881
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
882
+ "created_at": "2026-02-24T09:43:07.851582+00:00"
883
+ },
884
+ {
885
+ "id": "f9be4c0d-7608-42c6-8ff7-35aeb9898fc5",
886
+ "username": "admin",
887
+ "r2_key": "creatives/8-Shadow-Play-c4deda87.png",
888
+ "concept_name": "Shadow Play",
889
+ "creative_id": 8,
890
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
891
+ "created_at": "2026-02-24T09:43:11.405852+00:00"
892
+ },
893
+ {
894
+ "id": "4f27fdba-65e8-4d7a-976d-00d4a2919813",
895
+ "username": "admin",
896
+ "r2_key": "creatives/19-WhatsApp-Chat-Style-Inquiry-15fb01ed.png",
897
+ "concept_name": "WhatsApp Chat Style Inquiry",
898
+ "creative_id": 19,
899
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
900
+ "created_at": "2026-02-24T09:43:16.715506+00:00"
901
+ },
902
+ {
903
+ "id": "785980b5-6dda-4015-8acd-40c4f2b66682",
904
+ "username": "admin",
905
+ "r2_key": "creatives/20-Customer-Review-Card-89e5c2c9.png",
906
+ "concept_name": "Customer Review Card",
907
+ "creative_id": 20,
908
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
909
+ "created_at": "2026-02-24T09:43:19.206733+00:00"
910
+ },
911
+ {
912
+ "id": "8765c0e9-cabd-4d69-8af1-e7267e4d3504",
913
+ "username": "admin",
914
+ "r2_key": "creatives/18-Instagram-DM---Customer-Inquiry-cb8015f8.png",
915
+ "concept_name": "Instagram DM - Customer Inquiry",
916
+ "creative_id": 18,
917
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
918
+ "created_at": "2026-02-24T09:43:24.054305+00:00"
919
+ },
920
+ {
921
+ "id": "e779a1a4-e990-45f2-9a76-703e5c695d67",
922
+ "username": "admin",
923
+ "r2_key": "creatives/2-Dark-Moody-5e9c5a30.png",
924
+ "concept_name": "Dark Moody",
925
+ "creative_id": 2,
926
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
927
+ "created_at": "2026-02-24T10:17:32.198305+00:00"
928
+ },
929
+ {
930
+ "id": "1da2f0cc-aebf-4403-b554-c3ef41a40dd1",
931
+ "username": "admin",
932
+ "r2_key": "creatives/13-Macro-Extreme-Closeup-a35a0891.png",
933
+ "concept_name": "Macro Extreme Closeup",
934
+ "creative_id": 13,
935
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
936
+ "created_at": "2026-02-24T10:17:33.980116+00:00"
937
+ },
938
+ {
939
+ "id": "1c8bfabd-04ec-48ed-9b92-582bd974e897",
940
+ "username": "admin",
941
+ "r2_key": "creatives/6-Lifestyle-tight-crop-b544f17a.png",
942
+ "concept_name": "Lifestyle (tight crop)",
943
+ "creative_id": 6,
944
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
945
+ "created_at": "2026-02-24T10:17:37.519854+00:00"
946
+ },
947
+ {
948
+ "id": "92520dfc-8413-47a7-8886-02177b086fee",
949
+ "username": "admin",
950
+ "r2_key": "creatives/9-Gift-Reveal-46faee3e.png",
951
+ "concept_name": "Gift Reveal",
952
+ "creative_id": 9,
953
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
954
+ "created_at": "2026-02-24T10:17:46.424925+00:00"
955
+ },
956
+ {
957
+ "id": "ba2a758a-8997-4373-ab11-5d5da5442dd5",
958
+ "username": "admin",
959
+ "r2_key": "creatives/5-Witty-Copy-Led-01e8af7e.png",
960
+ "concept_name": "Witty Copy-Led",
961
+ "creative_id": 5,
962
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
963
+ "created_at": "2026-02-24T10:17:48.823124+00:00"
964
+ },
965
+ {
966
+ "id": "6525db1c-0b89-466b-a37f-31dd654faccb",
967
+ "username": "admin",
968
+ "r2_key": "creatives/16-Power-Suit-5b601f35.png",
969
+ "concept_name": "Power Suit",
970
+ "creative_id": 16,
971
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
972
+ "created_at": "2026-02-24T10:17:48.903056+00:00"
973
+ },
974
+ {
975
+ "id": "8aff2830-d0d8-4aa0-ae7a-df0468d00151",
976
+ "username": "admin",
977
+ "r2_key": "creatives/1-Dark-Moody-daaf89fc.png",
978
+ "concept_name": "Dark Moody",
979
+ "creative_id": 1,
980
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
981
+ "created_at": "2026-02-24T10:18:00.348071+00:00"
982
+ },
983
+ {
984
+ "id": "77e32f63-58f5-4698-9420-8ed34feb1020",
985
+ "username": "admin",
986
+ "r2_key": "creatives/8-Shadow-Play-a926da7e.png",
987
+ "concept_name": "Shadow Play",
988
+ "creative_id": 8,
989
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
990
+ "created_at": "2026-02-24T10:18:04.896400+00:00"
991
+ },
992
+ {
993
+ "id": "fffc0377-e16d-433a-8ad8-105f5912bd2f",
994
+ "username": "admin",
995
+ "r2_key": "creatives/12-One-Word-Dominance-926a030c.png",
996
+ "concept_name": "One-Word Dominance",
997
+ "creative_id": 12,
998
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
999
+ "created_at": "2026-02-24T10:18:08.775630+00:00"
1000
+ },
1001
+ {
1002
+ "id": "a07d35ce-42ee-402f-acaa-e61dfdb73a9f",
1003
+ "username": "admin",
1004
+ "r2_key": "creatives/15-Heirloom-Depth-1097ecb1.png",
1005
+ "concept_name": "Heirloom Depth",
1006
+ "creative_id": 15,
1007
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1008
+ "created_at": "2026-02-24T10:18:13.762131+00:00"
1009
+ },
1010
+ {
1011
+ "id": "c5cd5f72-e335-4a04-a955-309464a19226",
1012
+ "username": "admin",
1013
+ "r2_key": "creatives/4-Social-Proof-74e453e7.png",
1014
+ "concept_name": "Social Proof",
1015
+ "creative_id": 4,
1016
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1017
+ "created_at": "2026-02-24T10:18:20.469506+00:00"
1018
+ },
1019
+ {
1020
+ "id": "b0cad0b8-c8c1-4156-9f44-16f6925dacdb",
1021
+ "username": "admin",
1022
+ "r2_key": "creatives/11-Price-Confidence-06686ebf.png",
1023
+ "concept_name": "Price Confidence",
1024
+ "creative_id": 11,
1025
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1026
+ "created_at": "2026-02-24T10:18:29.878120+00:00"
1027
+ },
1028
+ {
1029
+ "id": "7d9cc1c6-7370-49de-baae-971805858412",
1030
+ "username": "admin",
1031
+ "r2_key": "creatives/14-Minimal-8e1010b3.png",
1032
+ "concept_name": "Minimal",
1033
+ "creative_id": 14,
1034
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1035
+ "created_at": "2026-02-24T10:18:30.723586+00:00"
1036
+ },
1037
+ {
1038
+ "id": "8daba081-7103-4681-9f08-918626c74391",
1039
+ "username": "admin",
1040
+ "r2_key": "creatives/7-Texture-Contrast-618a17e1.png",
1041
+ "concept_name": "Texture Contrast",
1042
+ "creative_id": 7,
1043
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1044
+ "created_at": "2026-02-24T10:18:32.607349+00:00"
1045
+ },
1046
+ {
1047
+ "id": "d52f7628-8272-4388-b361-85b68ae73f69",
1048
+ "username": "admin",
1049
+ "r2_key": "creatives/3-Flat-Lay-a26cb391.png",
1050
+ "concept_name": "Flat Lay",
1051
+ "creative_id": 3,
1052
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1053
+ "created_at": "2026-02-24T10:18:42.769422+00:00"
1054
+ },
1055
+ {
1056
+ "id": "5e15219b-b2be-45a4-a35f-79b245b60fa5",
1057
+ "username": "admin",
1058
+ "r2_key": "creatives/10-Current-Festival-e3d83c12.png",
1059
+ "concept_name": "Current Festival",
1060
+ "creative_id": 10,
1061
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1062
+ "created_at": "2026-02-24T10:18:48.000973+00:00"
1063
+ },
1064
+ {
1065
+ "id": "db94cad0-ad72-4e6d-a9bd-d9df9303d117",
1066
+ "username": "admin",
1067
+ "r2_key": "creatives/15-Minimalist-Charm-fc4c7956.png",
1068
+ "concept_name": "Minimalist Charm",
1069
+ "creative_id": 15,
1070
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1071
+ "created_at": "2026-02-24T10:33:58.596349+00:00"
1072
+ },
1073
+ {
1074
+ "id": "d168f8a4-1a66-4fc7-98bc-8202e377bc23",
1075
+ "username": "admin",
1076
+ "r2_key": "creatives/7-Texture-Play-a2f3db6b.png",
1077
+ "concept_name": "Texture Play",
1078
+ "creative_id": 7,
1079
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1080
+ "created_at": "2026-02-24T10:33:58.604281+00:00"
1081
+ },
1082
+ {
1083
+ "id": "0edfb96b-8796-44b9-acbd-1d523a4ffb5d",
1084
+ "username": "admin",
1085
+ "r2_key": "creatives/3-Chic-Flat-Lay-36c1bf05.png",
1086
+ "concept_name": "Chic Flat Lay",
1087
+ "creative_id": 3,
1088
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1089
+ "created_at": "2026-02-24T10:34:12.398133+00:00"
1090
+ },
1091
+ {
1092
+ "id": "160285c4-62a8-4130-b877-a9ae74cbb43b",
1093
+ "username": "admin",
1094
+ "r2_key": "creatives/10-Festival-Vibes-34cccf88.png",
1095
+ "concept_name": "Festival Vibes",
1096
+ "creative_id": 10,
1097
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1098
+ "created_at": "2026-02-24T10:34:14.297871+00:00"
1099
+ },
1100
+ {
1101
+ "id": "a353329a-e0a6-4c58-b5e9-494e9d760fa0",
1102
+ "username": "admin",
1103
+ "r2_key": "creatives/6-Lifestyle-Glam-04e24cde.png",
1104
+ "concept_name": "Lifestyle Glam",
1105
+ "creative_id": 6,
1106
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1107
+ "created_at": "2026-02-24T10:34:18.430501+00:00"
1108
+ },
1109
+ {
1110
+ "id": "c03d2072-b79e-4657-9243-9495ee926bbd",
1111
+ "username": "admin",
1112
+ "r2_key": "creatives/14-Macro-Closeup-10fa60dc.png",
1113
+ "concept_name": "Macro Closeup",
1114
+ "creative_id": 14,
1115
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1116
+ "created_at": "2026-02-24T10:34:23.923682+00:00"
1117
+ },
1118
+ {
1119
+ "id": "8684c452-0c1b-49d9-a6a5-ac969990d204",
1120
+ "username": "admin",
1121
+ "r2_key": "creatives/2-Radiant-Elegance-9dcff3dd.png",
1122
+ "concept_name": "Radiant Elegance",
1123
+ "creative_id": 2,
1124
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1125
+ "created_at": "2026-02-24T10:34:32.729580+00:00"
1126
+ },
1127
+ {
1128
+ "id": "a2646d94-75dc-4e7d-8283-a3e6591c647c",
1129
+ "username": "admin",
1130
+ "r2_key": "creatives/5-Witty-Charm-bd3d3b1e.png",
1131
+ "concept_name": "Witty Charm",
1132
+ "creative_id": 5,
1133
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1134
+ "created_at": "2026-02-24T10:34:34.106254+00:00"
1135
+ },
1136
+ {
1137
+ "id": "e92abf81-ec8b-4531-b648-4cf55dc3bc04",
1138
+ "username": "admin",
1139
+ "r2_key": "creatives/9-Gift-Reveal-0b35de7a.png",
1140
+ "concept_name": "Gift Reveal",
1141
+ "creative_id": 9,
1142
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1143
+ "created_at": "2026-02-24T10:34:36.671070+00:00"
1144
+ },
1145
+ {
1146
+ "id": "57ea76a9-5444-4710-a33d-07423268efff",
1147
+ "username": "admin",
1148
+ "r2_key": "creatives/1-Dark-Moody-Elegance-5aa2ce05.png",
1149
+ "concept_name": "Dark Moody Elegance",
1150
+ "creative_id": 1,
1151
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1152
+ "created_at": "2026-02-24T10:34:46.851431+00:00"
1153
+ },
1154
+ {
1155
+ "id": "f7298eef-76f7-4724-a042-49da6a7dcf60",
1156
+ "username": "admin",
1157
+ "r2_key": "creatives/8-Shadow-Play-5a09b719.png",
1158
+ "concept_name": "Shadow Play",
1159
+ "creative_id": 8,
1160
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1161
+ "created_at": "2026-02-24T10:34:47.289001+00:00"
1162
+ },
1163
+ {
1164
+ "id": "abc36468-387c-4433-a7d2-7aee945defcf",
1165
+ "username": "admin",
1166
+ "r2_key": "creatives/13-One-Word-Dominance-296bdf4e.png",
1167
+ "concept_name": "One-Word Dominance",
1168
+ "creative_id": 13,
1169
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1170
+ "created_at": "2026-02-24T10:34:50.335849+00:00"
1171
+ },
1172
+ {
1173
+ "id": "d1b41950-b145-45b1-ab07-919d5d096a99",
1174
+ "username": "admin",
1175
+ "r2_key": "creatives/17-Power-Suit-a775ba83.png",
1176
+ "concept_name": "Power Suit",
1177
+ "creative_id": 17,
1178
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1179
+ "created_at": "2026-02-24T10:34:59.210348+00:00"
1180
+ },
1181
+ {
1182
+ "id": "f5482521-7f1a-489f-b272-4d2c6735f738",
1183
+ "username": "admin",
1184
+ "r2_key": "creatives/12-Price-Confidence---Small-981718c6.png",
1185
+ "concept_name": "Price Confidence - Small",
1186
+ "creative_id": 12,
1187
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1188
+ "created_at": "2026-02-24T10:35:01.517523+00:00"
1189
+ },
1190
+ {
1191
+ "id": "f7c14cfe-ebae-4ee3-a709-648e8903a27d",
1192
+ "username": "admin",
1193
+ "r2_key": "creatives/4-Real-Voices-5b17579a.png",
1194
+ "concept_name": "Real Voices",
1195
+ "creative_id": 4,
1196
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1197
+ "created_at": "2026-02-24T10:35:07.338190+00:00"
1198
+ },
1199
+ {
1200
+ "id": "2ed1b057-7aec-4e93-bff2-6db3f75aaf5a",
1201
+ "username": "admin",
1202
+ "r2_key": "creatives/16-Texture-Contrast-a6e856cf.png",
1203
+ "concept_name": "Texture Contrast",
1204
+ "creative_id": 16,
1205
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1206
+ "created_at": "2026-02-24T10:55:10.063248+00:00"
1207
+ },
1208
+ {
1209
+ "id": "543ae31d-cb35-4d5f-b4f2-97e213f89814",
1210
+ "username": "admin",
1211
+ "r2_key": "creatives/8-Minimalistic-Charm-c6ad6f97.png",
1212
+ "concept_name": "Minimalistic Charm",
1213
+ "creative_id": 8,
1214
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1215
+ "created_at": "2026-02-24T10:55:10.112898+00:00"
1216
+ },
1217
+ {
1218
+ "id": "bd14ee56-2a6c-452a-9e21-e17d91b6b00c",
1219
+ "username": "admin",
1220
+ "r2_key": "creatives/5-Versatile-Everyday-Wear-e6412444.png",
1221
+ "concept_name": "Versatile Everyday Wear",
1222
+ "creative_id": 5,
1223
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1224
+ "created_at": "2026-02-24T10:55:11.259314+00:00"
1225
+ },
1226
+ {
1227
+ "id": "80d6d5cb-24d5-4e54-8613-24562cdd3503",
1228
+ "username": "admin",
1229
+ "r2_key": "creatives/7-Price-Confidence-6d6b9e66.png",
1230
+ "concept_name": "Price Confidence",
1231
+ "creative_id": 7,
1232
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1233
+ "created_at": "2026-02-24T10:55:22.743246+00:00"
1234
+ },
1235
+ {
1236
+ "id": "d3e70d20-89fd-4e4b-b606-13f5e8d1aebe",
1237
+ "username": "admin",
1238
+ "r2_key": "creatives/12-Power-Suit-44eb169d.png",
1239
+ "concept_name": "Power Suit",
1240
+ "creative_id": 12,
1241
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1242
+ "created_at": "2026-02-24T10:55:27.395562+00:00"
1243
+ },
1244
+ {
1245
+ "id": "5c2c1b6a-bb23-4f5f-acac-e4aac38e6d24",
1246
+ "username": "admin",
1247
+ "r2_key": "creatives/4-Festive-Glam-6d464c14.png",
1248
+ "concept_name": "Festive Glam",
1249
+ "creative_id": 4,
1250
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1251
+ "created_at": "2026-02-24T10:55:27.807595+00:00"
1252
+ },
1253
+ {
1254
+ "id": "95671da4-b03f-40f4-a870-76207561f655",
1255
+ "username": "admin",
1256
+ "r2_key": "creatives/20-Every-Moment-Matters-206d3041.png",
1257
+ "concept_name": "Every Moment Matters",
1258
+ "creative_id": 20,
1259
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1260
+ "created_at": "2026-02-24T10:55:33.318649+00:00"
1261
+ },
1262
+ {
1263
+ "id": "a8cc3082-b5ba-443a-825c-a14328bc2d0b",
1264
+ "username": "admin",
1265
+ "r2_key": "creatives/1-Self-Love-Celebration-f0da018d.png",
1266
+ "concept_name": "Self-Love Celebration",
1267
+ "creative_id": 1,
1268
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1269
+ "created_at": "2026-02-24T10:55:39.876208+00:00"
1270
+ },
1271
+ {
1272
+ "id": "7d5bae94-92a1-4902-bf64-c5b219feeda1",
1273
+ "username": "admin",
1274
+ "r2_key": "creatives/3-Anniversary-Sparkle-45039534.png",
1275
+ "concept_name": "Anniversary Sparkle",
1276
+ "creative_id": 3,
1277
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1278
+ "created_at": "2026-02-24T10:55:46.136806+00:00"
1279
+ },
1280
+ {
1281
+ "id": "9f154b9f-b754-4331-8f13-449e481e7fb6",
1282
+ "username": "admin",
1283
+ "r2_key": "creatives/10-Macro-Detail-d7f2ddad.png",
1284
+ "concept_name": "Macro Detail",
1285
+ "creative_id": 10,
1286
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1287
+ "created_at": "2026-02-24T10:55:47.065962+00:00"
1288
+ },
1289
+ {
1290
+ "id": "2502a941-04ee-45e5-b040-db943cfa1d0f",
1291
+ "username": "admin",
1292
+ "r2_key": "creatives/6-Bridesmaids-Choice-c2b885cd.png",
1293
+ "concept_name": "Bridesmaid's Choice",
1294
+ "creative_id": 6,
1295
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1296
+ "created_at": "2026-02-24T10:55:51.332813+00:00"
1297
+ },
1298
+ {
1299
+ "id": "3b77b4e1-20d6-419b-a0de-c38fe5496b44",
1300
+ "username": "admin",
1301
+ "r2_key": "creatives/2-Self-Gift-for-Rakhi-6521f2dc.png",
1302
+ "concept_name": "Self-Gift for Rakhi",
1303
+ "creative_id": 2,
1304
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1305
+ "created_at": "2026-02-24T10:55:59.240941+00:00"
1306
+ },
1307
+ {
1308
+ "id": "0a5e24d7-fda6-4ada-ba13-936a7d84f3a1",
1309
+ "username": "admin",
1310
+ "r2_key": "creatives/9-One-Word-Dominance-14fa2ead.png",
1311
+ "concept_name": "One-Word Dominance",
1312
+ "creative_id": 9,
1313
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1314
+ "created_at": "2026-02-24T10:56:02.161194+00:00"
1315
+ },
1316
+ {
1317
+ "id": "a86e0dd7-ed23-4bda-ab59-70005c67611f",
1318
+ "username": "admin",
1319
+ "r2_key": "creatives/17-Shadow-Play-797fb720.png",
1320
+ "concept_name": "Shadow Play",
1321
+ "creative_id": 17,
1322
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1323
+ "created_at": "2026-02-24T10:56:07.772501+00:00"
1324
+ },
1325
+ {
1326
+ "id": "6f152d6c-4fa3-419a-8835-6b9ed8ed63fb",
1327
+ "username": "admin",
1328
+ "r2_key": "creatives/1-Self-Love-Celebration-45dd8fa4.png",
1329
+ "concept_name": "Self-Love Celebration",
1330
+ "creative_id": 1,
1331
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1332
+ "created_at": "2026-02-24T10:56:54.917666+00:00"
1333
+ },
1334
+ {
1335
+ "id": "99d130ae-ec14-47ae-a93b-8270eee1a927",
1336
+ "username": "admin",
1337
+ "r2_key": "creatives/1-Self-Love-Celebration-f572159f.png",
1338
+ "concept_name": "Self-Love Celebration",
1339
+ "creative_id": 1,
1340
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1341
+ "created_at": "2026-02-24T10:59:11.757749+00:00"
1342
+ },
1343
+ {
1344
+ "id": "1fa39ae8-1bd8-4c07-8d85-fb1f65d039d8",
1345
+ "username": "admin",
1346
+ "r2_key": "creatives/2-Self-Gift-for-Rakhi-24be702d.png",
1347
+ "concept_name": "Self-Gift for Rakhi",
1348
+ "creative_id": 2,
1349
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1350
+ "created_at": "2026-02-24T10:59:42.683083+00:00"
1351
+ },
1352
+ {
1353
+ "id": "675d3c29-08ab-4c72-95ce-1d78c609cd36",
1354
+ "username": "admin",
1355
+ "r2_key": "creatives/3-Anniversary-Sparkle-344c0228.png",
1356
+ "concept_name": "Anniversary Sparkle",
1357
+ "creative_id": 3,
1358
+ "product_name": "Aria Tassel Dangler - Trendy & Stylish Earrings for Women",
1359
+ "created_at": "2026-02-24T10:59:44.437991+00:00"
1360
  }
1361
  ]
backend/requirements.txt CHANGED
@@ -10,4 +10,4 @@ httpx==0.28.1
10
  bcrypt>=4.0,<5
11
  pyjwt==2.10.1
12
  boto3>=1.35,<2
13
- duckduckgo-search>=7.0,<8
 
10
  bcrypt>=4.0,<5
11
  pyjwt==2.10.1
12
  boto3>=1.35,<2
13
+ ddgs>=7.0,<8
frontend/src/App.jsx CHANGED
@@ -25,6 +25,107 @@ const THEMES = [
25
  { id: 'champagne', label: 'Champagne' },
26
  ]
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  const THEME_CYCLE_MS = 60000
29
 
30
  function authHeaders() {
@@ -130,12 +231,27 @@ export default function App() {
130
  const [generateAdsError, setGenerateAdsError] = useState(null)
131
  const [editedCreatives, setEditedCreatives] = useState({}) // creative id -> full edited creative object
132
  const [referenceImageUrl, setReferenceImageUrl] = useState(null) // user custom/upload; null = use product image
 
 
 
 
133
  const [hasStoredSession, setHasStoredSession] = useState(false)
134
  const [storedSession, setStoredSession] = useState(null)
135
  const [lightboxImage, setLightboxImage] = useState(null)
136
  const generatedAdsSectionRef = useRef(null)
137
  const prevGeneratingRef = useRef(false)
138
 
 
 
 
 
 
 
 
 
 
 
 
139
  const handleLogout = useCallback(() => {
140
  localStorage.removeItem(AUTH_TOKEN_KEY)
141
  setToken(null)
@@ -230,6 +346,7 @@ export default function App() {
230
  setGenerateAdsError(null)
231
  setEditedCreatives({})
232
  setReferenceImageUrl(null)
 
233
  setHasStoredSession(false)
234
  setStoredSession(null)
235
  try {
@@ -240,7 +357,7 @@ export default function App() {
240
  const res = await fetch(`${API_BASE}/run/stream`, {
241
  method: 'POST',
242
  headers: { 'Content-Type': 'application/json', ...authHeaders() },
243
- body: JSON.stringify({ url: trimmed }),
244
  })
245
  if (res.status === 401) {
246
  localStorage.removeItem(AUTH_TOKEN_KEY)
@@ -375,6 +492,59 @@ export default function App() {
375
  {loading ? 'Generating…' : 'Generate'}
376
  </button>
377
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  {error && <p className="error">{error}</p>}
379
  </section>
380
 
@@ -388,7 +558,21 @@ export default function App() {
388
 
389
  {(productData || analysis || (adCreatives && adCreatives.length > 0)) && (
390
  <div className="results">
391
- {productData && <ProductCard data={productData} onImageClick={(src, alt) => setLightboxImage({ src, alt })} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  {analysis && (
393
  <AnalysisSection
394
  data={analysis}
@@ -407,7 +591,8 @@ export default function App() {
407
  }}
408
  onSelectAll={() => setSelectedForAds((adCreatives || []).map((c) => c.id))}
409
  onDeselectAll={() => setSelectedForAds([])}
410
- productImageUrl={(productData?.product_images || '').split(',')[0]?.trim() || null}
 
411
  referenceImageUrl={referenceImageUrl}
412
  onReferenceImageUrlChange={setReferenceImageUrl}
413
  imageModels={imageModels}
@@ -422,6 +607,11 @@ export default function App() {
422
  setGeneratingAds(true)
423
  setGeneratingAdsTotal(selected.length)
424
  setGeneratedAds([])
 
 
 
 
 
425
  const body = {
426
  creatives: selected.map((c) => {
427
  const base = editedCreatives[c.id] ?? c
@@ -429,7 +619,8 @@ export default function App() {
429
  return { ...base, scene_prompt: prompt }
430
  }),
431
  model_key: selectedImageModel,
432
- product_image_url: (productData?.product_images || '').split(',')[0]?.trim() || null,
 
433
  reference_image_url: referenceImageUrl || null,
434
  product_name: productName,
435
  }
@@ -505,23 +696,46 @@ export default function App() {
505
  )
506
  }
507
 
508
- function ProductCard({ data, onImageClick }) {
509
- const imgUrl = (data?.product_images || '').split(',')[0]?.trim()
 
 
 
 
 
 
510
  return (
511
  <section className="card product-card">
512
  <h2>Product</h2>
513
  <div className="product-grid">
514
- {imgUrl && (
515
- <img
516
- src={imgUrl}
517
- alt={data?.product_name}
518
- className={`product-img ${onImageClick ? 'img-expandable' : ''}`}
519
- onClick={() => onImageClick?.(imgUrl, data?.product_name)}
520
- role={onImageClick ? 'button' : undefined}
521
- tabIndex={onImageClick ? 0 : undefined}
522
- onKeyDown={(e) => onImageClick && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onImageClick(imgUrl, data?.product_name))}
523
- />
524
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  <div className="product-meta">
526
  <h3>{data?.product_name || '—'}</h3>
527
  <p className="product-price">{data?.price || '—'}</p>
@@ -548,11 +762,14 @@ function AnalysisSection({ data, defaultCollapsed = false }) {
548
  : data.positioning.slice(0, MAX_PREVIEW_LEN).trim() + '…')
549
  : ''
550
  const parts = []
 
551
  if (data.tagline_options?.length) parts.push('taglines')
552
  if (ideal.age_range || ideal.lifestyle) parts.push('ideal customer')
553
  if (data.emotional_triggers?.length) parts.push('emotional triggers')
554
  if (priceAnalysis.perception || priceAnalysis.value_framing) parts.push('price')
555
  if (data.ad_angles?.length) parts.push('ad angles')
 
 
556
  if (data.unique_selling_points?.length) parts.push('USPs')
557
  if (copyDir.tone) parts.push('copy direction')
558
  const summaryLabel = parts.length ? parts.join(', ') : ''
@@ -576,6 +793,12 @@ function AnalysisSection({ data, defaultCollapsed = false }) {
576
  ) : (
577
  <>
578
  <div className="analysis-grid">
 
 
 
 
 
 
579
  {data.positioning && (
580
  <div className="analysis-block">
581
  <h4>Positioning</h4>
@@ -625,6 +848,18 @@ function AnalysisSection({ data, defaultCollapsed = false }) {
625
  </ul>
626
  </div>
627
  )}
 
 
 
 
 
 
 
 
 
 
 
 
628
  {data.unique_selling_points?.length > 0 && (
629
  <div className="analysis-block">
630
  <h4>USPs</h4>
@@ -1022,10 +1257,17 @@ function CreativesSection({
1022
  >
1023
  {effective.ad_copy?.price_original}
1024
  </span>{' '}
1025
- {effective.ad_copy?.price_final}
1026
  </p>
1027
  )}
1028
- <p className="cta-copy">{effective.ad_copy?.cta}</p>
 
 
 
 
 
 
 
1029
  </div>
1030
  <div className="prompt-block">
1031
  <h5>Scene prompt</h5>
 
25
  { id: 'champagne', label: 'Champagne' },
26
  ]
27
 
28
+ const TARGET_AUDIENCE_OPTIONS = [
29
+ "Women 18–24",
30
+ "Women 25–34",
31
+ "Women 35–44",
32
+ "Urban Tier 1 Women",
33
+ "Urban Tier 2 Women",
34
+ "Working Professionals",
35
+ "Corporate Women",
36
+ "Women Entrepreneurs",
37
+ "Disposable Income ₹30k+",
38
+ "Living Independently",
39
+ "Married, No Kids",
40
+ "Newly Married",
41
+ "Single Women",
42
+ "Monthly Online Shoppers",
43
+ "English Digital-first",
44
+ "Demi-fine Jewelry Buyers",
45
+ "Minimalist Lovers",
46
+ "Statement Buyers",
47
+ "Everyday Wear Buyers",
48
+ "Occasion Shoppers",
49
+ "Layering Lovers",
50
+ "Choker Buyers",
51
+ "CZ Jewelry Fans",
52
+ "Anti-tarnish Seekers",
53
+ "Hypoallergenic Buyers",
54
+ "Gold Finish Lovers",
55
+ "Silver Finish Lovers",
56
+ "Indo-western Fans",
57
+ "Contemporary Ethnic",
58
+ "Sustainable Shoppers",
59
+ "Premium Accessories",
60
+ "IG Jewelry Followers",
61
+ "Pinterest Users",
62
+ "Fashion Discovery",
63
+ "Outfit Reel Savers",
64
+ "Online Jewelry Shoppers",
65
+ "Cart Abandoners",
66
+ "Instagram Shop Users",
67
+ "Google Shoppers",
68
+ "Self-Gifters",
69
+ "Repeat Buyers",
70
+ "Sale-responsive",
71
+ "Value Premium Buyers",
72
+ "₹1.5k–₹3k Buyers",
73
+ "COD Buyers",
74
+ "UPI-first",
75
+ "Mobile-only",
76
+ "D2C Followers",
77
+ "Instagram Trusters",
78
+ "Brand Switchers",
79
+ "Limited Edition Buyers",
80
+ "First-time Buyers",
81
+ "Impulse Buyers",
82
+ "Birthday Self-Gift",
83
+ "Anniversary Buyers",
84
+ "Wedding Guests",
85
+ "Bridesmaids",
86
+ "Festive Buyers",
87
+ "Rakhi Self-Gift",
88
+ "Valentine Self-love",
89
+ "New Year Parties",
90
+ "Office Parties",
91
+ "Vacation Shoppers",
92
+ "Date-night",
93
+ "Wedding Season",
94
+ "Festive Office",
95
+ "Outfit Completion",
96
+ "Reel Jewelry",
97
+ "Fashion Influencer Followers",
98
+ "Jewelry Influencer Fans",
99
+ "Styling Reel Fans",
100
+ "Vogue India",
101
+ "Elle India",
102
+ "Harper's Bazaar",
103
+ "Nykaa Fashion",
104
+ "Myntra Premium",
105
+ "Ajio Luxe",
106
+ "Fashion Page Followers",
107
+ "Self-love Believers",
108
+ "Self-rewarders",
109
+ "Aesthetic Buyers",
110
+ "Aspirational Value",
111
+ "Uniqueness Seekers",
112
+ "Anti-mass Market",
113
+ "Creative Women",
114
+ "Fashion Experimenters",
115
+ "Early Adopters",
116
+ "Global Trend Followers",
117
+ "Quiet Luxury Fans",
118
+ "Instagram-first",
119
+ "UGC Creators",
120
+ "Event Goers",
121
+ "Photo Dressers",
122
+ "Website Visitors",
123
+ "Product Viewers",
124
+ "Add-to-Cart",
125
+ "Past Purchasers",
126
+ "Top 10% LAL"
127
+ ]
128
+
129
  const THEME_CYCLE_MS = 60000
130
 
131
  function authHeaders() {
 
231
  const [generateAdsError, setGenerateAdsError] = useState(null)
232
  const [editedCreatives, setEditedCreatives] = useState({}) // creative id -> full edited creative object
233
  const [referenceImageUrl, setReferenceImageUrl] = useState(null) // user custom/upload; null = use product image
234
+ const [selectedReferenceUrls, setSelectedReferenceUrls] = useState([]) // up to 3 product image URLs user selected for ad generation; order = ref order
235
+ const [targetAudiences, setTargetAudiences] = useState([]) // selected audience segments for analysis/creatives (multi-select)
236
+ const [targetAudienceOpen, setTargetAudienceOpen] = useState(false)
237
+ const targetAudienceDropdownRef = useRef(null)
238
  const [hasStoredSession, setHasStoredSession] = useState(false)
239
  const [storedSession, setStoredSession] = useState(null)
240
  const [lightboxImage, setLightboxImage] = useState(null)
241
  const generatedAdsSectionRef = useRef(null)
242
  const prevGeneratingRef = useRef(false)
243
 
244
+ // Close target audience dropdown on outside click
245
+ useEffect(() => {
246
+ if (!targetAudienceOpen) return
247
+ const el = targetAudienceDropdownRef.current
248
+ function handleClick(e) {
249
+ if (el && !el.contains(e.target)) setTargetAudienceOpen(false)
250
+ }
251
+ document.addEventListener('mousedown', handleClick)
252
+ return () => document.removeEventListener('mousedown', handleClick)
253
+ }, [targetAudienceOpen])
254
+
255
  const handleLogout = useCallback(() => {
256
  localStorage.removeItem(AUTH_TOKEN_KEY)
257
  setToken(null)
 
346
  setGenerateAdsError(null)
347
  setEditedCreatives({})
348
  setReferenceImageUrl(null)
349
+ setSelectedReferenceUrls([])
350
  setHasStoredSession(false)
351
  setStoredSession(null)
352
  try {
 
357
  const res = await fetch(`${API_BASE}/run/stream`, {
358
  method: 'POST',
359
  headers: { 'Content-Type': 'application/json', ...authHeaders() },
360
+ body: JSON.stringify({ url: trimmed, target_audience: targetAudiences.length ? targetAudiences : undefined }),
361
  })
362
  if (res.status === 401) {
363
  localStorage.removeItem(AUTH_TOKEN_KEY)
 
492
  {loading ? 'Generating…' : 'Generate'}
493
  </button>
494
  </div>
495
+ <div className="target-audience-row" ref={targetAudienceDropdownRef}>
496
+ <span className="target-audience-label">Target audiences (optional)</span>
497
+ <div className="target-audience-multiselect">
498
+ <button
499
+ type="button"
500
+ onClick={() => !loading && setTargetAudienceOpen((o) => !o)}
501
+ disabled={loading}
502
+ className="target-audience-trigger"
503
+ aria-expanded={targetAudienceOpen}
504
+ aria-haspopup="listbox"
505
+ >
506
+ {targetAudiences.length === 0
507
+ ? 'Select target audiences…'
508
+ : `${targetAudiences.length} selected`}
509
+ </button>
510
+ {targetAudienceOpen && (
511
+ <div className="target-audience-dropdown" role="listbox">
512
+ <div className="target-audience-actions">
513
+ <button
514
+ type="button"
515
+ className="target-audience-action-btn"
516
+ onClick={() => setTargetAudiences(TARGET_AUDIENCE_OPTIONS.slice())}
517
+ >
518
+ Select all
519
+ </button>
520
+ <button
521
+ type="button"
522
+ className="target-audience-action-btn"
523
+ onClick={() => setTargetAudiences([])}
524
+ >
525
+ Clear
526
+ </button>
527
+ </div>
528
+ <div className="target-audience-list">
529
+ {TARGET_AUDIENCE_OPTIONS.map((opt) => (
530
+ <label key={opt} className="target-audience-option">
531
+ <input
532
+ type="checkbox"
533
+ checked={targetAudiences.includes(opt)}
534
+ onChange={() => {
535
+ setTargetAudiences((prev) =>
536
+ prev.includes(opt) ? prev.filter((x) => x !== opt) : [...prev, opt]
537
+ )
538
+ }}
539
+ />
540
+ <span>{opt}</span>
541
+ </label>
542
+ ))}
543
+ </div>
544
+ </div>
545
+ )}
546
+ </div>
547
+ </div>
548
  {error && <p className="error">{error}</p>}
549
  </section>
550
 
 
558
 
559
  {(productData || analysis || (adCreatives && adCreatives.length > 0)) && (
560
  <div className="results">
561
+ {productData && (
562
+ <ProductCard
563
+ data={productData}
564
+ selectedReferenceUrls={selectedReferenceUrls}
565
+ onToggleReference={(url) => {
566
+ setSelectedReferenceUrls((prev) => {
567
+ const has = prev.includes(url)
568
+ if (has) return prev.filter((u) => u !== url)
569
+ if (prev.length >= 3) return prev
570
+ return [...prev, url]
571
+ })
572
+ }}
573
+ onImageClick={(src, alt) => setLightboxImage({ src, alt })}
574
+ />
575
+ )}
576
  {analysis && (
577
  <AnalysisSection
578
  data={analysis}
 
591
  }}
592
  onSelectAll={() => setSelectedForAds((adCreatives || []).map((c) => c.id))}
593
  onDeselectAll={() => setSelectedForAds([])}
594
+ productImageUrl={(selectedReferenceUrls && selectedReferenceUrls[0]) || (productData?.product_images || '').split(',')[0]?.trim() || null}
595
+ selectedReferenceUrls={selectedReferenceUrls}
596
  referenceImageUrl={referenceImageUrl}
597
  onReferenceImageUrlChange={setReferenceImageUrl}
598
  imageModels={imageModels}
 
607
  setGeneratingAds(true)
608
  setGeneratingAdsTotal(selected.length)
609
  setGeneratedAds([])
610
+ const imageUrls = (productData?.product_images || '').split(',').map((s) => s.trim()).filter(Boolean)
611
+ const refs = (selectedReferenceUrls && selectedReferenceUrls.length > 0)
612
+ ? selectedReferenceUrls.filter((u) => u && imageUrls.includes(u)).slice(0, 3)
613
+ : imageUrls.slice(0, 3)
614
+ const primary = refs[0] || null
615
  const body = {
616
  creatives: selected.map((c) => {
617
  const base = editedCreatives[c.id] ?? c
 
619
  return { ...base, scene_prompt: prompt }
620
  }),
621
  model_key: selectedImageModel,
622
+ product_image_url: primary,
623
+ product_image_urls: refs.length ? refs : null,
624
  reference_image_url: referenceImageUrl || null,
625
  product_name: productName,
626
  }
 
696
  )
697
  }
698
 
699
+ function ProductCard({ data, selectedReferenceUrls = [], onToggleReference, onImageClick }) {
700
+ const imageUrls = (data?.product_images || '')
701
+ .split(',')
702
+ .map((s) => s.trim())
703
+ .filter(Boolean)
704
+ const multi = imageUrls.length > 1
705
+ const many = imageUrls.length >= 6
706
+ const refList = Array.isArray(selectedReferenceUrls) ? selectedReferenceUrls : []
707
  return (
708
  <section className="card product-card">
709
  <h2>Product</h2>
710
  <div className="product-grid">
711
+ <div className={`product-gallery ${multi ? 'product-gallery--multi' : ''} ${many ? 'product-gallery--many' : ''}`}>
712
+ {imageUrls.map((imgUrl, i) => {
713
+ const refIndex = refList.indexOf(imgUrl)
714
+ const isSelected = refIndex >= 0
715
+ const refLabel = isSelected ? `Ref ${refIndex + 1}` : null
716
+ return (
717
+ <button
718
+ key={i}
719
+ type="button"
720
+ className={`product-img-wrap ${isSelected ? 'product-img-wrap--selected' : ''}`}
721
+ onClick={() => onToggleReference?.(imgUrl)}
722
+ onDoubleClick={() => onImageClick?.(imgUrl, data?.product_name)}
723
+ title={isSelected ? `Reference ${refIndex + 1} for ad generation (click to remove, double-click to enlarge)` : `Select as reference for ad generation (up to 3; double-click to enlarge)`}
724
+ >
725
+ <img
726
+ src={imgUrl}
727
+ alt={data?.product_name ? `${data.product_name} (${i + 1})` : ''}
728
+ className={`product-img ${onImageClick ? 'img-expandable' : ''}`}
729
+ draggable={false}
730
+ />
731
+ {refLabel && <span className="product-img-badge">{refLabel}</span>}
732
+ </button>
733
+ )
734
+ })}
735
+ {imageUrls.length > 0 && (
736
+ <p className="product-gallery-note">Click images to select up to 3 as reference for ad generation (order = ref order). Used in marketing analysis.</p>
737
+ )}
738
+ </div>
739
  <div className="product-meta">
740
  <h3>{data?.product_name || '—'}</h3>
741
  <p className="product-price">{data?.price || '—'}</p>
 
762
  : data.positioning.slice(0, MAX_PREVIEW_LEN).trim() + '…')
763
  : ''
764
  const parts = []
765
+ if (data.product_visual_notes) parts.push('product visual')
766
  if (data.tagline_options?.length) parts.push('taglines')
767
  if (ideal.age_range || ideal.lifestyle) parts.push('ideal customer')
768
  if (data.emotional_triggers?.length) parts.push('emotional triggers')
769
  if (priceAnalysis.perception || priceAnalysis.value_framing) parts.push('price')
770
  if (data.ad_angles?.length) parts.push('ad angles')
771
+ if (data.shooting_angles?.length) parts.push('shooting angles')
772
+ if (data.color_worlds?.length) parts.push('color worlds')
773
  if (data.unique_selling_points?.length) parts.push('USPs')
774
  if (copyDir.tone) parts.push('copy direction')
775
  const summaryLabel = parts.length ? parts.join(', ') : ''
 
793
  ) : (
794
  <>
795
  <div className="analysis-grid">
796
+ {data.product_visual_notes && (
797
+ <div className="analysis-block analysis-block--product-visual">
798
+ <h4>Product visual (from images)</h4>
799
+ <p>{data.product_visual_notes}</p>
800
+ </div>
801
+ )}
802
  {data.positioning && (
803
  <div className="analysis-block">
804
  <h4>Positioning</h4>
 
848
  </ul>
849
  </div>
850
  )}
851
+ {data.shooting_angles?.length > 0 && (
852
+ <div className="analysis-block">
853
+ <h4>Shooting angles</h4>
854
+ <ul>{data.shooting_angles.map((a, i) => <li key={i}>{a}</li>)}</ul>
855
+ </div>
856
+ )}
857
+ {data.color_worlds?.length > 0 && (
858
+ <div className="analysis-block">
859
+ <h4>Color worlds</h4>
860
+ <ul>{data.color_worlds.map((c, i) => <li key={i}>{c}</li>)}</ul>
861
+ </div>
862
+ )}
863
  {data.unique_selling_points?.length > 0 && (
864
  <div className="analysis-block">
865
  <h4>USPs</h4>
 
1257
  >
1258
  {effective.ad_copy?.price_original}
1259
  </span>{' '}
1260
+ <span className="price-final">{effective.ad_copy?.price_final}</span>
1261
  </p>
1262
  )}
1263
+ <p className={`cta-copy${effective.cta_position ? ` cta-position-${effective.cta_position}` : ''}`}>
1264
+ {effective.ad_copy?.cta}
1265
+ {effective.cta_position && (
1266
+ <span className="cta-position-badge" title="CTA placement in the ad image">
1267
+ {' '}({effective.cta_position.replace(/_/g, ' ')})
1268
+ </span>
1269
+ )}
1270
+ </p>
1271
  </div>
1272
  <div className="prompt-block">
1273
  <h5>Scene prompt</h5>
frontend/src/index.css CHANGED
@@ -366,6 +366,123 @@ a:hover {
366
  flex-wrap: wrap;
367
  }
368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  .url-input {
370
  flex: 1;
371
  min-width: 200px;
@@ -480,6 +597,90 @@ a:hover {
480
  }
481
  }
482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  .product-img {
484
  width: 100%;
485
  aspect-ratio: 1;
@@ -919,6 +1120,11 @@ a:hover {
919
  color: var(--accent);
920
  }
921
 
 
 
 
 
 
922
  /* Original price display styles (vary per creative) */
923
  .price-original {
924
  margin-right: 0.35em;
@@ -962,6 +1168,12 @@ a:hover {
962
  font-weight: 500;
963
  }
964
 
 
 
 
 
 
 
965
  .prompt-block {
966
  margin-top: 1rem;
967
  }
 
366
  flex-wrap: wrap;
367
  }
368
 
369
+ .target-audience-row {
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 0.75rem;
373
+ flex-wrap: wrap;
374
+ margin-top: 1rem;
375
+ }
376
+
377
+ .target-audience-label {
378
+ font-size: 0.9rem;
379
+ color: var(--text-soft);
380
+ white-space: nowrap;
381
+ }
382
+
383
+ .target-audience-multiselect {
384
+ position: relative;
385
+ min-width: 220px;
386
+ max-width: 100%;
387
+ }
388
+
389
+ .target-audience-trigger {
390
+ width: 100%;
391
+ padding: 0.6rem 0.85rem;
392
+ background: var(--surface-soft);
393
+ border: 1px solid var(--border);
394
+ border-radius: 8px;
395
+ color: var(--text);
396
+ font-size: 0.9rem;
397
+ text-align: left;
398
+ cursor: pointer;
399
+ transition: border-color 0.15s, box-shadow 0.15s;
400
+ }
401
+
402
+ .target-audience-trigger:hover:not(:disabled) {
403
+ border-color: var(--accent);
404
+ }
405
+
406
+ .target-audience-trigger:focus {
407
+ outline: none;
408
+ border-color: var(--accent);
409
+ box-shadow: 0 0 0 3px rgba(var(--accent-focus, 100, 116, 139), 0.2);
410
+ }
411
+
412
+ .target-audience-trigger:disabled {
413
+ opacity: 0.7;
414
+ cursor: not-allowed;
415
+ }
416
+
417
+ .target-audience-dropdown {
418
+ position: absolute;
419
+ top: 100%;
420
+ left: 0;
421
+ margin-top: 4px;
422
+ min-width: 320px;
423
+ max-width: 90vw;
424
+ max-height: 360px;
425
+ background: var(--surface);
426
+ border: 1px solid var(--border);
427
+ border-radius: 8px;
428
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
429
+ z-index: 100;
430
+ display: flex;
431
+ flex-direction: column;
432
+ overflow: hidden;
433
+ }
434
+
435
+ .target-audience-actions {
436
+ display: flex;
437
+ gap: 0.5rem;
438
+ padding: 0.5rem 0.75rem;
439
+ border-bottom: 1px solid var(--border);
440
+ flex-shrink: 0;
441
+ }
442
+
443
+ .target-audience-action-btn {
444
+ padding: 0.35rem 0.6rem;
445
+ font-size: 0.8rem;
446
+ background: var(--surface-soft);
447
+ border: 1px solid var(--border);
448
+ border-radius: 6px;
449
+ color: var(--text-soft);
450
+ cursor: pointer;
451
+ }
452
+
453
+ .target-audience-action-btn:hover {
454
+ color: var(--text);
455
+ border-color: var(--accent);
456
+ }
457
+
458
+ .target-audience-list {
459
+ overflow-y: auto;
460
+ padding: 0.5rem 0;
461
+ max-height: 300px;
462
+ }
463
+
464
+ .target-audience-option {
465
+ display: flex;
466
+ align-items: center;
467
+ gap: 0.5rem;
468
+ padding: 0.4rem 0.75rem;
469
+ font-size: 0.875rem;
470
+ color: var(--text);
471
+ cursor: pointer;
472
+ white-space: nowrap;
473
+ overflow: hidden;
474
+ text-overflow: ellipsis;
475
+ }
476
+
477
+ .target-audience-option:hover {
478
+ background: var(--surface-soft);
479
+ }
480
+
481
+ .target-audience-option input {
482
+ flex-shrink: 0;
483
+ accent-color: var(--accent);
484
+ }
485
+
486
  .url-input {
487
  flex: 1;
488
  min-width: 200px;
 
597
  }
598
  }
599
 
600
+ .product-gallery {
601
+ display: flex;
602
+ flex-direction: column;
603
+ gap: 0.5rem;
604
+ }
605
+
606
+ .product-gallery--multi {
607
+ flex-direction: row;
608
+ flex-wrap: wrap;
609
+ gap: 0.5rem;
610
+ }
611
+
612
+ .product-gallery--multi .product-img-wrap {
613
+ width: 70px;
614
+ height: 70px;
615
+ flex-shrink: 0;
616
+ }
617
+
618
+ .product-gallery--multi .product-img-wrap .product-img {
619
+ width: 100%;
620
+ height: 100%;
621
+ aspect-ratio: 1;
622
+ object-fit: cover;
623
+ cursor: pointer;
624
+ }
625
+
626
+ .product-gallery--many .product-img-wrap {
627
+ width: 56px;
628
+ height: 56px;
629
+ }
630
+
631
+ .product-gallery--many .product-img-wrap .product-img {
632
+ width: 100%;
633
+ height: 100%;
634
+ }
635
+
636
+ .product-img-wrap {
637
+ display: inline-block;
638
+ position: relative;
639
+ padding: 0;
640
+ border: 2px solid transparent;
641
+ border-radius: 12px;
642
+ background: none;
643
+ cursor: pointer;
644
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
645
+ }
646
+
647
+ .product-gallery:not(.product-gallery--multi) .product-img-wrap {
648
+ width: 100%;
649
+ }
650
+
651
+ .product-img-wrap:hover {
652
+ border-color: var(--border);
653
+ }
654
+
655
+ .product-img-wrap--selected {
656
+ border-color: var(--accent);
657
+ box-shadow: 0 0 0 1px var(--accent);
658
+ }
659
+
660
+ .product-img-wrap .product-img {
661
+ display: block;
662
+ }
663
+
664
+ .product-img-badge {
665
+ position: absolute;
666
+ bottom: 4px;
667
+ left: 4px;
668
+ font-size: 0.65rem;
669
+ font-weight: 600;
670
+ text-transform: uppercase;
671
+ letter-spacing: 0.02em;
672
+ color: var(--surface);
673
+ background: var(--accent);
674
+ padding: 2px 6px;
675
+ border-radius: 6px;
676
+ }
677
+
678
+ .product-gallery-note {
679
+ font-size: 0.75rem;
680
+ color: var(--muted);
681
+ margin: 0;
682
+ }
683
+
684
  .product-img {
685
  width: 100%;
686
  aspect-ratio: 1;
 
1120
  color: var(--accent);
1121
  }
1122
 
1123
+ /* Final price: never strikethrough (only original price is styled) */
1124
+ .price-final {
1125
+ text-decoration: none !important;
1126
+ }
1127
+
1128
  /* Original price display styles (vary per creative) */
1129
  .price-original {
1130
  margin-right: 0.35em;
 
1168
  font-weight: 500;
1169
  }
1170
 
1171
+ .cta-position-badge {
1172
+ font-size: 0.8rem;
1173
+ color: var(--muted);
1174
+ font-weight: 400;
1175
+ }
1176
+
1177
  .prompt-block {
1178
  margin-top: 1rem;
1179
  }