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:
|
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:
|
osrm:
|
||||||
image: osrm/osrm-backend
|
image: osrm/osrm-backend
|
||||||
container_name: osrm
|
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 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,14 +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,
|
||||||
|
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) => {
|
||||||
@ -30,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(
|
||||||
|
|||||||
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 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,13 +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 [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '')
|
const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
|
||||||
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '')
|
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 [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)
|
||||||
@ -623,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]
|
||||||
@ -641,10 +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,
|
||||||
|
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
|
||||||
@ -652,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',
|
||||||
@ -675,16 +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)
|
||||||
setCatOverride('')
|
setFeatured(item.featured ?? false)
|
||||||
setCatLabel('')
|
setVinylEnabled(false)
|
||||||
|
setVinylPromo(false)
|
||||||
|
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
|
||||||
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, {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -707,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">
|
||||||
@ -723,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"
|
||||||
@ -734,37 +897,121 @@ 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>
|
||||||
|
|
||||||
{/* Category */}
|
{requiresDelivery && (
|
||||||
<div className="field">
|
<div className="field" style={{ background: '#fff8f8', border: '1px solid #f5c6c6', borderRadius: 6, padding: '0.6rem 0.8rem', marginBottom: '0.75rem' }}>
|
||||||
<label className="label is-small">Category</label>
|
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.4rem' }}>
|
||||||
<div className="control">
|
Custom delivery rates for this item (leave blank to use global tier defaults)
|
||||||
<div className="select is-small is-fullwidth">
|
</p>
|
||||||
<select
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
value={catOverride || item._rawCategory}
|
<div>
|
||||||
onChange={(e) => {
|
<label className="label is-small" style={{ marginBottom: 2 }}>Base charge ($)</label>
|
||||||
const selected = categories.find((c) => catSlug(c.name) === e.target.value)
|
<input
|
||||||
setCatOverride(e.target.value)
|
className="input is-small"
|
||||||
setCatLabel(selected?.name ?? e.target.value)
|
type="number"
|
||||||
}}
|
min="0"
|
||||||
>
|
step="0.01"
|
||||||
<option value={item._rawCategory}>{item._rawCategoryLabel} (Square default)</option>
|
placeholder="e.g. 75.00"
|
||||||
{categories
|
value={deliveryBase}
|
||||||
.filter((c) => catSlug(c.name) !== item._rawCategory)
|
onChange={(e) => setDeliveryBase(e.target.value)}
|
||||||
.map((c) => (
|
style={{ width: 110 }}
|
||||||
<option key={c.id} value={catSlug(c.name)}>{c.name}</option>
|
/>
|
||||||
))}
|
</div>
|
||||||
</select>
|
<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>
|
||||||
</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
|
<button
|
||||||
className="button is-ghost is-small"
|
className="button is-ghost is-small"
|
||||||
style={{ padding: '0 2px', fontSize: '0.75rem', marginTop: 4 }}
|
style={{ padding: '0 2px', fontSize: '0.75rem', marginTop: 4 }}
|
||||||
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 }}>
|
||||||
@ -858,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">
|
||||||
@ -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.
|
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>
|
||||||
@ -1204,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('')
|
||||||
@ -1354,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>
|
||||||
|
|
||||||
@ -1456,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -119,6 +119,10 @@ export default function CartDrawer() {
|
|||||||
|
|
||||||
// Unit price — uses selected variation price if set, otherwise product default
|
// Unit price — uses selected variation price if set, otherwise product default
|
||||||
const entryUnitPrice = useCallback((entry: (typeof entries)[number]) => {
|
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
|
const base = entry.selectedVariationId
|
||||||
? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0))
|
? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0))
|
||||||
: (entry.product.price ?? 0)
|
: (entry.product.price ?? 0)
|
||||||
@ -151,23 +155,53 @@ export default function CartDrawer() {
|
|||||||
const grandTotal = subtotal + deliveryTotal + taxCents
|
const grandTotal = subtotal + deliveryTotal + taxCents
|
||||||
|
|
||||||
// Build the payload sent to /api/checkout (recomputed only when dependencies change)
|
// Build the payload sent to /api/checkout (recomputed only when dependencies change)
|
||||||
|
type LI = CheckoutPayload['lineItems'][number]
|
||||||
const checkoutPayload = useMemo<CheckoutPayload>(() => ({
|
const checkoutPayload = useMemo<CheckoutPayload>(() => ({
|
||||||
lineItems: entries.map((e) => ({
|
lineItems: entries.flatMap((e): LI[] => {
|
||||||
name: e.product.name,
|
if (e.vinylText && e.vinylShapeVariationId) {
|
||||||
quantity: e.quantity,
|
const letterCount = e.vinylText.replace(/ /g, '').length
|
||||||
priceCents: entryUnitPrice(e),
|
const vinylCents = letterCount * 65
|
||||||
catalogItemId: e.selectedVariationId ?? e.product.id,
|
return [
|
||||||
colors: e.selectedColors.length ? e.selectedColors : undefined,
|
{
|
||||||
note: e.notes || undefined,
|
name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
|
||||||
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
|
quantity: e.quantity,
|
||||||
const ml = e.product.modifiers.find((m) => m.id === listId)
|
priceCents: e.vinylShapePriceCents ?? 450,
|
||||||
if (!ml) return []
|
catalogItemId: e.vinylShapeVariationId,
|
||||||
return optIds.map((optId) => ({
|
note: 'For custom vinyl',
|
||||||
catalogObjectId: optId,
|
},
|
||||||
name: ml.options.find((o) => o.id === optId)?.name ?? optId,
|
{
|
||||||
}))
|
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),
|
selectedColors: entries.flatMap((e) => e.selectedColors),
|
||||||
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
|
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
|
||||||
driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : 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>
|
<span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{entry.selectedColors.length > 0 && (
|
{entry.vinylText ? (
|
||||||
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
|
<div style={{ fontSize: '0.8rem', color: '#5a3e9e', marginTop: '0.3rem', background: '#f3eeff', borderRadius: '6px', padding: '4px 8px' }}>
|
||||||
Colors: {entry.selectedColors.join(', ')}
|
<div>Shape: 18" {entry.vinylShapeName}</div>
|
||||||
</div>
|
<div>Text: “{entry.vinylText}”</div>
|
||||||
)}
|
{entry.vinylFontName && <div>Font: {entry.vinylFontName}</div>}
|
||||||
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => {
|
{entry.notes && <div style={{ fontStyle: 'italic', color: '#888' }}>“{entry.notes}”</div>}
|
||||||
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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{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>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
|
|||||||
import { useCart } from '@/context/CartContext'
|
import { useCart } from '@/context/CartContext'
|
||||||
import type { CartEntry } from '@/context/CartContext'
|
import type { CartEntry } from '@/context/CartContext'
|
||||||
import { fmt } from '@/lib/format'
|
import { fmt } from '@/lib/format'
|
||||||
|
import type { VinylConfig, VinylShape, VinylFont } from '@/app/api/vinyl-config/route'
|
||||||
|
|
||||||
interface ColorEntry {
|
interface ColorEntry {
|
||||||
name: string
|
name: string
|
||||||
@ -49,12 +50,46 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
() => editingEntry?.selectedVariationId ?? product.variations[0]?.id
|
() => 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(() => {
|
useEffect(() => {
|
||||||
fetch('/colors.json')
|
fetch('/colors.json')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: ColorFamily[]) => setFamilies(data))
|
.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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
@ -101,7 +136,17 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
return ml.minSelected > 0 && chosen < ml.minSelected
|
return ml.minSelected > 0 && chosen < ml.minSelected
|
||||||
})
|
})
|
||||||
const needsColors = product.showColors && selected.size < colorMin
|
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)
|
// Selectable variations: everything except the chrome variation (auto-applied by color choice)
|
||||||
const selectableVariations = product.variations.filter(
|
const selectableVariations = product.variations.filter(
|
||||||
@ -143,8 +188,10 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
}, 0)
|
}, 0)
|
||||||
const basePrice = activeVariation?.priceCents ?? product.price ?? 0
|
const basePrice = activeVariation?.priceCents ?? product.price ?? 0
|
||||||
const chromeDelta = chromeCount * surchargePerColor
|
const chromeDelta = chromeCount * surchargePerColor
|
||||||
const unitPrice = basePrice + modDelta + chromeDelta
|
const unitPrice = product.vinylEnabled
|
||||||
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote'
|
? vinylPriceCents
|
||||||
|
: basePrice + modDelta + chromeDelta
|
||||||
|
const total = unitPrice > 0 ? fmt(unitPrice * quantity) : product.vinylEnabled ? fmt(0) : 'Get Quote'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal is-active" onClick={onClose}>
|
<div className="modal is-active" onClick={onClose}>
|
||||||
@ -225,6 +272,13 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
</p>
|
</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 ── */}
|
{/* ── Size / variation selector ── */}
|
||||||
{selectableVariations.length > 1 && (
|
{selectableVariations.length > 1 && (
|
||||||
<div className="field" style={{ marginBottom: '1.25rem' }}>
|
<div className="field" style={{ marginBottom: '1.25rem' }}>
|
||||||
@ -484,6 +538,113 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
</div>
|
</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 ── */}
|
{/* ── Notes ── */}
|
||||||
<div className="field mt-4">
|
<div className="field mt-4">
|
||||||
<label className="label">Special notes (optional)</label>
|
<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' }}>
|
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
{!canAdd && (
|
{!canAdd && (
|
||||||
<p className="is-size-7 has-text-danger" style={{ width: '100%', marginBottom: '0.25rem' }}>
|
<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,
|
needsColors ? `select at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
|
||||||
...missingModifiers.map((ml) => ml.name),
|
needsVinylText ? 'enter your vinyl message' : null,
|
||||||
|
needsVinylFont ? 'choose a font style' : null,
|
||||||
|
...missingModifiers.map((ml) => `choose ${ml.name}`),
|
||||||
].filter(Boolean).join(', ')}
|
].filter(Boolean).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -544,12 +707,20 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
if (v.size) choices[k] = Array.from(v)
|
if (v.size) choices[k] = Array.from(v)
|
||||||
})
|
})
|
||||||
const selectedVariationId = activeVariation?.id
|
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 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) {
|
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 {
|
} else {
|
||||||
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId })
|
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { fmt } from '@/lib/format'
|
import { fmt } from '@/lib/format'
|
||||||
|
|
||||||
// ── Minimal Square Web Payments SDK types ─────────────────────────────────────
|
// ── Square Web Payments SDK types ─────────────────────────────────────────────
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
Square?: {
|
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 {
|
interface SquarePayments {
|
||||||
card(options?: object): Promise<SquareCard>
|
card(options?: object): Promise<SquareCard>
|
||||||
|
googlePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
|
||||||
|
applePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
|
||||||
|
paymentRequest(options: PaymentRequestOptions): SquarePaymentRequest
|
||||||
}
|
}
|
||||||
interface SquareCard {
|
interface SquareCard {
|
||||||
attach(selector: string): Promise<void>
|
attach(selector: string): Promise<void>
|
||||||
tokenize(): Promise<{
|
tokenize(): Promise<TokenResult>
|
||||||
status: string
|
|
||||||
token?: string
|
|
||||||
errors?: Array<{ message: string }>
|
|
||||||
}>
|
|
||||||
destroy(): Promise<void>
|
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 {
|
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 appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? ''
|
||||||
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
|
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
|
||||||
|
|
||||||
const [sdkReady, setSdkReady] = useState(false)
|
const [sdkReady, setSdkReady] = useState(false)
|
||||||
const [cardReady, setCardReady] = useState(false)
|
const [cardReady, setCardReady] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [googlePayReady, setGooglePayReady] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [applePayReady, setApplePayReady] = useState(false)
|
||||||
const cardRef = useRef<SquareCard | null>(null)
|
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)
|
// 1 — Load Square SDK script (idempotent)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -90,7 +110,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
// Clear any previous error when the booking slot changes
|
// Clear any previous error when the booking slot changes
|
||||||
useEffect(() => { setError('') }, [payload.deliverySlotISO, payload.pickupSlotISO])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!active || !sdkReady || !window.Square || !appId || !locationId) return
|
if (!active || !sdkReady || !window.Square || !appId || !locationId) return
|
||||||
if (cardRef.current) return // already initialised
|
if (cardRef.current) return // already initialised
|
||||||
@ -98,7 +118,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
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) =>
|
await new Promise<void>((resolve) =>
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => 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 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')
|
await card.attach('#sq-card')
|
||||||
if (mounted) {
|
if (mounted) { cardRef.current = card; setCardReady(true) }
|
||||||
cardRef.current = card
|
else { card.destroy().catch(() => {}); return }
|
||||||
setCardReady(true)
|
|
||||||
} else {
|
// Google Pay (available in Chrome and most modern browsers)
|
||||||
card.destroy().catch(() => {})
|
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) {
|
} catch (e) {
|
||||||
console.error('[PaymentForm] init:', e)
|
console.error('[PaymentForm] init:', e)
|
||||||
if (mounted) setError('Could not load payment form — please refresh and try again.')
|
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?.destroy().catch(() => {})
|
||||||
cardRef.current = null
|
cardRef.current = null
|
||||||
setCardReady(false)
|
setCardReady(false)
|
||||||
|
googlePayRef.current?.destroy().catch(() => {})
|
||||||
|
googlePayRef.current = null
|
||||||
|
setGooglePayReady(false)
|
||||||
|
applePayRef.current?.destroy().catch(() => {})
|
||||||
|
applePayRef.current = null
|
||||||
|
setApplePayReady(false)
|
||||||
}
|
}
|
||||||
}, [active, sdkReady, appId, locationId])
|
}, [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
|
if (!cardRef.current || submitting) return
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenResult = await cardRef.current.tokenize()
|
const tokenResult = await cardRef.current.tokenize()
|
||||||
|
|
||||||
if (tokenResult.status !== 'OK' || !tokenResult.token) {
|
if (tokenResult.status !== 'OK' || !tokenResult.token) {
|
||||||
setError(
|
setError(
|
||||||
tokenResult.errors?.map((e) => e.message).join(' ') ??
|
tokenResult.errors?.map((e) => e.message).join(' ') ??
|
||||||
@ -146,48 +229,29 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await submitToken(tokenResult.token)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const checkoutBody = JSON.stringify({ ...payload, sourceId: tokenResult.token })
|
const handleWalletPay = async (walletRef: { current: SquareWalletMethod | null }) => {
|
||||||
|
if (!walletRef.current || submitting) return
|
||||||
// Attempt the checkout request. On a network-level failure (fetch throws),
|
setSubmitting(true)
|
||||||
// wait 2 seconds and retry once automatically — the idempotency key ensures
|
setError('')
|
||||||
// no double charge and will return success if the first attempt already
|
try {
|
||||||
// captured payment but the response was lost.
|
const tokenResult = await walletRef.current.tokenize()
|
||||||
const attemptCheckout = () => fetch('/api/checkout', {
|
if (tokenResult.status !== 'OK' || !tokenResult.token) {
|
||||||
method: 'POST',
|
// 'Cancel' means the user dismissed the native sheet — not an error
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (tokenResult.status !== 'Cancel') {
|
||||||
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.
|
|
||||||
setError(
|
setError(
|
||||||
'Connection issue — your payment may have already been processed. ' +
|
tokenResult.errors?.map((e) => e.message).join(' ') ??
|
||||||
'Please tap "Place Order" again to confirm, or contact us if this persists.'
|
'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
|
return
|
||||||
}
|
}
|
||||||
|
await submitToken(tokenResult.token)
|
||||||
onSuccess(data.orderId as string, data.shortRef as string)
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@ -205,8 +269,34 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasWallet = googlePayReady || applePayReady
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 */}
|
{/* Accepted card types */}
|
||||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
|
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
|
||||||
@ -246,7 +336,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
<button
|
<button
|
||||||
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
|
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
|
||||||
disabled={!cardReady || submitting}
|
disabled={!cardReady || submitting}
|
||||||
onClick={handlePay}
|
onClick={handleCardPay}
|
||||||
style={{ marginTop: '1rem' }}
|
style={{ marginTop: '1rem' }}
|
||||||
>
|
>
|
||||||
Place Order · {fmt(payload.grandTotal)}
|
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
|
modifierChoices: Record<string, string[]> // listId → optionIds
|
||||||
notes: string
|
notes: string
|
||||||
selectedVariationId?: string // set when a non-default variation is chosen
|
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 {
|
interface CartState {
|
||||||
|
|||||||
@ -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,20 @@ 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. */
|
||||||
|
vinylEnabled?: boolean
|
||||||
|
/** When true, a note is shown suggesting the customer also add a Custom Vinyl item. */
|
||||||
|
vinylPromo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MOCK_CATALOG: CatalogItem[] = (([
|
export const MOCK_CATALOG: CatalogItem[] = (([
|
||||||
@ -149,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,20 @@ 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. */
|
||||||
|
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>
|
export type OverridesMap = Record<string, ItemOverride>
|
||||||
|
|||||||
@ -91,9 +91,10 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
|
|||||||
const items = objects
|
const items = objects
|
||||||
.filter((o) => o.type === 'ITEM')
|
.filter((o) => o.type === 'ITEM')
|
||||||
.filter((o) =>
|
.filter((o) =>
|
||||||
onlineCategoryId
|
// If an "online" category exists in Square, only show items tagged with it.
|
||||||
? (o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
|
// If the category doesn't exist in this account, show all items.
|
||||||
: false
|
!onlineCategoryId ||
|
||||||
|
(o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
|
||||||
)
|
)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const data = item.itemData!
|
const data = item.itemData!
|
||||||
@ -143,23 +144,27 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
|
|||||||
.filter((ml): ml is ModifierList => ml !== null)
|
.filter((ml): ml is ModifierList => ml !== null)
|
||||||
|
|
||||||
const itemCategories: { id?: string }[] = data.categories ?? []
|
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 skipIds = new Set([onlineCategoryId, latexCategoryId].filter(Boolean) as string[])
|
||||||
const displayCatName = itemCategories
|
const displayCatNames = itemCategories
|
||||||
.filter((c) => c.id && !skipIds.has(c.id))
|
.filter((c) => c.id && !skipIds.has(c.id))
|
||||||
.map((c) => categoryNameMap.get(c.id!))
|
.map((c) => categoryNameMap.get(c.id!))
|
||||||
.find(Boolean) ?? 'Other'
|
.filter((n): n is string => !!n)
|
||||||
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
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 {
|
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: primaryCatName,
|
||||||
|
categories: categorySlugs,
|
||||||
|
categoryLabels: categoryLbls,
|
||||||
price: priceAmount ? Number(priceAmount) : null,
|
price: priceAmount ? Number(priceAmount) : null,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageUrls,
|
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