import React, { useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import {
ArrowLeft, ArrowRight, Check, ChevronRight, ClipboardList, Edit3,
Heart, LayoutDashboard, Menu, Package, Palette, Plus, Search, Settings,
ShoppingBag, Sparkles, Trash2, X
} from 'lucide-react'
import './styles.css'
import { api, API_URL, AUTH_EXPIRED_EVENT } from './api'
const initialCategories = [
{ id: 'canecas', name: 'Canecas', emoji: '☕', color: '#ffe8d8' },
{ id: 'garrafas', name: 'Garrafas', emoji: '🧴', color: '#e4efff' },
{ id: 'imas', name: 'Ímãs de geladeira', emoji: '🧲', color: '#e4f6e9' },
{ id: 'kits', name: 'Kits presente', emoji: '🎁', color: '#f2e8ff' }
]
const initialProducts = [
{ id: 'caneca-classica', category: 'canecas', name: 'Caneca cerâmica clássica', description: 'Caneca branca de cerâmica, impressão frente e verso.', price: 39.9, oldPrice: 49.9, image: 'https://images.unsplash.com/photo-1514228742587-6b1558fcca3d?auto=format&fit=crop&w=900&q=85', badge: 'Mais vendida' },
{ id: 'caneca-colorida', category: 'canecas', name: 'Caneca com interior colorido', description: 'Escolha sua cor favorita e crie algo único.', price: 44.9, image: 'https://images.unsplash.com/photo-1577937927133-66ef06acdf18?auto=format&fit=crop&w=900&q=85', badge: 'Novidade' },
{ id: 'garrafa-termica', category: 'garrafas', name: 'Garrafa térmica 500ml', description: 'Aço inoxidável, mantém a bebida na temperatura ideal.', price: 79.9, oldPrice: 99.9, image: 'https://images.unsplash.com/photo-1602143407151-7111542de6e8?auto=format&fit=crop&w=900&q=85', badge: 'Oferta' },
{ id: 'ima-polaroid', category: 'imas', name: 'Ímã estilo polaroid', description: 'Transforme seus melhores momentos em lembranças.', price: 12.9, image: 'https://images.unsplash.com/photo-1500534623283-312aade485b7?auto=format&fit=crop&w=900&q=85', badge: '' },
{ id: 'kit-afeto', category: 'kits', name: 'Kit afeto personalizado', description: 'Caneca, cartão e embalagem especial para presentear.', price: 89.9, image: 'https://images.unsplash.com/photo-1513883049090-d0b7439799bf?auto=format&fit=crop&w=900&q=85', badge: 'Presenteie' }
]
const initialArtworks = [
{ id: 'amor', name: 'Amor em cada detalhe', category: 'Românticas', color: '#f9d4db', accent: '#e86d83', texts: ['Meu amor', 'é você'] },
{ id: 'minimal', name: 'Frase minimalista', category: 'Frases', color: '#e7e2d7', accent: '#2c2c2c', texts: ['Faça', 'acontecer'] },
{ id: 'sun', name: 'Good vibes', category: 'Alegres', color: '#ffe5a6', accent: '#ef8b37', texts: ['Good', 'vibes'] },
{ id: 'family', name: 'Família', category: 'Afeto', color: '#d9eddf', accent: '#4f8f69', texts: ['Lar é onde', 'o amor está'] }
]
const TEXT_FIELD_IDS = ['texto_1', 'texto_2', 'texto_3', 'texto_4']
const LEGACY_TEXT_FIELD_IDS = ['texto-superior', 'frase-principal', 'frase-secundaria', 'nome-personalizavel']
const FONT_OPTIONS = ['Georgia', 'Licorice', 'Indie Flower', 'Playfair Display', 'DM Sans']
const FONT_SIZES = [24, 32, 40, 48, 56, 64, 72, 84, 96, 108]
function textFieldIndex(id, fields) {
const configuredIndex = fields.findIndex(field => field.id === id)
if (configuredIndex >= 0) return configuredIndex
return TEXT_FIELD_IDS.indexOf(id) >= 0
? TEXT_FIELD_IDS.indexOf(id)
: LEGACY_TEXT_FIELD_IDS.indexOf(id)
}
function load(key, fallback) {
try { return JSON.parse(localStorage.getItem(key)) || fallback } catch { return fallback }
}
function normalizeProduct(product) {
if (!product) return null
const image = product.image || product.image_url
return {
...product,
category: product.category || product.category_slug || product.category_name,
price: Number(product.price),
oldPrice: product.oldPrice === undefined ? (product.old_price === null ? null : Number(product.old_price)) : Number(product.oldPrice),
active: product.active === 0 || product.active === false ? false : true,
image: image?.startsWith('/uploads/') ? `${API_URL.replace(/\/api$/, '')}${image}` : image,
art_width_cm: Number(product.art_width_cm) || null,
art_height_cm: Number(product.art_height_cm) || null
,category_ids: product.category_ids || (product.category_id ? [product.category_id] : [])
}
}
function normalizeArtwork(artwork) {
const fields = artwork.text_fields
const texts = Array.isArray(fields) ? fields.map(field => field.default || '').slice(0, 4) : []
const fileUrl = artwork.file_url
return { ...artwork, textFields: Array.isArray(fields) ? fields : [], file_url: fileUrl?.startsWith('/uploads/') ? `${API_URL.replace(/\/api$/, '')}${fileUrl}` : fileUrl, color: artwork.color || '#e8e2da', accent: artwork.accent || '#423b36', texts: texts.length ? texts : ['', '', '', ''], width_cm: Number(artwork.width_cm) || null, height_cm: Number(artwork.height_cm) || null }
}
function App() {
const [page, setPage] = useState('home')
const [products, setProducts] = useState(() => load('fp-products', initialProducts))
const [categories, setCategories] = useState(() => load('fp-categories', initialCategories))
const [artworks, setArtworks] = useState(() => load('fp-artworks', initialArtworks))
const [orders, setOrders] = useState(() => load('fp-orders', []))
const [cart, setCart] = useState(() => load('fp-cart', []))
const [selectedProduct, setSelectedProduct] = useState(null)
const [selectedArtwork, setSelectedArtwork] = useState(null)
const [search, setSearch] = useState('')
const [adminOpen, setAdminOpen] = useState(false)
useEffect(() => localStorage.setItem('fp-orders', JSON.stringify(orders)), [orders])
useEffect(() => localStorage.setItem('fp-cart', JSON.stringify(cart)), [cart])
useEffect(() => {
Promise.all([api.getProducts(), api.getCategories(), api.getArtworks()])
.then(([remoteProducts, remoteCategories, remoteArtworks]) => {
if (remoteProducts.length) setProducts(remoteProducts.map(normalizeProduct))
if (remoteCategories.length) setCategories(remoteCategories)
if (remoteArtworks.length) setArtworks(remoteArtworks.map(normalizeArtwork))
})
.catch(error => console.warn('API indisponível; usando catálogo local:', error.message))
}, [])
const navigate = (target) => { setPage(target); setAdminOpen(false); window.scrollTo(0, 0) }
const openProduct = (product) => {
const compatible = product.art_width_cm && product.art_height_cm
? artworks.filter(artwork => artwork.width_cm === Number(product.art_width_cm) && artwork.height_cm === Number(product.art_height_cm))
: artworks
setSelectedProduct(product); setSelectedArtwork(compatible[0] || null); navigate('product')
}
const addToCart = (item) => setCart(current => [...current, { ...item, cartId: Date.now() }])
const removeFromCart = (id) => setCart(current => current.filter(item => item.cartId !== id))
const submitOrder = async (customer) => {
try {
const orderItems = cart.map(item => {
const numericId = Number(item.productId)
const catalogProduct = Number.isInteger(numericId) && numericId > 0
? products.find(product => Number(product.id) === numericId)
: products.find(product => product.name === item.name && Number.isInteger(Number(product.id)))
if (!catalogProduct) throw new Error(`O produto "${item.name}" não está disponível no catálogo atual. Remova-o e adicione novamente.`)
return {
product_id: Number(catalogProduct.id),
artwork_name: item.artwork,
custom_text: item.customText,
artwork_svg: item.artworkSvg || null,
quantity: item.quantity
}
})
const order = await api.createOrder({
customer_name: customer.name, customer_email: customer.email, customer_phone: customer.phone,
delivery_address: customer.address,
items: orderItems
})
setOrders(current => [{ id: order.order_code, date: new Date().toLocaleDateString('pt-BR'), status: 'Recebido', customer, items: cart, total: order.total }, ...current])
setCart([]); navigate('success')
} catch (error) {
window.alert(`Não foi possível enviar o pedido: ${error.message}`)
}
}
const filteredProducts = products.filter(p => `${p.name} ${p.description}`.toLowerCase().includes(search.toLowerCase()))
return
setAdminOpen(true)} />
{adminOpen
? setAdminOpen(false)} />
: <>
{page === 'home' && }
{page === 'categories' && }
{page === 'product' && selectedProduct && }
{page === 'create' && selectedProduct && }
{page === 'cart' && }
{page === 'success' && }
>}
}
function Header({ page, navigate, cartCount, search, setSearch, onAdmin }) {
return
}
function Home({ products, categories, navigate, openProduct }) {
const heroSlides = [
{
id: 'bridix-1',
eyebrow: 'Bridix personalizados',
title: 'Peças únicas',
accent: 'com identidade.',
text: 'Crie presentes, lembranças e detalhes exclusivos que refletem sua personalidade em cada produto.',
cta: 'Explorar coleção',
badge: 'Bridix',
accentIcon: '☕',
cardText: 'Seu presente,\ndo seu jeito.',
custom: 'feito para você ♡'
},
{
id: 'bridix-2',
eyebrow: 'Presentes com sentimento',
title: 'Tudo com alma',
accent: 'e história.',
text: 'Personalize canecas, garrafas e itens especiais para comemorar qualquer momento com charme e exclusividade.',
cta: 'Ver produtos',
badge: 'Mais vendidos',
accentIcon: '🎁',
cardText: 'Presente com\nmemória.',
custom: 'feito para celebrar ♡'
},
{
id: 'bridix-3',
eyebrow: 'Crie do seu jeito',
title: 'Seu design',
accent: 'na sua cara.',
text: 'Escolha a arte, ajuste os textos e transforme cada item em uma peça que surpreende e emociona.',
cta: 'Personalizar agora',
badge: 'Custom',
accentIcon: '✨',
cardText: 'Seu estilo\nem cada detalhe.',
custom: 'feito para você ♡'
}
]
const [currentSlide, setCurrentSlide] = useState(0)
const [dragOffset, setDragOffset] = useState(0)
const [dragging, setDragging] = useState(false)
const dragStartX = useRef(0)
const goToSlide = (nextIndex) => {
setCurrentSlide((nextIndex + heroSlides.length) % heroSlides.length)
}
const handlePointerDown = (event) => {
dragStartX.current = event.clientX
setDragging(true)
}
const handlePointerMove = (event) => {
if (!dragging) return
setDragOffset(event.clientX - dragStartX.current)
}
const handlePointerEnd = () => {
if (!dragging) return
if (dragOffset < -60) {
goToSlide(currentSlide + 1)
} else if (dragOffset > 60) {
goToSlide(currentSlide - 1)
}
setDragOffset(0)
setDragging(false)
}
return
{heroSlides.map(slide =>
{slide.eyebrow}
{slide.title}
{slide.accent}
{slide.text}
BRI
+2.500 pedidos personalizados
entregues com estilo
{slide.badge}
{slide.accentIcon}{slide.cardText}{slide.custom}
✦
♡
) }
{heroSlides.map((slide, index) =>
navigate('categories')} />{categories.map(category =>
)}
navigate('categories')} />{products.slice(0, 4).map(product =>
)}
Do seu jeitoTem coisa melhor que
presente com história?
Escolha uma arte, escreva sua mensagem e deixe a gente cuidar do resto.
“As melhores coisas
são feitas à mão
e com o coração.
}
function SectionTitle({ eyebrow, title, action, onAction }) { return {eyebrow}
{title}
{action &&
}
}
function ProductCard({ product, openProduct }) { return openProduct(product)}>{product.badge &&
{product.badge}}

{product.category}{product.name}
{product.description}
{product.oldPrice && R$ {product.oldPrice.toFixed(2).replace('.', ',')}}R$ {product.price.toFixed(2).replace('.', ',')}
}
function Catalog({ products, categories, navigate, openProduct }) { return Nosso catálogoProdutos que contam
uma história.
Escolha um produto, uma arte e coloque sua personalidade em cada detalhe.
{categories.map(c => )}
{!products.length && Nenhum produto encontrado. Tente outra busca.
} }
function Product({ product, artworks, selectedArtwork, setSelectedArtwork, navigate, addToCart }) {
const [quantity, setQuantity] = useState(1)
const compatibleArtworks = product.art_width_cm && product.art_height_cm ? artworks.filter(art => Number(art.width_cm) === Number(product.art_width_cm) && Number(art.height_cm) === Number(product.art_height_cm)) : artworks
return
feito pra você ♡{product.category}{product.name}
{product.description}
R$ {product.price.toFixed(2).replace('.', ',')} à vista
01Escolha uma arte{product.art_width_cm ? `Medida compatível: ${product.art_width_cm} × ${product.art_height_cm} cm` : 'Você poderá editar os textos no próximo passo'}
{compatibleArtworks.map(art =>
)}{!compatibleArtworks.length &&
Nenhuma arte cadastrada para este tamanho.}
02QuantidadePersonalize quantas unidades quiser
{quantity}
}
function artworkFields(artwork) {
return artwork.textFields?.length ? artwork.textFields : [
{ id: TEXT_FIELD_IDS[0], default: artwork.texts[0] },
{ id: TEXT_FIELD_IDS[1], default: artwork.texts[1] },
{ id: TEXT_FIELD_IDS[2], default: artwork.texts[2] },
{ id: TEXT_FIELD_IDS[3], default: artwork.texts[3] }
]
}
function renderArtworkSource(source, artwork, values, styles = [], positions = []) {
const fields = artworkFields(artwork)
const fieldValues = fields.map((field, index) => values[index] === undefined ? field.default || '' : values[index])
return source.replace(/(]*>)([\s\S]*?)(<\/text>)/gi, (full, opening, content, closing) => {
const id = opening.match(/\bid=["']([^"']+)["']/i)?.[1]
const index = textFieldIndex(id, fields)
if (index < 0 || index >= fieldValues.length) return full
const style = styles[index] || {}
const position = positions[index]
let nextOpening = opening.replace(/\sdata-artwork-field=["'][^"']*["']/i, '').replace(/>\s*$/, '')
const setAttribute = (name, value) => {
const escaped = escapeXml(value)
const expression = new RegExp(`\\s${name}=(?:"[^"]*"|'[^']*')`, 'i')
nextOpening = expression.test(nextOpening)
? nextOpening.replace(expression, ` ${name}="${escaped}"`)
: `${nextOpening} ${name}="${escaped}"`
}
setAttribute('data-artwork-field', index)
if (style.font) setAttribute('font-family', style.font)
if (style.size) setAttribute('font-size', Number(style.size))
if (style.color) setAttribute('fill', style.color)
if (position?.x !== undefined) setAttribute('x', Number(position.x))
if (position?.y !== undefined) setAttribute('y', Number(position.y))
nextOpening += '>'
const value = index === 3 ? content.replace(/Ana/g, escapeXml(fieldValues[index])) : escapeXml(fieldValues[index])
return `${nextOpening}${value}${closing}`
})
}
function SvgArtwork({ artwork, values, styles = [], positions = [], onPositionsChange }) {
const [markup, setMarkup] = useState('')
const containerRef = useRef(null)
useEffect(() => {
let active = true
fetch(artwork.file_url).then(response => response.text()).then(source => {
if (active) setMarkup(renderArtworkSource(source, artwork, values, styles, positions))
}).catch(() => { if (active) setMarkup('') })
return () => { active = false }
}, [artwork, values, styles, positions])
useEffect(() => {
if (!markup || !onPositionsChange) return undefined
const svg = containerRef.current?.querySelector('svg')
if (!svg) return undefined
const viewBox = (svg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number)
const onPointerDown = event => {
const text = event.target.closest('[data-artwork-field]')
if (!text) return
event.preventDefault()
const index = Number(text.getAttribute('data-artwork-field'))
const rect = svg.getBoundingClientRect()
const toSvgPoint = pointerEvent => ({
x: viewBox[0] + ((pointerEvent.clientX - rect.left) / rect.width) * viewBox[2],
y: viewBox[1] + ((pointerEvent.clientY - rect.top) / rect.height) * viewBox[3]
})
const pointerStart = toSvgPoint(event)
const bounds = text.getBBox()
const textX = Number(text.getAttribute('x')) || bounds.x + bounds.width / 2
const textY = Number(text.getAttribute('y')) || bounds.y + bounds.height
const offsetX = textX - pointerStart.x
const offsetY = textY - pointerStart.y
const move = moveEvent => {
const point = toSvgPoint(moveEvent)
const x = Math.max(viewBox[0], Math.min(viewBox[0] + viewBox[2], point.x + offsetX))
const y = Math.max(viewBox[1], Math.min(viewBox[1] + viewBox[3], point.y + offsetY))
onPositionsChange(index, { x: Math.round(x), y: Math.round(y) })
}
const stop = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', stop)
}
svg.addEventListener('pointerdown', onPointerDown)
return () => svg.removeEventListener('pointerdown', onPointerDown)
}, [markup, onPositionsChange])
return markup ? : Carregando arte...
}
function escapeXml(value) { return String(value).replace(/[<>&'"]/g, character => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[character])) }
async function getArtworkSvg(artwork, values, styles, positions) {
if (!artwork.file_url) return null
const source = await fetch(artwork.file_url).then(response => response.text())
return renderArtworkSource(source, artwork, values, styles, positions)
}
function ArtPreview({ artwork, large = false, text0, text1, text2, text3, styles, positions, onPositionsChange }) { return {artwork.file_url ?
:
✦{text1 || artwork.texts[0]}
{text2 || artwork.texts[1]}feito pra você
}
}
function Creator({ product, artwork, addToCart, navigate }) {
const nameDefault = (artwork.texts[2] || '').replace(/^.*?,\s*/, '').replace(/\s*♡.*$/, '').trim() || 'Ana'
const [texts, setTexts] = useState([artwork.texts[0] || '', artwork.texts[1] || '', artwork.texts[2] || '', nameDefault])
const [styles, setStyles] = useState(artwork.textFields?.map(field => ({ font: 'Georgia', size: 40, color: '#71384d', ...field.style })) || TEXT_FIELD_IDS.map(() => ({ font: 'Georgia', size: 40, color: '#71384d' })))
const [positions, setPositions] = useState([])
const [quantity, setQuantity] = useState(1)
const updateText = (index, value) => setTexts(current => current.map((text, textIndex) => textIndex === index ? value : text))
const updateStyle = (index, key, value) => setStyles(current => current.map((style, styleIndex) => styleIndex === index ? { ...style, [key]: value } : style))
const updatePosition = (index, position) => setPositions(current => { const next = [...current]; next[index] = position; return next })
const saveCreation = async () => { const artworkSvg = await getArtworkSvg(artwork, texts, styles, positions); addToCart({ productId: product.id, name: product.name, price: product.price, image: product.image, artwork: artwork.name, artworkSvg, customText: texts.join(' / '), quantity }); navigate('cart') }
return Área de criaçãoDeixe com a sua cara.
Edite os textos e arraste cada parte na pré-visualização.
123
{texts.map((text, index) =>
)}
Uma dica carinhosa
Arraste os textos na arte para posicioná-los como preferir.

Seu produto{product.name}R$ {product.price.toFixed(2).replace('.', ',')}
{quantity}
Pré-visualização · arraste os textosAs cores podem variar na impressão.
}
function Checkout({ cart, removeFromCart, submitOrder, navigate }) { const [form, setForm] = useState({ name: '', email: '', phone: '', address: '' }); const total = cart.reduce((s, i) => s + i.price * i.quantity, 0); return Quase lá
Finalize seu pedido.
{!cart.length ? Seu carrinho está vazio
Encontre algo especial para personalizar.
: Seus produtos {cart.length} itens
{cart.map(item =>

{item.name}{item.artwork || 'Sem arte'} {item.customText && `· ${item.customText}`}Qtd. {item.quantity}
R$ {(item.price * item.quantity).toFixed(2).replace('.', ',')})}
} }
function Success({ navigate }) { return
Pedido recebidoQue alegria, obrigado!
Recebemos seu pedido e já vamos começar a preparar tudo com muito carinho. Em breve entraremos em contato para confirmar os detalhes.
}
function Admin({ products, setProducts, categories, setCategories, artworks, setArtworks, orders, close }) {
const [tab, setTab] = useState('overview'); const [notice, setNotice] = useState('')
const [showArtworkForm, setShowArtworkForm] = useState(false)
const [token, setToken] = useState(() => localStorage.getItem('fp-admin-token'))
const [remoteOrders, setRemoteOrders] = useState(orders)
const notify = (message) => { setNotice(message); setTimeout(() => setNotice(''), 2500) }
useEffect(() => {
const handleAuthExpired = () => setToken(null)
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired)
}, [])
useEffect(() => {
if (!token) return
api.getOrders(token).then(setRemoteOrders).catch(error => notify(error.message))
}, [token])
if (!token) return { localStorage.setItem('fp-admin-token', value); setToken(value) }} close={close} />
const addProduct = async () => {
try {
const data = new FormData()
data.append('category_id', categories[0]?.id || '')
data.append('name', 'Novo produto')
data.append('description', 'Descrição do novo produto.')
data.append('price', '39.90')
data.append('badge', 'Novo')
const product = await api.createProduct(data, token)
setProducts([...products, normalizeProduct(product)]); notify('Produto adicionado')
} catch (error) { notify(error.message) }
}
const addCategory = async () => {
try {
const category = await api.createCategory({ name: 'Nova categoria', emoji: '✨', color: '#eee8ff' }, token)
setCategories([...categories, category]); notify('Categoria adicionada')
} catch (error) { notify(error.message) }
}
const addArtwork = async () => {
setTab('artworks')
setShowArtworkForm(true)
}
return Gerenciador
{tab === 'overview' ? 'Olá, vamos criar?' : tab === 'orders' ? 'Pedidos feitos' : tab === 'products' ? 'Produtos' : tab === 'artworks' ? 'Artes prontas' : 'Categorias'}
{notice && {notice}
}{tab === 'overview' && }{tab === 'products' && }{tab === 'artworks' && setShowArtworkForm(false)} addArtwork={addArtwork} token={token} />}{tab === 'categories' && }{tab === 'orders' && }
}
function AdminLogin({ onLogin, close }) {
const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState('')
const submit = async event => { event.preventDefault(); try { const result = await api.login(email, password); if (result.user.role !== 'admin') throw new Error('Este usuário não é administrador'); onLogin(result.token) } catch (loginError) { setError(loginError.message) } }
return ✦Área restritaEntrar no gerenciador
Use o e-mail e a senha do administrador cadastrados no banco.
}
function Overview({ products, artworks, categories, orders, setTab }) { return Produtos ativos{products.length} Artes prontas{artworks.length} Categorias{categories.length}
Pedidos recebidos{orders.length}
Tudo em um só lugarSeu ateliê, mais organizado.
Cadastre produtos, prepare artes para seus clientes e acompanhe cada pedido recebido.
✦ }
function AdminProducts({ products, setProducts, categories, addProduct, token }) {
const [editing, setEditing] = useState(null)
const remove = async id => { try { await api.deleteProduct(id, token); setProducts(products.filter(item => item.id !== id)) } catch (error) { window.alert(error.message) } }
const save = async product => {
try {
const data = new FormData()
data.append('category_id', product.category_id || product.categoryId || '')
data.append('category_ids', JSON.stringify(product.category_ids || [product.category_id || product.categoryId]))
data.append('name', product.name)
data.append('description', product.description || '')
data.append('price', String(product.price))
data.append('old_price', product.oldPrice ? String(product.oldPrice) : '')
data.append('badge', product.badge || '')
data.append('active', product.active === false ? 'false' : 'true')
data.append('art_width_cm', product.art_width_cm || '')
data.append('art_height_cm', product.art_height_cm || '')
if (product.imageFile) data.append('image', product.imageFile)
const isDatabaseProduct = Number.isInteger(Number(product.id)) && Number(product.id) > 0
const updated = isDatabaseProduct
? await api.updateProduct(product.id, data, token)
: await api.createProduct(data, token)
const normalized = normalizeProduct(updated || { ...product, image: product.imagePreview || product.image })
const refreshed = await api.getProducts()
setProducts(refreshed.map(normalizeProduct))
setEditing(null)
} catch (error) { window.alert(error.message) }
}
const changeImage = (event) => {
const file = event.target.files[0]
if (!file) return
const image = new Image()
image.onload = () => {
if (image.width !== 1000 || image.height !== 1000) window.alert('A imagem precisa ter exatamente 1000 x 1000 px.')
else setEditing(current => ({ ...current, imageFile: file, imagePreview: URL.createObjectURL(file) }))
}
image.onerror = () => window.alert('Não foi possível ler a imagem selecionada.')
image.src = URL.createObjectURL(file)
}
return {products.map(p =>
setEditing({ ...p, category_id: p.category_id || categories.find(category => category.slug === p.category)?.id, category_ids: p.category_ids?.length ? p.category_ids : [p.category_id] })}>
{p.name}{p.category} · R$ {p.price.toFixed(2).replace('.', ',')}
)}
{editing && event.stopPropagation()}>
Editando produto
{editing.name}

Imagem do produtoA imagem deve ter exatamente 1000 × 1000 px.
}
}
function AdminArtworks({ artworks, setArtworks, addArtwork, token, showCreate, closeCreate }) {
const [editing, setEditing] = useState(null)
const remove = async id => { try { await api.deleteArtwork(id, token); setArtworks(artworks.filter(item => item.id !== id)) } catch (error) { window.alert(error.message) } }
const startEditing = artwork => setEditing({
...artwork,
fields: (artwork.textFields?.length ? artwork.textFields : artwork.texts.map((text, index) => ({ id: TEXT_FIELD_IDS[index], default: text }))).map((field, index) => ({ ...field, id: TEXT_FIELD_IDS[index] }))
})
const save = async artwork => {
try {
const updated = await api.updateArtwork(artwork.id, { name: artwork.name, category: artwork.category, width_cm: artwork.width_cm, height_cm: artwork.height_cm, text_fields: artwork.fields }, token)
setArtworks(artworks.map(item => item.id === artwork.id ? normalizeArtwork(updated) : item))
setEditing(null)
} catch (error) { window.alert(error.message) }
}
const [form, setForm] = useState({ name: '', category: 'Frases', text1: '', text2: '', text3: '', text4: '', fieldIds: TEXT_FIELD_IDS, file: null, preview: '', width_cm: '', height_cm: '' })
const create = async () => {
if (!form.name.trim() || !form.file) return window.alert('Informe o nome e selecione um arquivo SVG.')
const data = new FormData()
data.append('name', form.name); data.append('category', form.category)
data.append('width_cm', form.width_cm); data.append('height_cm', form.height_cm)
data.append('text_fields', JSON.stringify([{ id: form.fieldIds[0], default: form.text1 }, { id: form.fieldIds[1], default: form.text2 }, { id: form.fieldIds[2], default: form.text3 }, { id: form.fieldIds[3], default: form.text4 }]))
data.append('file', form.file)
try {
const artwork = await api.createArtwork(data, token)
setArtworks([...artworks, normalizeArtwork(artwork)])
setForm({ name: '', category: 'Frases', text1: '', text2: '', text3: '', text4: '', fieldIds: TEXT_FIELD_IDS, file: null, preview: '', width_cm: '', height_cm: '' })
notify('Arte adicionada')
} catch (error) { notify(error.message) }
}
const selectFile = event => {
const file = event.target.files[0]
if (!file) return
if (file.type !== 'image/svg+xml' && !file.name.toLowerCase().endsWith('.svg')) return window.alert('Selecione um arquivo SVG.')
const reader = new FileReader()
reader.onload = () => {
const source = String(reader.result)
const ids = [...source.matchAll(/]*\bid=["']([^"']+)["']/gi)].map(match => match[1]).slice(0, 4)
const document = new DOMParser().parseFromString(source, 'image/svg+xml')
const textById = id => document.getElementById(id)?.textContent?.trim() || ''
const findText = index => {
const value = textById(TEXT_FIELD_IDS[index]) || textById(LEGACY_TEXT_FIELD_IDS[index])
if (value) return index === 3 ? value.replace(/^.*?,\s*/, '').replace(/\s*♡.*$/, '').trim() : value
return [text1, text2, text3, text4][index]
}
const opening = source.match(/