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 +52 -15
- backend/app/creatives.py +28 -8
- backend/app/llm.py +30 -0
- backend/app/main.py +57 -15
- backend/app/r2.py +13 -2
- backend/app/replicate_image.py +14 -2
- backend/app/scraper.py +38 -10
- backend/data/gallery/admin.json +621 -0
- backend/requirements.txt +1 -1
- frontend/src/App.jsx +261 -19
- frontend/src/index.css +212 -0
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 |
-
"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
|
|
|
|
|
|
| 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,
|
| 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
|
| 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,
|
| 113 |
-
"price_final": "
|
| 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 (
|
|
|
|
| 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 |
-
|
|
|
|
| 265 |
model_key: str,
|
| 266 |
request: Request | None,
|
| 267 |
) -> list[str]:
|
| 268 |
-
"""Build list of reference image URLs
|
| 269 |
-
urls = []
|
| 270 |
-
if
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[:
|
| 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 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
return (
|
| 511 |
<section className="card product-card">
|
| 512 |
<h2>Product</h2>
|
| 513 |
<div className="product-grid">
|
| 514 |
-
{
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|