From e7fec9ea72b50532158967537be083339359bd51 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 29 Apr 2026 16:27:27 -0400 Subject: [PATCH] Merge beachPartyBalloons estore features into balloons-shop - Multi-category support: CatalogItem gains categories/categoryLabels arrays; catalog route applies categoriesOverride; FeaturedProducts filters by array - Featured sorting: featured items sort first in catalog route - Admin panel: featured toggle, requiresDelivery with per-item rate overrides, multi-category checkboxes, variation visibility, AdminColorFilter modal, delivery rates tab (DeliveryRatesEditor) - Per-item delivery rate overrides: delivery-quote route accepts rateOverride and reads from delivery-rates.json via readDeliveryRates() - disabledColors, hiddenVariationIds applied in catalog and admin routes - ScrollToTop button added to layout - GuidedTour gains optional onStart prop; tourInit resets category/search - Occasion tab deduplication in FeaturedProducts - New components: ScrollToTop, AdminColorFilter, useLockBodyScroll, delivery-rates lib, admin/delivery-rates API route Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/admin/delivery-rates/route.ts | 27 ++ src/app/api/admin/items/route.ts | 34 +- src/app/api/catalog/route.ts | 38 ++- src/app/api/delivery-quote/route.ts | 21 +- src/app/layout.tsx | 2 + src/app/shop/admin/page.tsx | 359 +++++++++++++++++++--- src/components/AdminColorFilter.tsx | 154 ++++++++++ src/components/FeaturedProducts.tsx | 34 +- src/components/GuidedTour.tsx | 10 +- src/components/ScrollToTop.tsx | 47 +++ src/data/mock-catalog.ts | 12 + src/lib/delivery-rates.ts | 31 ++ src/lib/delivery.ts | 7 +- src/lib/overrides.ts | 12 + src/lib/square.ts | 24 +- src/lib/useLockBodyScroll.ts | 10 + 16 files changed, 751 insertions(+), 71 deletions(-) create mode 100644 src/app/api/admin/delivery-rates/route.ts create mode 100644 src/components/AdminColorFilter.tsx create mode 100644 src/components/ScrollToTop.tsx create mode 100644 src/lib/delivery-rates.ts create mode 100644 src/lib/useLockBodyScroll.ts diff --git a/src/app/api/admin/delivery-rates/route.ts b/src/app/api/admin/delivery-rates/route.ts new file mode 100644 index 0000000..4998c7a --- /dev/null +++ b/src/app/api/admin/delivery-rates/route.ts @@ -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 }) + } +} diff --git a/src/app/api/admin/items/route.ts b/src/app/api/admin/items/route.ts index efc7a71..3a90bce 100644 --- a/src/app/api/admin/items/route.ts +++ b/src/app/api/admin/items/route.ts @@ -11,18 +11,47 @@ export async function GET() { const withOverrides = items.map((item) => { const ov = overrides[item.id] ?? {} + const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + + // Resolve categories (same logic as catalog route) + const resolvedCats = ov.categoriesOverride?.length + ? (() => { + const cats = ov.categoriesOverride!.map(toSlug) + return { + categories: cats, + categoryLabels: ov.categoriesOverride!, + category: cats[0], + categoryLabel: ov.categoriesOverride![0], + } + })() + : { + category: ov.categoryOverride ?? item.category, + categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, + categories: ov.categoryOverride + ? [ov.categoryOverride, ...(item.categories ?? [item.category]).slice(1)] + : (item.categories ?? [item.category]), + categoryLabels: ov.categoryLabelOverride + ? [ov.categoryLabelOverride, ...(item.categoryLabels ?? [item.categoryLabel]).slice(1)] + : (item.categoryLabels ?? [item.categoryLabel]), + } + return { ...item, // Resolved values (what the customer sees) hidden: ov.hidden ?? false, - category: ov.categoryOverride ?? item.category, - categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, + featured: ov.featured ?? item.featured ?? false, + ...resolvedCats, sortOrder: ov.sortOrder ?? 0, showColors: ov.showColors != null ? ov.showColors : item.showColors, colorMin: ov.colorMin ?? item.colorMin, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, + disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors, + requiresDelivery: ov.requiresDelivery != null ? ov.requiresDelivery : item.requiresDelivery, + deliveryBaseOverride: ov.deliveryBaseOverride !== undefined ? ov.deliveryBaseOverride : item.deliveryBaseOverride, + deliveryPerMileOverride: ov.deliveryPerMileOverride !== undefined ? ov.deliveryPerMileOverride : item.deliveryPerMileOverride, description: ov.descriptionOverride ?? item.description, + variations: item.variations.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), modifiers: item.modifiers .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .map((m) => { @@ -33,6 +62,7 @@ export async function GET() { _rawCategory: item.category, _rawCategoryLabel: item.categoryLabel, _rawShowColors: item.showColors, + _rawVariations: item.variations, _rawModifiers: item.modifiers, _rawDescription: item.description, _override: ov, diff --git a/src/app/api/catalog/route.ts b/src/app/api/catalog/route.ts index 62afba7..73bb345 100644 --- a/src/app/api/catalog/route.ts +++ b/src/app/api/catalog/route.ts @@ -3,6 +3,8 @@ import { getCatalog } from '@/lib/catalog-cache' import { readOverrides } from '@/lib/overrides' import type { CatalogItem } from '@/data/mock-catalog' +export const dynamic = 'force-dynamic' + function applyOverrides(items: CatalogItem[]): CatalogItem[] { const overrides = readOverrides() @@ -12,16 +14,44 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] { if (!ov) return item return { ...item, - category: ov.categoryOverride ?? item.category, - categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, + featured: ov.featured ?? item.featured, + // categoriesOverride (array of names) takes precedence over the old single-field overrides + ...(ov.categoriesOverride?.length + ? (() => { + const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + const cats = ov.categoriesOverride!.map(toSlug) + return { + categories: cats, + categoryLabels: ov.categoriesOverride!, + category: cats[0], + categoryLabel: ov.categoriesOverride![0], + } + })() + : { + category: ov.categoryOverride ?? item.category, + categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, + categories: ov.categoryOverride + ? [ov.categoryOverride, ...(item.categories ?? [item.category]).slice(1)] + : (item.categories ?? [item.category]), + categoryLabels: ov.categoryLabelOverride + ? [ov.categoryLabelOverride, ...(item.categoryLabels ?? [item.categoryLabel]).slice(1)] + : (item.categoryLabels ?? [item.categoryLabel]), + } + ), showColors: ov.showColors != null ? ov.showColors : item.showColors, colorMin: ov.colorMin ?? item.colorMin, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, - quantityUnit: ov.quantityUnit ?? item.quantityUnit, + disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors, + quantityUnit: ov.quantityUnit ?? item.quantityUnit, + requiresDelivery: ov.requiresDelivery != null ? ov.requiresDelivery : item.requiresDelivery, + deliveryBaseOverride: ov.deliveryBaseOverride !== undefined ? ov.deliveryBaseOverride : item.deliveryBaseOverride, + deliveryPerMileOverride: ov.deliveryPerMileOverride !== undefined ? ov.deliveryPerMileOverride : item.deliveryPerMileOverride, vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled, vinylPromo: ov.vinylPromo ?? item.vinylPromo, description: ov.descriptionOverride ?? item.description, + variations: item.variations + .filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), modifiers: item.modifiers .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .map((m) => { @@ -32,6 +62,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] { }) .filter((item) => !(overrides[item.id]?.hidden)) .sort((a, b) => { + const featDiff = (b.featured ? 1 : 0) - (a.featured ? 1 : 0) + if (featDiff !== 0) return featDiff const aOrder = overrides[a.id]?.sortOrder ?? 0 const bOrder = overrides[b.id]?.sortOrder ?? 0 return aOrder - bOrder diff --git a/src/app/api/delivery-quote/route.ts b/src/app/api/delivery-quote/route.ts index b3a64f7..abf64e0 100644 --- a/src/app/api/delivery-quote/route.ts +++ b/src/app/api/delivery-quote/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from 'next/server' import { geocode, calcDelivery, inferTier } from '@/lib/delivery' +import { readDeliveryRates } from '@/lib/delivery-rates' export async function POST(request: Request) { - const { address, itemNames } = await request.json() as { - address: string - itemNames: string[] + const { address, itemNames, rateOverride } = await request.json() as { + address: string + itemNames: string[] + rateOverride?: { base: number; perMile: number } } if (!address?.trim()) { @@ -17,7 +19,18 @@ export async function POST(request: Request) { } const tier = inferTier(itemNames ?? []) - const quote = await calcDelivery(coords.lat, coords.lng, tier) + const rates = readDeliveryRates() + + // Apply per-item rate override if provided (overrides just base and perMile for the inferred tier) + if (rateOverride) { + rates[tier] = { + ...rates[tier], + base: rateOverride.base, + perMile: rateOverride.perMile, + } + } + + const quote = await calcDelivery(coords.lat, coords.lng, tier, rates) if (quote.miles > 40) { return NextResponse.json( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 032c4e2..40387ce 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import Navbar from '@/components/Navbar' import Footer from '@/components/Footer' import CartDrawer from '@/components/CartDrawer' import CartFab from '@/components/CartFab' +import ScrollToTop from '@/components/ScrollToTop' import { CartProvider } from '@/context/CartContext' export const metadata: Metadata = { @@ -49,6 +50,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}