Merge beachPartyBalloons estore features into balloons-shop
- Multi-category support: CatalogItem gains categories/categoryLabels arrays; catalog route applies categoriesOverride; FeaturedProducts filters by array - Featured sorting: featured items sort first in catalog route - Admin panel: featured toggle, requiresDelivery with per-item rate overrides, multi-category checkboxes, variation visibility, AdminColorFilter modal, delivery rates tab (DeliveryRatesEditor) - Per-item delivery rate overrides: delivery-quote route accepts rateOverride and reads from delivery-rates.json via readDeliveryRates() - disabledColors, hiddenVariationIds applied in catalog and admin routes - ScrollToTop button added to layout - GuidedTour gains optional onStart prop; tourInit resets category/search - Occasion tab deduplication in FeaturedProducts - New components: ScrollToTop, AdminColorFilter, useLockBodyScroll, delivery-rates lib, admin/delivery-rates API route Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b1606302b0
commit
e7fec9ea72
27
src/app/api/admin/delivery-rates/route.ts
Normal file
27
src/app/api/admin/delivery-rates/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readDeliveryRates, writeDeliveryRates } from '@/lib/delivery-rates'
|
||||
import type { DeliveryRatesConfig } from '@/lib/delivery'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(readDeliveryRates())
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as DeliveryRatesConfig
|
||||
const tiers = ['dropoff', 'classic', 'organic'] as const
|
||||
for (const tier of tiers) {
|
||||
const t = body[tier]
|
||||
if (!t || typeof t.base !== 'number' || typeof t.perMile !== 'number' || typeof t.label !== 'string') {
|
||||
return NextResponse.json({ error: `Invalid config for tier: ${tier}` }, { status: 400 })
|
||||
}
|
||||
}
|
||||
writeDeliveryRates(body)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[admin/delivery-rates] error:', err)
|
||||
return NextResponse.json({ error: 'Failed to save delivery rates' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -11,18 +11,47 @@ export async function GET() {
|
||||
|
||||
const withOverrides = items.map((item) => {
|
||||
const ov = overrides[item.id] ?? {}
|
||||
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
// Resolve categories (same logic as catalog route)
|
||||
const resolvedCats = ov.categoriesOverride?.length
|
||||
? (() => {
|
||||
const cats = ov.categoriesOverride!.map(toSlug)
|
||||
return {
|
||||
categories: cats,
|
||||
categoryLabels: ov.categoriesOverride!,
|
||||
category: cats[0],
|
||||
categoryLabel: ov.categoriesOverride![0],
|
||||
}
|
||||
})()
|
||||
: {
|
||||
category: ov.categoryOverride ?? item.category,
|
||||
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
|
||||
categories: ov.categoryOverride
|
||||
? [ov.categoryOverride, ...(item.categories ?? [item.category]).slice(1)]
|
||||
: (item.categories ?? [item.category]),
|
||||
categoryLabels: ov.categoryLabelOverride
|
||||
? [ov.categoryLabelOverride, ...(item.categoryLabels ?? [item.categoryLabel]).slice(1)]
|
||||
: (item.categoryLabels ?? [item.categoryLabel]),
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
// Resolved values (what the customer sees)
|
||||
hidden: ov.hidden ?? false,
|
||||
category: ov.categoryOverride ?? item.category,
|
||||
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
|
||||
featured: ov.featured ?? item.featured ?? false,
|
||||
...resolvedCats,
|
||||
sortOrder: ov.sortOrder ?? 0,
|
||||
showColors: ov.showColors != null ? ov.showColors : item.showColors,
|
||||
colorMin: ov.colorMin ?? item.colorMin,
|
||||
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
|
||||
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
|
||||
disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors,
|
||||
requiresDelivery: ov.requiresDelivery != null ? ov.requiresDelivery : item.requiresDelivery,
|
||||
deliveryBaseOverride: ov.deliveryBaseOverride !== undefined ? ov.deliveryBaseOverride : item.deliveryBaseOverride,
|
||||
deliveryPerMileOverride: ov.deliveryPerMileOverride !== undefined ? ov.deliveryPerMileOverride : item.deliveryPerMileOverride,
|
||||
description: ov.descriptionOverride ?? item.description,
|
||||
variations: item.variations.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
|
||||
modifiers: item.modifiers
|
||||
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
|
||||
.map((m) => {
|
||||
@ -33,6 +62,7 @@ export async function GET() {
|
||||
_rawCategory: item.category,
|
||||
_rawCategoryLabel: item.categoryLabel,
|
||||
_rawShowColors: item.showColors,
|
||||
_rawVariations: item.variations,
|
||||
_rawModifiers: item.modifiers,
|
||||
_rawDescription: item.description,
|
||||
_override: ov,
|
||||
|
||||
@ -3,6 +3,8 @@ import { getCatalog } from '@/lib/catalog-cache'
|
||||
import { readOverrides } from '@/lib/overrides'
|
||||
import type { CatalogItem } from '@/data/mock-catalog'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
||||
const overrides = readOverrides()
|
||||
|
||||
@ -12,16 +14,44 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
||||
if (!ov) return item
|
||||
return {
|
||||
...item,
|
||||
category: ov.categoryOverride ?? item.category,
|
||||
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
|
||||
featured: ov.featured ?? item.featured,
|
||||
// categoriesOverride (array of names) takes precedence over the old single-field overrides
|
||||
...(ov.categoriesOverride?.length
|
||||
? (() => {
|
||||
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
const cats = ov.categoriesOverride!.map(toSlug)
|
||||
return {
|
||||
categories: cats,
|
||||
categoryLabels: ov.categoriesOverride!,
|
||||
category: cats[0],
|
||||
categoryLabel: ov.categoriesOverride![0],
|
||||
}
|
||||
})()
|
||||
: {
|
||||
category: ov.categoryOverride ?? item.category,
|
||||
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
|
||||
categories: ov.categoryOverride
|
||||
? [ov.categoryOverride, ...(item.categories ?? [item.category]).slice(1)]
|
||||
: (item.categories ?? [item.category]),
|
||||
categoryLabels: ov.categoryLabelOverride
|
||||
? [ov.categoryLabelOverride, ...(item.categoryLabels ?? [item.categoryLabel]).slice(1)]
|
||||
: (item.categoryLabels ?? [item.categoryLabel]),
|
||||
}
|
||||
),
|
||||
showColors: ov.showColors != null ? ov.showColors : item.showColors,
|
||||
colorMin: ov.colorMin ?? item.colorMin,
|
||||
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
|
||||
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
|
||||
quantityUnit: ov.quantityUnit ?? item.quantityUnit,
|
||||
disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors,
|
||||
quantityUnit: ov.quantityUnit ?? item.quantityUnit,
|
||||
requiresDelivery: ov.requiresDelivery != null ? ov.requiresDelivery : item.requiresDelivery,
|
||||
deliveryBaseOverride: ov.deliveryBaseOverride !== undefined ? ov.deliveryBaseOverride : item.deliveryBaseOverride,
|
||||
deliveryPerMileOverride: ov.deliveryPerMileOverride !== undefined ? ov.deliveryPerMileOverride : item.deliveryPerMileOverride,
|
||||
vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled,
|
||||
vinylPromo: ov.vinylPromo ?? item.vinylPromo,
|
||||
description: ov.descriptionOverride ?? item.description,
|
||||
variations: item.variations
|
||||
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
|
||||
modifiers: item.modifiers
|
||||
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
|
||||
.map((m) => {
|
||||
@ -32,6 +62,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
||||
})
|
||||
.filter((item) => !(overrides[item.id]?.hidden))
|
||||
.sort((a, b) => {
|
||||
const featDiff = (b.featured ? 1 : 0) - (a.featured ? 1 : 0)
|
||||
if (featDiff !== 0) return featDiff
|
||||
const aOrder = overrides[a.id]?.sortOrder ?? 0
|
||||
const bOrder = overrides[b.id]?.sortOrder ?? 0
|
||||
return aOrder - bOrder
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { geocode, calcDelivery, inferTier } from '@/lib/delivery'
|
||||
import { readDeliveryRates } from '@/lib/delivery-rates'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { address, itemNames } = await request.json() as {
|
||||
address: string
|
||||
itemNames: string[]
|
||||
const { address, itemNames, rateOverride } = await request.json() as {
|
||||
address: string
|
||||
itemNames: string[]
|
||||
rateOverride?: { base: number; perMile: number }
|
||||
}
|
||||
|
||||
if (!address?.trim()) {
|
||||
@ -17,7 +19,18 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const tier = inferTier(itemNames ?? [])
|
||||
const quote = await calcDelivery(coords.lat, coords.lng, tier)
|
||||
const rates = readDeliveryRates()
|
||||
|
||||
// Apply per-item rate override if provided (overrides just base and perMile for the inferred tier)
|
||||
if (rateOverride) {
|
||||
rates[tier] = {
|
||||
...rates[tier],
|
||||
base: rateOverride.base,
|
||||
perMile: rateOverride.perMile,
|
||||
}
|
||||
}
|
||||
|
||||
const quote = await calcDelivery(coords.lat, coords.lng, tier, rates)
|
||||
|
||||
if (quote.miles > 40) {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -4,6 +4,7 @@ import Navbar from '@/components/Navbar'
|
||||
import Footer from '@/components/Footer'
|
||||
import CartDrawer from '@/components/CartDrawer'
|
||||
import CartFab from '@/components/CartFab'
|
||||
import ScrollToTop from '@/components/ScrollToTop'
|
||||
import { CartProvider } from '@/context/CartContext'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -49,6 +50,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<CartFab />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<ScrollToTop />
|
||||
</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -6,6 +6,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
|
||||
import type { ItemOverride } from '@/lib/overrides'
|
||||
import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config'
|
||||
import type { HoursConfig, DayHours } from '@/lib/hours-config'
|
||||
import AdminColorFilter from '@/components/AdminColorFilter'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -15,6 +16,7 @@ interface AdminItem extends CatalogItem {
|
||||
_rawCategory: string
|
||||
_rawCategoryLabel: string
|
||||
_rawShowColors: boolean
|
||||
_rawVariations: CatalogItem['variations']
|
||||
_rawModifiers: ModifierList[]
|
||||
_rawDescription: string
|
||||
_override: ItemOverride
|
||||
@ -579,6 +581,133 @@ function OccasionsEditor() {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Delivery Rates Editor ────────────────────────────────────────────────────
|
||||
|
||||
interface TierRate { base: number; perMile: number; label: string }
|
||||
interface DeliveryRatesConfig { dropoff: TierRate; classic: TierRate; organic: TierRate }
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
dropoff: 'Drop-off',
|
||||
classic: 'Setup & strike',
|
||||
organic: 'Organic setup & strike',
|
||||
}
|
||||
|
||||
function DeliveryRatesEditor() {
|
||||
const [rates, setRates] = useState<DeliveryRatesConfig | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/delivery-rates')
|
||||
.then((r) => r.json())
|
||||
.then(setRates)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
function updateTier(tier: keyof DeliveryRatesConfig, field: keyof TierRate, value: string) {
|
||||
setRates((prev) => {
|
||||
if (!prev) return prev
|
||||
const updated = { ...prev[tier] }
|
||||
if (field === 'label') {
|
||||
updated.label = value
|
||||
} else {
|
||||
updated[field] = Math.round(parseFloat(value) * 100) || 0
|
||||
}
|
||||
return { ...prev, [tier]: updated }
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!rates) return
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
const res = await fetch('/api/admin/delivery-rates', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rates),
|
||||
})
|
||||
setSaving(false)
|
||||
setMsg(res.ok ? 'Saved' : 'Save failed')
|
||||
setTimeout(() => setMsg(''), 3000)
|
||||
}
|
||||
|
||||
if (!rates) return <p className="has-text-grey">Loading…</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="is-size-7 has-text-grey" style={{ marginBottom: '1rem' }}>
|
||||
Set the base fee and per-mile rate for each delivery type. Changes apply to new quotes immediately.
|
||||
</p>
|
||||
<table className="table is-narrow is-fullwidth" style={{ fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ minWidth: 200 }}>Tier</th>
|
||||
<th style={{ width: 130 }}>Base fee ($)</th>
|
||||
<th style={{ width: 130 }}>Per mile ($)</th>
|
||||
<th style={{ minWidth: 240 }}>Label</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(['dropoff', 'classic', 'organic'] as const).map((tier) => (
|
||||
<tr key={tier}>
|
||||
<td style={{ verticalAlign: 'middle', fontWeight: 500 }}>{TIER_LABELS[tier]}</td>
|
||||
<td style={{ verticalAlign: 'middle' }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="input is-small"
|
||||
value={(rates[tier].base / 100).toFixed(2)}
|
||||
onChange={(e) => updateTier(tier, 'base', e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ verticalAlign: 'middle' }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="input is-small"
|
||||
value={(rates[tier].perMile / 100).toFixed(2)}
|
||||
onChange={(e) => updateTier(tier, 'perMile', e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ verticalAlign: 'middle' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input is-small"
|
||||
value={rates[tier].label}
|
||||
onChange={(e) => updateTier(tier, 'label', e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="help" style={{ marginBottom: '0.75rem' }}>
|
||||
Formula: <strong>base + ceil(miles) × per-mile</strong>. Example: drop-off to a 5-mile address =
|
||||
{' '}base + 5 × per-mile.
|
||||
</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button
|
||||
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
>
|
||||
Save rates
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={`is-size-7 ${msg === 'Saved' ? 'has-text-success' : 'has-text-danger'}`}>
|
||||
{msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Item Editor ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ItemEditor({
|
||||
@ -594,15 +723,19 @@ function ItemEditor({
|
||||
}) {
|
||||
const ov = item._override
|
||||
|
||||
const [hidden, setHidden] = useState(ov.hidden ?? false)
|
||||
const [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false)
|
||||
const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false)
|
||||
const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '')
|
||||
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '')
|
||||
const [hidden, setHidden] = useState(ov.hidden ?? false)
|
||||
const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
|
||||
const [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false)
|
||||
const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false)
|
||||
// Multi-category selection: stores category names (labels).
|
||||
const [selectedCatNames, setSelectedCatNames] = useState<string[]>(
|
||||
ov.categoriesOverride ?? item.categoryLabels ?? [item.categoryLabel]
|
||||
)
|
||||
const [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? ''))
|
||||
const [showColors, setShowColors] = useState<boolean | null>(
|
||||
ov.showColors != null ? ov.showColors : null
|
||||
)
|
||||
const [hiddenVars, setHiddenVars] = useState<string[]>(ov.hiddenVariationIds ?? [])
|
||||
const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? [])
|
||||
const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
@ -625,13 +758,28 @@ function ItemEditor({
|
||||
const [chromeSurcharge, setChromeSurcharge] = useState<string>(
|
||||
ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : ''
|
||||
)
|
||||
const [disabledColors, setDisabledColors] = useState<string[]>(ov.disabledColors ?? [])
|
||||
const [showColorFilter, setShowColorFilter] = useState(false)
|
||||
const [quantityUnit, setQuantityUnit] = useState<string>(ov.quantityUnit ?? '')
|
||||
const [requiresDelivery, setRequiresDelivery] = useState(ov.requiresDelivery ?? false)
|
||||
const [deliveryBase, setDeliveryBase] = useState<string>(
|
||||
ov.deliveryBaseOverride != null ? String(ov.deliveryBaseOverride / 100) : ''
|
||||
)
|
||||
const [deliveryPerMile, setDeliveryPerMile] = useState<string>(
|
||||
ov.deliveryPerMileOverride != null ? String(ov.deliveryPerMileOverride / 100) : ''
|
||||
)
|
||||
|
||||
// Create category
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [creatingCat, setCreatingCat] = useState(false)
|
||||
const [showNewCat, setShowNewCat] = useState(false)
|
||||
|
||||
function toggleVar(id: string) {
|
||||
setHiddenVars((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
function toggleMod(id: string) {
|
||||
setHiddenMods((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
@ -643,12 +791,14 @@ function ItemEditor({
|
||||
setError('')
|
||||
const patch: Partial<ItemOverride> = {
|
||||
hidden,
|
||||
featured,
|
||||
hiddenVariationIds: hiddenVars,
|
||||
hiddenModifierIds: hiddenMods,
|
||||
vinylEnabled: vinylEnabled || undefined,
|
||||
vinylPromo: vinylPromo || undefined,
|
||||
}
|
||||
if (catOverride) patch.categoryOverride = catOverride
|
||||
if (catLabel) patch.categoryLabelOverride = catLabel
|
||||
// Always save categoriesOverride (replaces old single-field overrides)
|
||||
patch.categoriesOverride = selectedCatNames
|
||||
if (sortOrder !== '') patch.sortOrder = Number(sortOrder)
|
||||
if (showColors !== null) patch.showColors = showColors
|
||||
if (descOverride) patch.descriptionOverride = descOverride
|
||||
@ -656,8 +806,12 @@ function ItemEditor({
|
||||
if (colorMin !== '') patch.colorMin = Number(colorMin)
|
||||
if (colorMax !== '') patch.colorMax = Number(colorMax)
|
||||
if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100)
|
||||
patch.disabledColors = disabledColors.length ? disabledColors : undefined
|
||||
if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim()
|
||||
else patch.quantityUnit = undefined
|
||||
patch.requiresDelivery = requiresDelivery || undefined
|
||||
patch.deliveryBaseOverride = deliveryBase !== '' ? Math.round(Number(deliveryBase) * 100) : null
|
||||
patch.deliveryPerMileOverride = deliveryPerMile !== '' ? Math.round(Number(deliveryPerMile) * 100) : null
|
||||
|
||||
const res = await fetch(`/api/admin/items/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
@ -679,18 +833,23 @@ function ItemEditor({
|
||||
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setHidden(false)
|
||||
setFeatured(item.featured ?? false)
|
||||
setVinylEnabled(false)
|
||||
setVinylPromo(false)
|
||||
setCatOverride('')
|
||||
setCatLabel('')
|
||||
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
|
||||
setSortOrder('')
|
||||
setShowColors(null)
|
||||
setHiddenVars([])
|
||||
setHiddenMods([])
|
||||
setDescOverride('')
|
||||
setModifierMins({})
|
||||
setColorMin('')
|
||||
setColorMax('')
|
||||
setChromeSurcharge('')
|
||||
setDisabledColors([])
|
||||
setRequiresDelivery(false)
|
||||
setDeliveryBase('')
|
||||
setDeliveryPerMile('')
|
||||
onSaved(item.id, {})
|
||||
}
|
||||
}
|
||||
@ -713,15 +872,13 @@ function ItemEditor({
|
||||
if (!newCatName.trim()) return
|
||||
setCreatingCat(true)
|
||||
const cat = await onCreateCategory(newCatName.trim())
|
||||
setCatOverride(cat.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''))
|
||||
setCatLabel(cat.name)
|
||||
// Auto-select the newly created category
|
||||
if (cat.id) setSelectedCatNames((prev) => [...prev, cat.name])
|
||||
setNewCatName('')
|
||||
setShowNewCat(false)
|
||||
setCreatingCat(false)
|
||||
}
|
||||
|
||||
const catSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1rem', borderTop: '1px solid #eee', backgroundColor: '#fafafa' }}>
|
||||
<div className="columns is-multiline">
|
||||
@ -729,8 +886,8 @@ function ItemEditor({
|
||||
{/* Left column */}
|
||||
<div className="column is-half">
|
||||
|
||||
{/* Hidden toggle */}
|
||||
<div className="field">
|
||||
{/* Visibility toggles */}
|
||||
<div className="field" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<label className="checkbox" style={{ fontWeight: 600 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -740,8 +897,62 @@ function ItemEditor({
|
||||
/>
|
||||
Hidden from storefront
|
||||
</label>
|
||||
<label className="checkbox" style={{ fontWeight: 600, color: '#11b3be' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={featured}
|
||||
onChange={(e) => setFeatured(e.target.checked)}
|
||||
style={{ marginRight: 6, accentColor: '#11b3be' }}
|
||||
/>
|
||||
⭐ Featured
|
||||
</label>
|
||||
<label className="checkbox" style={{ fontWeight: 600, color: '#c0392b' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requiresDelivery}
|
||||
onChange={(e) => setRequiresDelivery(e.target.checked)}
|
||||
style={{ marginRight: 6, accentColor: '#c0392b' }}
|
||||
/>
|
||||
🚗 Requires delivery
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{requiresDelivery && (
|
||||
<div className="field" style={{ background: '#fff8f8', border: '1px solid #f5c6c6', borderRadius: 6, padding: '0.6rem 0.8rem', marginBottom: '0.75rem' }}>
|
||||
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.4rem' }}>
|
||||
Custom delivery rates for this item (leave blank to use global tier defaults)
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<label className="label is-small" style={{ marginBottom: 2 }}>Base charge ($)</label>
|
||||
<input
|
||||
className="input is-small"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 75.00"
|
||||
value={deliveryBase}
|
||||
onChange={(e) => setDeliveryBase(e.target.value)}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label is-small" style={{ marginBottom: 2 }}>Per mile ($)</label>
|
||||
<input
|
||||
className="input is-small"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 4.00"
|
||||
value={deliveryPerMile}
|
||||
onChange={(e) => setDeliveryPerMile(e.target.value)}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vinyl options */}
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
@ -768,27 +979,31 @@ function ItemEditor({
|
||||
<p className="help">Shows a note on this item prompting customers to also add a Custom Vinyl balloon.</p>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{/* Category — multi-select checkboxes */}
|
||||
<div className="field">
|
||||
<label className="label is-small">Category</label>
|
||||
<div className="control">
|
||||
<div className="select is-small is-fullwidth">
|
||||
<select
|
||||
value={catOverride || item._rawCategory}
|
||||
onChange={(e) => {
|
||||
const selected = categories.find((c) => catSlug(c.name) === e.target.value)
|
||||
setCatOverride(e.target.value)
|
||||
setCatLabel(selected?.name ?? e.target.value)
|
||||
}}
|
||||
>
|
||||
<option value={item._rawCategory}>{item._rawCategoryLabel} (Square default)</option>
|
||||
{categories
|
||||
.filter((c) => catSlug(c.name) !== item._rawCategory)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={catSlug(c.name)}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="label is-small">Categories <span className="has-text-grey-light" style={{ fontWeight: 'normal' }}>(item appears in all checked tabs)</span></label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 160, overflowY: 'auto', border: '1px solid #e8e8e8', borderRadius: 6, padding: '6px 8px' }}>
|
||||
{categories.map((c) => (
|
||||
<label key={c.id} className="checkbox" style={{ fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCatNames.includes(c.name)}
|
||||
onChange={(e) => {
|
||||
setSelectedCatNames((prev) =>
|
||||
e.target.checked ? [...prev, c.name] : prev.filter((n) => n !== c.name)
|
||||
)
|
||||
}}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{c.name}
|
||||
{(item.categoryLabels ?? [item.categoryLabel]).includes(c.name) && (
|
||||
<span className="has-text-grey-light" style={{ fontSize: '0.72rem', marginLeft: 6 }}>Square</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{categories.length === 0 && (
|
||||
<p className="is-size-7 has-text-grey">No categories found — refresh from Square.</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="button is-ghost is-small"
|
||||
@ -796,7 +1011,7 @@ function ItemEditor({
|
||||
onClick={() => setShowNewCat(!showNewCat)}
|
||||
type="button"
|
||||
>
|
||||
+ Create new category
|
||||
+ Create new category in Square
|
||||
</button>
|
||||
{showNewCat && (
|
||||
<div className="field has-addons" style={{ marginTop: 6 }}>
|
||||
@ -890,6 +1105,42 @@ function ItemEditor({
|
||||
{/* Right column */}
|
||||
<div className="column is-half">
|
||||
|
||||
{/* Variations */}
|
||||
{item._rawVariations.length > 1 && (
|
||||
<div className="field">
|
||||
<label className="label is-small">Variations</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{item._rawVariations.map((v) => {
|
||||
const visible = !hiddenVars.includes(v.id)
|
||||
return (
|
||||
<div key={v.id} style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
background: visible ? '#fff' : '#fafafa',
|
||||
opacity: visible ? 1 : 0.5,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label className="checkbox" style={{ fontWeight: 500, fontSize: '0.875rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onChange={() => toggleVar(v.id)}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
{v.name}
|
||||
</label>
|
||||
<span className="has-text-grey-light" style={{ fontSize: '0.75rem' }}>
|
||||
${(v.priceCents / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modifiers */}
|
||||
{item._rawModifiers.length > 0 && (
|
||||
<div className="field">
|
||||
@ -1036,9 +1287,37 @@ function ItemEditor({
|
||||
Each chrome color selected adds this amount. Leave blank (or 0) to use a flat "Chrome" Square variation instead.
|
||||
{item.chromeSurchargePerColor > 0 && ` Current: $${(item.chromeSurchargePerColor / 100).toFixed(2)}/color.`}
|
||||
</p>
|
||||
|
||||
{/* Color availability */}
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button is-small is-light"
|
||||
onClick={() => setShowColorFilter(true)}
|
||||
>
|
||||
🎨 Manage available colors
|
||||
{disabledColors.length > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 6, background: '#c07000', color: '#fff',
|
||||
borderRadius: 999, fontSize: '0.68rem', fontWeight: 'bold',
|
||||
padding: '1px 7px',
|
||||
}}>
|
||||
{disabledColors.length} hidden
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showColorFilter && (
|
||||
<AdminColorFilter
|
||||
disabledColors={disabledColors}
|
||||
onSave={setDisabledColors}
|
||||
onClose={() => setShowColorFilter(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quantity unit */}
|
||||
<div className="field" style={{ marginTop: '1rem' }}>
|
||||
<label className="label is-small">Quantity unit</label>
|
||||
@ -1236,7 +1515,7 @@ export default function AdminPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions'>('items')
|
||||
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery'>('items')
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [refreshMsg, setRefreshMsg] = useState('')
|
||||
@ -1386,6 +1665,9 @@ export default function AdminPage() {
|
||||
<li className={tab === 'occasions' ? 'is-active' : ''}>
|
||||
<a onClick={() => setTab('occasions')}>Holidays</a>
|
||||
</li>
|
||||
<li className={tab === 'delivery' ? 'is-active' : ''}>
|
||||
<a onClick={() => setTab('delivery')}>Delivery rates</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -1488,6 +1770,9 @@ export default function AdminPage() {
|
||||
|
||||
{/* Holidays tab */}
|
||||
{tab === 'occasions' && <OccasionsEditor />}
|
||||
|
||||
{/* Delivery rates tab */}
|
||||
{tab === 'delivery' && <DeliveryRatesEditor />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
154
src/components/AdminColorFilter.tsx
Normal file
154
src/components/AdminColorFilter.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLockBodyScroll } from '@/lib/useLockBodyScroll'
|
||||
|
||||
interface ColorEntry {
|
||||
name: string
|
||||
hex: string
|
||||
metallic?: boolean
|
||||
pearlType?: string
|
||||
chromeType?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface ColorFamily {
|
||||
family: string
|
||||
colors: ColorEntry[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
disabledColors: string[]
|
||||
onSave: (disabled: string[]) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function AdminColorFilter({ disabledColors, onSave, onClose }: Props) {
|
||||
useLockBodyScroll()
|
||||
const [families, setFamilies] = useState<ColorFamily[]>([])
|
||||
const [disabled, setDisabled] = useState<Set<string>>(() => new Set(disabledColors))
|
||||
const [openFamily, setOpenFamily] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/colors.json')
|
||||
.then((r) => r.json())
|
||||
.then((data: ColorFamily[]) => setFamilies(data))
|
||||
}, [])
|
||||
|
||||
const toggle = (name: string) =>
|
||||
setDisabled((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(name) ? next.delete(name) : next.add(name)
|
||||
return next
|
||||
})
|
||||
|
||||
const disabledCount = disabled.size
|
||||
const totalColors = families.reduce((n, f) => n + f.colors.length, 0)
|
||||
|
||||
return (
|
||||
<div className="modal is-active" onClick={onClose}>
|
||||
<div className="modal-card" style={{ maxWidth: 680, width: '95vw' }} onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-card-head" style={{ background: '#11b3be', gap: '0.75rem' }}>
|
||||
<p className="modal-card-title has-text-white" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
Available Colors
|
||||
</p>
|
||||
<button className="delete" aria-label="close" onClick={onClose} style={{ flexShrink: 0 }} />
|
||||
</header>
|
||||
|
||||
<section className="modal-card-body" style={{ maxHeight: '70vh', overflowY: 'auto', padding: '1rem 1.25rem' }}>
|
||||
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.75rem' }}>
|
||||
Click a color to hide it from customers ordering this item.{' '}
|
||||
{disabledCount > 0
|
||||
? <strong style={{ color: '#c07000' }}>{disabledCount} of {totalColors} hidden.</strong>
|
||||
: <span>All {totalColors} colors are currently shown.</span>}
|
||||
</p>
|
||||
|
||||
{families.map((family) => {
|
||||
const isOpen = openFamily === family.family
|
||||
const hiddenInFam = family.colors.filter((c) => disabled.has(c.name)).length
|
||||
|
||||
return (
|
||||
<div key={family.family} style={{ marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => setOpenFamily(isOpen ? null : family.family)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
background: isOpen ? '#f0fafb' : '#fafafa',
|
||||
border: '1px solid ' + (isOpen ? '#b2e0e4' : '#e8e8e8'),
|
||||
borderRadius: isOpen ? '10px 10px 0 0' : '10px',
|
||||
padding: '0.55rem 0.9rem', cursor: 'pointer', textAlign: 'left', transition: 'background 0.15s',
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
{family.colors.slice(0, 7).map((c) => (
|
||||
<span key={c.name} style={{
|
||||
width: 13, height: 13, borderRadius: '50%',
|
||||
background: c.image ? `url('/color-picker/${c.image}') center/cover` : c.hex,
|
||||
border: '1px solid rgba(0,0,0,0.12)', flexShrink: 0, display: 'inline-block',
|
||||
opacity: disabled.has(c.name) ? 0.25 : 1,
|
||||
}} />
|
||||
))}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: '#15384c' }}>{family.family}</span>
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
{hiddenInFam > 0 && (
|
||||
<span style={{ background: '#c07000', color: '#fff', borderRadius: 999, fontSize: '0.68rem', fontWeight: 'bold', padding: '2px 8px' }}>
|
||||
{hiddenInFam} hidden
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '0.75rem', color: '#888' }}>{isOpen ? '▲' : '▼'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ border: '1px solid #b2e0e4', borderTop: 'none', borderRadius: '0 0 10px 10px', padding: '0.75rem 0.9rem', background: '#f8fdfd' }}>
|
||||
<div className="swatch-container">
|
||||
{family.colors.map((color) => {
|
||||
const isDisabled = disabled.has(color.name)
|
||||
const imageSrc = color.image ? `/color-picker/${color.image}` : null
|
||||
return (
|
||||
<div
|
||||
key={color.name}
|
||||
className="swatch-wrapper"
|
||||
onClick={() => toggle(color.name)}
|
||||
title={isDisabled ? `Enable ${color.name}` : `Hide ${color.name}`}
|
||||
style={{ opacity: isDisabled ? 0.3 : 1, cursor: 'pointer' }}
|
||||
>
|
||||
<div className="color-swatch">
|
||||
{imageSrc
|
||||
? <div className="color-background finish-image" style={{ backgroundImage: `url('${imageSrc}')` }} />
|
||||
: <div className="color-background" style={{ background: color.hex }} />}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="color-shine" src="/color-picker/images/shine.svg" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
<span style={{ fontSize: '0.6rem', textAlign: 'center', color: isDisabled ? '#c07000' : '#334854', fontWeight: isDisabled ? 700 : 400, lineHeight: 1.2, maxWidth: '100%', wordBreak: 'break-word' }}>
|
||||
{isDisabled ? '✕ ' : ''}{color.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<footer className="modal-card-foot" style={{ justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="button is-small" type="button" onClick={() => setDisabled(new Set())}>Enable all</button>
|
||||
<button className="button is-small is-warning" type="button" onClick={() => setDisabled(new Set(families.flatMap((f) => f.colors.map((c) => c.name))))}>Disable all</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="button is-small" type="button" onClick={onClose}>Cancel</button>
|
||||
<button className="button is-small is-info" type="button" onClick={() => { onSave(Array.from(disabled)); onClose() }}>Apply</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -47,6 +47,12 @@ export default function FeaturedProducts() {
|
||||
setShowTour(true)
|
||||
}
|
||||
|
||||
const tourInit = () => {
|
||||
setCategory('all')
|
||||
setSearch('')
|
||||
setSearchOpen(false)
|
||||
}
|
||||
|
||||
const endTour = () => {
|
||||
setShowTour(false)
|
||||
// Close any customization modal that may have been opened during the tour
|
||||
@ -56,7 +62,11 @@ export default function FeaturedProducts() {
|
||||
const productCategories = useMemo(() => {
|
||||
const seen = new Map<string, string>()
|
||||
items.forEach((item) => {
|
||||
if (!seen.has(item.category)) seen.set(item.category, item.categoryLabel)
|
||||
const cats = item.categories ?? [item.category]
|
||||
const labels = item.categoryLabels ?? [item.categoryLabel]
|
||||
cats.forEach((slug, i) => {
|
||||
if (!seen.has(slug)) seen.set(slug, labels[i] ?? slug)
|
||||
})
|
||||
})
|
||||
const all = Array.from(seen.entries()).map(([key, label]) => ({ key, label }))
|
||||
const visible = all.filter((c) => !catHidden.includes(c.key))
|
||||
@ -71,11 +81,15 @@ export default function FeaturedProducts() {
|
||||
return visible
|
||||
}, [items, catOrder, catHidden])
|
||||
|
||||
const tabs = useMemo(() => [
|
||||
...activeOccasions.map((o) => ({ key: o.key, label: `${o.emoji} ${o.label}`, occasion: true })),
|
||||
{ key: 'all', label: 'All', occasion: false },
|
||||
...productCategories.map((c) => ({ ...c, occasion: false })),
|
||||
], [activeOccasions, productCategories])
|
||||
const tabs = useMemo(() => {
|
||||
// Category slugs already represented by an occasion tab — hide them from the regular tabs
|
||||
const occasionSlugs = new Set(activeOccasions.map((o) => o.squareCategorySlug).filter(Boolean) as string[])
|
||||
return [
|
||||
...activeOccasions.map((o) => ({ key: o.key, label: `${o.emoji} ${o.label}`, occasion: true })),
|
||||
{ key: 'all', label: 'All', occasion: false },
|
||||
...productCategories.filter((c) => !occasionSlugs.has(c.key)).map((c) => ({ ...c, occasion: false })),
|
||||
]
|
||||
}, [activeOccasions, productCategories])
|
||||
|
||||
const activeOccasion: ActiveOccasion | undefined = useMemo(
|
||||
() => activeOccasions.find((o) => o.key === category),
|
||||
@ -115,11 +129,11 @@ export default function FeaturedProducts() {
|
||||
|
||||
const filtered = (activeOccasion
|
||||
? activeOccasion.squareCategorySlug
|
||||
? items.filter((i) => i.category === activeOccasion.squareCategorySlug)
|
||||
? items.filter((i) => (i.categories ?? [i.category]).includes(activeOccasion.squareCategorySlug!))
|
||||
: items
|
||||
: category === 'all'
|
||||
? items
|
||||
: items.filter((i) => i.category === category)
|
||||
: items.filter((i) => (i.categories ?? [i.category]).includes(category))
|
||||
).filter((i) =>
|
||||
!q || i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q)
|
||||
)
|
||||
@ -182,7 +196,7 @@ export default function FeaturedProducts() {
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
autoFocus
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); if (e.target.value) setCategory('all') }}
|
||||
onBlur={() => { if (!search) setSearchOpen(false) }}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
|
||||
style={{ width: '160px' }}
|
||||
@ -223,7 +237,7 @@ export default function FeaturedProducts() {
|
||||
|
||||
{/* Welcome modal + guided tour */}
|
||||
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
|
||||
{showTour && <GuidedTour onDone={endTour} />}
|
||||
{showTour && <GuidedTour onDone={endTour} onStart={tourInit} />}
|
||||
|
||||
{/* Product grid */}
|
||||
{loading ? (
|
||||
|
||||
@ -57,10 +57,11 @@ const PAD = 10 // px padding around spotlight
|
||||
const TIP_WIDTH = 300 // tooltip width in px
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
onDone: () => void
|
||||
onStart?: () => void
|
||||
}
|
||||
|
||||
export default function GuidedTour({ onDone }: Props) {
|
||||
export default function GuidedTour({ onDone, onStart }: Props) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
||||
|
||||
@ -72,6 +73,11 @@ export default function GuidedTour({ onDone }: Props) {
|
||||
if (el) setTargetRect(el.getBoundingClientRect())
|
||||
}, [current.target])
|
||||
|
||||
useEffect(() => {
|
||||
onStart?.()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// On step change: fire onEnter, poll until target appears, then scroll + measure.
|
||||
useEffect(() => {
|
||||
setTargetRect(null) // clear stale rect immediately
|
||||
|
||||
47
src/components/ScrollToTop.tsx
Normal file
47
src/components/ScrollToTop.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCart } from '@/context/CartContext'
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const { drawerOpen } = useCart()
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||||
setScrolled(scrollTop > 130)
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
if (!scrolled || drawerOpen) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Back to top"
|
||||
onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '12px',
|
||||
right: '10px',
|
||||
zIndex: 98,
|
||||
border: '1px solid #363636',
|
||||
outline: 'none',
|
||||
background: '#94d601',
|
||||
cursor: 'pointer',
|
||||
padding: '15px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '18px',
|
||||
boxShadow: '3px 3px 3px #363636',
|
||||
fontFamily: '"Autour One", serif',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#aedad3' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#94d601' }}
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -28,6 +28,9 @@ export interface CatalogItem {
|
||||
description: string
|
||||
category: string
|
||||
categoryLabel: string
|
||||
/** All display categories this item belongs to (multi-category support). First entry matches category/categoryLabel. */
|
||||
categories: string[]
|
||||
categoryLabels: string[]
|
||||
/** Price in cents of the default variation. null = custom quote required. */
|
||||
price: number | null
|
||||
imageUrl: string | null
|
||||
@ -39,9 +42,16 @@ export interface CatalogItem {
|
||||
colorMin: number // minimum colors required when showColors=true (default 1)
|
||||
colorMax: number | null // maximum colors allowed (null = unlimited)
|
||||
chromeSurchargePerColor: number // extra cents per chrome color selected (0 = flat chrome variation instead)
|
||||
disabledColors?: string[] // color names hidden from the picker for this item
|
||||
variations: CatalogVariation[] // all enabled variations; first is the default
|
||||
/** Unit label for quantity, e.g. "ft". Omitted for plain count items. */
|
||||
quantityUnit?: string
|
||||
/** When true, this item cannot be picked up — delivery is required. */
|
||||
requiresDelivery?: boolean
|
||||
/** Per-item delivery base charge override in cents. null = use tier default. */
|
||||
deliveryBaseOverride?: number | null
|
||||
/** Per-item per-mile rate override in cents. null = use tier default. */
|
||||
deliveryPerMileOverride?: number | null
|
||||
/** When true, the custom vinyl configurator is shown for this item. */
|
||||
vinylEnabled?: boolean
|
||||
/** When true, a note is shown suggesting the customer also add a Custom Vinyl item. */
|
||||
@ -153,5 +163,7 @@ export const MOCK_CATALOG: CatalogItem[] = (([
|
||||
chromeSurchargePerColor: 0,
|
||||
imageUrls: item.imageUrl ? [item.imageUrl] : [],
|
||||
variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [],
|
||||
categories: [item.category],
|
||||
categoryLabels: [item.categoryLabel],
|
||||
...item,
|
||||
})) as CatalogItem[]
|
||||
|
||||
31
src/lib/delivery-rates.ts
Normal file
31
src/lib/delivery-rates.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { atomicWriteJSON } from './file-utils'
|
||||
import { RATES, type DeliveryRatesConfig, type DeliveryTier } from './delivery'
|
||||
|
||||
const RATES_PATH = path.join(process.cwd(), 'data', 'delivery-rates.json')
|
||||
|
||||
const TIERS: DeliveryTier[] = ['dropoff', 'classic', 'organic']
|
||||
|
||||
export function readDeliveryRates(): DeliveryRatesConfig {
|
||||
const defaults: DeliveryRatesConfig = {
|
||||
dropoff: { ...RATES.dropoff },
|
||||
classic: { ...RATES.classic },
|
||||
organic: { ...RATES.organic },
|
||||
}
|
||||
if (!existsSync(RATES_PATH)) return defaults
|
||||
try {
|
||||
const stored = JSON.parse(readFileSync(RATES_PATH, 'utf-8')) as Partial<DeliveryRatesConfig>
|
||||
const merged = { ...defaults }
|
||||
for (const tier of TIERS) {
|
||||
if (stored[tier]) merged[tier] = { ...defaults[tier], ...stored[tier] }
|
||||
}
|
||||
return merged
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDeliveryRates(config: DeliveryRatesConfig): void {
|
||||
atomicWriteJSON(RATES_PATH, config)
|
||||
}
|
||||
@ -5,7 +5,9 @@ export const SHOP_LNG = -73.0590 // 554 Boston Post Rd, Milford CT
|
||||
// ── Rates ─────────────────────────────────────────────────────────────────────
|
||||
export type DeliveryTier = 'dropoff' | 'classic' | 'organic'
|
||||
|
||||
export const RATES: Record<DeliveryTier, { base: number; perMile: number; label: string }> = {
|
||||
export type DeliveryRatesConfig = Record<DeliveryTier, { base: number; perMile: number; label: string }>
|
||||
|
||||
export const RATES: DeliveryRatesConfig = {
|
||||
dropoff: {
|
||||
base: 20_00, // cents
|
||||
perMile: 1_60,
|
||||
@ -111,8 +113,9 @@ export async function calcDelivery(
|
||||
destLat: number,
|
||||
destLng: number,
|
||||
tier: DeliveryTier,
|
||||
rates?: DeliveryRatesConfig,
|
||||
): Promise<DeliveryQuote> {
|
||||
const rate = RATES[tier]
|
||||
const rate = (rates ?? RATES)[tier]
|
||||
const { miles: rawMiles, minutes: driveMinutes } =
|
||||
await drivingInfo(SHOP_LAT, SHOP_LNG, destLat, destLng)
|
||||
const miles = Math.ceil(rawMiles * 10) / 10
|
||||
|
||||
@ -4,11 +4,15 @@ import { atomicWriteJSON } from './file-utils'
|
||||
|
||||
export interface ItemOverride {
|
||||
hidden?: boolean
|
||||
featured?: boolean
|
||||
categoryOverride?: string
|
||||
categoryLabelOverride?: string
|
||||
/** Replaces categoryOverride — item appears in all listed category tabs (stores label names). */
|
||||
categoriesOverride?: string[] | null
|
||||
sortOrder?: number
|
||||
showColors?: boolean
|
||||
hiddenModifierIds?: string[]
|
||||
hiddenVariationIds?: string[]
|
||||
descriptionOverride?: string
|
||||
/** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */
|
||||
modifierMinSelected?: Record<string, number>
|
||||
@ -18,8 +22,16 @@ export interface ItemOverride {
|
||||
colorMax?: number
|
||||
/** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */
|
||||
chromeSurchargePerColor?: number
|
||||
/** Color names hidden from the customer picker for this item. */
|
||||
disabledColors?: string[]
|
||||
/** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */
|
||||
quantityUnit?: string
|
||||
/** When true, pickup is not offered — item must be delivered. */
|
||||
requiresDelivery?: boolean
|
||||
/** Override delivery base charge in cents for this item (replaces the tier default). */
|
||||
deliveryBaseOverride?: number | null
|
||||
/** Override per-mile rate in cents for this item (replaces the tier default). */
|
||||
deliveryPerMileOverride?: number | null
|
||||
/** When true, shows the custom vinyl text configurator on this item's product modal. */
|
||||
vinylEnabled?: boolean
|
||||
/** When true, shows a promo note suggesting the customer also add a Custom Vinyl item. */
|
||||
|
||||
@ -155,20 +155,22 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
|
||||
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
return {
|
||||
id: item.id!,
|
||||
name: data.name ?? 'Unnamed item',
|
||||
description: data.description ?? '',
|
||||
category: categorySlug,
|
||||
categoryLabel: displayCatName,
|
||||
price: priceAmount ? Number(priceAmount) : null,
|
||||
id: item.id!,
|
||||
name: data.name ?? 'Unnamed item',
|
||||
description: data.description ?? '',
|
||||
category: categorySlug,
|
||||
categoryLabel: displayCatName,
|
||||
categories: [categorySlug],
|
||||
categoryLabels: [displayCatName],
|
||||
price: priceAmount ? Number(priceAmount) : null,
|
||||
imageUrl,
|
||||
imageUrls,
|
||||
featured: false,
|
||||
tags: [],
|
||||
featured: false,
|
||||
tags: [],
|
||||
modifiers,
|
||||
showColors: hasLatexColors,
|
||||
colorMin: 1,
|
||||
colorMax: null,
|
||||
showColors: hasLatexColors,
|
||||
colorMin: 1,
|
||||
colorMax: null,
|
||||
chromeSurchargePerColor: 0,
|
||||
variations,
|
||||
}
|
||||
|
||||
10
src/lib/useLockBodyScroll.ts
Normal file
10
src/lib/useLockBodyScroll.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** Locks body scroll while the calling component is mounted. */
|
||||
export function useLockBodyScroll() {
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = prev }
|
||||
}, [])
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user