diff --git a/estore/src/app/admin/page.tsx b/estore/src/app/admin/page.tsx index 57dcbac..a0df5b1 100644 --- a/estore/src/app/admin/page.tsx +++ b/estore/src/app/admin/page.tsx @@ -762,6 +762,13 @@ function ItemEditor({ const [disabledColors, setDisabledColors] = useState(ov.disabledColors ?? []) const [showColorFilter, setShowColorFilter] = useState(false) const [quantityUnit, setQuantityUnit] = useState(ov.quantityUnit ?? '') + const [requiresDelivery, setRequiresDelivery] = useState(ov.requiresDelivery ?? false) + const [deliveryBase, setDeliveryBase] = useState( + ov.deliveryBaseOverride != null ? String(ov.deliveryBaseOverride / 100) : '' + ) + const [deliveryPerMile, setDeliveryPerMile] = useState( + ov.deliveryPerMileOverride != null ? String(ov.deliveryPerMileOverride / 100) : '' + ) // Create category const [newCatName, setNewCatName] = useState('') @@ -801,6 +808,9 @@ function ItemEditor({ patch.disabledColors = disabledColors.length ? disabledColors : undefined if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim() else patch.quantityUnit = undefined + patch.requiresDelivery = requiresDelivery || undefined + patch.deliveryBaseOverride = deliveryBase !== '' ? Math.round(Number(deliveryBase) * 100) : null + patch.deliveryPerMileOverride = deliveryPerMile !== '' ? Math.round(Number(deliveryPerMile) * 100) : null const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'PATCH', @@ -833,6 +843,9 @@ function ItemEditor({ setColorMin('') setColorMax('') setChromeSurcharge('') + setRequiresDelivery(false) + setDeliveryBase('') + setDeliveryPerMile('') onSaved(item.id, {}) } } @@ -891,8 +904,53 @@ function ItemEditor({ /> ⭐ Featured + + {requiresDelivery && ( +
+

+ Custom delivery rates for this item (leave blank to use global tier defaults) +

+
+
+ + setDeliveryBase(e.target.value)} + style={{ width: 110 }} + /> +
+
+ + setDeliveryPerMile(e.target.value)} + style={{ width: 110 }} + /> +
+
+
+ )} + {/* Category */}
diff --git a/estore/src/app/api/catalog/route.ts b/estore/src/app/api/catalog/route.ts index 9364511..68c6d65 100644 --- a/estore/src/app/api/catalog/route.ts +++ b/estore/src/app/api/catalog/route.ts @@ -26,7 +26,10 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] { colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors, - quantityUnit: ov.quantityUnit ?? item.quantityUnit, + 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, description: ov.descriptionOverride ?? item.description, variations: item.variations .filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), diff --git a/estore/src/app/api/delivery-quote/route.ts b/estore/src/app/api/delivery-quote/route.ts index 8018f1c..abf64e0 100644 --- a/estore/src/app/api/delivery-quote/route.ts +++ b/estore/src/app/api/delivery-quote/route.ts @@ -3,9 +3,10 @@ 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()) { @@ -19,6 +20,16 @@ export async function POST(request: Request) { const tier = inferTier(itemNames ?? []) 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) { diff --git a/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx index 1e2d05b..77dfb16 100644 --- a/estore/src/components/CartDrawer.tsx +++ b/estore/src/components/CartDrawer.tsx @@ -67,6 +67,26 @@ export default function CartDrawer() { const [shortRef, setShortRef] = useState(null) const [fulfillmentType, setFulfillmentType] = useState<'delivery' | 'pickup'>('pickup') + // If any item requires delivery, force delivery mode and suppress pickup option + const cartRequiresDelivery = useMemo( + () => entries.some((e) => e.product.requiresDelivery), + [entries] + ) + // Effective fulfillment type — pickup blocked when any item requires delivery + const effectiveFulfillment = cartRequiresDelivery ? 'delivery' : fulfillmentType + + // Merged delivery rate override: highest base + highest perMile across requires-delivery items + const deliveryRateOverride = useMemo(() => { + const overrideItems = entries.filter( + (e) => e.product.requiresDelivery && + (e.product.deliveryBaseOverride != null || e.product.deliveryPerMileOverride != null) + ) + if (!overrideItems.length) return undefined + const base = Math.max(...overrideItems.map((e) => e.product.deliveryBaseOverride ?? 0)) + const perMile = Math.max(...overrideItems.map((e) => e.product.deliveryPerMileOverride ?? 0)) + return { base, perMile } + }, [entries]) + // Delivery step — persisted const [street, setStreet] = useStoredString('bpb_street', '') const [city, setCity] = useStoredString('bpb_city', '') @@ -155,7 +175,7 @@ export default function CartDrawer() { const CT_TAX_RATE = 0.0635 const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0) - const deliveryTotal = fulfillmentType === 'delivery' ? (quote?.totalCents ?? 0) : 0 + const deliveryTotal = effectiveFulfillment === 'delivery' ? (quote?.totalCents ?? 0) : 0 const taxCents = Math.round(subtotal * CT_TAX_RATE) const grandTotal = subtotal + deliveryTotal + taxCents @@ -178,13 +198,13 @@ export default function CartDrawer() { }), })), selectedColors: entries.flatMap((e) => e.selectedColors), - deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined, - driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined, - deliveryAddress: fulfillmentType === 'delivery' ? (fullAddress || undefined) : undefined, - deliveryTier: fulfillmentType === 'delivery' ? quote?.tier : undefined, - deliveryNotes: fulfillmentType === 'delivery' && deliveryInstructions ? deliveryInstructions : undefined, - deliveryCents: fulfillmentType === 'delivery' ? (quote?.totalCents ?? 0) : undefined, - pickupSlotISO: fulfillmentType === 'pickup' ? pickupSlot?.slotISO : undefined, + deliverySlotISO: effectiveFulfillment === 'delivery' ? deliverySlot?.slotISO : undefined, + driveMinutes: effectiveFulfillment === 'delivery' ? deliverySlot?.driveMinutes : undefined, + deliveryAddress: effectiveFulfillment === 'delivery' ? (fullAddress || undefined) : undefined, + deliveryTier: effectiveFulfillment === 'delivery' ? quote?.tier : undefined, + deliveryNotes: effectiveFulfillment === 'delivery' && deliveryInstructions ? deliveryInstructions : undefined, + deliveryCents: effectiveFulfillment === 'delivery' ? (quote?.totalCents ?? 0) : undefined, + pickupSlotISO: effectiveFulfillment === 'pickup' ? pickupSlot?.slotISO : undefined, customerFirstName: custFirst, customerLastName: custLast, customerEmail: custEmail, @@ -192,7 +212,7 @@ export default function CartDrawer() { grandTotal, idempotencyKey: checkoutKey || undefined, // eslint-disable-next-line react-hooks/exhaustive-deps - }), [entries, fulfillmentType, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice]) + }), [entries, effectiveFulfillment, deliverySlot, pickupSlot, fullAddress, quote, deliveryInstructions, custFirst, custLast, custEmail, custPhone, grandTotal, entryUnitPrice]) const handleSuccess = (id: string, ref: string) => { setOrderId(id) @@ -224,7 +244,11 @@ export default function CartDrawer() { const res = await fetch(BASE + '/api/delivery-quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address: fullAddress, itemNames: entries.map((e) => e.product.name) }), + body: JSON.stringify({ + address: fullAddress, + itemNames: entries.map((e) => e.product.name), + rateOverride: deliveryRateOverride, + }), }) const data = await res.json() if (!res.ok) { setQuoteErr(data.error ?? 'Could not calculate delivery.'); return } @@ -313,31 +337,37 @@ export default function CartDrawer() { )} {/* Fulfillment toggle */} -
- {(['delivery', 'pickup'] as const).map((type) => ( - - ))} -
+ {cartRequiresDelivery ? ( +

+ 🚗 One or more items require delivery & setup — pickup is not available for this order. +

+ ) : ( +
+ {(['delivery', 'pickup'] as const).map((type) => ( + + ))} +
+ )} ) @@ -491,14 +521,14 @@ export default function CartDrawer() { const deliveryFooter = ( <> - {fulfillmentType === 'delivery' && ( + {effectiveFulfillment === 'delivery' && (

Delivery fee is based on driving distance from our shop.

)}