sushilideaclan01's picture
Import Hawbeez Creative Studio app
5d54b3c
import { useState, useEffect, useRef, useCallback } from 'react'
import { Link, Route, Routes } from 'react-router-dom'
import JSZip from 'jszip'
import Gallery from './Gallery'
import ImageLightbox from './ImageLightbox'
const API_BASE = '/api'
const SESSION_STORAGE_KEY = 'hawbeez_last_run'
const AUTH_TOKEN_KEY = 'hawbeez_auth_token'
/** Use backend proxy for image URLs to avoid CORS when loading from R2. */
function proxyImageUrl(url) {
return url ? `${API_BASE}/proxy-image?url=${encodeURIComponent(url)}` : ''
}
const TARGET_AUDIENCE_OPTIONS = [
"Parents of toddlers (1–2.5 yrs)",
"Parents of 3–5 yrs",
"Gift buyers (kids)",
"Montessori / alternative education",
"Urban Tier 1 parents",
"Urban Tier 2 parents",
"First-time parents",
"Eco-conscious parents",
"Value-seeking parents (combos/deals)",
"Grandparents (gifting)",
"Screen-time worriers",
"Learning-through-play seekers",
"Educational toy buyers",
"Wooden toy fans",
"Birthday / party gifting",
"Festive gifting (Diwali, Children's Day)",
"COD / UPI-first",
"Instagram / Facebook parents",
"D2C toy buyers",
"₹1k–₹3k toy buyers",
"Repeat Hawbeez buyers",
"Desk organizer / school supplies",
"Bilingual (Hindi/English) families",
"Preschool / nursery parents",
"KG / early primary parents",
]
function authHeaders() {
const token = typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
if (!token) return {}
return { Authorization: `Bearer ${token}` }
}
function LoginPage({ onLogin }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
setError(data.detail || 'Login failed')
return
}
if (data.access_token) {
localStorage.setItem(AUTH_TOKEN_KEY, data.access_token)
onLogin(data.access_token)
}
} catch (_) {
setError('Network error')
} finally {
setLoading(false)
}
}
return (
<div className="app login-page">
<div className="promo-strip">Ad Creative Studio — Hawbeez</div>
<header className="header header--centered">
<div className="header-brand">
<img src="/api/logo" alt="Hawbeez" className="header-logo" />
<h1>Hawbeez</h1>
<p className="tagline">Eco-friendly Toys · Ad Creative Studio</p>
</div>
</header>
<section className="card login-card">
<h2>Sign in</h2>
<p className="login-hint">Use your Hawbeez studio account.</p>
<form onSubmit={handleSubmit} className="login-form">
<label>
<span className="label-text">Username</span>
<input
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
required
/>
</label>
<label>
<span className="label-text">Password</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<button type="submit" className="btn-login" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</section>
<footer className="footer">
<p>Because love deserves a little extra ❤️</p>
</footer>
</div>
)
}
export default function App() {
const [token, setToken] = useState(() => (typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null))
const [url, setUrl] = useState('')
const [loading, setLoading] = useState(false)
const [streamStep, setStreamStep] = useState(null)
const [error, setError] = useState(null)
const [productData, setProductData] = useState(null)
const [analysis, setAnalysis] = useState(null)
const [adCreatives, setAdCreatives] = useState(null)
const [selectedForAds, setSelectedForAds] = useState([])
const [imageModels, setImageModels] = useState([])
const [selectedImageModel, setSelectedImageModel] = useState('nano-banana')
const [generatedAds, setGeneratedAds] = useState([])
const [generatingAds, setGeneratingAds] = useState(false)
const [generatingAdsTotal, setGeneratingAdsTotal] = useState(0)
const [generateAdsError, setGenerateAdsError] = useState(null)
const [editedCreatives, setEditedCreatives] = useState({}) // creative id -> full edited creative object
const [referenceImageUrl, setReferenceImageUrl] = useState(null) // user custom/upload; null = use product image
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState([]) // up to 3 product image URLs user selected for ad generation; order = ref order
const [targetAudiences, setTargetAudiences] = useState([]) // selected audience segments for analysis/creatives (multi-select)
const [targetAudienceOpen, setTargetAudienceOpen] = useState(false)
const targetAudienceDropdownRef = useRef(null)
const [hasStoredSession, setHasStoredSession] = useState(false)
const [storedSession, setStoredSession] = useState(null)
const [lightboxImage, setLightboxImage] = useState(null)
const [backendOpenAIReady, setBackendOpenAIReady] = useState(null) // null = not checked yet, true/false = from /api/health
const generatedAdsSectionRef = useRef(null)
const prevGeneratingRef = useRef(false)
// Close target audience dropdown on outside click
useEffect(() => {
if (!targetAudienceOpen) return
const el = targetAudienceDropdownRef.current
function handleClick(e) {
if (el && !el.contains(e.target)) setTargetAudienceOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [targetAudienceOpen])
const handleLogout = useCallback(() => {
localStorage.removeItem(AUTH_TOKEN_KEY)
setToken(null)
}, [])
// Computed for any code that still expects the old single-result shape
const result =
productData != null || analysis != null || (adCreatives && adCreatives.length > 0)
? {
product_data: productData,
analysis,
ad_creatives: adCreatives || [],
}
: null
useEffect(() => {
if (!adCreatives?.length) return
fetch(`${API_BASE}/image-models`, { headers: authHeaders() })
.then((r) => {
if (r.status === 401) { localStorage.removeItem(AUTH_TOKEN_KEY); setToken(null); return null }
return r.json()
})
.then((data) => {
if (!data) return
setImageModels(data.models || [])
if (data.default && !selectedImageModel) setSelectedImageModel(data.default)
})
.catch(() => {})
}, [adCreatives?.length])
// Check backend OpenAI config (so we can show a clear message before user clicks Generate).
// 404 or errors (e.g. old backend without /api/health) → leave null (don't show warning).
useEffect(() => {
fetch(`${API_BASE}/health`)
.then((r) => {
if (!r.ok) return null
return r.json()
})
.then((data) => {
if (data && typeof data.openai_configured === 'boolean') {
setBackendOpenAIReady(data.openai_configured)
}
// else leave backendOpenAIReady as null (unknown)
})
.catch(() => { /* ignore: backend may not have /api/health */ })
}, [])
// Restore session offer on load
useEffect(() => {
try {
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY)
if (raw) {
const data = JSON.parse(raw)
if (data?.product_data && data?.ad_creatives?.length) {
setHasStoredSession(true)
setStoredSession(data)
}
}
} catch (_) {}
}, [])
// Persist run to session when we have product + analysis + creatives
useEffect(() => {
if (!productData || !analysis || !adCreatives?.length) return
try {
sessionStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({
product_data: productData,
analysis,
ad_creatives: adCreatives,
})
)
} catch (_) {}
}, [productData, analysis, adCreatives])
// Scroll generated ads section into view when generation completes
useEffect(() => {
if (prevGeneratingRef.current && !generatingAds && generatedAds.length > 0) {
generatedAdsSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
prevGeneratingRef.current = generatingAds
}, [generatingAds, generatedAds.length])
async function handleRun() {
const trimmed = url.trim()
if (!trimmed) {
setError('Please enter a Hawbeez product URL.')
return
}
setError(null)
setStreamStep(null)
setProductData(null)
setAnalysis(null)
setAdCreatives(null)
setSelectedForAds([])
setGeneratedAds([])
setGenerateAdsError(null)
setEditedCreatives({})
setReferenceImageUrl(null)
setSelectedReferenceUrls([])
setHasStoredSession(false)
setStoredSession(null)
try {
sessionStorage.removeItem(SESSION_STORAGE_KEY)
} catch (_) {}
setLoading(true)
try {
const res = await fetch(`${API_BASE}/run/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ url: trimmed, target_audience: targetAudiences.length ? targetAudiences : undefined }),
})
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY)
setToken(null)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || res.statusText)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const chunk of lines) {
const dataLine = chunk.split('\n').find((l) => l.startsWith('data: '))
if (!dataLine) continue
try {
const payload = JSON.parse(dataLine.slice(6))
if (payload.event === 'step') {
setStreamStep(payload.message || payload.step)
} else if (payload.event === 'scrape_done') {
setProductData(payload.data)
} else if (payload.event === 'analysis_done') {
setAnalysis(payload.data)
} else if (payload.event === 'creatives_done') {
setAdCreatives(payload.data || [])
} else if (payload.event === 'done') {
setStreamStep(null)
} else if (payload.event === 'error') {
setError(payload.message || 'Something went wrong.')
setStreamStep(null)
}
} catch (_) {}
}
}
} catch (e) {
setError(e.message || 'Something went wrong.')
setStreamStep(null)
} finally {
setLoading(false)
}
}
if (!token) {
return <LoginPage onLogin={(t) => setToken(t)} />
}
return (
<div className="app">
{lightboxImage && (
<ImageLightbox
src={lightboxImage.src}
alt={lightboxImage.alt}
onClose={() => setLightboxImage(null)}
/>
)}
<header className="header">
<div className="header-brand">
<img src="/api/logo" alt="Hawbeez" className="header-logo" />
<h1>Hawbeez</h1>
<p className="tagline">Eco-friendly Toys · Ad Creative Studio</p>
</div>
<div className="header-actions">
<nav className="header-nav">
<Link to="/" className="header-nav-link">Studio</Link>
<Link to="/gallery" className="header-nav-link">Gallery</Link>
</nav>
<button
type="button"
className="btn-logout"
onClick={() => {
localStorage.removeItem(AUTH_TOKEN_KEY)
setToken(null)
}}
>
Sign out
</button>
</div>
</header>
<Routes>
<Route path="/gallery" element={<Gallery onLogout={handleLogout} />} />
<Route path="/" element={(
<>
<div className="promo-strip">
Scrape, analyze & generate creatives for Hawbeez
</div>
{hasStoredSession && storedSession && (
<div className="session-restore-bar">
<span>You have a previous session saved.</span>
<button
type="button"
className="btn-restore-session"
onClick={() => {
setProductData(storedSession.product_data)
setAnalysis(storedSession.analysis)
setAdCreatives(storedSession.ad_creatives)
setHasStoredSession(false)
setStoredSession(null)
}}
>
Restore last session
</button>
<button type="button" className="btn-dismiss-session" onClick={() => { setHasStoredSession(false); setStoredSession(null) }}>
Dismiss
</button>
</div>
)}
<section className="hero">
{backendOpenAIReady === false && (
<p className="error" style={{ marginBottom: '0.75rem' }}>
Backend is missing OPENAI_API_KEY. Set it in backend/.env and restart the backend.
</p>
)}
<p className="hero-desc">
Paste a Hawbeez product page URL. We’ll scrape the product, run a deep marketing analysis, and generate ad creative packages (product & no-product) for Meta.
</p>
<div className="input-row">
<input
type="url"
placeholder="https://hawbeez.com/products/..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRun()}
disabled={loading}
className="url-input"
/>
<button
type="button"
onClick={handleRun}
disabled={loading}
className="btn-run"
>
{loading ? 'Generating…' : 'Generate'}
</button>
</div>
<div className="target-audience-row" ref={targetAudienceDropdownRef}>
<span className="target-audience-label">Target audiences (optional)</span>
<div className="target-audience-multiselect">
<button
type="button"
onClick={() => !loading && setTargetAudienceOpen((o) => !o)}
disabled={loading}
className="target-audience-trigger"
aria-expanded={targetAudienceOpen}
aria-haspopup="listbox"
>
{targetAudiences.length === 0
? 'Select target audiences…'
: `${targetAudiences.length} selected`}
</button>
{targetAudienceOpen && (
<div className="target-audience-dropdown" role="listbox">
<div className="target-audience-actions">
<button
type="button"
className="target-audience-action-btn"
onClick={() => setTargetAudiences(TARGET_AUDIENCE_OPTIONS.slice())}
>
Select all
</button>
<button
type="button"
className="target-audience-action-btn"
onClick={() => setTargetAudiences([])}
>
Clear
</button>
</div>
<div className="target-audience-list">
{TARGET_AUDIENCE_OPTIONS.map((opt) => (
<label key={opt} className="target-audience-option">
<input
type="checkbox"
checked={targetAudiences.includes(opt)}
onChange={() => {
setTargetAudiences((prev) =>
prev.includes(opt) ? prev.filter((x) => x !== opt) : [...prev, opt]
)
}}
/>
<span>{opt}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
{error && <p className="error">{error}</p>}
</section>
{loading && (
<section className="loading">
<div className="spinner" />
<p className="loading-step">{streamStep || 'Starting…'}</p>
<p className="loading-hint">Results will appear below as each step completes.</p>
</section>
)}
{(productData || analysis || (adCreatives && adCreatives.length > 0)) && (
<div className="results">
{productData && (
<ProductCard
data={productData}
selectedReferenceUrls={selectedReferenceUrls}
onToggleReference={(url) => {
setSelectedReferenceUrls((prev) => {
const has = prev.includes(url)
if (has) return prev.filter((u) => u !== url)
if (prev.length >= 3) return prev
return [...prev, url]
})
}}
onImageClick={(src, alt) => setLightboxImage({ src, alt })}
/>
)}
{analysis && (
<AnalysisSection
data={analysis}
defaultCollapsed={!!(adCreatives && adCreatives.length > 0)}
/>
)}
{adCreatives && adCreatives.length > 0 && (
<CreativesSection
productName={productData?.product_name}
creatives={adCreatives}
selectedForAds={selectedForAds}
onToggleSelect={(id) => {
setSelectedForAds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}}
onSelectAll={() => setSelectedForAds((adCreatives || []).map((c) => c.id))}
onDeselectAll={() => setSelectedForAds([])}
productImageUrl={(selectedReferenceUrls && selectedReferenceUrls[0]) || (productData?.product_images || '').split(',')[0]?.trim() || null}
selectedReferenceUrls={selectedReferenceUrls}
referenceImageUrl={referenceImageUrl}
onReferenceImageUrlChange={setReferenceImageUrl}
imageModels={imageModels}
selectedImageModel={selectedImageModel}
onModelChange={setSelectedImageModel}
onGenerateAds={async () => {
const list = adCreatives || []
const selected = list.filter((c) => selectedForAds.includes(c.id))
if (!selected.length) return
const productName = productData?.product_name ?? null
setGenerateAdsError(null)
setGeneratingAds(true)
setGeneratingAdsTotal(selected.length)
setGeneratedAds([])
const imageUrls = (productData?.product_images || '').split(',').map((s) => s.trim()).filter(Boolean)
const refs = (selectedReferenceUrls && selectedReferenceUrls.length > 0)
? selectedReferenceUrls.filter((u) => u && imageUrls.includes(u)).slice(0, 3)
: imageUrls.slice(0, 3)
const primary = refs[0] || null
const body = {
creatives: selected.map((c) => {
const base = editedCreatives[c.id] ?? c
const prompt = (base.scene_prompt ?? base.image_prompt ?? '').trim() || base.scene_prompt || base.image_prompt || ''
return { ...base, scene_prompt: prompt }
}),
model_key: selectedImageModel,
product_image_url: primary,
product_image_urls: refs.length ? refs : null,
reference_image_url: referenceImageUrl || null,
product_name: productName,
}
try {
const res = await fetch(`${API_BASE}/generate-ads/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
})
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY)
setToken(null)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || res.statusText)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const chunk of lines) {
const dataLine = chunk.split('\n').find((l) => l.startsWith('data: '))
if (!dataLine) continue
try {
const payload = JSON.parse(dataLine.slice(6))
if (payload.event === 'result' && payload.data) {
setGeneratedAds((prev) => {
const next = prev.filter((r) => r.creative_id !== payload.data.creative_id)
return [...next, payload.data]
})
} else if (payload.event === 'done') {
// stream finished
} else if (payload.event === 'error') {
setGenerateAdsError(payload.message || 'Generation failed')
}
} catch (_) {}
}
}
} catch (e) {
setGenerateAdsError(e.message || 'Failed to generate ads')
} finally {
setGeneratingAds(false)
setGeneratingAdsTotal(0)
}
}}
generatingAds={generatingAds}
generatingAdsTotal={generatingAdsTotal}
generateAdsError={generateAdsError}
generatedAds={generatedAds}
editedCreatives={editedCreatives}
onEditedCreativeChange={(id, creative) => setEditedCreatives((prev) => ({ ...prev, [id]: creative }))}
generatedAdsSectionRef={generatedAdsSectionRef}
onImageClick={(src, alt) => setLightboxImage({ src, alt })}
/>
)}
</div>
)}
<footer className="footer">
<p>Because love deserves a little extra ❤️</p>
</footer>
</>
)} />
</Routes>
</div>
)
}
function ProductCard({ data, selectedReferenceUrls = [], onToggleReference, onImageClick }) {
const imageUrls = (data?.product_images || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const multi = imageUrls.length > 1
const many = imageUrls.length >= 6
const refList = Array.isArray(selectedReferenceUrls) ? selectedReferenceUrls : []
return (
<section className="card product-card">
<h2>Product</h2>
<div className="product-grid">
<div className={`product-gallery ${multi ? 'product-gallery--multi' : ''} ${many ? 'product-gallery--many' : ''}`}>
{imageUrls.map((imgUrl, i) => {
const refIndex = refList.indexOf(imgUrl)
const isSelected = refIndex >= 0
const refLabel = isSelected ? `Ref ${refIndex + 1}` : null
return (
<button
key={i}
type="button"
className={`product-img-wrap ${isSelected ? 'product-img-wrap--selected' : ''}`}
onClick={() => onToggleReference?.(imgUrl)}
onDoubleClick={() => onImageClick?.(imgUrl, data?.product_name)}
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)`}
>
<img
src={imgUrl}
alt={data?.product_name ? `${data.product_name} (${i + 1})` : ''}
className={`product-img ${onImageClick ? 'img-expandable' : ''}`}
draggable={false}
/>
{refLabel && <span className="product-img-badge">{refLabel}</span>}
</button>
)
})}
{imageUrls.length > 0 && (
<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>
)}
</div>
<div className="product-meta">
<h3>{data?.product_name || '—'}</h3>
<p className="product-price">{data?.price || '—'}</p>
<p className="product-category">{data?.brand} · {data?.category || 'Jewellery'}</p>
{data?.description && (
<p className="product-desc">{data.description}</p>
)}
</div>
</div>
</section>
)
}
function AnalysisSection({ data, defaultCollapsed = false }) {
const [isExpanded, setIsExpanded] = useState(!defaultCollapsed)
if (!data) return null
const ideal = data.ideal_customer || {}
const priceAnalysis = data.price_analysis || {}
const copyDir = data.copy_direction || {}
const MAX_PREVIEW_LEN = 180
const positioningPreview = data.positioning
? (data.positioning.length <= MAX_PREVIEW_LEN
? data.positioning
: data.positioning.slice(0, MAX_PREVIEW_LEN).trim() + '…')
: ''
const parts = []
if (data.product_visual_notes) parts.push('product visual')
if (data.tagline_options?.length) parts.push('taglines')
if (ideal.age_range || ideal.lifestyle) parts.push('ideal customer')
if (data.emotional_triggers?.length) parts.push('emotional triggers')
if (priceAnalysis.perception || priceAnalysis.value_framing) parts.push('price')
if (data.ad_angles?.length) parts.push('ad angles')
if (data.shooting_angles?.length) parts.push('shooting angles')
if (data.color_worlds?.length) parts.push('color worlds')
if (data.unique_selling_points?.length) parts.push('USPs')
if (copyDir.tone) parts.push('copy direction')
const summaryLabel = parts.length ? parts.join(', ') : ''
return (
<section className={`card analysis-card ${isExpanded ? 'analysis-card--expanded' : 'analysis-card--collapsed'}`}>
<h2>Marketing Analysis</h2>
{!isExpanded ? (
<div className="analysis-preview">
{positioningPreview && <p className="analysis-preview-text">{positioningPreview}</p>}
{summaryLabel && <p className="analysis-preview-meta">{summaryLabel}</p>}
<button
type="button"
className="analysis-expand-btn"
onClick={() => setIsExpanded(true)}
aria-expanded="false"
>
Show full analysis
</button>
</div>
) : (
<>
<div className="analysis-grid">
{data.product_visual_notes && (
<div className="analysis-block analysis-block--product-visual">
<h4>Product visual (from images)</h4>
<p>{data.product_visual_notes}</p>
</div>
)}
{data.positioning && (
<div className="analysis-block">
<h4>Positioning</h4>
<p>{data.positioning}</p>
</div>
)}
{data.tagline_options?.length > 0 && (
<div className="analysis-block">
<h4>Taglines</h4>
<ul>{data.tagline_options.map((t, i) => <li key={i}>{t}</li>)}</ul>
</div>
)}
{ideal.age_range && (
<div className="analysis-block">
<h4>Ideal customer</h4>
<p><strong>Age:</strong> {ideal.age_range} · <strong>Platform:</strong> {ideal.platform}</p>
<p>{ideal.lifestyle}</p>
{ideal.pain_points?.length > 0 && (
<ul className="pain-points">
{ideal.pain_points.map((p, i) => <li key={i}>{p}</li>)}
</ul>
)}
</div>
)}
{data.emotional_triggers?.length > 0 && (
<div className="analysis-block">
<h4>Emotional triggers</h4>
<ul>{data.emotional_triggers.map((t, i) => <li key={i}>{t}</li>)}</ul>
</div>
)}
{(priceAnalysis.perception || priceAnalysis.value_framing) && (
<div className="analysis-block">
<h4>Price</h4>
<p>{priceAnalysis.perception}</p>
<p>{priceAnalysis.value_framing}</p>
</div>
)}
{data.ad_angles?.length > 0 && (
<div className="analysis-block">
<h4>Ad angles</h4>
<ul className="ad-angles">
{data.ad_angles.slice(0, 5).map((a, i) => (
<li key={i}>
<strong>{a.angle_name}</strong>: {a.hook} — {a.why_it_works}
</li>
))}
</ul>
</div>
)}
{data.shooting_angles?.length > 0 && (
<div className="analysis-block">
<h4>Shooting angles</h4>
<ul>{data.shooting_angles.map((a, i) => <li key={i}>{a}</li>)}</ul>
</div>
)}
{data.color_worlds?.length > 0 && (
<div className="analysis-block">
<h4>Color worlds</h4>
<ul>{data.color_worlds.map((c, i) => <li key={i}>{c}</li>)}</ul>
</div>
)}
{data.unique_selling_points?.length > 0 && (
<div className="analysis-block">
<h4>USPs</h4>
<ul>{data.unique_selling_points.map((u, i) => <li key={i}>{u}</li>)}</ul>
</div>
)}
{copyDir.tone && (
<div className="analysis-block">
<h4>Copy direction</h4>
<p><strong>Tone:</strong> {copyDir.tone} · <strong>Style:</strong> {copyDir.headline_style}</p>
</div>
)}
</div>
<button
type="button"
className="analysis-collapse-btn"
onClick={() => setIsExpanded(false)}
aria-expanded="true"
>
Show less
</button>
</>
)}
</section>
)
}
function CreativesSection({
productName = null,
creatives,
selectedForAds = [],
onToggleSelect,
onSelectAll,
onDeselectAll,
productImageUrl = null,
referenceImageUrl = null,
onReferenceImageUrlChange,
imageModels = [],
selectedImageModel,
onModelChange,
onGenerateAds,
generatingAds,
generatingAdsTotal = 0,
generateAdsError,
generatedAds = [],
editedCreatives = {},
onEditedCreativeChange,
generatedAdsSectionRef,
onImageClick,
}) {
const list = Array.isArray(creatives) ? creatives : []
const zipBase = (productName || 'Hawbeez').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'Hawbeez'
const [expandedId, setExpandedId] = useState(null)
const [customUrlInput, setCustomUrlInput] = useState('')
const [uploadingRef, setUploadingRef] = useState(false)
const [refError, setRefError] = useState(null)
const [refPreviewBlobUrl, setRefPreviewBlobUrl] = useState(null) // immediate preview while uploading
const [copiedId, setCopiedId] = useState(null)
const [jsonEditDraft, setJsonEditDraft] = useState({}) // id -> raw string
const [jsonParseError, setJsonParseError] = useState(null) // per-section parse error
const [jsonEditorOpenId, setJsonEditorOpenId] = useState(null) // which creative's JSON editor is open
const [downloadingZip, setDownloadingZip] = useState(false)
const [selectedForDownload, setSelectedForDownload] = useState([]) // creative_id of generated ads to download
const creativeMap = list.reduce((acc, c) => ({ ...acc, [c.id]: c }), {})
function getEffectiveCreative(c) {
const base = editedCreatives[c.id] ?? c
const prompt = (base.scene_prompt ?? base.image_prompt ?? '').trim() || base.scene_prompt || base.image_prompt || ''
return { ...base, scene_prompt: prompt }
}
// Use pathname for display when URL is from another origin so img loads via current origin (proxy)
function refUrlForDisplay(url) {
if (!url) return null
try {
const u = new URL(url, window.location.origin)
if (u.origin !== window.location.origin) return u.pathname + u.search
return url
} catch {
return url
}
}
const displayRefUrl = refPreviewBlobUrl || refUrlForDisplay(referenceImageUrl) || productImageUrl
const isUsingProduct = !referenceImageUrl
async function uploadImageBlob(blob) {
setRefError(null)
// Show preview immediately so user sees their image
const prevBlob = refPreviewBlobUrl
const blobUrl = URL.createObjectURL(blob)
setRefPreviewBlobUrl(blobUrl)
if (prevBlob) URL.revokeObjectURL(prevBlob)
setUploadingRef(true)
try {
const form = new FormData()
form.append('file', blob, blob.name || 'image.png')
const res = await fetch('/api/upload-reference', { method: 'POST', headers: authHeaders(), body: form })
const data = await res.json()
if (!res.ok) throw new Error(data.detail || res.statusText)
onReferenceImageUrlChange?.(data.url)
setRefPreviewBlobUrl(null)
URL.revokeObjectURL(blobUrl)
} catch (e) {
setRefError(e.message || 'Upload failed')
setRefPreviewBlobUrl(null)
URL.revokeObjectURL(blobUrl)
} finally {
setUploadingRef(false)
}
}
function handleFileSelect(e) {
const file = e.target?.files?.[0]
if (file && file.type.startsWith('image/')) uploadImageBlob(file)
e.target.value = ''
}
return (
<section className="card creatives-card">
<h2>Ad Creatives ({list.length})</h2>
<p className="creatives-hint">
Select creatives below, choose an image model, then click Generate ads to create images via Replicate.
</p>
{generatedAds.length > 0 && (
<div className="generated-ads-section" ref={generatedAdsSectionRef}>
<div className="generated-ads-section-header">
<h3>Generated ads</h3>
{generatedAds.some((r) => r.image_url) && (
<div className="generated-ads-download-actions">
<button
type="button"
className="btn-download-selected"
disabled={downloadingZip || selectedForDownload.length === 0}
onClick={async () => {
const toDownload = generatedAds.filter((r) => r.image_url && selectedForDownload.includes(r.creative_id))
if (!toDownload.length) return
setDownloadingZip(true)
try {
const zip = new JSZip()
await Promise.all(
toDownload.map(async (r) => {
const creative = creativeMap[r.creative_id]
const id = String(r.creative_id).padStart(2, '0')
const slug = (creative?.concept_name || 'image').replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-|-$/g, '') || 'image'
const fileName = `Hawbeez-ad-${id}-${slug}.png`
const res = await fetch(proxyImageUrl(r.image_url))
const blob = await res.blob()
zip.file(fileName, blob)
})
)
const blob = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${zipBase}-selected-ads.zip`
a.click()
URL.revokeObjectURL(url)
} catch (_) {}
setDownloadingZip(false)
}}
>
{downloadingZip ? 'Preparing…' : `Download selected (${selectedForDownload.length})`}
</button>
<button
type="button"
className="btn-download-zip"
disabled={downloadingZip}
onClick={async () => {
setDownloadingZip(true)
try {
const zip = new JSZip()
const withUrls = generatedAds.filter((r) => r.image_url)
await Promise.all(
withUrls.map(async (r) => {
const creative = creativeMap[r.creative_id]
const id = String(r.creative_id).padStart(2, '0')
const slug = (creative?.concept_name || 'image').replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-|-$/g, '') || 'image'
const fileName = `Hawbeez-ad-${id}-${slug}.png`
const res = await fetch(proxyImageUrl(r.image_url))
const blob = await res.blob()
zip.file(fileName, blob)
})
)
const blob = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${zipBase}-all-ads.zip`
a.click()
URL.revokeObjectURL(url)
} catch (_) {}
setDownloadingZip(false)
}}
>
{downloadingZip ? 'Preparing ZIP…' : 'Download all as ZIP'}
</button>
</div>
)}
</div>
<div className="generated-ads-grid">
{generatedAds.map((r) => {
const creative = creativeMap[r.creative_id]
const id = String(r.creative_id).padStart(2, '0')
const slug = (creative?.concept_name || 'image').replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-|-$/g, '') || 'image'
const fileName = `Hawbeez-ad-${id}-${slug}.png`
return (
<div key={r.creative_id} className="generated-ad-card">
{r.error ? (
<div className="generated-ad-error">
<span>#{r.creative_id}</span>
<p>{r.error}</p>
</div>
) : r.image_url ? (
<>
<div
className="generated-ad-img-wrap"
onMouseEnter={(e) => e.currentTarget.classList.add('is-hover')}
onMouseLeave={(e) => e.currentTarget.classList.remove('is-hover')}
>
<img
src={proxyImageUrl(r.image_url)}
alt={creative?.concept_name || `Ad ${r.creative_id}`}
className={`generated-ad-img ${onImageClick ? 'img-expandable' : ''}`}
onClick={(e) => { e.stopPropagation(); onImageClick?.(proxyImageUrl(r.image_url), creative?.concept_name || `Ad ${r.creative_id}`) }}
role={onImageClick ? 'button' : undefined}
tabIndex={onImageClick ? 0 : undefined}
onKeyDown={(e) => onImageClick && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onImageClick(proxyImageUrl(r.image_url), creative?.concept_name || `Ad ${r.creative_id}`))}
/>
<label className="generated-ad-select-checkbox">
<input
type="checkbox"
checked={selectedForDownload.includes(r.creative_id)}
onChange={(e) => {
e.stopPropagation()
setSelectedForDownload((prev) =>
e.target.checked
? [...prev, r.creative_id]
: prev.filter((id) => id !== r.creative_id)
)
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="generated-ad-select-label">Select</span>
</label>
</div>
<div className="generated-ad-card-footer">
<p className="generated-ad-label">
#{r.creative_id} {creative?.concept_name || ''}
</p>
</div>
</>
) : null}
</div>
)
})}
</div>
</div>
)}
<div className="reference-image-section">
<h4>Reference image for generation</h4>
<p className="reference-hint">Used by the model when supported (e.g. nano-banana). Product image or your own.</p>
<div className="reference-image-row">
{displayRefUrl ? (
<img
src={displayRefUrl}
alt="Reference"
className={`reference-thumb ${onImageClick ? 'img-expandable' : ''}`}
onClick={() => onImageClick?.(displayRefUrl, 'Reference')}
role={onImageClick ? 'button' : undefined}
tabIndex={onImageClick ? 0 : undefined}
onKeyDown={(e) => onImageClick && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onImageClick(displayRefUrl, 'Reference'))}
/>
) : (
<div className="reference-thumb reference-thumb-placeholder">No image</div>
)}
<div className="reference-controls">
{referenceImageUrl ? (
<button type="button" className="btn-reference-use-product" onClick={() => onReferenceImageUrlChange?.(null)}>
Use product image
</button>
) : null}
<input
type="url"
placeholder="Custom image URL"
value={customUrlInput}
onChange={(e) => setCustomUrlInput(e.target.value)}
onBlur={() => {
const v = customUrlInput.trim()
if (v) onReferenceImageUrlChange?.(v)
}}
onKeyDown={(e) => e.key === 'Enter' && (customUrlInput.trim() ? onReferenceImageUrlChange?.(customUrlInput.trim()) : null)}
className="reference-url-input"
/>
<label className="reference-upload-btn">
<span>Upload file</span>
<input type="file" accept="image/*" onChange={handleFileSelect} disabled={uploadingRef} style={{ display: 'none' }} />
</label>
</div>
</div>
{refError && <p className="error reference-error">{refError}</p>}
</div>
<div className="generate-ads-toolbar">
<label className="toolbar-label">
Image model
<select
value={selectedImageModel}
onChange={(e) => onModelChange(e.target.value)}
disabled={generatingAds}
className="toolbar-select"
>
{imageModels.map((m) => (
<option key={m.key} value={m.key}>
{m.key}
</option>
))}
</select>
</label>
<button
type="button"
onClick={onGenerateAds}
disabled={selectedForAds.length === 0 || generatingAds}
className="btn-generate-ads"
>
{generatingAds
? `Generating ${generatedAds.length}/${generatingAdsTotal}…`
: `Generate ads (${selectedForAds.length} selected)`}
</button>
</div>
{generateAdsError && <p className="error">{generateAdsError}</p>}
<div className="creatives-select-actions">
<button type="button" className="btn-select-all" onClick={onSelectAll} disabled={generatingAds || list.length === 0}>
Select all
</button>
<button type="button" className="btn-deselect-all" onClick={onDeselectAll} disabled={generatingAds || selectedForAds.length === 0}>
Deselect all
</button>
</div>
<div className="creatives-list">
{list.map((c) => (
<div
key={c.id}
className={`creative-item ${c.creative_type === 'NO_PRODUCT' ? 'no-product' : 'product'}`}
>
<div className="creative-header" onClick={() => {
const next = expandedId === c.id ? null : c.id
setExpandedId(next)
if (next === null) setJsonEditorOpenId(null)
}}>
<input
type="checkbox"
checked={selectedForAds.includes(c.id)}
onChange={(e) => {
e.stopPropagation()
onToggleSelect(c.id)
}}
onClick={(e) => e.stopPropagation()}
className="creative-checkbox"
title="Select to generate ad image"
/>
<span className="creative-id">#{c.id}</span>
<span className="creative-concept">{c.concept_name}</span>
<span className="creative-type">{c.creative_type}</span>
<span className="creative-category">{c.category}</span>
<span className="creative-platform">{c.best_platform}</span>
</div>
{expandedId === c.id && (() => {
const effective = getEffectiveCreative(c)
return (
<div className="creative-detail">
<div className="copy-block">
<div className="copy-block-header">
<h5>Copy</h5>
<button
type="button"
className="btn-copy-copy"
onClick={async () => {
const text = JSON.stringify(effective, null, 2)
try {
await navigator.clipboard.writeText(text)
setCopiedId(c.id)
setTimeout(() => setCopiedId(null), 2000)
} catch (_) {}
}}
>
{copiedId === c.id ? 'Copied!' : 'Copy to clipboard'}
</button>
</div>
<p className="headline-serif">{effective.ad_copy?.headline_serif}</p>
<p className="headline-script">{effective.ad_copy?.headline_script}</p>
<p className="body-copy">{effective.ad_copy?.body}</p>
{effective.includes_price && (
<p className="price-copy">
<span
className={`price-original price-original-${['strikethrough', 'slant', 'faded', 'small', 'double'].includes(effective.ad_copy?.price_original_style) ? effective.ad_copy.price_original_style : 'strikethrough'}`}
>
{effective.ad_copy?.price_original}
</span>{' '}
<span className="price-final">{effective.ad_copy?.price_final}</span>
</p>
)}
<p className={`cta-copy${effective.cta_position ? ` cta-position-${effective.cta_position}` : ''}`}>
{effective.ad_copy?.cta}
{effective.cta_position && (
<span className="cta-position-badge" title="CTA placement in the ad image">
{' '}({effective.cta_position.replace(/_/g, ' ')})
</span>
)}
</p>
</div>
<div className="prompt-block">
<h5>Scene prompt</h5>
<p className="prompt-block-hint">The full creative JSON above is sent to the image model; this field shows the scene/visual description only.</p>
<textarea
value={effective.scene_prompt ?? effective.image_prompt ?? ''}
readOnly
placeholder="Scene / visual description for the ad image…"
className="prompt-textarea prompt-textarea-readonly"
rows={4}
/>
</div>
{jsonEditorOpenId !== c.id ? (
<button
type="button"
className="btn-open-edit-json"
onClick={() => setJsonEditorOpenId(c.id)}
>
Edit full JSON
</button>
) : (
<div className="edit-json-block">
<div className="edit-json-header">
<h5>Edit full JSON</h5>
<button type="button" className="btn-close-edit-json" onClick={() => setJsonEditorOpenId(null)}>
Close
</button>
</div>
<textarea
className="edit-json-textarea"
value={jsonEditDraft[c.id] ?? JSON.stringify(effective, null, 2)}
onChange={(e) => {
setJsonParseError(null)
setJsonEditDraft((prev) => ({ ...prev, [c.id]: e.target.value }))
}}
placeholder="Full creative JSON…"
rows={12}
spellCheck={false}
/>
<div className="edit-json-actions">
<button
type="button"
className="btn-apply-json"
onClick={() => {
const raw = jsonEditDraft[c.id] ?? JSON.stringify(effective, null, 2)
setJsonParseError(null)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.id === 'number') {
onEditedCreativeChange?.(c.id, parsed)
setJsonEditDraft((prev) => { const next = { ...prev }; delete next[c.id]; return next })
} else {
setJsonParseError('JSON must include an "id" number.')
}
} catch (e) {
setJsonParseError(e.message || 'Invalid JSON')
}
}}
>
Apply
</button>
{jsonParseError && <span className="edit-json-error">{jsonParseError}</span>}
</div>
</div>
)}
{effective.layout_notes && (
<p className="layout-notes"><strong>Layout:</strong> {effective.layout_notes}</p>
)}
</div>
)
})()}
</div>
))}
</div>
</section>
)
}