Compare commits
2 Commits
107ef43a0e
...
27093bcd54
| Author | SHA1 | Date | |
|---|---|---|---|
| 27093bcd54 | |||
| 0ea1b98a1f |
@ -728,10 +728,12 @@ 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 [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
|
const [featured, setFeatured] = useState(ov.featured ?? item.featured ?? false)
|
||||||
const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '')
|
// Multi-category selection: stores category names (labels). Initialise from new override or fall back to Square assignment.
|
||||||
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '')
|
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
|
||||||
@ -762,6 +764,13 @@ function ItemEditor({
|
|||||||
const [disabledColors, setDisabledColors] = useState<string[]>(ov.disabledColors ?? [])
|
const [disabledColors, setDisabledColors] = useState<string[]>(ov.disabledColors ?? [])
|
||||||
const [showColorFilter, setShowColorFilter] = useState(false)
|
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('')
|
||||||
@ -789,8 +798,8 @@ function ItemEditor({
|
|||||||
hiddenVariationIds: hiddenVars,
|
hiddenVariationIds: hiddenVars,
|
||||||
hiddenModifierIds: hiddenMods,
|
hiddenModifierIds: hiddenMods,
|
||||||
}
|
}
|
||||||
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
|
||||||
@ -801,6 +810,9 @@ function ItemEditor({
|
|||||||
patch.disabledColors = disabledColors.length ? disabledColors : undefined
|
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(`${BASE}/api/admin/items/${item.id}`, {
|
const res = await fetch(`${BASE}/api/admin/items/${item.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@ -823,8 +835,7 @@ function ItemEditor({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setHidden(false)
|
setHidden(false)
|
||||||
setFeatured(item.featured ?? false)
|
setFeatured(item.featured ?? false)
|
||||||
setCatOverride('')
|
setSelectedCatNames(item.categoryLabels ?? [item.categoryLabel])
|
||||||
setCatLabel('')
|
|
||||||
setSortOrder('')
|
setSortOrder('')
|
||||||
setShowColors(null)
|
setShowColors(null)
|
||||||
setHiddenMods([])
|
setHiddenMods([])
|
||||||
@ -833,6 +844,9 @@ function ItemEditor({
|
|||||||
setColorMin('')
|
setColorMin('')
|
||||||
setColorMax('')
|
setColorMax('')
|
||||||
setChromeSurcharge('')
|
setChromeSurcharge('')
|
||||||
|
setRequiresDelivery(false)
|
||||||
|
setDeliveryBase('')
|
||||||
|
setDeliveryPerMile('')
|
||||||
onSaved(item.id, {})
|
onSaved(item.id, {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -855,15 +869,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">
|
||||||
@ -891,37 +903,86 @@ function ItemEditor({
|
|||||||
/>
|
/>
|
||||||
⭐ Featured
|
⭐ Featured
|
||||||
</label>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 }}>
|
||||||
|
|||||||
@ -13,20 +13,38 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
|
|||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
featured: ov.featured ?? item.featured,
|
featured: ov.featured ?? item.featured,
|
||||||
category: ov.categoryOverride ?? item.category,
|
// categoriesOverride (array of names) takes precedence over the old single-field overrides
|
||||||
categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel,
|
...(ov.categoriesOverride?.length
|
||||||
categories: ov.categoryOverride
|
? (() => {
|
||||||
? [ov.categoryOverride, ...(item.categories ?? [item.category]).slice(1)]
|
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
: (item.categories ?? [item.category]),
|
const cats = ov.categoriesOverride!.map(toSlug)
|
||||||
categoryLabels: ov.categoryLabelOverride
|
return {
|
||||||
? [ov.categoryLabelOverride, ...(item.categoryLabels ?? [item.categoryLabel]).slice(1)]
|
categories: cats,
|
||||||
: (item.categoryLabels ?? [item.categoryLabel]),
|
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,
|
||||||
disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors,
|
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,
|
description: ov.descriptionOverride ?? item.description,
|
||||||
variations: item.variations
|
variations: item.variations
|
||||||
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
|
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { geocode, calcDelivery, inferTier } from '@/lib/delivery'
|
|||||||
import { readDeliveryRates } from '@/lib/delivery-rates'
|
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()) {
|
||||||
@ -19,6 +20,16 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const tier = inferTier(itemNames ?? [])
|
const tier = inferTier(itemNames ?? [])
|
||||||
const rates = readDeliveryRates()
|
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)
|
const quote = await calcDelivery(coords.lat, coords.lng, tier, rates)
|
||||||
|
|
||||||
if (quote.miles > 40) {
|
if (quote.miles > 40) {
|
||||||
|
|||||||
@ -67,6 +67,26 @@ export default function CartDrawer() {
|
|||||||
const [shortRef, setShortRef] = useState<string | null>(null)
|
const [shortRef, setShortRef] = useState<string | null>(null)
|
||||||
const [fulfillmentType, setFulfillmentType] = useState<'delivery' | 'pickup'>('pickup')
|
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
|
// Delivery step — persisted
|
||||||
const [street, setStreet] = useStoredString('bpb_street', '')
|
const [street, setStreet] = useStoredString('bpb_street', '')
|
||||||
const [city, setCity] = useStoredString('bpb_city', '')
|
const [city, setCity] = useStoredString('bpb_city', '')
|
||||||
@ -155,7 +175,7 @@ export default function CartDrawer() {
|
|||||||
|
|
||||||
const CT_TAX_RATE = 0.0635
|
const CT_TAX_RATE = 0.0635
|
||||||
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
|
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 taxCents = Math.round(subtotal * CT_TAX_RATE)
|
||||||
const grandTotal = subtotal + deliveryTotal + taxCents
|
const grandTotal = subtotal + deliveryTotal + taxCents
|
||||||
|
|
||||||
@ -178,13 +198,13 @@ export default function CartDrawer() {
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
selectedColors: entries.flatMap((e) => e.selectedColors),
|
selectedColors: entries.flatMap((e) => e.selectedColors),
|
||||||
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
|
deliverySlotISO: effectiveFulfillment === 'delivery' ? deliverySlot?.slotISO : undefined,
|
||||||
driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined,
|
driveMinutes: effectiveFulfillment === 'delivery' ? deliverySlot?.driveMinutes : undefined,
|
||||||
deliveryAddress: fulfillmentType === 'delivery' ? (fullAddress || undefined) : undefined,
|
deliveryAddress: effectiveFulfillment === 'delivery' ? (fullAddress || undefined) : undefined,
|
||||||
deliveryTier: fulfillmentType === 'delivery' ? quote?.tier : undefined,
|
deliveryTier: effectiveFulfillment === 'delivery' ? quote?.tier : undefined,
|
||||||
deliveryNotes: fulfillmentType === 'delivery' && deliveryInstructions ? deliveryInstructions : undefined,
|
deliveryNotes: effectiveFulfillment === 'delivery' && deliveryInstructions ? deliveryInstructions : undefined,
|
||||||
deliveryCents: fulfillmentType === 'delivery' ? (quote?.totalCents ?? 0) : undefined,
|
deliveryCents: effectiveFulfillment === 'delivery' ? (quote?.totalCents ?? 0) : undefined,
|
||||||
pickupSlotISO: fulfillmentType === 'pickup' ? pickupSlot?.slotISO : undefined,
|
pickupSlotISO: effectiveFulfillment === 'pickup' ? pickupSlot?.slotISO : undefined,
|
||||||
customerFirstName: custFirst,
|
customerFirstName: custFirst,
|
||||||
customerLastName: custLast,
|
customerLastName: custLast,
|
||||||
customerEmail: custEmail,
|
customerEmail: custEmail,
|
||||||
@ -192,7 +212,7 @@ export default function CartDrawer() {
|
|||||||
grandTotal,
|
grandTotal,
|
||||||
idempotencyKey: checkoutKey || undefined,
|
idempotencyKey: checkoutKey || undefined,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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) => {
|
const handleSuccess = (id: string, ref: string) => {
|
||||||
setOrderId(id)
|
setOrderId(id)
|
||||||
@ -224,7 +244,11 @@ export default function CartDrawer() {
|
|||||||
const res = await fetch(BASE + '/api/delivery-quote', {
|
const res = await fetch(BASE + '/api/delivery-quote', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
const data = await res.json()
|
||||||
if (!res.ok) { setQuoteErr(data.error ?? 'Could not calculate delivery.'); return }
|
if (!res.ok) { setQuoteErr(data.error ?? 'Could not calculate delivery.'); return }
|
||||||
@ -313,31 +337,37 @@ export default function CartDrawer() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fulfillment toggle */}
|
{/* Fulfillment toggle */}
|
||||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem' }}>
|
{cartRequiresDelivery ? (
|
||||||
{(['delivery', 'pickup'] as const).map((type) => (
|
<p style={{ fontSize: '0.8rem', color: '#555', marginBottom: '0.75rem', background: '#f5f5f5', padding: '7px 10px', borderRadius: 6 }}>
|
||||||
<button
|
🚗 One or more items require delivery & setup — pickup is not available for this order.
|
||||||
key={type}
|
</p>
|
||||||
type="button"
|
) : (
|
||||||
onClick={() => { setFulfillmentType(type); setPickupSlot(null); setPickupDate('') }}
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem' }}>
|
||||||
style={{
|
{(['delivery', 'pickup'] as const).map((type) => (
|
||||||
flex: 1, padding: '7px 4px', fontSize: '0.82rem',
|
<button
|
||||||
borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit',
|
key={type}
|
||||||
border: `1px solid ${fulfillmentType === type ? '#11b3be' : '#d0d0d0'}`,
|
type="button"
|
||||||
background: fulfillmentType === type ? '#11b3be' : '#fff',
|
onClick={() => { setFulfillmentType(type); setPickupSlot(null); setPickupDate('') }}
|
||||||
color: fulfillmentType === type ? '#fff' : '#555',
|
style={{
|
||||||
fontWeight: fulfillmentType === type ? 'bold' : 'normal',
|
flex: 1, padding: '7px 4px', fontSize: '0.82rem',
|
||||||
}}
|
borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
>
|
border: `1px solid ${effectiveFulfillment === type ? '#11b3be' : '#d0d0d0'}`,
|
||||||
{type === 'delivery' ? '🚗 Delivery' : '🏪 Pick Up'}
|
background: effectiveFulfillment === type ? '#11b3be' : '#fff',
|
||||||
</button>
|
color: effectiveFulfillment === type ? '#fff' : '#555',
|
||||||
))}
|
fontWeight: effectiveFulfillment === type ? 'bold' : 'normal',
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
{type === 'delivery' ? '🚗 Delivery' : '🏪 Pick Up'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="button is-info is-fullwidth"
|
className="button is-info is-fullwidth"
|
||||||
onClick={() => setStep('delivery')}
|
onClick={() => setStep('delivery')}
|
||||||
>
|
>
|
||||||
{fulfillmentType === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
|
{effectiveFulfillment === 'pickup' ? 'Choose Pickup Time →' : 'Continue to Delivery →'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -491,14 +521,14 @@ export default function CartDrawer() {
|
|||||||
|
|
||||||
const deliveryFooter = (
|
const deliveryFooter = (
|
||||||
<>
|
<>
|
||||||
{fulfillmentType === 'delivery' && (
|
{effectiveFulfillment === 'delivery' && (
|
||||||
<p style={{ fontSize: '0.72rem', color: '#999', marginBottom: '0.5rem' }}>
|
<p style={{ fontSize: '0.72rem', color: '#999', marginBottom: '0.5rem' }}>
|
||||||
Delivery fee is based on driving distance from our shop.
|
Delivery fee is based on driving distance from our shop.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="button is-info is-fullwidth"
|
className="button is-info is-fullwidth"
|
||||||
disabled={fulfillmentType === 'delivery' ? (!quote || !deliverySlot) : !pickupSlot}
|
disabled={effectiveFulfillment === 'delivery' ? (!quote || !deliverySlot) : !pickupSlot}
|
||||||
onClick={() => setStep('info')}
|
onClick={() => setStep('info')}
|
||||||
>
|
>
|
||||||
Continue to Your Info →
|
Continue to Your Info →
|
||||||
@ -583,7 +613,7 @@ export default function CartDrawer() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||||
<span>Items</span><span>{fmt(subtotal)}</span>
|
<span>Items</span><span>{fmt(subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
{fulfillmentType === 'delivery' && quote && (
|
{effectiveFulfillment === 'delivery' && quote && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||||
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
|
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -594,12 +624,12 @@ export default function CartDrawer() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
|
||||||
<span>Estimated total</span><span>{fmt(grandTotal)}</span>
|
<span>Estimated total</span><span>{fmt(grandTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
{fulfillmentType === 'delivery' && deliverySlot && (
|
{effectiveFulfillment === 'delivery' && deliverySlot && (
|
||||||
<p style={{ color: '#555', marginTop: '6px' }}>
|
<p style={{ color: '#555', marginTop: '6px' }}>
|
||||||
Delivery: {deliverySlot.date} at {deliverySlot.label}
|
Delivery: {deliverySlot.date} at {deliverySlot.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{fulfillmentType === 'pickup' && pickupSlot && (
|
{effectiveFulfillment === 'pickup' && pickupSlot && (
|
||||||
<p style={{ color: '#555', marginTop: '6px' }}>
|
<p style={{ color: '#555', marginTop: '6px' }}>
|
||||||
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
||||||
</p>
|
</p>
|
||||||
@ -656,7 +686,7 @@ export default function CartDrawer() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||||
<span>Items</span><span>{fmt(subtotal)}</span>
|
<span>Items</span><span>{fmt(subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
{fulfillmentType === 'delivery' && quote && (
|
{effectiveFulfillment === 'delivery' && quote && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||||
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
|
<span>Delivery</span><span>{fmt(quote.totalCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -667,12 +697,12 @@ export default function CartDrawer() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', borderTop: '1px solid #ddd', paddingTop: '4px', marginTop: '4px' }}>
|
||||||
<span>Total</span><span>{fmt(grandTotal)}</span>
|
<span>Total</span><span>{fmt(grandTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
{fulfillmentType === 'delivery' && deliverySlot && (
|
{effectiveFulfillment === 'delivery' && deliverySlot && (
|
||||||
<p style={{ color: '#555', marginTop: '6px' }}>
|
<p style={{ color: '#555', marginTop: '6px' }}>
|
||||||
Delivery: {deliverySlot.date} at {deliverySlot.label}
|
Delivery: {deliverySlot.date} at {deliverySlot.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{fulfillmentType === 'pickup' && pickupSlot && (
|
{effectiveFulfillment === 'pickup' && pickupSlot && (
|
||||||
<p style={{ color: '#555', marginTop: '6px' }}>
|
<p style={{ color: '#555', marginTop: '6px' }}>
|
||||||
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
||||||
</p>
|
</p>
|
||||||
@ -684,7 +714,7 @@ export default function CartDrawer() {
|
|||||||
|
|
||||||
const bodyContent: Record<Step, React.ReactNode> = {
|
const bodyContent: Record<Step, React.ReactNode> = {
|
||||||
cart: cartBody,
|
cart: cartBody,
|
||||||
delivery: fulfillmentType === 'pickup' ? pickupBody : deliveryBody,
|
delivery: effectiveFulfillment === 'pickup' ? pickupBody : deliveryBody,
|
||||||
info: infoBody,
|
info: infoBody,
|
||||||
payment: paymentSummary, // PaymentForm rendered separately below, always mounted
|
payment: paymentSummary, // PaymentForm rendered separately below, always mounted
|
||||||
}
|
}
|
||||||
@ -756,7 +786,7 @@ export default function CartDrawer() {
|
|||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{step === 'delivery' && fulfillmentType === 'pickup' ? 'Pickup Time' : STEP_TITLES[step]}
|
{step === 'delivery' && effectiveFulfillment === 'pickup' ? 'Pickup Time' : STEP_TITLES[step]}
|
||||||
{step === 'cart' && totalItems > 0 && ` (${totalItems})`}
|
{step === 'cart' && totalItems > 0 && ` (${totalItems})`}
|
||||||
</strong>
|
</strong>
|
||||||
{/* Step indicator dots */}
|
{/* Step indicator dots */}
|
||||||
@ -793,17 +823,17 @@ export default function CartDrawer() {
|
|||||||
</p>
|
</p>
|
||||||
<p style={{ color: '#555', fontSize: '0.88rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
<p style={{ color: '#555', fontSize: '0.88rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||||
Order <strong>#{shortRef}</strong> confirmed.{' '}
|
Order <strong>#{shortRef}</strong> confirmed.{' '}
|
||||||
{fulfillmentType === 'pickup'
|
{effectiveFulfillment === 'pickup'
|
||||||
? <>Your pickup is all set — see you at the shop! A confirmation will be sent to <strong>{custEmail}</strong>.</>
|
? <>Your pickup is all set — see you at the shop! A confirmation will be sent to <strong>{custEmail}</strong>.</>
|
||||||
: <>We’ll reach out to <strong>{custEmail}</strong> to confirm final delivery details.</>
|
: <>We’ll reach out to <strong>{custEmail}</strong> to confirm final delivery details.</>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
{fulfillmentType === 'delivery' && deliverySlot && (
|
{effectiveFulfillment === 'delivery' && deliverySlot && (
|
||||||
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
|
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
|
||||||
Delivery: {deliverySlot.date} at {deliverySlot.label}
|
Delivery: {deliverySlot.date} at {deliverySlot.label}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{fulfillmentType === 'pickup' && pickupSlot && (
|
{effectiveFulfillment === 'pickup' && pickupSlot && (
|
||||||
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
|
<p style={{ color: '#0d6e75', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
|
||||||
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
Pickup: {pickupSlot.date} at {pickupSlot.label}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -89,6 +89,11 @@ export default function ProductCard({ item }: Props) {
|
|||||||
Only {stock} left
|
Only {stock} left
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{item.requiresDelivery && (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: '#555', fontWeight: 600, marginBottom: '0.35rem' }}>
|
||||||
|
🚗 Delivery & setup required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="is-size-7">{item.description}</p>
|
<p className="is-size-7">{item.description}</p>
|
||||||
|
|
||||||
{item.tags.length > 0 && (
|
{item.tags.length > 0 && (
|
||||||
|
|||||||
@ -46,6 +46,12 @@ export interface CatalogItem {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MOCK_CATALOG: CatalogItem[] = (([
|
export const MOCK_CATALOG: CatalogItem[] = (([
|
||||||
|
|||||||
@ -24,6 +24,14 @@ export interface ItemOverride {
|
|||||||
disabledColors?: string[]
|
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
|
||||||
|
/** Override the full list of display categories (stores category NAMES/labels). Replaces categoryOverride + categoryLabelOverride. */
|
||||||
|
categoriesOverride?: string[] | null
|
||||||
|
/** 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
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OverridesMap = Record<string, ItemOverride>
|
export type OverridesMap = Record<string, ItemOverride>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user