import { useState, useEffect, useRef } from 'react'; import Layout from '../components/Layout'; import LoadingButton from '../components/LoadingButton'; import SortTh from '../components/SortTh'; import { useSortable } from '../hooks/useSortable'; import { supabase } from '../lib/supabase'; import { generateBrandBookEditorPDF } from '../lib/brandBookEditor'; import { cleanupBrandBookStorage } from '../lib/deleteHelpers'; import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles'; const BUCKET = 'brand-books'; const EMPTY_SIGN = () => ({ _key: Math.random().toString(36).slice(2), signNumber: '', type: '', recommendation: '', specifications: '', notes: '', photo: null, photoPath: '', _photoPreview: '', existingPhoto: null, existingPhotoPath: '', _existingPhotoPreview: '', recommendationPhoto: null, recommendationPhotoPath: '', _recommendationPhotoPreview: '', signDetailPhoto: null, signDetailPhotoPath: '', _signDetailPhotoPreview: '', }); const EMPTY_BOOK_INFO = { clientId: '', clientName: '', projectName: '', siteAddress: '', bookDate: new Date().toISOString().split('T')[0], preparedBy: '', revision: '01', template: 'fourge', // Cover page creationDate: new Date().toISOString().slice(0, 10), revisionDate: '', customerName: '', customerAddress: '', clientLogoUrl: '', clientContactName: '', clientContactEmail: '', clientContactPhone: '', approvedDate: '', approvalNotes: '', }; const TEMPLATE_OPTIONS = [ { value: 'fourge', label: 'Fourge Branding Template' }, { value: 'bolchoz', label: 'Bolchoz Sign Solutions' }, ]; const PHOTO_FILE_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif'; const PHOTO_FILE_EXTENSIONS = new Set(['heic', 'heif', 'avif', 'tif', 'tiff', 'bmp', 'webp', 'jpeg', 'jpg', 'png', 'gif']); const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN || ''; const normalizeRevision = (value) => { const digits = String(value ?? '').replace(/\D/g, '').slice(0, 2); if (!digits) return '00'; return digits.padStart(2, '0'); }; const isPhotoFile = (file) => { if (!file) return false; if (file.type?.startsWith('image/')) return true; const extension = file.name?.split('.').pop()?.toLowerCase(); return extension ? PHOTO_FILE_EXTENSIONS.has(extension) : false; }; const getMapboxCoordinates = async (address) => { const features = await getMapboxAddressSuggestions(address, 1); const coordinates = features[0]?.center; if (!coordinates || coordinates.length < 2) throw new Error('No map result found for that address.'); return coordinates; }; const getMapboxAddressSuggestions = async (address, limit = 5) => { const params = new URLSearchParams({ access_token: MAPBOX_TOKEN, autocomplete: 'true', country: 'us', limit: String(limit), types: 'address,place,postcode,locality,neighborhood,poi', }); const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?${params.toString()}`); if (!response.ok) throw new Error('Unable to find that address.'); const data = await response.json(); return data.features || []; }; const buildStaticMapUrl = ([lng, lat], style = 'satellite-streets-v12', zoom = 18) => { if (!MAPBOX_TOKEN || lng == null || lat == null) return ''; const marker = `pin-s+f5a523(${lng},${lat})`; const camera = `${lng},${lat},${zoom},0`; return `https://api.mapbox.com/styles/v1/mapbox/${style}/static/${marker}/${camera}/700x467@2x?access_token=${encodeURIComponent(MAPBOX_TOKEN)}`; }; // ─── Helpers ────────────────────────────────────────────────────────────────── const getSignedUrl = async (path) => { if (!path) return null; const { data } = await supabase.storage.from(BUCKET).createSignedUrl(path, 86400 * 7); return data?.signedUrl || null; }; const uploadFile = async (file, path) => { const { error } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: true }); if (error) throw error; return path; }; // ─── Main Component ─────────────────────────────────────────────────────────── export default function BrandBook() { const [view, setView] = useState('list'); const [savedBooks, setSavedBooks] = useState([]); const [loadingBooks, setLoadingBooks] = useState(true); const [currentId, setCurrentId] = useState(null); const [clients, setClients] = useState([]); const [bookInfo, setBookInfo] = useState(EMPTY_BOOK_INFO); const [siteMapFile, setSiteMapFile] = useState(null); const [siteMapPath, setSiteMapPath] = useState(''); const [siteMapPreview, setSiteMapPreview] = useState(null); const [autoMapAddress, setAutoMapAddress] = useState(''); const [autoMapLoading, setAutoMapLoading] = useState(false); const [autoMapError, setAutoMapError] = useState(''); const [siteMapManuallyCleared, setSiteMapManuallyCleared] = useState(false); const [inventoryMapManuallyCleared, setInventoryMapManuallyCleared] = useState(false); const [addressSuggestions, setAddressSuggestions] = useState([]); const [addressSuggesting, setAddressSuggesting] = useState(false); const [showAddressSuggestions, setShowAddressSuggestions] = useState(false); const [signs, setSigns] = useState([EMPTY_SIGN()]); const [photoItems, setPhotoItems] = useState([]); const [inventoryMapFile, setInventoryMapFile] = useState(null); const [inventoryMapPath, setInventoryMapPath] = useState(''); const [inventoryMapPreview, setInventoryMapPreview] = useState(null); const [generating, setGenerating] = useState(false); const [saving, setSaving] = useState(false); const [notification, setNotification] = useState(null); const [projectLogoFile, setProjectLogoFile] = useState(null); const [projectLogoPath, setProjectLogoPath] = useState(''); const [projectLogoPreview, setProjectLogoPreview] = useState(''); const [uploadingClientLogo, setUploadingClientLogo] = useState(false); const [savingClientInfo, setSavingClientInfo] = useState(false); const [clientInfoSaved, setClientInfoSaved] = useState(false); const siteMapRef = useRef(); const inventoryMapRef = useRef(); const sitePhotosRef = useRef(); const projectLogoRef = useRef(); const clientLogoRef = useRef(); const [filterCompany, setFilterCompany] = useState(''); const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at'); useEffect(() => { supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || [])); fetchBooks(); }, []); useEffect(() => { const address = bookInfo.customerAddress.trim(); if (!address || autoMapAddress === address) return undefined; if ((siteMapManuallyCleared || siteMapPath || (siteMapFile && !autoMapAddress)) && (inventoryMapManuallyCleared || inventoryMapPath || (inventoryMapFile && !autoMapAddress))) return undefined; const timeoutId = setTimeout(() => { if (address.includes(',') && address.length >= 12) { // Called through a ref so the debounce isn't reset by the handler's identity. loadMapsFromAddressRef.current(address, { silent: true }); } }, 900); return () => clearTimeout(timeoutId); }, [ bookInfo.customerAddress, siteMapPath, siteMapFile, siteMapManuallyCleared, inventoryMapPath, inventoryMapFile, inventoryMapManuallyCleared, autoMapAddress, ]); useEffect(() => { const query = bookInfo.customerAddress.trim(); if (!MAPBOX_TOKEN || query.length < 3) { setAddressSuggestions([]); setAddressSuggesting(false); return undefined; } let cancelled = false; const timeoutId = setTimeout(async () => { setAddressSuggesting(true); try { const suggestions = await getMapboxAddressSuggestions(query, 5); if (!cancelled) setAddressSuggestions(suggestions); } catch { if (!cancelled) setAddressSuggestions([]); } finally { if (!cancelled) setAddressSuggesting(false); } }, 250); return () => { cancelled = true; clearTimeout(timeoutId); }; }, [bookInfo.customerAddress]); const fetchBooks = async () => { setLoadingBooks(true); const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false }); setSavedBooks(data || []); setLoadingBooks(false); }; const set = (field) => (e) => setBookInfo(b => ({ ...b, [field]: e.target.value })); const handleRevisionChange = (e) => { const digits = e.target.value.replace(/\D/g, '').slice(0, 2); setBookInfo(b => ({ ...b, revision: digits })); }; const handleRevisionBlur = () => { setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) })); }; const loadMapsFromAddressRef = useRef(null); const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => { const trimmed = String(address || '').trim(); if (!trimmed) { if (!silent) setAutoMapError('Enter a customer address first.'); return; } if (!MAPBOX_TOKEN) { if (!silent) setAutoMapError('Mapbox token is not configured yet. Add VITE_MAPBOX_TOKEN in Vercel to enable automatic site maps.'); return; } if (autoMapLoading) return; setAutoMapLoading(true); setAutoMapError(''); try { const coordinates = feature?.center || await getMapboxCoordinates(trimmed); const shouldLoadSiteMap = !siteMapManuallyCleared || !siteMapFile; const shouldLoadInventoryMap = !inventoryMapManuallyCleared || !inventoryMapFile; const [siteResponse, inventoryResponse] = await Promise.all([ shouldLoadSiteMap ? fetch(buildStaticMapUrl(coordinates, 'satellite-streets-v12')) : Promise.resolve(null), shouldLoadInventoryMap ? fetch(buildStaticMapUrl(coordinates, 'streets-v12', 18.2)) : Promise.resolve(null), ]); if ((siteResponse && !siteResponse.ok) || (inventoryResponse && !inventoryResponse.ok)) throw new Error('Unable to load map image.'); const [siteBlob, inventoryBlob] = await Promise.all([ siteResponse ? siteResponse.blob() : Promise.resolve(null), inventoryResponse ? inventoryResponse.blob() : Promise.resolve(null), ]); if ((siteBlob && !siteBlob.type.startsWith('image/')) || (inventoryBlob && !inventoryBlob.type.startsWith('image/'))) throw new Error('Map service did not return an image.'); const fullAddress = feature?.place_name || trimmed; setBookInfo(b => ({ ...b, customerAddress: fullAddress, siteAddress: fullAddress })); if (siteBlob) { const siteFile = new File([siteBlob], 'site-map.png', { type: siteBlob.type || 'image/png' }); setSiteMapFile(siteFile); setSiteMapPath(''); setSiteMapPreview(URL.createObjectURL(siteFile)); setSiteMapManuallyCleared(false); } if (inventoryBlob) { const inventoryFile = new File([inventoryBlob], 'inventory-map.png', { type: inventoryBlob.type || 'image/png' }); setInventoryMapFile(inventoryFile); setInventoryMapPath(''); setInventoryMapPreview(URL.createObjectURL(inventoryFile)); setInventoryMapManuallyCleared(false); } setAutoMapAddress(fullAddress); setShowAddressSuggestions(false); } catch (error) { setAutoMapError(error.message || 'Unable to load map for this address.'); } finally { setAutoMapLoading(false); } }; useEffect(() => { loadMapsFromAddressRef.current = loadMapsFromAddress; }); const handleCustomerAddressChange = (e) => { const value = e.target.value; setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value })); setAutoMapError(''); setSiteMapManuallyCleared(false); setInventoryMapManuallyCleared(false); setShowAddressSuggestions(true); }; const handleSelectAddressSuggestion = (feature) => { const fullAddress = feature.place_name || ''; setBookInfo(b => ({ ...b, customerAddress: fullAddress, siteAddress: fullAddress })); setAddressSuggestions([]); setShowAddressSuggestions(false); loadMapsFromAddress(fullAddress, { feature }); }; const clearSiteMap = () => { setSiteMapFile(null); setSiteMapPath(''); setSiteMapPreview(null); setSiteMapManuallyCleared(true); }; const clearInventoryMap = () => { setInventoryMapFile(null); setInventoryMapPath(''); setInventoryMapPreview(null); setInventoryMapManuallyCleared(true); }; const handleClientChange = async (e) => { const id = e.target.value; const client = clients.find(c => c.id === id); const autoTemplate = client?.name?.toLowerCase().includes('bolchoz') ? 'bolchoz' : 'fourge'; setBookInfo(b => ({ ...b, clientId: id, clientName: client ? client.name : '', customerName: b.customerName || '', template: autoTemplate, })); if (id) { const { data: co } = await supabase.from('companies').select('*').eq('id', id).single(); if (co) { setBookInfo(b => ({ ...b, clientLogoUrl: co.client_logo_url || '', clientContactName: co.contact_name || '', clientContactEmail: co.contact_email || '', clientContactPhone: co.contact_phone || '', customerAddress: b.customerAddress || co.address || b.siteAddress || '', siteAddress: b.siteAddress || b.customerAddress || co.address || '', })); } } }; const resetForm = () => { setBookInfo(EMPTY_BOOK_INFO); setSiteMapFile(null); setSiteMapPath(''); setSiteMapPreview(null); setAutoMapAddress(''); setAutoMapError(''); setSiteMapManuallyCleared(false); setInventoryMapManuallyCleared(false); setAddressSuggestions([]); setShowAddressSuggestions(false); setInventoryMapFile(null); setInventoryMapPath(''); setInventoryMapPreview(null); setSigns([EMPTY_SIGN()]); setPhotoItems([]); setCurrentId(null); setNotification(null); setProjectLogoFile(null); setProjectLogoPath(''); setProjectLogoPreview(''); }; const handleNew = () => { resetForm(); setView('form'); }; const buildLoadedState = async (book) => { const [siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl] = await Promise.all([ getSignedUrl(book.site_map_path), getSignedUrl(book.inventory_map_path), getSignedUrl(book.project_logo_path), ]); const signsWithPreviews = await Promise.all((book.signs || []).map(async (sign) => { const [preview, existingPreview, recommendationPreview, signDetailPreview] = await Promise.all([ getSignedUrl(sign.photoPath), getSignedUrl(sign.existingPhotoPath), getSignedUrl(sign.recommendationPhotoPath), getSignedUrl(sign.signDetailPhotoPath), ]); return { ...sign, photo: null, _photoPreview: preview || '', existingPhoto: null, _existingPhotoPreview: existingPreview || '', recommendationPhoto: null, _recommendationPhotoPreview: recommendationPreview || '', signDetailPhoto: null, _signDetailPhotoPreview: signDetailPreview || '', }; })); const surveyItems = await Promise.all((book.survey_photo_paths || []).map(async (path) => { const preview = await getSignedUrl(path); return { file: null, path, preview: preview || '' }; })); return { siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl, signsWithPreviews, surveyItems }; }; const handleLoad = async (book) => { setNotification(null); const { siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book); setBookInfo({ clientId: book.client_id || '', clientName: book.client_name || '', projectName: book.project_name || '', siteAddress: book.site_address || '', bookDate: book.book_date || new Date().toISOString().split('T')[0], preparedBy: book.prepared_by || '', revision: normalizeRevision(book.revision || '01'), template: book.template || 'fourge', creationDate: book.creation_date || new Date().toISOString().slice(0, 10), revisionDate: book.revision_date || '', customerName: book.customer_name || '', customerAddress: book.customer_address || book.site_address || '', clientLogoUrl: book.client_logo_url || '', clientContactName: book.client_contact_name || '', clientContactEmail: book.client_contact_email || '', clientContactPhone: book.client_contact_phone || '', approvedDate: book.approved_date || '', approvalNotes: book.approval_notes || '', }); setSiteMapFile(null); setSiteMapPath(book.site_map_path || ''); setSiteMapPreview(siteMapSignedUrl); setInventoryMapFile(null); setInventoryMapPath(book.inventory_map_path || ''); setInventoryMapPreview(inventoryMapSignedUrl); setProjectLogoFile(null); setProjectLogoPath(book.project_logo_path || ''); setProjectLogoPreview(projectLogoSignedUrl || ''); setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]); setPhotoItems(surveyItems); setCurrentId(book.id); setView('form'); }; const handleDelete = async (book) => { if (!window.confirm('Delete this brand book? This cannot be undone.')) return; await cleanupBrandBookStorage(book); await supabase.from('brand_books').delete().eq('id', book.id); fetchBooks(); }; const handleSave = async () => { if (!bookInfo.clientName.trim()) { setNotification({ type: 'error', msg: 'Please select a company.' }); return; } setSaving(true); setNotification(null); try { const bookId = currentId || crypto.randomUUID(); // Upload project logo if new file let finalProjectLogoPath = projectLogoPath; if (projectLogoFile) { const ext = projectLogoFile.name.split('.').pop().toLowerCase(); finalProjectLogoPath = `${bookId}/project-logo.${ext}`; await uploadFile(projectLogoFile, finalProjectLogoPath); } // Upload site map if new file let finalSiteMapPath = siteMapPath; if (siteMapFile) { const ext = siteMapFile.name.split('.').pop().toLowerCase(); finalSiteMapPath = `${bookId}/site-map.${ext}`; await uploadFile(siteMapFile, finalSiteMapPath); } // Upload inventory map if new file let finalInventoryMapPath = inventoryMapPath; if (inventoryMapFile) { const ext = inventoryMapFile.name.split('.').pop().toLowerCase(); finalInventoryMapPath = `${bookId}/inventory-map.${ext}`; await uploadFile(inventoryMapFile, finalInventoryMapPath); } // Upload sign photos if new file const finalSigns = await Promise.all(signs.map(async (sign) => { let photoPath = sign.photoPath || ''; if (sign.photo) { const ext = sign.photo.name.split('.').pop().toLowerCase(); photoPath = `${bookId}/sign-${sign._key}.${ext}`; await uploadFile(sign.photo, photoPath); } let existingPhotoPath = sign.existingPhotoPath || ''; if (sign.existingPhoto) { const ext = sign.existingPhoto.name.split('.').pop().toLowerCase(); existingPhotoPath = `${bookId}/sign-${sign._key}-existing.${ext}`; await uploadFile(sign.existingPhoto, existingPhotoPath); } let recommendationPhotoPath = sign.recommendationPhotoPath || ''; if (sign.recommendationPhoto) { const ext = sign.recommendationPhoto.name.split('.').pop().toLowerCase(); recommendationPhotoPath = `${bookId}/sign-${sign._key}-recommendation.${ext}`; await uploadFile(sign.recommendationPhoto, recommendationPhotoPath); } let signDetailPhotoPath = sign.signDetailPhotoPath || ''; if (sign.signDetailPhoto) { const ext = sign.signDetailPhoto.name.split('.').pop().toLowerCase(); signDetailPhotoPath = `${bookId}/sign-${sign._key}-detail.${ext}`; await uploadFile(sign.signDetailPhoto, signDetailPhotoPath); } const { photo: _photo, _photoPreview, existingPhoto: _existingPhoto, _existingPhotoPreview, recommendationPhoto: _recommendationPhoto, _recommendationPhotoPreview, signDetailPhoto: _signDetailPhoto, _signDetailPhotoPreview, ...rest } = sign; return { ...rest, photoPath, existingPhotoPath, recommendationPhotoPath, signDetailPhotoPath }; })); // Upload survey photos if new file const finalSurveyPaths = await Promise.all(photoItems.map(async (item, i) => { if (item.file) { const ext = item.file.name.split('.').pop().toLowerCase(); const path = `${bookId}/survey-${i}-${Date.now()}.${ext}`; await uploadFile(item.file, path); return path; } return item.path; })); const dbData = { id: bookId, client_id: bookInfo.clientId || null, client_name: bookInfo.clientName, project_name: bookInfo.projectName, site_address: bookInfo.siteAddress, book_date: bookInfo.bookDate || null, prepared_by: bookInfo.preparedBy, revision: normalizeRevision(bookInfo.revision), template: bookInfo.template || 'fourge', site_map_path: finalSiteMapPath, inventory_map_path: finalInventoryMapPath || null, signs: finalSigns, survey_photo_paths: finalSurveyPaths.filter(Boolean), updated_at: new Date().toISOString(), // Cover page fields project_logo_path: finalProjectLogoPath || null, creation_date: bookInfo.creationDate || null, revision_date: bookInfo.revisionDate || null, customer_name: bookInfo.customerName || null, customer_address: bookInfo.customerAddress || null, client_logo_url: bookInfo.clientLogoUrl || null, client_contact_name: bookInfo.clientContactName || null, client_contact_email: bookInfo.clientContactEmail || null, client_contact_phone: bookInfo.clientContactPhone || null, approved_date: bookInfo.approvedDate || null, approval_notes: bookInfo.approvalNotes || null, }; const { error: upsertError } = await supabase.from('brand_books').upsert(dbData); if (upsertError) throw upsertError; const savedSignsWithPreviews = await Promise.all(finalSigns.map(async (sign) => { const [photoPreview, existingPhotoPreview, recommendationPhotoPreview, signDetailPhotoPreview] = await Promise.all([ getSignedUrl(sign.photoPath), getSignedUrl(sign.existingPhotoPath), getSignedUrl(sign.recommendationPhotoPath), getSignedUrl(sign.signDetailPhotoPath), ]); return { ...sign, photo: null, existingPhoto: null, recommendationPhoto: null, signDetailPhoto: null, _photoPreview: photoPreview || '', _existingPhotoPreview: existingPhotoPreview || '', _recommendationPhotoPreview: recommendationPhotoPreview || '', _signDetailPhotoPreview: signDetailPhotoPreview || '', }; })); setCurrentId(bookId); setSiteMapPath(finalSiteMapPath); setSiteMapFile(null); setInventoryMapPath(finalInventoryMapPath); setInventoryMapFile(null); setProjectLogoPath(finalProjectLogoPath); setProjectLogoFile(null); setSigns(savedSignsWithPreviews); setPhotoItems(finalSurveyPaths.filter(Boolean).map((path, i) => ({ file: null, path, preview: photoItems[i]?.preview || '', }))); await fetchBooks(); setNotification({ type: 'success', msg: '✓ Brand book saved!' }); } catch (err) { setNotification({ type: 'error', msg: `Save failed: ${err.message}` }); } finally { setSaving(false); } }; const handleGenerate = async () => { if (!bookInfo.clientName.trim()) { setNotification({ type: 'error', msg: 'Please select a company.' }); return; } setGenerating(true); setNotification(null); try { const [siteMapSource, inventoryMapSource] = await Promise.all([ siteMapFile ? Promise.resolve(siteMapFile) : (siteMapPath ? getSignedUrl(siteMapPath) : null), inventoryMapFile ? Promise.resolve(inventoryMapFile) : (inventoryMapPath ? getSignedUrl(inventoryMapPath) : null), ]); const signsWithSource = await Promise.all(signs.map(async s => ({ ...s, photoSource: s.photo || (s.photoPath ? await getSignedUrl(s.photoPath) : null), existingPhotoSource: s.existingPhoto || (s.existingPhotoPath ? await getSignedUrl(s.existingPhotoPath) : null), recommendationPhotoSource: s.recommendationPhoto || (s.recommendationPhotoPath ? await getSignedUrl(s.recommendationPhotoPath) : null), signDetailPhotoSource: s.signDetailPhoto || (s.signDetailPhotoPath ? await getSignedUrl(s.signDetailPhotoPath) : null), }))); const sitePhotoSources = await Promise.all(photoItems.map(async item => item.file || (item.path ? await getSignedUrl(item.path) : null) )); const projectLogoSource = projectLogoFile || (projectLogoPath ? await getSignedUrl(projectLogoPath) : null); await generateBrandBookEditorPDF({ template: bookInfo.template || 'fourge', clientName: bookInfo.clientName, projectName: bookInfo.projectName, siteAddress: bookInfo.siteAddress, bookDate: bookInfo.bookDate, preparedBy: bookInfo.preparedBy, revision: normalizeRevision(bookInfo.revision), siteMapSource, inventoryMapSource, signs: signsWithSource, sitePhotoSources, // Cover page projectLogoSource, creationDate: bookInfo.creationDate, revisionDate: bookInfo.revisionDate, customerName: bookInfo.customerName, customerAddress: bookInfo.customerAddress || bookInfo.siteAddress, clientLogoSource: bookInfo.clientLogoUrl || null, clientContactName: bookInfo.clientContactName, clientContactEmail: bookInfo.clientContactEmail, clientContactPhone: bookInfo.clientContactPhone, approvedDate: bookInfo.approvedDate, approvalNotes: bookInfo.approvalNotes, }); setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' }); } catch (err) { setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` }); } finally { setGenerating(false); } }; // ── Project logo helpers ────────────────────────────────────────────────────── const handleProjectLogoUpload = (e) => { const file = e.target.files[0]; if (!file) return; setProjectLogoFile(file); setProjectLogoPreview(URL.createObjectURL(file)); setProjectLogoPath(''); }; // ── Client logo / info helpers ──────────────────────────────────────────────── const handleClientLogoUpload = async (e) => { const file = e.target.files[0]; if (!file) return; if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; } setUploadingClientLogo(true); const ext = file.name.split('.').pop().toLowerCase(); const path = `${bookInfo.clientId}/logo.${ext}`; await supabase.storage.from('company-logos').remove([path]); const { error } = await supabase.storage.from('company-logos').upload(path, file, { upsert: true }); if (!error) { const { data: { publicUrl } } = supabase.storage.from('company-logos').getPublicUrl(path); await supabase.from('companies').update({ client_logo_url: publicUrl }).eq('id', bookInfo.clientId); setBookInfo(b => ({ ...b, clientLogoUrl: publicUrl })); } setUploadingClientLogo(false); }; const handleSaveClientInfo = async () => { if (!bookInfo.clientId) return; setSavingClientInfo(true); await supabase.from('companies').update({ address: bookInfo.customerAddress || null, contact_name: bookInfo.clientContactName || null, contact_email: bookInfo.clientContactEmail || null, contact_phone: bookInfo.clientContactPhone || null, }).eq('id', bookInfo.clientId); setSavingClientInfo(false); setClientInfoSaved(true); setTimeout(() => setClientInfoSaved(false), 2500); }; // ── Sign helpers ───────────────────────────────────────────────────────────── const updateSign = (key, field, value) => setSigns(prev => prev.map(s => s._key === key ? { ...s, [field]: value } : s)); const addSign = () => { setSigns(prev => [...prev, EMPTY_SIGN()]); }; const removeSign = (key) => setSigns(prev => prev.filter(s => s._key !== key)); const handleSignPhoto = (key, field, file) => { const previewField = `_${field}Preview`; const pathField = `${field}Path`; updateSign(key, field, file); updateSign(key, previewField, URL.createObjectURL(file)); updateSign(key, pathField, ''); }; // ── Map helpers ─────────────────────────────────────────────────────────────── const handleSiteMapFile = (file) => { setSiteMapFile(file); setSiteMapPath(''); setSiteMapPreview(URL.createObjectURL(file)); setAutoMapAddress(''); setAutoMapError(''); setSiteMapManuallyCleared(false); }; const handleInventoryMapFile = (file) => { setInventoryMapFile(file); setInventoryMapPath(''); setInventoryMapPreview(URL.createObjectURL(file)); setAutoMapAddress(''); setAutoMapError(''); setInventoryMapManuallyCleared(false); }; // ── Survey photo helpers ────────────────────────────────────────────────────── const handleSitePhotos = (files) => { const newItems = Array.from(files) .filter(isPhotoFile) .map(file => ({ file, path: '', preview: URL.createObjectURL(file) })); setPhotoItems(prev => [...prev, ...newItems]); }; const removeSitePhoto = (i) => setPhotoItems(prev => prev.filter((_, idx) => idx !== i)); // ── Unsaved changes indicator ───────────────────────────────────────────────── const hasUnsavedPhotos = siteMapFile || inventoryMapFile || signs.some(s => ( s.photo || s.existingPhoto || s.recommendationPhoto || s.signDetailPhoto )) || photoItems.some(i => i.file); // ═══════════════════════════════════════════════════════════════════════════════ // LIST VIEW // ═══════════════════════════════════════════════════════════════════════════════ if (view === 'list') { const companyNames = [...new Set(savedBooks.map(book => book.client_name || clients.find(client => client.id === book.client_id)?.name).filter(Boolean))].sort(); const filteredBooks = savedBooks.filter(book => { if (!filterCompany) return true; const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name; return clientName === filterCompany; }); return (
Brand Book Maker
Saved brand books — click to edit or add a revision.
{companyNames.length > 0 && (
)} {loadingBooks ? (

Loading...

) : filteredBooks.length === 0 ? (
{savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
{savedBooks.length === 0 && }
) : (
NameRevisionSign CountCompanyUpdated {bbSort(filteredBooks, (book, key) => { if (key === 'sign_count') return Array.isArray(book.signs) ? book.signs.length : 0; if (key === 'client') return book.client_name || clients.find(c => c.id === book.client_id)?.name || ''; if (key === 'updated_at') return book.updated_at ? new Date(book.updated_at).getTime() : 0; return book[key] || ''; }).map(book => { const signCount = Array.isArray(book.signs) ? book.signs.length : 0; const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name || 'No client'; const updated = book.updated_at ? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; return ( handleLoad(book)} style={{ cursor: 'pointer' }}> ); })}
{book.project_name || 'Brand Book'} {`R${String(book.revision || '01').padStart(2, '0')}`} {signCount} {clientName} {updated} e.stopPropagation()}>
)}
); } // ═══════════════════════════════════════════════════════════════════════════════ // FORM VIEW // ═══════════════════════════════════════════════════════════════════════════════ return (
{currentId ? `${bookInfo.projectName || 'Brand Book'} R${String(bookInfo.revision).padStart(2, '0')}` : 'New Brand Book'}
{currentId ? 'Editing saved brand book' : 'Unsaved — fill in details and save'} {hasUnsavedPhotos && · Unsaved photos}
Generate PDF
{notification && ( {notification.msg} )}
{/* ── BRAND BOOK INFO ──────────────────────────────────────────────── */}
Brand Book Info
R
{/* ── COVER PAGE ───────────────────────────────────────────────────── */}
Cover Page
{/* Project logo */}
{projectLogoPreview && ( Project logo )}
{projectLogoPreview && ( )}
{/* Dates */}
{/* Customer info */}
setShowAddressSuggestions(true)} style={{ margin: 0 }} /> loadMapsFromAddress()} > Load Maps
{showAddressSuggestions && (addressSuggestions.length > 0 || addressSuggesting) && (
{addressSuggesting && (
Searching...
)} {addressSuggestions.map(feature => ( ))}
)}
{autoMapError && (
{autoMapError}
)}
{/* Client info (saved per company) */}
Company Info
Logo and contact saved to company — reused across all brand books.
{bookInfo.clientLogoUrl && ( Client logo )}
{!bookInfo.clientId && Select a client first}
{/* Approval */}
Approval