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 withOverrides = items.map((item) => {
|
||||||
const ov = overrides[item.id] ?? {}
|
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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
// Resolved values (what the customer sees)
|
// Resolved values (what the customer sees)
|
||||||
hidden: ov.hidden ?? false,
|
hidden: ov.hidden ?? false,
|
||||||
category: ov.categoryOverride ?? item.category,
|
featured: ov.featured ?? item.featured ?? false,
|
||||||
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
|
...resolvedCats,
|
||||||
sortOrder: ov.sortOrder ?? 0,
|
sortOrder: ov.sortOrder ?? 0,
|
||||||
showColors: ov.showColors != null ? ov.showColors : item.showColors,
|
showColors: ov.showColors != null ? ov.showColors : item.showColors,
|
||||||
colorMin: ov.colorMin ?? item.colorMin,
|
colorMin: ov.colorMin ?? item.colorMin,
|
||||||
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
|
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
|
||||||
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
|
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,
|
description: ov.descriptionOverride ?? item.description,
|
||||||
|
variations: item.variations.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
|
||||||
modifiers: item.modifiers
|
modifiers: item.modifiers
|
||||||
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
|
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
@ -33,6 +62,7 @@ export async function GET() {
|
|||||||
_rawCategory: item.category,
|
_rawCategory: item.category,
|
||||||
_rawCategoryLabel: item.categoryLabel,
|
_rawCategoryLabel: item.categoryLabel,
|
||||||
_rawShowColors: item.showColors,
|
_rawShowColors: item.showColors,
|
||||||
|
_rawVariations: item.variations,
|
||||||
_rawModifiers: item.modifiers,
|
_rawModifiers: item.modifiers,
|
||||||
_rawDescription: item.description,
|
_rawDescription: item.description,
|
||||||
_override: ov,
|
_override: ov,
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { getCatalog } from '@/lib/catalog-cache'
|
|||||||
import { readOverrides } from '@/lib/overrides'
|
import { readOverrides } from '@/lib/overrides'
|
||||||
import type { CatalogItem } from '@/data/mock-catalog'
|
import type { CatalogItem } from '@/data/mock-catalog'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
||||||
const overrides = readOverrides()
|
const overrides = readOverrides()
|
||||||
|
|
||||||
@ -12,16 +14,44 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
|||||||
if (!ov) return item
|
if (!ov) return item
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
category: ov.categoryOverride ?? item.category,
|
featured: ov.featured ?? item.featured,
|
||||||
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
|
// 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,
|
showColors: ov.showColors != null ? ov.showColors : item.showColors,
|
||||||
colorMin: ov.colorMin ?? item.colorMin,
|
colorMin: ov.colorMin ?? item.colorMin,
|
||||||
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
|
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
|
||||||
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
|
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,
|
vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled,
|
||||||
vinylPromo: ov.vinylPromo ?? item.vinylPromo,
|
vinylPromo: ov.vinylPromo ?? item.vinylPromo,
|
||||||
description: ov.descriptionOverride ?? item.description,
|
description: ov.descriptionOverride ?? item.description,
|
||||||
|
variations: item.variations
|
||||||
|
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
|
||||||
modifiers: item.modifiers
|
modifiers: item.modifiers
|
||||||
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
|
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
@ -32,6 +62,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
|||||||
})
|
})
|
||||||
.filter((item) => !(overrides[item.id]?.hidden))
|
.filter((item) => !(overrides[item.id]?.hidden))
|
||||||
.sort((a, b) => {
|
.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 aOrder = overrides[a.id]?.sortOrder ?? 0
|
||||||
const bOrder = overrides[b.id]?.sortOrder ?? 0
|
const bOrder = overrides[b.id]?.sortOrder ?? 0
|
||||||
return aOrder - bOrder
|
return aOrder - bOrder
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { geocode, calcDelivery, inferTier } from '@/lib/delivery'
|
import { geocode, calcDelivery, inferTier } from '@/lib/delivery'
|
||||||
|
import { readDeliveryRates } from '@/lib/delivery-rates'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { address, itemNames } = await request.json() as {
|
const { address, itemNames, rateOverride } = await request.json() as {
|
||||||
address: string
|
address: string
|
||||||
itemNames: string[]
|
itemNames: string[]
|
||||||
|
rateOverride?: { base: number; perMile: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!address?.trim()) {
|
if (!address?.trim()) {
|
||||||
@ -17,7 +19,18 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tier = inferTier(itemNames ?? [])
|
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) {
|
if (quote.miles > 40) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Navbar from '@/components/Navbar'
|
|||||||
import Footer from '@/components/Footer'
|
import Footer from '@/components/Footer'
|
||||||
import CartDrawer from '@/components/CartDrawer'
|
import CartDrawer from '@/components/CartDrawer'
|
||||||
import CartFab from '@/components/CartFab'
|
import CartFab from '@/components/CartFab'
|
||||||
|
import ScrollToTop from '@/components/ScrollToTop'
|
||||||
import { CartProvider } from '@/context/CartContext'
|
import { CartProvider } from '@/context/CartContext'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -49,6 +50,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<CartFab />
|
<CartFab />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<ScrollToTop />
|
||||||
</CartProvider>
|
</CartProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
|
|||||||
import type { ItemOverride } from '@/lib/overrides'
|
import type { ItemOverride } from '@/lib/overrides'
|
||||||
import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config'
|
import { DEFAULT_HOURS, minsToTime, timeToMins } from '@/lib/hours-config'
|
||||||
import type { HoursConfig, DayHours } from '@/lib/hours-config'
|
import type { HoursConfig, DayHours } from '@/lib/hours-config'
|
||||||
|
import AdminColorFilter from '@/components/AdminColorFilter'
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ interface AdminItem extends CatalogItem {
|
|||||||
_rawCategory: string
|
_rawCategory: string
|
||||||
_rawCategoryLabel: string
|
_rawCategoryLabel: string
|
||||||
_rawShowColors: boolean
|
_rawShowColors: boolean
|
||||||
|
_rawVariations: CatalogItem['variations']
|
||||||
_rawModifiers: ModifierList[]
|
_rawModifiers: ModifierList[]
|
||||||
_rawDescription: string
|
_rawDescription: string
|
||||||
_override: ItemOverride
|
_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 ──────────────────────────────────────────────────────────────
|
// ─── Item Editor ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ItemEditor({
|
function ItemEditor({
|
||||||
@ -594,15 +723,19 @@ function ItemEditor({
|
|||||||
}) {
|
}) {
|
||||||
const ov = item._override
|
const ov = item._override
|
||||||
|
|
||||||
const [hidden, setHidden] = useState(ov.hidden ?? false)
|
const [hidden, setHidden] = useState(ov.hidden ?? false)
|
||||||
const [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false)
|
const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
|
||||||
const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false)
|
const [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false)
|
||||||
const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '')
|
const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false)
|
||||||
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '')
|
// 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 [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? ''))
|
||||||
const [showColors, setShowColors] = useState<boolean | null>(
|
const [showColors, setShowColors] = useState<boolean | null>(
|
||||||
ov.showColors != null ? ov.showColors : null
|
ov.showColors != null ? ov.showColors : null
|
||||||
)
|
)
|
||||||
|
const [hiddenVars, setHiddenVars] = useState<string[]>(ov.hiddenVariationIds ?? [])
|
||||||
const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? [])
|
const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? [])
|
||||||
const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '')
|
const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@ -625,13 +758,28 @@ function ItemEditor({
|
|||||||
const [chromeSurcharge, setChromeSurcharge] = useState<string>(
|
const [chromeSurcharge, setChromeSurcharge] = useState<string>(
|
||||||
ov.chromeSurchargePerColor ? String(ov.chromeSurchargePerColor / 100) : ''
|
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 [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
|
// Create category
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
const [creatingCat, setCreatingCat] = useState(false)
|
const [creatingCat, setCreatingCat] = useState(false)
|
||||||
const [showNewCat, setShowNewCat] = 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) {
|
function toggleMod(id: string) {
|
||||||
setHiddenMods((prev) =>
|
setHiddenMods((prev) =>
|
||||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
@ -643,12 +791,14 @@ function ItemEditor({
|
|||||||
setError('')
|
setError('')
|
||||||
const patch: Partial<ItemOverride> = {
|
const patch: Partial<ItemOverride> = {
|
||||||
hidden,
|
hidden,
|
||||||
|
featured,
|
||||||
|
hiddenVariationIds: hiddenVars,
|
||||||
hiddenModifierIds: hiddenMods,
|
hiddenModifierIds: hiddenMods,
|
||||||
vinylEnabled: vinylEnabled || undefined,
|
vinylEnabled: vinylEnabled || undefined,
|
||||||
vinylPromo: vinylPromo || undefined,
|
vinylPromo: vinylPromo || undefined,
|
||||||
}
|
}
|
||||||
if (catOverride) patch.categoryOverride = catOverride
|
// Always save categoriesOverride (replaces old single-field overrides)
|
||||||
if (catLabel) patch.categoryLabelOverride = catLabel
|
patch.categoriesOverride = selectedCatNames
|
||||||
if (sortOrder !== '') patch.sortOrder = Number(sortOrder)
|
if (sortOrder !== '') patch.sortOrder = Number(sortOrder)
|
||||||
if (showColors !== null) patch.showColors = showColors
|
if (showColors !== null) patch.showColors = showColors
|
||||||
if (descOverride) patch.descriptionOverride = descOverride
|
if (descOverride) patch.descriptionOverride = descOverride
|
||||||
@ -656,8 +806,12 @@ function ItemEditor({
|
|||||||
if (colorMin !== '') patch.colorMin = Number(colorMin)
|
if (colorMin !== '') patch.colorMin = Number(colorMin)
|
||||||
if (colorMax !== '') patch.colorMax = Number(colorMax)
|
if (colorMax !== '') patch.colorMax = Number(colorMax)
|
||||||
if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100)
|
if (chromeSurcharge !== '') patch.chromeSurchargePerColor = Math.round(Number(chromeSurcharge) * 100)
|
||||||
|
patch.disabledColors = disabledColors.length ? disabledColors : undefined
|
||||||
if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim()
|
if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim()
|
||||||
else patch.quantityUnit = undefined
|
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}`, {
|
const res = await fetch(`/api/admin/items/${item.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@ -679,18 +833,23 @@ function ItemEditor({
|
|||||||
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setHidden(false)
|
setHidden(false)
|
||||||
|
setFeatured(item.featured ?? false)
|
||||||
setVinylEnabled(false)
|
setVinylEnabled(false)
|
||||||
setVinylPromo(false)
|
setVinylPromo(false)
|
||||||
setCatOverride('')
|
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
|
||||||
setCatLabel('')
|
|
||||||
setSortOrder('')
|
setSortOrder('')
|
||||||
setShowColors(null)
|
setShowColors(null)
|
||||||
|
setHiddenVars([])
|
||||||
setHiddenMods([])
|
setHiddenMods([])
|
||||||
setDescOverride('')
|
setDescOverride('')
|
||||||
setModifierMins({})
|
setModifierMins({})
|
||||||
setColorMin('')
|
setColorMin('')
|
||||||
setColorMax('')
|
setColorMax('')
|
||||||
setChromeSurcharge('')
|
setChromeSurcharge('')
|
||||||
|
setDisabledColors([])
|
||||||
|
setRequiresDelivery(false)
|
||||||
|
setDeliveryBase('')
|
||||||
|
setDeliveryPerMile('')
|
||||||
onSaved(item.id, {})
|
onSaved(item.id, {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -713,15 +872,13 @@ function ItemEditor({
|
|||||||
if (!newCatName.trim()) return
|
if (!newCatName.trim()) return
|
||||||
setCreatingCat(true)
|
setCreatingCat(true)
|
||||||
const cat = await onCreateCategory(newCatName.trim())
|
const cat = await onCreateCategory(newCatName.trim())
|
||||||
setCatOverride(cat.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''))
|
// Auto-select the newly created category
|
||||||
setCatLabel(cat.name)
|
if (cat.id) setSelectedCatNames((prev) => [...prev, cat.name])
|
||||||
setNewCatName('')
|
setNewCatName('')
|
||||||
setShowNewCat(false)
|
setShowNewCat(false)
|
||||||
setCreatingCat(false)
|
setCreatingCat(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const catSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1rem', borderTop: '1px solid #eee', backgroundColor: '#fafafa' }}>
|
<div style={{ padding: '1rem', borderTop: '1px solid #eee', backgroundColor: '#fafafa' }}>
|
||||||
<div className="columns is-multiline">
|
<div className="columns is-multiline">
|
||||||
@ -729,8 +886,8 @@ function ItemEditor({
|
|||||||
{/* Left column */}
|
{/* Left column */}
|
||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
|
|
||||||
{/* Hidden toggle */}
|
{/* Visibility toggles */}
|
||||||
<div className="field">
|
<div className="field" style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
<label className="checkbox" style={{ fontWeight: 600 }}>
|
<label className="checkbox" style={{ fontWeight: 600 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -740,8 +897,62 @@ function ItemEditor({
|
|||||||
/>
|
/>
|
||||||
Hidden from storefront
|
Hidden from storefront
|
||||||
</label>
|
</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>
|
</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 */}
|
{/* Vinyl options */}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="checkbox">
|
<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>
|
<p className="help">Shows a note on this item prompting customers to also add a Custom Vinyl balloon.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category — multi-select checkboxes */}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label is-small">Category</label>
|
<label className="label is-small">Categories <span className="has-text-grey-light" style={{ fontWeight: 'normal' }}>(item appears in all checked tabs)</span></label>
|
||||||
<div className="control">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 160, overflowY: 'auto', border: '1px solid #e8e8e8', borderRadius: 6, padding: '6px 8px' }}>
|
||||||
<div className="select is-small is-fullwidth">
|
{categories.map((c) => (
|
||||||
<select
|
<label key={c.id} className="checkbox" style={{ fontSize: '0.85rem' }}>
|
||||||
value={catOverride || item._rawCategory}
|
<input
|
||||||
onChange={(e) => {
|
type="checkbox"
|
||||||
const selected = categories.find((c) => catSlug(c.name) === e.target.value)
|
checked={selectedCatNames.includes(c.name)}
|
||||||
setCatOverride(e.target.value)
|
onChange={(e) => {
|
||||||
setCatLabel(selected?.name ?? e.target.value)
|
setSelectedCatNames((prev) =>
|
||||||
}}
|
e.target.checked ? [...prev, c.name] : prev.filter((n) => n !== c.name)
|
||||||
>
|
)
|
||||||
<option value={item._rawCategory}>{item._rawCategoryLabel} (Square default)</option>
|
}}
|
||||||
{categories
|
style={{ marginRight: 6 }}
|
||||||
.filter((c) => catSlug(c.name) !== item._rawCategory)
|
/>
|
||||||
.map((c) => (
|
{c.name}
|
||||||
<option key={c.id} value={catSlug(c.name)}>{c.name}</option>
|
{(item.categoryLabels ?? [item.categoryLabel]).includes(c.name) && (
|
||||||
))}
|
<span className="has-text-grey-light" style={{ fontSize: '0.72rem', marginLeft: 6 }}>Square</span>
|
||||||
</select>
|
)}
|
||||||
</div>
|
</label>
|
||||||
|
))}
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<p className="is-size-7 has-text-grey">No categories found — refresh from Square.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="button is-ghost is-small"
|
className="button is-ghost is-small"
|
||||||
@ -796,7 +1011,7 @@ function ItemEditor({
|
|||||||
onClick={() => setShowNewCat(!showNewCat)}
|
onClick={() => setShowNewCat(!showNewCat)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
+ Create new category
|
+ Create new category in Square
|
||||||
</button>
|
</button>
|
||||||
{showNewCat && (
|
{showNewCat && (
|
||||||
<div className="field has-addons" style={{ marginTop: 6 }}>
|
<div className="field has-addons" style={{ marginTop: 6 }}>
|
||||||
@ -890,6 +1105,42 @@ function ItemEditor({
|
|||||||
{/* Right column */}
|
{/* Right column */}
|
||||||
<div className="column is-half">
|
<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 */}
|
{/* Modifiers */}
|
||||||
{item._rawModifiers.length > 0 && (
|
{item._rawModifiers.length > 0 && (
|
||||||
<div className="field">
|
<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.
|
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.`}
|
{item.chromeSurchargePerColor > 0 && ` Current: $${(item.chromeSurchargePerColor / 100).toFixed(2)}/color.`}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showColorFilter && (
|
||||||
|
<AdminColorFilter
|
||||||
|
disabledColors={disabledColors}
|
||||||
|
onSave={setDisabledColors}
|
||||||
|
onClose={() => setShowColorFilter(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quantity unit */}
|
{/* Quantity unit */}
|
||||||
<div className="field" style={{ marginTop: '1rem' }}>
|
<div className="field" style={{ marginTop: '1rem' }}>
|
||||||
<label className="label is-small">Quantity unit</label>
|
<label className="label is-small">Quantity unit</label>
|
||||||
@ -1236,7 +1515,7 @@ export default function AdminPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [search, setSearch] = 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 [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [refreshMsg, setRefreshMsg] = useState('')
|
const [refreshMsg, setRefreshMsg] = useState('')
|
||||||
@ -1386,6 +1665,9 @@ export default function AdminPage() {
|
|||||||
<li className={tab === 'occasions' ? 'is-active' : ''}>
|
<li className={tab === 'occasions' ? 'is-active' : ''}>
|
||||||
<a onClick={() => setTab('occasions')}>Holidays</a>
|
<a onClick={() => setTab('occasions')}>Holidays</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li className={tab === 'delivery' ? 'is-active' : ''}>
|
||||||
|
<a onClick={() => setTab('delivery')}>Delivery rates</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1488,6 +1770,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Holidays tab */}
|
{/* Holidays tab */}
|
||||||
{tab === 'occasions' && <OccasionsEditor />}
|
{tab === 'occasions' && <OccasionsEditor />}
|
||||||
|
|
||||||
|
{/* Delivery rates tab */}
|
||||||
|
{tab === 'delivery' && <DeliveryRatesEditor />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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)
|
setShowTour(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tourInit = () => {
|
||||||
|
setCategory('all')
|
||||||
|
setSearch('')
|
||||||
|
setSearchOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
const endTour = () => {
|
const endTour = () => {
|
||||||
setShowTour(false)
|
setShowTour(false)
|
||||||
// Close any customization modal that may have been opened during the tour
|
// Close any customization modal that may have been opened during the tour
|
||||||
@ -56,7 +62,11 @@ export default function FeaturedProducts() {
|
|||||||
const productCategories = useMemo(() => {
|
const productCategories = useMemo(() => {
|
||||||
const seen = new Map<string, string>()
|
const seen = new Map<string, string>()
|
||||||
items.forEach((item) => {
|
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 all = Array.from(seen.entries()).map(([key, label]) => ({ key, label }))
|
||||||
const visible = all.filter((c) => !catHidden.includes(c.key))
|
const visible = all.filter((c) => !catHidden.includes(c.key))
|
||||||
@ -71,11 +81,15 @@ export default function FeaturedProducts() {
|
|||||||
return visible
|
return visible
|
||||||
}, [items, catOrder, catHidden])
|
}, [items, catOrder, catHidden])
|
||||||
|
|
||||||
const tabs = useMemo(() => [
|
const tabs = useMemo(() => {
|
||||||
...activeOccasions.map((o) => ({ key: o.key, label: `${o.emoji} ${o.label}`, occasion: true })),
|
// Category slugs already represented by an occasion tab — hide them from the regular tabs
|
||||||
{ key: 'all', label: 'All', occasion: false },
|
const occasionSlugs = new Set(activeOccasions.map((o) => o.squareCategorySlug).filter(Boolean) as string[])
|
||||||
...productCategories.map((c) => ({ ...c, occasion: false })),
|
return [
|
||||||
], [activeOccasions, productCategories])
|
...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(
|
const activeOccasion: ActiveOccasion | undefined = useMemo(
|
||||||
() => activeOccasions.find((o) => o.key === category),
|
() => activeOccasions.find((o) => o.key === category),
|
||||||
@ -115,11 +129,11 @@ export default function FeaturedProducts() {
|
|||||||
|
|
||||||
const filtered = (activeOccasion
|
const filtered = (activeOccasion
|
||||||
? activeOccasion.squareCategorySlug
|
? activeOccasion.squareCategorySlug
|
||||||
? items.filter((i) => i.category === activeOccasion.squareCategorySlug)
|
? items.filter((i) => (i.categories ?? [i.category]).includes(activeOccasion.squareCategorySlug!))
|
||||||
: items
|
: items
|
||||||
: category === 'all'
|
: category === 'all'
|
||||||
? items
|
? items
|
||||||
: items.filter((i) => i.category === category)
|
: items.filter((i) => (i.categories ?? [i.category]).includes(category))
|
||||||
).filter((i) =>
|
).filter((i) =>
|
||||||
!q || i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q)
|
!q || i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q)
|
||||||
)
|
)
|
||||||
@ -182,7 +196,7 @@ export default function FeaturedProducts() {
|
|||||||
placeholder="Search…"
|
placeholder="Search…"
|
||||||
value={search}
|
value={search}
|
||||||
autoFocus
|
autoFocus
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => { setSearch(e.target.value); if (e.target.value) setCategory('all') }}
|
||||||
onBlur={() => { if (!search) setSearchOpen(false) }}
|
onBlur={() => { if (!search) setSearchOpen(false) }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
|
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
|
||||||
style={{ width: '160px' }}
|
style={{ width: '160px' }}
|
||||||
@ -223,7 +237,7 @@ export default function FeaturedProducts() {
|
|||||||
|
|
||||||
{/* Welcome modal + guided tour */}
|
{/* Welcome modal + guided tour */}
|
||||||
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
|
{showWelcome && <WelcomeModal onTour={startTour} onDismiss={dismissWelcome} />}
|
||||||
{showTour && <GuidedTour onDone={endTour} />}
|
{showTour && <GuidedTour onDone={endTour} onStart={tourInit} />}
|
||||||
|
|
||||||
{/* Product grid */}
|
{/* Product grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@ -57,10 +57,11 @@ const PAD = 10 // px padding around spotlight
|
|||||||
const TIP_WIDTH = 300 // tooltip width in px
|
const TIP_WIDTH = 300 // tooltip width in px
|
||||||
|
|
||||||
interface Props {
|
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 [step, setStep] = useState(0)
|
||||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
||||||
|
|
||||||
@ -72,6 +73,11 @@ export default function GuidedTour({ onDone }: Props) {
|
|||||||
if (el) setTargetRect(el.getBoundingClientRect())
|
if (el) setTargetRect(el.getBoundingClientRect())
|
||||||
}, [current.target])
|
}, [current.target])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onStart?.()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
// On step change: fire onEnter, poll until target appears, then scroll + measure.
|
// On step change: fire onEnter, poll until target appears, then scroll + measure.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTargetRect(null) // clear stale rect immediately
|
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
|
description: string
|
||||||
category: string
|
category: string
|
||||||
categoryLabel: 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 in cents of the default variation. null = custom quote required. */
|
||||||
price: number | null
|
price: number | null
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
@ -39,9 +42,16 @@ export interface CatalogItem {
|
|||||||
colorMin: number // minimum colors required when showColors=true (default 1)
|
colorMin: number // minimum colors required when showColors=true (default 1)
|
||||||
colorMax: number | null // maximum colors allowed (null = unlimited)
|
colorMax: number | null // maximum colors allowed (null = unlimited)
|
||||||
chromeSurchargePerColor: number // extra cents per chrome color selected (0 = flat chrome variation instead)
|
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
|
variations: CatalogVariation[] // all enabled variations; first is the default
|
||||||
/** Unit label for quantity, e.g. "ft". Omitted for plain count items. */
|
/** Unit label for quantity, e.g. "ft". Omitted for plain count items. */
|
||||||
quantityUnit?: string
|
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. */
|
/** When true, the custom vinyl configurator is shown for this item. */
|
||||||
vinylEnabled?: boolean
|
vinylEnabled?: boolean
|
||||||
/** When true, a note is shown suggesting the customer also add a Custom Vinyl item. */
|
/** 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,
|
chromeSurchargePerColor: 0,
|
||||||
imageUrls: item.imageUrl ? [item.imageUrl] : [],
|
imageUrls: item.imageUrl ? [item.imageUrl] : [],
|
||||||
variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [],
|
variations: item.price != null ? [{ id: item.id, name: 'Regular', priceCents: item.price, imageUrls: [], inventory: null }] : [],
|
||||||
|
categories: [item.category],
|
||||||
|
categoryLabels: [item.categoryLabel],
|
||||||
...item,
|
...item,
|
||||||
})) as CatalogItem[]
|
})) 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 ─────────────────────────────────────────────────────────────────────
|
// ── Rates ─────────────────────────────────────────────────────────────────────
|
||||||
export type DeliveryTier = 'dropoff' | 'classic' | 'organic'
|
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: {
|
dropoff: {
|
||||||
base: 20_00, // cents
|
base: 20_00, // cents
|
||||||
perMile: 1_60,
|
perMile: 1_60,
|
||||||
@ -111,8 +113,9 @@ export async function calcDelivery(
|
|||||||
destLat: number,
|
destLat: number,
|
||||||
destLng: number,
|
destLng: number,
|
||||||
tier: DeliveryTier,
|
tier: DeliveryTier,
|
||||||
|
rates?: DeliveryRatesConfig,
|
||||||
): Promise<DeliveryQuote> {
|
): Promise<DeliveryQuote> {
|
||||||
const rate = RATES[tier]
|
const rate = (rates ?? RATES)[tier]
|
||||||
const { miles: rawMiles, minutes: driveMinutes } =
|
const { miles: rawMiles, minutes: driveMinutes } =
|
||||||
await drivingInfo(SHOP_LAT, SHOP_LNG, destLat, destLng)
|
await drivingInfo(SHOP_LAT, SHOP_LNG, destLat, destLng)
|
||||||
const miles = Math.ceil(rawMiles * 10) / 10
|
const miles = Math.ceil(rawMiles * 10) / 10
|
||||||
|
|||||||
@ -4,11 +4,15 @@ import { atomicWriteJSON } from './file-utils'
|
|||||||
|
|
||||||
export interface ItemOverride {
|
export interface ItemOverride {
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
|
featured?: boolean
|
||||||
categoryOverride?: string
|
categoryOverride?: string
|
||||||
categoryLabelOverride?: string
|
categoryLabelOverride?: string
|
||||||
|
/** Replaces categoryOverride — item appears in all listed category tabs (stores label names). */
|
||||||
|
categoriesOverride?: string[] | null
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
showColors?: boolean
|
showColors?: boolean
|
||||||
hiddenModifierIds?: string[]
|
hiddenModifierIds?: string[]
|
||||||
|
hiddenVariationIds?: string[]
|
||||||
descriptionOverride?: string
|
descriptionOverride?: string
|
||||||
/** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */
|
/** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */
|
||||||
modifierMinSelected?: Record<string, number>
|
modifierMinSelected?: Record<string, number>
|
||||||
@ -18,8 +22,16 @@ export interface ItemOverride {
|
|||||||
colorMax?: number
|
colorMax?: number
|
||||||
/** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */
|
/** Extra charge in cents added per chrome color selected (0 = no per-color surcharge). */
|
||||||
chromeSurchargePerColor?: number
|
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". */
|
/** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */
|
||||||
quantityUnit?: string
|
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. */
|
/** When true, shows the custom vinyl text configurator on this item's product modal. */
|
||||||
vinylEnabled?: boolean
|
vinylEnabled?: boolean
|
||||||
/** When true, shows a promo note suggesting the customer also add a Custom Vinyl item. */
|
/** 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, '')
|
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id!,
|
id: item.id!,
|
||||||
name: data.name ?? 'Unnamed item',
|
name: data.name ?? 'Unnamed item',
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
category: categorySlug,
|
category: categorySlug,
|
||||||
categoryLabel: displayCatName,
|
categoryLabel: displayCatName,
|
||||||
price: priceAmount ? Number(priceAmount) : null,
|
categories: [categorySlug],
|
||||||
|
categoryLabels: [displayCatName],
|
||||||
|
price: priceAmount ? Number(priceAmount) : null,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
featured: false,
|
featured: false,
|
||||||
tags: [],
|
tags: [],
|
||||||
modifiers,
|
modifiers,
|
||||||
showColors: hasLatexColors,
|
showColors: hasLatexColors,
|
||||||
colorMin: 1,
|
colorMin: 1,
|
||||||
colorMax: null,
|
colorMax: null,
|
||||||
chromeSurchargePerColor: 0,
|
chromeSurchargePerColor: 0,
|
||||||
variations,
|
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