242 lines
7.3 KiB
TypeScript
242 lines
7.3 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import type { Cart, CartTemplate, Section, Product, Drone } from '../types'
|
|
import { cacheImagesInCart, resolveImagesInCart, pruneImageCache } from '../utils/imageCache'
|
|
|
|
const CARTS_KEY = 'carts'
|
|
const ACTIVE_KEY = 'active-cart-id'
|
|
|
|
function loadCarts(): Cart[] {
|
|
try {
|
|
const raw = localStorage.getItem(CARTS_KEY)
|
|
if (raw) return JSON.parse(raw) as Cart[]
|
|
} catch {}
|
|
return []
|
|
}
|
|
|
|
function loadActiveId(carts: Cart[]): string | null {
|
|
const stored = localStorage.getItem(ACTIVE_KEY)
|
|
if (stored && carts.find(c => c.id === stored)) return stored
|
|
return carts[0]?.id ?? null
|
|
}
|
|
|
|
function saveCarts(carts: Cart[]): string | null {
|
|
try {
|
|
localStorage.setItem(CARTS_KEY, JSON.stringify(carts))
|
|
return null
|
|
} catch (e) {
|
|
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
|
|
return 'Storage quota exceeded — images may be too large. Try removing unused images.'
|
|
}
|
|
return 'Failed to save carts.'
|
|
}
|
|
}
|
|
|
|
export function useCarts() {
|
|
const [carts, setCarts] = useState<Cart[]>(() => loadCarts())
|
|
const [activeId, setActiveId] = useState<string | null>(() => {
|
|
const c = loadCarts()
|
|
return loadActiveId(c)
|
|
})
|
|
|
|
// Migrate any inline base64 images to IndexedDB on first load
|
|
useEffect(() => {
|
|
const loaded = loadCarts()
|
|
const needsMigration = loaded.some(cart =>
|
|
cart.sections.some(s => s.items.some(item =>
|
|
(item.image && !item.image.startsWith('img:')) ||
|
|
('parts' in item && item.parts.some(p => p.image && !p.image.startsWith('img:')))
|
|
))
|
|
)
|
|
if (!needsMigration) return
|
|
Promise.all(loaded.map(cacheImagesInCart)).then(migrated => {
|
|
pruneImageCache(migrated)
|
|
saveCarts(migrated)
|
|
setCarts(migrated)
|
|
})
|
|
}, [])
|
|
const [storageError, setStorageError] = useState<string | null>(null)
|
|
|
|
const activeCart = carts.find(c => c.id === activeId) ?? null
|
|
|
|
function mutateCarts(fn: (carts: Cart[]) => Cart[]) {
|
|
setCarts(prev => {
|
|
const next = fn(prev)
|
|
pruneImageCache(next) // fire-and-forget async
|
|
const err = saveCarts(next)
|
|
setStorageError(err)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function mutateActiveCart(fn: (cart: Cart) => Cart) {
|
|
mutateCarts(carts => carts.map(c => c.id === activeId ? fn(c) : c))
|
|
}
|
|
|
|
// ── Cart management ────────────────────────────────────────────────────────
|
|
|
|
function createCart(name: string, template?: CartTemplate): string {
|
|
const id = crypto.randomUUID()
|
|
const cart: Cart = {
|
|
id,
|
|
name,
|
|
createdAt: new Date().toISOString().slice(0, 10),
|
|
templateId: template?.id,
|
|
sections: template ? structuredClone(template.sections) : [],
|
|
}
|
|
mutateCarts(prev => [...prev, cart])
|
|
setActiveCart(id)
|
|
return id
|
|
}
|
|
|
|
function deleteCart(id: string) {
|
|
mutateCarts(prev => {
|
|
const next = prev.filter(c => c.id !== id)
|
|
if (activeId === id) {
|
|
const newActive = next[0]?.id ?? null
|
|
setActiveId(newActive)
|
|
if (newActive) localStorage.setItem(ACTIVE_KEY, newActive)
|
|
else localStorage.removeItem(ACTIVE_KEY)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
function renameCart(id: string, name: string) {
|
|
mutateCarts(carts => carts.map(c => c.id === id ? { ...c, name } : c))
|
|
}
|
|
|
|
function setActiveCart(id: string) {
|
|
setActiveId(id)
|
|
localStorage.setItem(ACTIVE_KEY, id)
|
|
}
|
|
|
|
async function importCart(json: string): Promise<boolean> {
|
|
try {
|
|
const cart = JSON.parse(json) as Cart
|
|
if (!cart.id || !cart.name || !Array.isArray(cart.sections)) return false
|
|
const imported = await cacheImagesInCart({ ...cart, id: crypto.randomUUID() })
|
|
mutateCarts(prev => [...prev, imported])
|
|
setActiveCart(imported.id)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function exportCart() {
|
|
if (!activeCart) return
|
|
const resolved = await resolveImagesInCart(activeCart)
|
|
const blob = new Blob([JSON.stringify(resolved, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${activeCart.name.toLowerCase().replace(/\s+/g, '-')}.json`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
// ── Section management ────────────────────────────────────────────────────
|
|
|
|
function addSection(section: Omit<Section, 'items'>) {
|
|
mutateActiveCart(cart => ({
|
|
...cart,
|
|
sections: [...cart.sections, { ...section, items: [] }],
|
|
}))
|
|
}
|
|
|
|
function removeSection(sectionId: string) {
|
|
mutateActiveCart(cart => ({
|
|
...cart,
|
|
sections: cart.sections.filter(s => s.id !== sectionId),
|
|
}))
|
|
}
|
|
|
|
function renameSection(sectionId: string, label: string) {
|
|
mutateActiveCart(cart => ({
|
|
...cart,
|
|
sections: cart.sections.map(s => s.id === sectionId ? { ...s, label } : s),
|
|
}))
|
|
}
|
|
|
|
function reorderSections(fromId: string, toId: string) {
|
|
mutateActiveCart(cart => {
|
|
const sections = [...cart.sections]
|
|
const fromIdx = sections.findIndex(s => s.id === fromId)
|
|
const toIdx = sections.findIndex(s => s.id === toId)
|
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return cart
|
|
const [moved] = sections.splice(fromIdx, 1)
|
|
sections.splice(toIdx, 0, moved)
|
|
return { ...cart, sections }
|
|
})
|
|
}
|
|
|
|
// ── Item management ───────────────────────────────────────────────────────
|
|
|
|
function addItem(sectionId: string, item: Product | Drone) {
|
|
mutateActiveCart(cart => ({
|
|
...cart,
|
|
sections: cart.sections.map(s =>
|
|
s.id === sectionId ? { ...s, items: [...s.items, item] } : s
|
|
),
|
|
}))
|
|
}
|
|
|
|
function updateItem(sectionId: string, item: Product | Drone) {
|
|
mutateActiveCart(cart => ({
|
|
...cart,
|
|
sections: cart.sections.map(s =>
|
|
s.id === sectionId
|
|
? { ...s, items: s.items.map(i => i.id === item.id ? item : i) }
|
|
: s
|
|
),
|
|
}))
|
|
}
|
|
|
|
function removeItem(sectionId: string, itemId: string) {
|
|
mutateActiveCart(cart => ({
|
|
...cart,
|
|
sections: cart.sections.map(s =>
|
|
s.id === sectionId
|
|
? { ...s, items: s.items.filter(i => i.id !== itemId) }
|
|
: s
|
|
),
|
|
}))
|
|
}
|
|
|
|
function copyItemToCart(item: Product | Drone, targetCartId: string, targetSectionId: string) {
|
|
const copy = { ...structuredClone(item), id: crypto.randomUUID() }
|
|
mutateCarts(carts => carts.map(c => {
|
|
if (c.id !== targetCartId) return c
|
|
const hasSection = c.sections.some(s => s.id === targetSectionId)
|
|
if (!hasSection) return c
|
|
return {
|
|
...c,
|
|
sections: c.sections.map(s =>
|
|
s.id === targetSectionId ? { ...s, items: [...s.items, copy] } : s
|
|
),
|
|
}
|
|
}))
|
|
}
|
|
|
|
return {
|
|
carts,
|
|
activeCart,
|
|
activeId,
|
|
storageError,
|
|
setActiveCart,
|
|
createCart,
|
|
deleteCart,
|
|
renameCart,
|
|
importCart,
|
|
exportCart,
|
|
addSection,
|
|
removeSection,
|
|
renameSection,
|
|
reorderSections,
|
|
addItem,
|
|
updateItem,
|
|
removeItem,
|
|
copyItemToCart,
|
|
}
|
|
}
|