Files
fpvshop/src/App.tsx
T

284 lines
12 KiB
TypeScript

import { useRef, useState } from 'react'
import { useCatalog } from './hooks/useCatalog'
import { useCarts } from './hooks/useCarts'
import { SectionBlock } from './components/SectionBlock'
import { AddSectionDialog } from './components/AddSectionDialog'
import type { Product, Drone } from './types'
const THEMES = [
{ value: '', label: 'OS default' },
{ value: 'sonokai-default', label: 'Sonokai Default' },
{ value: 'sonokai-default-darker', label: 'Sonokai Default Darker' },
{ value: 'sonokai-shusia', label: 'Sonokai Shusia' },
{ value: 'sonokai-andromeda', label: 'Sonokai Andromeda' },
{ value: 'sonokai-atlantis', label: 'Sonokai Atlantis' },
{ value: 'sonokai-maia', label: 'Sonokai Maia' },
{ value: 'sonokai-espresso', label: 'Sonokai Espresso' },
{ value: 'axis-dark', label: 'Axis Dark' },
{ value: 'axis-light', label: 'Axis Light' },
]
const THEME_KEY = 'theme'
function cartTotal(sections: { items: (Product | Drone)[] }[]): string {
let total = 0
let currency = 'SEK'
for (const section of sections) {
for (const item of section.items) {
if ('buildType' in item) {
if (item.buildType === 'complete') { total += item.price.amount; currency = item.price.currency }
else if (item.parts[0]) { total += item.parts.reduce((s, p) => s + p.price.amount, 0); currency = item.parts[0].price.currency }
} else {
total += item.price.amount
currency = item.price.currency
}
}
}
return total === 0 ? '—' : new Intl.NumberFormat('en', { style: 'currency', currency }).format(total)
}
export default function App() {
const [theme, setTheme] = useState(() => {
const stored = localStorage.getItem(THEME_KEY) ?? ''
if (stored) document.documentElement.setAttribute('data-theme', stored)
return stored
})
const [showAddSection, setShowAddSection] = useState(false)
const [showNewCart, setShowNewCart] = useState(false)
const [newCartName, setNewCartName] = useState('')
const [cartToDelete, setCartToDelete] = useState<string | null>(null)
const [renamingCartId, setRenamingCartId] = useState<string | null>(null)
const [renameCartValue, setRenameCartValue] = useState('')
const importRef = useRef<HTMLInputElement>(null)
const { catalog, loading: catalogLoading, error: catalogError } = useCatalog()
const { carts, activeCart, activeId, storageError, setActiveCart, createCart, deleteCart, renameCart, importCart, exportCart, addSection, removeSection, renameSection, reorderSections, addItem, updateItem, removeItem } = useCarts()
const [dragSectionId, setDragSectionId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
function commitCartRename() {
if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim())
setRenamingCartId(null)
}
function handleThemeChange(value: string) {
setTheme(value)
if (value) { localStorage.setItem(THEME_KEY, value); document.documentElement.setAttribute('data-theme', value) }
else { localStorage.removeItem(THEME_KEY); document.documentElement.removeAttribute('data-theme') }
}
async function handleCreateFromTemplate(templateId: string) {
const existing = carts.find(c => c.templateId === templateId)
if (existing) { setActiveCart(existing.id); return }
const template = catalog?.templates.find(t => t.id === templateId)
if (!template) return
await createCart(template.name, template)
}
async function handleCreateEmpty() {
if (!newCartName.trim()) return
await createCart(newCartName.trim())
setNewCartName('')
setShowNewCart(false)
}
const selectStyle = { background: 'var(--color-bg1)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)' }
return (
<div className="min-h-screen" style={{ background: 'var(--color-bg0)' }}>
{/* Header */}
<header className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b px-4 py-3 sm:px-6" style={{ background: 'var(--color-bg0)', borderColor: 'var(--color-bg2)' }}>
<h1 className="font-semibold tracking-tight" style={{ color: 'var(--color-fg)' }}>FPV Shop List</h1>
<div className="flex items-center gap-2 flex-wrap">
{activeCart && (
<span className="text-sm font-medium" style={{ color: 'var(--color-yellow)' }}>
{cartTotal(activeCart.sections)}
</span>
)}
{activeCart && (
<button onClick={exportCart} className="rounded px-3 py-1.5 text-sm" style={{ background: 'var(--color-bg2)', color: 'var(--color-cyan)' }}>
Export
</button>
)}
<select value={theme} onChange={e => handleThemeChange(e.target.value)} className="rounded border px-2 py-1.5 text-sm" style={selectStyle}>
{THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
</header>
{/* Cart bar */}
<div className="flex items-center gap-2 border-b px-4 py-2 overflow-x-auto sm:px-6" style={{ borderColor: 'var(--color-bg2)' }}>
{carts.map(cart => (
<div key={cart.id} className="flex shrink-0 items-center gap-1 rounded px-2 py-1" style={cart.id === activeId ? { background: 'var(--color-bg2)' } : {}}>
{renamingCartId === cart.id ? (
<input
autoFocus
value={renameCartValue}
onChange={e => setRenameCartValue(e.target.value)}
onBlur={commitCartRename}
onKeyDown={e => { if (e.key === 'Enter') commitCartRename(); if (e.key === 'Escape') setRenamingCartId(null) }}
className="rounded border px-1 py-0.5 text-sm outline-none"
style={{ background: 'var(--color-bg2)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)', width: `${Math.max(renameCartValue.length, 4)}ch` }}
/>
) : (
<button
onClick={() => setActiveCart(cart.id)}
onDoubleClick={() => { setRenamingCartId(cart.id); setRenameCartValue(cart.name) }}
className="text-sm"
style={cart.id === activeId ? { color: 'var(--color-fg)' } : { color: 'var(--color-grey)' }}
title="Double-click to rename"
>
{cart.name}
</button>
)}
<button
onClick={() => setCartToDelete(cart.id)}
className="text-xs leading-none"
style={{ color: 'var(--color-grey)' }}
title="Remove cart"
>
</button>
</div>
))}
{/* New cart */}
{showNewCart ? (
<form onSubmit={e => { e.preventDefault(); handleCreateEmpty() }} className="flex items-center gap-1">
<input
autoFocus
value={newCartName}
onChange={e => setNewCartName(e.target.value)}
onBlur={() => { if (!newCartName.trim()) setShowNewCart(false) }}
placeholder="Cart name"
className="rounded border px-2 py-1 text-sm outline-none"
style={{ background: 'var(--color-bg2)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)' }}
/>
</form>
) : (
<button onClick={() => setShowNewCart(true)} className="shrink-0 text-sm" style={{ color: 'var(--color-grey)' }}>
+ New cart
</button>
)}
{/* Import cart from file */}
<input
ref={importRef}
type="file"
accept=".json"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (!file) return
file.text().then(json => {
importCart(json).then(ok => { if (!ok) alert('Invalid cart file.') })
})
e.target.value = ''
}}
/>
<button onClick={() => importRef.current?.click()} className="shrink-0 text-sm" style={{ color: 'var(--color-grey)' }}>
Import
</button>
</div>
{/* Template row */}
{catalog && catalog.templates.length > 0 && (
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-2 sm:px-6" style={{ borderColor: 'var(--color-bg2)' }}>
<span className="text-xs shrink-0" style={{ color: 'var(--color-grey)' }}>Templates:</span>
{catalog.templates.map(t => (
<button
key={t.id}
onClick={() => handleCreateFromTemplate(t.id)}
className="shrink-0 rounded border px-2 py-0.5 text-xs"
style={{ borderColor: 'var(--color-bg3)', color: 'var(--color-grey)', background: 'var(--color-bg1)' }}
>
{t.name}
</button>
))}
</div>
)}
{/* Main */}
<main className="mx-auto flex max-w-2xl flex-col gap-4 px-4 py-6">
{catalogLoading && <p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>Loading</p>}
{catalogError && <p className="py-12 text-center text-sm" style={{ color: 'var(--color-red)' }}>{catalogError}</p>}
{storageError && <p className="rounded-lg border px-4 py-3 text-sm" style={{ color: 'var(--color-red)', borderColor: 'var(--color-red)', background: 'var(--color-bg1)' }}>{storageError}</p>}
{!activeCart && !catalogLoading && (
<p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>
No cart yet create one or load a template above
</p>
)}
{activeCart && <>
{activeCart.sections.map(section => (
<SectionBlock
key={section.id}
section={section}
onRemoveItem={itemId => removeItem(section.id, itemId)}
onAddItem={item => addItem(section.id, item)}
onEditItem={item => updateItem(section.id, item)}
onRemoveSection={() => removeSection(section.id)}
onRenameSection={label => renameSection(section.id, label)}
isDragging={dragSectionId === section.id}
isDragOver={dragOverId === section.id}
onDragStart={() => setDragSectionId(section.id)}
onDragOver={e => { e.preventDefault(); setDragOverId(section.id) }}
onDrop={() => { if (dragSectionId) reorderSections(dragSectionId, section.id); setDragOverId(null) }}
onDragEnd={() => { setDragSectionId(null); setDragOverId(null) }}
/>
))}
<button
onClick={() => setShowAddSection(true)}
className="rounded-lg border py-3 text-sm"
style={{ borderColor: 'var(--color-bg3)', color: 'var(--color-grey)', borderStyle: 'dashed' }}
>
+ Add section
</button>
</>}
</main>
{showAddSection && (
<AddSectionDialog
onAdd={(id, label, required) => addSection({ id, label, required })}
onClose={() => setShowAddSection(false)}
/>
)}
{cartToDelete && (() => {
const cart = carts.find(c => c.id === cartToDelete)
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.6)' }}
>
<div className="w-full max-w-sm rounded-lg border p-6 flex flex-col gap-4" style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}>
<p className="font-medium" style={{ color: 'var(--color-fg)' }}>Remove cart?</p>
<p className="text-sm" style={{ color: 'var(--color-grey)' }}>
"<span style={{ color: 'var(--color-fg)' }}>{cart?.name}</span>" will be permanently deleted.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setCartToDelete(null)}
className="rounded px-3 py-1.5 text-sm"
style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}
>
Cancel
</button>
<button
onClick={() => { deleteCart(cartToDelete); setCartToDelete(null) }}
className="rounded px-3 py-1.5 text-sm font-medium"
style={{ background: 'var(--color-filled-red)', color: 'var(--color-black)' }}
>
Remove
</button>
</div>
</div>
</div>
)
})()}
</div>
)
}