Spaces:
Sleeping
Sleeping
| 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> | |
| ) | |
| } | |