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 (
Ad Creative Studio — Hawbeez
Hawbeez

Hawbeez

Eco-friendly Toys · Ad Creative Studio

Sign in

Use your Hawbeez studio account.

{error &&

{error}

}
) } 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 setToken(t)} /> } return (
{lightboxImage && ( setLightboxImage(null)} /> )}
Hawbeez

Hawbeez

Eco-friendly Toys · Ad Creative Studio

} />
Scrape, analyze & generate creatives for Hawbeez
{hasStoredSession && storedSession && (
You have a previous session saved.
)}
{backendOpenAIReady === false && (

Backend is missing OPENAI_API_KEY. Set it in backend/.env and restart the backend.

)}

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.

setUrl(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleRun()} disabled={loading} className="url-input" />
Target audiences (optional)
{targetAudienceOpen && (
{TARGET_AUDIENCE_OPTIONS.map((opt) => ( ))}
)}
{error &&

{error}

}
{loading && (

{streamStep || 'Starting…'}

Results will appear below as each step completes.

)} {(productData || analysis || (adCreatives && adCreatives.length > 0)) && (
{productData && ( { 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 && ( 0)} /> )} {adCreatives && adCreatives.length > 0 && ( { 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 })} /> )}
)}

Because love deserves a little extra ❤️

)} />
) } 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 (

Product

{imageUrls.map((imgUrl, i) => { const refIndex = refList.indexOf(imgUrl) const isSelected = refIndex >= 0 const refLabel = isSelected ? `Ref ${refIndex + 1}` : null return ( ) })} {imageUrls.length > 0 && (

Click images to select up to 3 as reference for ad generation (order = ref order). Used in marketing analysis.

)}

{data?.product_name || '—'}

{data?.price || '—'}

{data?.brand} · {data?.category || 'Jewellery'}

{data?.description && (

{data.description}

)}
) } 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 (

Marketing Analysis

{!isExpanded ? (
{positioningPreview &&

{positioningPreview}

} {summaryLabel &&

{summaryLabel}

}
) : ( <>
{data.product_visual_notes && (

Product visual (from images)

{data.product_visual_notes}

)} {data.positioning && (

Positioning

{data.positioning}

)} {data.tagline_options?.length > 0 && (

Taglines

    {data.tagline_options.map((t, i) =>
  • {t}
  • )}
)} {ideal.age_range && (

Ideal customer

Age: {ideal.age_range} · Platform: {ideal.platform}

{ideal.lifestyle}

{ideal.pain_points?.length > 0 && (
    {ideal.pain_points.map((p, i) =>
  • {p}
  • )}
)}
)} {data.emotional_triggers?.length > 0 && (

Emotional triggers

    {data.emotional_triggers.map((t, i) =>
  • {t}
  • )}
)} {(priceAnalysis.perception || priceAnalysis.value_framing) && (

Price

{priceAnalysis.perception}

{priceAnalysis.value_framing}

)} {data.ad_angles?.length > 0 && (

Ad angles

    {data.ad_angles.slice(0, 5).map((a, i) => (
  • {a.angle_name}: {a.hook} — {a.why_it_works}
  • ))}
)} {data.shooting_angles?.length > 0 && (

Shooting angles

    {data.shooting_angles.map((a, i) =>
  • {a}
  • )}
)} {data.color_worlds?.length > 0 && (

Color worlds

    {data.color_worlds.map((c, i) =>
  • {c}
  • )}
)} {data.unique_selling_points?.length > 0 && (

USPs

    {data.unique_selling_points.map((u, i) =>
  • {u}
  • )}
)} {copyDir.tone && (

Copy direction

Tone: {copyDir.tone} · Style: {copyDir.headline_style}

)}
)}
) } 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 (

Ad Creatives ({list.length})

Select creatives below, choose an image model, then click Generate ads to create images via Replicate.

{generatedAds.length > 0 && (

Generated ads

{generatedAds.some((r) => r.image_url) && (
)}
{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 (
{r.error ? (
#{r.creative_id}

{r.error}

) : r.image_url ? ( <>
e.currentTarget.classList.add('is-hover')} onMouseLeave={(e) => e.currentTarget.classList.remove('is-hover')} > {creative?.concept_name { 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}`))} />

#{r.creative_id} {creative?.concept_name || ''}

) : null}
) })}
)}

Reference image for generation

Used by the model when supported (e.g. nano-banana). Product image or your own.

{displayRefUrl ? ( Reference onImageClick?.(displayRefUrl, 'Reference')} role={onImageClick ? 'button' : undefined} tabIndex={onImageClick ? 0 : undefined} onKeyDown={(e) => onImageClick && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onImageClick(displayRefUrl, 'Reference'))} /> ) : (
No image
)}
{referenceImageUrl ? ( ) : null} 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" />
{refError &&

{refError}

}
{generateAdsError &&

{generateAdsError}

}
{list.map((c) => (
{ const next = expandedId === c.id ? null : c.id setExpandedId(next) if (next === null) setJsonEditorOpenId(null) }}> { e.stopPropagation() onToggleSelect(c.id) }} onClick={(e) => e.stopPropagation()} className="creative-checkbox" title="Select to generate ad image" /> #{c.id} {c.concept_name} {c.creative_type} {c.category} {c.best_platform}
{expandedId === c.id && (() => { const effective = getEffectiveCreative(c) return (
Copy

{effective.ad_copy?.headline_serif}

{effective.ad_copy?.headline_script}

{effective.ad_copy?.body}

{effective.includes_price && (

{effective.ad_copy?.price_original} {' '} {effective.ad_copy?.price_final}

)}

{effective.ad_copy?.cta} {effective.cta_position && ( {' '}({effective.cta_position.replace(/_/g, ' ')}) )}

Scene prompt

The full creative JSON above is sent to the image model; this field shows the scene/visual description only.