Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbd3589add | |||
| e7fec9ea72 | |||
| b1606302b0 | |||
| 68bfe79db8 | |||
| 1f1dabdb31 |
22
data/vinyl-config.json
Normal file
22
data/vinyl-config.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"vinylItemId": "7RLHYXSCI3QBXXCSZHZHVEMG",
|
||||
"vinylVariationId": "6BORBAGKFLW3A6BREWMV3CB6",
|
||||
"pricePerLetterCents": 65,
|
||||
"maxCharacters": 30,
|
||||
"shapeItemId": "46JGZU6MYPMQL7M6USGL3KKB",
|
||||
"shapes": [
|
||||
{ "name": "Heart", "variationId": "OOR23NGE53SDQY6UQRE2X353", "priceCents": 450 },
|
||||
{ "name": "Star", "variationId": "7GQY7OXJ6HGNPD2PS7EMP7SM", "priceCents": 450 },
|
||||
{ "name": "Circle", "variationId": "EVI4L5I5ZT3RRONMXXZ3KQ3U", "priceCents": 450 }
|
||||
],
|
||||
"fonts": [
|
||||
{ "id": "HY4DFCCRUHIV5HLUBUNAFGQY", "name": "Anton", "family": "Anton" },
|
||||
{ "id": "KMUNDFS4G6QFWCOS5KOBCKHL", "name": "Montserrat", "family": "Montserrat" },
|
||||
{ "id": "TH7LNXBNV2NKJG5DOSJDOF4K", "name": "Indie Flower", "family": "Indie Flower" },
|
||||
{ "id": "56CZ2GXWZU3OBBXMGS4FECNX", "name": "Pacifico", "family": "Pacifico" },
|
||||
{ "id": "JVYX3XCQI2FRB23MOS3PXBJX", "name": "Style Script", "family": "Style Script" },
|
||||
{ "id": "5I7ICFBD2KUJFH7J3SHU4HX3", "name": "MedievalSharp", "family": "MedievalSharp" },
|
||||
{ "id": "73RT5RUWTAP4OKLCZZJHV47J", "name": "Luckiest Guy", "family": "Luckiest Guy" },
|
||||
{ "id": "ST3M6TDB6PRN2JMJXA6EBEZ4", "name": "Playfair Display","family": "Playfair Display" }
|
||||
]
|
||||
}
|
||||
@ -1,4 +1,16 @@
|
||||
services:
|
||||
osrm-init:
|
||||
image: osrm/osrm-backend
|
||||
container_name: osrm-init
|
||||
profiles: [init]
|
||||
volumes:
|
||||
- ./docker/osrm/data:/data
|
||||
entrypoint: /bin/sh -c
|
||||
command: >
|
||||
"osrm-extract -p /opt/car.lua /data/connecticut-latest.osm.pbf &&
|
||||
osrm-partition /data/connecticut-latest.osrm &&
|
||||
osrm-customize /data/connecticut-latest.osrm"
|
||||
|
||||
osrm:
|
||||
image: osrm/osrm-backend
|
||||
container_name: osrm
|
||||
|
||||
File diff suppressed because one or more lines are too long
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,14 +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) => {
|
||||
@ -30,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(
|
||||
|
||||
39
src/app/api/vinyl-config/route.ts
Normal file
39
src/app/api/vinyl-config/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface VinylShape {
|
||||
name: string
|
||||
variationId: string
|
||||
priceCents: number
|
||||
}
|
||||
|
||||
export interface VinylFont {
|
||||
id: string
|
||||
name: string
|
||||
family: string
|
||||
}
|
||||
|
||||
export interface VinylConfig {
|
||||
vinylItemId: string
|
||||
vinylVariationId: string
|
||||
pricePerLetterCents: number
|
||||
maxCharacters: number
|
||||
shapeItemId: string
|
||||
shapes: VinylShape[]
|
||||
fonts: VinylFont[]
|
||||
}
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'data', 'vinyl-config.json')
|
||||
|
||||
export async function GET() {
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
return NextResponse.json({ error: 'Vinyl config not found' }, { status: 404 })
|
||||
}
|
||||
try {
|
||||
const config: VinylConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
|
||||
return NextResponse.json(config)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to load vinyl config' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -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,13 +723,19 @@ function ItemEditor({
|
||||
}) {
|
||||
const ov = item._override
|
||||
|
||||
const [hidden, setHidden] = useState(ov.hidden ?? 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)
|
||||
@ -623,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]
|
||||
@ -641,10 +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
|
||||
@ -652,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',
|
||||
@ -675,16 +833,23 @@ function ItemEditor({
|
||||
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setHidden(false)
|
||||
setCatOverride('')
|
||||
setCatLabel('')
|
||||
setFeatured(item.featured ?? false)
|
||||
setVinylEnabled(false)
|
||||
setVinylPromo(false)
|
||||
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
|
||||
setSortOrder('')
|
||||
setShowColors(null)
|
||||
setHiddenVars([])
|
||||
setHiddenMods([])
|
||||
setDescOverride('')
|
||||
setModifierMins({})
|
||||
setColorMin('')
|
||||
setColorMax('')
|
||||
setChromeSurcharge('')
|
||||
setDisabledColors([])
|
||||
setRequiresDelivery(false)
|
||||
setDeliveryBase('')
|
||||
setDeliveryPerMile('')
|
||||
onSaved(item.id, {})
|
||||
}
|
||||
}
|
||||
@ -707,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">
|
||||
@ -723,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"
|
||||
@ -734,37 +897,121 @@ 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>
|
||||
|
||||
{/* Category */}
|
||||
<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>
|
||||
{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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={vinylEnabled}
|
||||
onChange={(e) => setVinylEnabled(e.target.checked)}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
Enable vinyl configurator
|
||||
</label>
|
||||
<p className="help">Shows the shape picker, text input, and font selector on this item (use for the Custom Vinyl product).</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={vinylPromo}
|
||||
onChange={(e) => setVinylPromo(e.target.checked)}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
Suggest custom vinyl add-on
|
||||
</label>
|
||||
<p className="help">Shows a note on this item prompting customers to also add a Custom Vinyl balloon.</p>
|
||||
</div>
|
||||
|
||||
{/* Category — multi-select checkboxes */}
|
||||
<div className="field">
|
||||
<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"
|
||||
style={{ padding: '0 2px', fontSize: '0.75rem', marginTop: 4 }}
|
||||
onClick={() => setShowNewCat(!showNewCat)}
|
||||
type="button"
|
||||
>
|
||||
+ Create new category
|
||||
+ Create new category in Square
|
||||
</button>
|
||||
{showNewCat && (
|
||||
<div className="field has-addons" style={{ marginTop: 6 }}>
|
||||
@ -858,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">
|
||||
@ -1004,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>
|
||||
@ -1204,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('')
|
||||
@ -1354,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>
|
||||
|
||||
@ -1456,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>
|
||||
)
|
||||
}
|
||||
@ -119,6 +119,10 @@ export default function CartDrawer() {
|
||||
|
||||
// Unit price — uses selected variation price if set, otherwise product default
|
||||
const entryUnitPrice = useCallback((entry: (typeof entries)[number]) => {
|
||||
if (entry.vinylText !== undefined && entry.vinylText !== '') {
|
||||
const letterCount = entry.vinylText.replace(/ /g, '').length
|
||||
return (entry.vinylShapePriceCents ?? 0) + letterCount * 65
|
||||
}
|
||||
const base = entry.selectedVariationId
|
||||
? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0))
|
||||
: (entry.product.price ?? 0)
|
||||
@ -151,23 +155,53 @@ export default function CartDrawer() {
|
||||
const grandTotal = subtotal + deliveryTotal + taxCents
|
||||
|
||||
// Build the payload sent to /api/checkout (recomputed only when dependencies change)
|
||||
type LI = CheckoutPayload['lineItems'][number]
|
||||
const checkoutPayload = useMemo<CheckoutPayload>(() => ({
|
||||
lineItems: entries.map((e) => ({
|
||||
name: e.product.name,
|
||||
quantity: e.quantity,
|
||||
priceCents: entryUnitPrice(e),
|
||||
catalogItemId: e.selectedVariationId ?? e.product.id,
|
||||
colors: e.selectedColors.length ? e.selectedColors : undefined,
|
||||
note: e.notes || undefined,
|
||||
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
|
||||
const ml = e.product.modifiers.find((m) => m.id === listId)
|
||||
if (!ml) return []
|
||||
return optIds.map((optId) => ({
|
||||
catalogObjectId: optId,
|
||||
name: ml.options.find((o) => o.id === optId)?.name ?? optId,
|
||||
}))
|
||||
}),
|
||||
})),
|
||||
lineItems: entries.flatMap((e): LI[] => {
|
||||
if (e.vinylText && e.vinylShapeVariationId) {
|
||||
const letterCount = e.vinylText.replace(/ /g, '').length
|
||||
const vinylCents = letterCount * 65
|
||||
return [
|
||||
{
|
||||
name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
|
||||
quantity: e.quantity,
|
||||
priceCents: e.vinylShapePriceCents ?? 450,
|
||||
catalogItemId: e.vinylShapeVariationId,
|
||||
note: 'For custom vinyl',
|
||||
},
|
||||
{
|
||||
name: 'Custom Vinyl',
|
||||
quantity: e.quantity,
|
||||
priceCents: vinylCents,
|
||||
catalogItemId: e.product.variations[0]?.id ?? e.product.id,
|
||||
note: [
|
||||
`Text: "${e.vinylText}"`,
|
||||
e.vinylFontName ? `Font: ${e.vinylFontName}` : null,
|
||||
e.notes || null,
|
||||
].filter(Boolean).join(' | ') || undefined,
|
||||
modifiers: e.vinylFontId
|
||||
? [{ catalogObjectId: e.vinylFontId, name: e.vinylFontName ?? '' }]
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{
|
||||
name: e.product.name,
|
||||
quantity: e.quantity,
|
||||
priceCents: entryUnitPrice(e),
|
||||
catalogItemId: e.selectedVariationId ?? e.product.id,
|
||||
colors: e.selectedColors.length ? e.selectedColors : undefined,
|
||||
note: e.notes || undefined,
|
||||
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
|
||||
const ml = e.product.modifiers.find((m) => m.id === listId)
|
||||
if (!ml) return []
|
||||
return optIds.map((optId) => ({
|
||||
catalogObjectId: optId,
|
||||
name: ml.options.find((o) => o.id === optId)?.name ?? optId,
|
||||
}))
|
||||
}),
|
||||
}]
|
||||
}),
|
||||
selectedColors: entries.flatMap((e) => e.selectedColors),
|
||||
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
|
||||
driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined,
|
||||
@ -267,26 +301,37 @@ export default function CartDrawer() {
|
||||
<span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.selectedColors.length > 0 && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
|
||||
Colors: {entry.selectedColors.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => {
|
||||
if (!optIds.length) return null
|
||||
const ml = entry.product.modifiers?.find((m) => m.id === listId)
|
||||
if (!ml) return null
|
||||
const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id)
|
||||
return (
|
||||
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
|
||||
{ml.name}: {names.join(', ')}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entry.notes && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
|
||||
“{entry.notes}”
|
||||
{entry.vinylText ? (
|
||||
<div style={{ fontSize: '0.8rem', color: '#5a3e9e', marginTop: '0.3rem', background: '#f3eeff', borderRadius: '6px', padding: '4px 8px' }}>
|
||||
<div>Shape: 18" {entry.vinylShapeName}</div>
|
||||
<div>Text: “{entry.vinylText}”</div>
|
||||
{entry.vinylFontName && <div>Font: {entry.vinylFontName}</div>}
|
||||
{entry.notes && <div style={{ fontStyle: 'italic', color: '#888' }}>“{entry.notes}”</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{entry.selectedColors.length > 0 && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
|
||||
Colors: {entry.selectedColors.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => {
|
||||
if (!optIds.length) return null
|
||||
const ml = entry.product.modifiers?.find((m) => m.id === listId)
|
||||
if (!ml) return null
|
||||
const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id)
|
||||
return (
|
||||
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
|
||||
{ml.name}: {names.join(', ')}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entry.notes && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
|
||||
“{entry.notes}”
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
|
||||
@ -5,6 +5,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
|
||||
import { useCart } from '@/context/CartContext'
|
||||
import type { CartEntry } from '@/context/CartContext'
|
||||
import { fmt } from '@/lib/format'
|
||||
import type { VinylConfig, VinylShape, VinylFont } from '@/app/api/vinyl-config/route'
|
||||
|
||||
interface ColorEntry {
|
||||
name: string
|
||||
@ -49,12 +50,46 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
() => editingEntry?.selectedVariationId ?? product.variations[0]?.id
|
||||
)
|
||||
|
||||
// Vinyl state
|
||||
const [vinylConfig, setVinylConfig] = useState<VinylConfig | null>(null)
|
||||
const [vinylText, setVinylText] = useState(editingEntry?.vinylText ?? '')
|
||||
const [vinylFontId, setVinylFontId] = useState(editingEntry?.vinylFontId ?? '')
|
||||
const [vinylShape, setVinylShape] = useState<VinylShape | null>(
|
||||
() => null // resolved after config loads
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/colors.json')
|
||||
.then((r) => r.json())
|
||||
.then((data: ColorFamily[]) => setFamilies(data))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!product.vinylEnabled) return
|
||||
fetch('/api/vinyl-config')
|
||||
.then((r) => r.json())
|
||||
.then((cfg: VinylConfig) => {
|
||||
setVinylConfig(cfg)
|
||||
// Restore shape selection when editing
|
||||
const savedId = editingEntry?.vinylShapeVariationId
|
||||
if (savedId) {
|
||||
setVinylShape(cfg.shapes.find((s) => s.variationId === savedId) ?? cfg.shapes[0])
|
||||
} else {
|
||||
setVinylShape(cfg.shapes[0])
|
||||
}
|
||||
// Load Google Fonts for previews
|
||||
const families = cfg.fonts.map((f) => `family=${encodeURIComponent(f.family).replace(/%20/g, '+')}`).join('&')
|
||||
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`
|
||||
if (!document.querySelector(`link[href="${href}"]`)) {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
})
|
||||
.catch(() => {/* vinyl config unavailable — fail silently */})
|
||||
}, [product.vinylEnabled, editingEntry?.vinylShapeVariationId])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
@ -101,7 +136,17 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
return ml.minSelected > 0 && chosen < ml.minSelected
|
||||
})
|
||||
const needsColors = product.showColors && selected.size < colorMin
|
||||
const canAdd = missingModifiers.length === 0 && !needsColors
|
||||
|
||||
// Vinyl validation
|
||||
const vinylLetterCount = vinylText.replace(/ /g, '').length
|
||||
const vinylMaxChars = vinylConfig?.maxCharacters ?? 30
|
||||
const vinylPriceCents = product.vinylEnabled
|
||||
? (vinylShape?.priceCents ?? 0) + vinylLetterCount * (vinylConfig?.pricePerLetterCents ?? 65)
|
||||
: 0
|
||||
const needsVinylText = product.vinylEnabled && vinylLetterCount === 0
|
||||
const needsVinylFont = product.vinylEnabled && !vinylFontId
|
||||
|
||||
const canAdd = missingModifiers.length === 0 && !needsColors && !needsVinylText && !needsVinylFont
|
||||
|
||||
// Selectable variations: everything except the chrome variation (auto-applied by color choice)
|
||||
const selectableVariations = product.variations.filter(
|
||||
@ -143,8 +188,10 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
}, 0)
|
||||
const basePrice = activeVariation?.priceCents ?? product.price ?? 0
|
||||
const chromeDelta = chromeCount * surchargePerColor
|
||||
const unitPrice = basePrice + modDelta + chromeDelta
|
||||
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote'
|
||||
const unitPrice = product.vinylEnabled
|
||||
? vinylPriceCents
|
||||
: basePrice + modDelta + chromeDelta
|
||||
const total = unitPrice > 0 ? fmt(unitPrice * quantity) : product.vinylEnabled ? fmt(0) : 'Get Quote'
|
||||
|
||||
return (
|
||||
<div className="modal is-active" onClick={onClose}>
|
||||
@ -225,6 +272,13 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Vinyl promo note ── */}
|
||||
{product.vinylPromo && (
|
||||
<div style={{ marginBottom: '1.25rem', padding: '0.65rem 0.9rem', background: '#f3eeff', border: '1px solid #d8c8f8', borderRadius: '8px', fontSize: '0.82rem', color: '#4a2d9e' }}>
|
||||
Want to personalize this balloon? Add a <strong>Custom Vinyl</strong> item to your order and we'll apply custom text in your choice of font — starting at $4.50 + $0.65/letter.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Size / variation selector ── */}
|
||||
{selectableVariations.length > 1 && (
|
||||
<div className="field" style={{ marginBottom: '1.25rem' }}>
|
||||
@ -484,6 +538,113 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Vinyl configurator ── */}
|
||||
{product.vinylEnabled && vinylConfig && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f8f4ff', border: '1px solid #d8c8f8', borderRadius: '10px' }}>
|
||||
<p className="label" style={{ marginBottom: '0.75rem', color: '#5a3e9e' }}>
|
||||
Custom Vinyl Text
|
||||
</p>
|
||||
|
||||
{/* Shape picker */}
|
||||
<div className="field" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label is-small">Balloon Shape</label>
|
||||
<div className="buttons">
|
||||
{vinylConfig.shapes.map((shape) => (
|
||||
<button
|
||||
key={shape.variationId}
|
||||
type="button"
|
||||
className={`button is-small${vinylShape?.variationId === shape.variationId ? ' is-info' : ''}`}
|
||||
onClick={() => setVinylShape(shape)}
|
||||
>
|
||||
{shape.name}
|
||||
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: '0.78em' }}>{fmt(shape.priceCents)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text input */}
|
||||
<div className="field" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label is-small">
|
||||
Your message
|
||||
<span style={{ fontWeight: 'normal', marginLeft: '8px', color: vinylLetterCount > vinylMaxChars ? '#e00' : '#888' }}>
|
||||
{vinylLetterCount}/{vinylMaxChars} letters (spaces free)
|
||||
</span>
|
||||
</label>
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="e.g. Happy Birthday!"
|
||||
value={vinylText}
|
||||
onChange={(e) => {
|
||||
// Strip anything outside standard ASCII printable range (no emojis, accented chars, etc.)
|
||||
const sanitized = e.target.value.replace(/[^\x20-\x7E]/g, '')
|
||||
const nonSpace = sanitized.replace(/ /g, '').length
|
||||
if (nonSpace <= vinylMaxChars) setVinylText(sanitized)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{vinylLetterCount > 0 && (
|
||||
<p className="is-size-7" style={{ marginTop: '0.25rem', color: '#5a3e9e' }}>
|
||||
{vinylLetterCount} letter{vinylLetterCount !== 1 ? 's' : ''} × {fmt(vinylConfig.pricePerLetterCents)} = {fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)} vinyl
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Font picker */}
|
||||
<div className="field" style={{ marginBottom: '0.5rem' }}>
|
||||
<label className="label is-small">Font Style</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: '8px' }}>
|
||||
{vinylConfig.fonts.map((font) => {
|
||||
const chosen = vinylFontId === font.id
|
||||
return (
|
||||
<button
|
||||
key={font.id}
|
||||
type="button"
|
||||
onClick={() => setVinylFontId(font.id)}
|
||||
style={{
|
||||
padding: '10px 8px',
|
||||
border: `2px solid ${chosen ? '#7c4dff' : '#d8c8f8'}`,
|
||||
borderRadius: '8px',
|
||||
background: chosen ? '#ede7ff' : '#fff',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: `'${font.family}', sans-serif`, fontSize: '1.3rem', display: 'block', color: '#2d1b6b', lineHeight: 1.2 }}>
|
||||
{vinylText || 'Aa'}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#888', marginTop: '4px', display: 'block' }}>
|
||||
{font.name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live price summary */}
|
||||
{vinylLetterCount > 0 && vinylShape && (
|
||||
<div style={{ marginTop: '0.75rem', padding: '0.6rem 0.85rem', background: '#ede7ff', borderRadius: '8px', fontSize: '0.82rem', color: '#3d2080' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>18" {vinylShape.name} balloon</span>
|
||||
<span>{fmt(vinylShape.priceCents)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Vinyl ({vinylLetterCount} letters)</span>
|
||||
<span>{fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, borderTop: '1px solid #c4b0f0', marginTop: '4px', paddingTop: '4px' }}>
|
||||
<span>Total</span>
|
||||
<span>{fmt(vinylPriceCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Notes ── */}
|
||||
<div className="field mt-4">
|
||||
<label className="label">Special notes (optional)</label>
|
||||
@ -526,10 +687,12 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{!canAdd && (
|
||||
<p className="is-size-7 has-text-danger" style={{ width: '100%', marginBottom: '0.25rem' }}>
|
||||
Please select:{' '}
|
||||
Please:{' '}
|
||||
{[
|
||||
needsColors ? `at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
|
||||
...missingModifiers.map((ml) => ml.name),
|
||||
needsColors ? `select at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
|
||||
needsVinylText ? 'enter your vinyl message' : null,
|
||||
needsVinylFont ? 'choose a font style' : null,
|
||||
...missingModifiers.map((ml) => `choose ${ml.name}`),
|
||||
].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
@ -544,12 +707,20 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
if (v.size) choices[k] = Array.from(v)
|
||||
})
|
||||
const selectedVariationId = activeVariation?.id
|
||||
// Store the user's manual variation choice (not the chrome override) so editing restores the right selection
|
||||
const storedVariationId = userVariation?.id ?? selectedVariationId
|
||||
const selectedFont = vinylConfig?.fonts.find((f) => f.id === vinylFontId)
|
||||
const vinylFields = product.vinylEnabled && vinylText && vinylShape ? {
|
||||
vinylText,
|
||||
vinylFontId,
|
||||
vinylFontName: selectedFont?.name,
|
||||
vinylShapeVariationId: vinylShape.variationId,
|
||||
vinylShapeName: vinylShape.name,
|
||||
vinylShapePriceCents: vinylShape.priceCents,
|
||||
} : {}
|
||||
if (editingEntry) {
|
||||
updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId })
|
||||
updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
|
||||
} else {
|
||||
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId })
|
||||
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { fmt } from '@/lib/format'
|
||||
|
||||
// ── Minimal Square Web Payments SDK types ─────────────────────────────────────
|
||||
// ── Square Web Payments SDK types ─────────────────────────────────────────────
|
||||
declare global {
|
||||
interface Window {
|
||||
Square?: {
|
||||
@ -11,18 +11,33 @@ declare global {
|
||||
}
|
||||
}
|
||||
}
|
||||
interface PaymentRequestOptions {
|
||||
countryCode: string
|
||||
currencyCode: string
|
||||
total: { label: string; amount: string }
|
||||
}
|
||||
interface SquarePaymentRequest {} // opaque handle passed to wallet methods
|
||||
interface SquarePayments {
|
||||
card(options?: object): Promise<SquareCard>
|
||||
googlePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
|
||||
applePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
|
||||
paymentRequest(options: PaymentRequestOptions): SquarePaymentRequest
|
||||
}
|
||||
interface SquareCard {
|
||||
attach(selector: string): Promise<void>
|
||||
tokenize(): Promise<{
|
||||
status: string
|
||||
token?: string
|
||||
errors?: Array<{ message: string }>
|
||||
}>
|
||||
tokenize(): Promise<TokenResult>
|
||||
destroy(): Promise<void>
|
||||
}
|
||||
interface SquareWalletMethod {
|
||||
attach(selector: string): Promise<void>
|
||||
tokenize(): Promise<TokenResult>
|
||||
destroy(): Promise<void>
|
||||
}
|
||||
interface TokenResult {
|
||||
status: string
|
||||
token?: string
|
||||
errors?: Array<{ message: string }>
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CheckoutPayload {
|
||||
@ -66,11 +81,16 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? ''
|
||||
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
|
||||
|
||||
const [sdkReady, setSdkReady] = useState(false)
|
||||
const [cardReady, setCardReady] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const cardRef = useRef<SquareCard | null>(null)
|
||||
const [sdkReady, setSdkReady] = useState(false)
|
||||
const [cardReady, setCardReady] = useState(false)
|
||||
const [googlePayReady, setGooglePayReady] = useState(false)
|
||||
const [applePayReady, setApplePayReady] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const cardRef = useRef<SquareCard | null>(null)
|
||||
const googlePayRef = useRef<SquareWalletMethod | null>(null)
|
||||
const applePayRef = useRef<SquareWalletMethod | null>(null)
|
||||
|
||||
// 1 — Load Square SDK script (idempotent)
|
||||
useEffect(() => {
|
||||
@ -90,7 +110,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
// Clear any previous error when the booking slot changes
|
||||
useEffect(() => { setError('') }, [payload.deliverySlotISO, payload.pickupSlotISO])
|
||||
|
||||
// 2 — Initialise card form once SDK is ready and the step is visible
|
||||
// 2 — Initialise payment methods once SDK is ready and the step is visible
|
||||
useEffect(() => {
|
||||
if (!active || !sdkReady || !window.Square || !appId || !locationId) return
|
||||
if (cardRef.current) return // already initialised
|
||||
@ -98,7 +118,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
// Double-rAF: wait for the browser to finish painting so #sq-card is in the DOM
|
||||
// Double-rAF: wait for the browser to finish painting so all containers are in the DOM
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
@ -109,14 +129,39 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
}
|
||||
|
||||
const payments = await window.Square!.payments(appId, locationId)
|
||||
const card = await payments.card()
|
||||
|
||||
// Card (always available)
|
||||
const card = await payments.card()
|
||||
await card.attach('#sq-card')
|
||||
if (mounted) {
|
||||
cardRef.current = card
|
||||
setCardReady(true)
|
||||
} else {
|
||||
card.destroy().catch(() => {})
|
||||
}
|
||||
if (mounted) { cardRef.current = card; setCardReady(true) }
|
||||
else { card.destroy().catch(() => {}); return }
|
||||
|
||||
// Google Pay (available in Chrome and most modern browsers)
|
||||
try {
|
||||
const req = payments.paymentRequest({
|
||||
countryCode: 'US',
|
||||
currencyCode: 'USD',
|
||||
total: { label: 'Beach Party Balloons', amount: (payload.grandTotal / 100).toFixed(2) },
|
||||
})
|
||||
const gp = await payments.googlePay(req)
|
||||
await gp.attach('#sq-google-pay')
|
||||
if (mounted) { googlePayRef.current = gp; setGooglePayReady(true) }
|
||||
else gp.destroy().catch(() => {})
|
||||
} catch { /* not available in this browser or environment */ }
|
||||
|
||||
// Apple Pay (Safari on Apple devices only, requires registered domain)
|
||||
try {
|
||||
const req = payments.paymentRequest({
|
||||
countryCode: 'US',
|
||||
currencyCode: 'USD',
|
||||
total: { label: 'Beach Party Balloons', amount: (payload.grandTotal / 100).toFixed(2) },
|
||||
})
|
||||
const ap = await payments.applePay(req)
|
||||
await ap.attach('#sq-apple-pay')
|
||||
if (mounted) { applePayRef.current = ap; setApplePayReady(true) }
|
||||
else ap.destroy().catch(() => {})
|
||||
} catch { /* not available (non-Safari / domain not yet registered with Apple) */ }
|
||||
|
||||
} catch (e) {
|
||||
console.error('[PaymentForm] init:', e)
|
||||
if (mounted) setError('Could not load payment form — please refresh and try again.')
|
||||
@ -128,17 +173,55 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
cardRef.current?.destroy().catch(() => {})
|
||||
cardRef.current = null
|
||||
setCardReady(false)
|
||||
googlePayRef.current?.destroy().catch(() => {})
|
||||
googlePayRef.current = null
|
||||
setGooglePayReady(false)
|
||||
applePayRef.current?.destroy().catch(() => {})
|
||||
applePayRef.current = null
|
||||
setApplePayReady(false)
|
||||
}
|
||||
}, [active, sdkReady, appId, locationId])
|
||||
|
||||
const handlePay = async () => {
|
||||
// Shared: POST token to checkout API with one auto-retry on network failure
|
||||
const submitToken = async (token: string) => {
|
||||
const checkoutBody = JSON.stringify({ ...payload, sourceId: token })
|
||||
const attemptCheckout = () => fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: checkoutBody,
|
||||
})
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await attemptCheckout()
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
try {
|
||||
res = await attemptCheckout()
|
||||
} catch {
|
||||
setError(
|
||||
'Connection issue — your payment may have already been processed. ' +
|
||||
'Please tap "Place Order" again to confirm, or contact us if this persists.'
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data.success) {
|
||||
setError(data.error ?? 'Checkout failed — please try again or contact us.')
|
||||
if (res.status !== 409) console.error('[checkout] response:', data)
|
||||
return
|
||||
}
|
||||
onSuccess(data.orderId as string, data.shortRef as string)
|
||||
}
|
||||
|
||||
const handleCardPay = async () => {
|
||||
if (!cardRef.current || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const tokenResult = await cardRef.current.tokenize()
|
||||
|
||||
if (tokenResult.status !== 'OK' || !tokenResult.token) {
|
||||
setError(
|
||||
tokenResult.errors?.map((e) => e.message).join(' ') ??
|
||||
@ -146,48 +229,29 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
)
|
||||
return
|
||||
}
|
||||
await submitToken(tokenResult.token)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkoutBody = JSON.stringify({ ...payload, sourceId: tokenResult.token })
|
||||
|
||||
// Attempt the checkout request. On a network-level failure (fetch throws),
|
||||
// wait 2 seconds and retry once automatically — the idempotency key ensures
|
||||
// no double charge and will return success if the first attempt already
|
||||
// captured payment but the response was lost.
|
||||
const attemptCheckout = () => fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: checkoutBody,
|
||||
})
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await attemptCheckout()
|
||||
} catch {
|
||||
// Network failure on first attempt — pause and retry once
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
try {
|
||||
res = await attemptCheckout()
|
||||
} catch {
|
||||
// Both attempts failed — payment may or may not have gone through.
|
||||
// The idempotency key is preserved in localStorage, so clicking
|
||||
// "Place Order" again will safely resolve either way.
|
||||
const handleWalletPay = async (walletRef: { current: SquareWalletMethod | null }) => {
|
||||
if (!walletRef.current || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const tokenResult = await walletRef.current.tokenize()
|
||||
if (tokenResult.status !== 'OK' || !tokenResult.token) {
|
||||
// 'Cancel' means the user dismissed the native sheet — not an error
|
||||
if (tokenResult.status !== 'Cancel') {
|
||||
setError(
|
||||
'Connection issue — your payment may have already been processed. ' +
|
||||
'Please tap "Place Order" again to confirm, or contact us if this persists.'
|
||||
tokenResult.errors?.map((e) => e.message).join(' ') ??
|
||||
'Payment was not completed. Please try again.'
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
setError(data.error ?? 'Checkout failed — please try again or contact us.')
|
||||
if (res.status !== 409) console.error('[checkout] response:', data)
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess(data.orderId as string, data.shortRef as string)
|
||||
await submitToken(tokenResult.token)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@ -205,8 +269,34 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const hasWallet = googlePayReady || applePayReady
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Wallet button containers — always in the DOM so the SDK can attach to them;
|
||||
hidden via display:none until the respective method initialises */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: hasWallet ? '1rem' : '0' }}>
|
||||
<div
|
||||
id="sq-google-pay"
|
||||
onClick={() => handleWalletPay(googlePayRef)}
|
||||
style={{ display: googlePayReady ? 'block' : 'none', cursor: 'pointer' }}
|
||||
/>
|
||||
<div
|
||||
id="sq-apple-pay"
|
||||
onClick={() => handleWalletPay(applePayRef)}
|
||||
style={{ display: applePayReady ? 'block' : 'none', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider between wallet buttons and card form */}
|
||||
{hasWallet && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem' }}>
|
||||
<div style={{ flex: 1, height: '1px', background: '#e0e0e0' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#999', whiteSpace: 'nowrap' }}>or pay with card</span>
|
||||
<div style={{ flex: 1, height: '1px', background: '#e0e0e0' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accepted card types */}
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
|
||||
@ -246,7 +336,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
<button
|
||||
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
|
||||
disabled={!cardReady || submitting}
|
||||
onClick={handlePay}
|
||||
onClick={handleCardPay}
|
||||
style={{ marginTop: '1rem' }}
|
||||
>
|
||||
Place Order · {fmt(payload.grandTotal)}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -13,6 +13,13 @@ export interface CartEntry {
|
||||
modifierChoices: Record<string, string[]> // listId → optionIds
|
||||
notes: string
|
||||
selectedVariationId?: string // set when a non-default variation is chosen
|
||||
// Vinyl fields — only set when the item has vinylEnabled=true
|
||||
vinylText?: string
|
||||
vinylFontId?: string // Square modifier option ID
|
||||
vinylFontName?: string
|
||||
vinylShapeVariationId?: string // Square variation ID (Heart/Star/Circle)
|
||||
vinylShapeName?: string
|
||||
vinylShapePriceCents?: number
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
|
||||
@ -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,20 @@ 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. */
|
||||
vinylPromo?: boolean
|
||||
}
|
||||
|
||||
export const MOCK_CATALOG: CatalogItem[] = (([
|
||||
@ -149,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,20 @@ 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. */
|
||||
vinylPromo?: boolean
|
||||
}
|
||||
|
||||
export type OverridesMap = Record<string, ItemOverride>
|
||||
|
||||
@ -91,9 +91,10 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
|
||||
const items = objects
|
||||
.filter((o) => o.type === 'ITEM')
|
||||
.filter((o) =>
|
||||
onlineCategoryId
|
||||
? (o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
|
||||
: false
|
||||
// If an "online" category exists in Square, only show items tagged with it.
|
||||
// If the category doesn't exist in this account, show all items.
|
||||
!onlineCategoryId ||
|
||||
(o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
|
||||
)
|
||||
.map((item) => {
|
||||
const data = item.itemData!
|
||||
@ -143,23 +144,27 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
|
||||
.filter((ml): ml is ModifierList => ml !== null)
|
||||
|
||||
const itemCategories: { id?: string }[] = data.categories ?? []
|
||||
const hasCategory = (id: string | undefined) =>
|
||||
!!id && itemCategories.some((c) => c.id === id)
|
||||
|
||||
// Derive display category from the first Square category that isn't 'online' or 'latex'
|
||||
// Derive display categories from all Square categories that aren't 'online' or 'latex'
|
||||
const skipIds = new Set([onlineCategoryId, latexCategoryId].filter(Boolean) as string[])
|
||||
const displayCatName = itemCategories
|
||||
const displayCatNames = itemCategories
|
||||
.filter((c) => c.id && !skipIds.has(c.id))
|
||||
.map((c) => categoryNameMap.get(c.id!))
|
||||
.find(Boolean) ?? 'Other'
|
||||
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
.filter((n): n is string => !!n)
|
||||
const primaryCatName = displayCatNames[0] ?? 'Other'
|
||||
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
const categorySlug = toSlug(primaryCatName)
|
||||
const categorySlugs = displayCatNames.length ? displayCatNames.map(toSlug) : [categorySlug]
|
||||
const categoryLbls = displayCatNames.length ? displayCatNames : [primaryCatName]
|
||||
|
||||
return {
|
||||
id: item.id!,
|
||||
name: data.name ?? 'Unnamed item',
|
||||
description: data.description ?? '',
|
||||
category: categorySlug,
|
||||
categoryLabel: displayCatName,
|
||||
id: item.id!,
|
||||
name: data.name ?? 'Unnamed item',
|
||||
description: data.description ?? '',
|
||||
category: categorySlug,
|
||||
categoryLabel: primaryCatName,
|
||||
categories: categorySlugs,
|
||||
categoryLabels: categoryLbls,
|
||||
price: priceAmount ? Number(priceAmount) : null,
|
||||
imageUrl,
|
||||
imageUrls,
|
||||
|
||||
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