fix/feat: hex conflict, scroll-to-top, search all, admin error emails

- Fix Chrome Rose Gold hex (#B76E79 → #C17F87) so it no longer
  conflicts with Classic Rose Gold; image still used for display
- ScrollToTop hides when cart drawer is open and uses z-index 98
  (below the drawer); uses drawerOpen from CartContext
- Search now switches to All tab automatically so results span every
  item, not just the active category
- Add sendAdminErrorAlert() to notify.ts; checkout route emails
  admin@beachpartyballoons.com on unexpected server errors and on
  critical calendar-write failures; card decline errors are not
  forwarded (customers can self-resolve those)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-04-17 14:19:29 -04:00
parent 01c908e919
commit 6705293e50
5 changed files with 50 additions and 6 deletions

View File

@ -331,6 +331,16 @@ export async function POST(req: NextRequest) {
console.error('[checkout] CRITICAL: calendar write failed — voiding pre-auth to avoid charge without booking:', { console.error('[checkout] CRITICAL: calendar write failed — voiding pre-auth to avoid charge without booking:', {
orderId: order.id, paymentId: payment.id, error: calendarWriteError, orderId: order.id, paymentId: payment.id, error: calendarWriteError,
}) })
void (async () => {
try {
const { sendAdminErrorAlert } = await import('@/lib/notify')
await sendAdminErrorAlert({
subject: 'Calendar write failed — order not booked',
message: `Calendar write failed for order ${order.id}. Pre-auth ${payment.id} is being voided. Customer: ${customerName} (${customerEmail}).`,
context: { orderId: order.id, paymentId: payment.id, error: String(calendarWriteError) },
})
} catch { /* best effort */ }
})()
try { try {
await cancelSquarePayment(payment.id!) await cancelSquarePayment(payment.id!)
console.log('[checkout] Pre-auth voided successfully:', payment.id) console.log('[checkout] Pre-auth voided successfully:', payment.id)
@ -447,6 +457,20 @@ export async function POST(req: NextRequest) {
const userMessage = CARD_MESSAGES[code] const userMessage = CARD_MESSAGES[code]
?? 'Something went wrong with your payment. Please try again or contact us for help.' ?? 'Something went wrong with your payment. Please try again or contact us for help.'
// Email admin for unexpected server errors (not card declines the customer can self-resolve)
if (!CARD_MESSAGES[code]) {
void (async () => {
try {
const { sendAdminErrorAlert } = await import('@/lib/notify')
await sendAdminErrorAlert({
subject: 'Checkout error',
message: err instanceof Error ? err.message : String(err),
context: { code: code || '(none)', customerEmail, customerName },
})
} catch { /* best effort */ }
})()
}
return NextResponse.json({ error: userMessage }, { status: 500 }) return NextResponse.json({ error: userMessage }, { status: 500 })
} }
} }

View File

@ -189,7 +189,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' }}

View File

@ -1,20 +1,22 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useCart } from '@/context/CartContext'
export default function ScrollToTop() { export default function ScrollToTop() {
const [visible, setVisible] = useState(false) const { drawerOpen } = useCart()
const [scrolled, setScrolled] = useState(false)
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0 const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
setVisible(scrollTop > 130) setScrolled(scrollTop > 130)
} }
window.addEventListener('scroll', onScroll, { passive: true }) window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll) return () => window.removeEventListener('scroll', onScroll)
}, []) }, [])
if (!visible) return null if (!scrolled || drawerOpen) return null
return ( return (
<button <button
@ -24,7 +26,7 @@ export default function ScrollToTop() {
position: 'fixed', position: 'fixed',
bottom: '12px', bottom: '12px',
right: '10px', right: '10px',
zIndex: 99, zIndex: 98,
border: '1px solid #363636', border: '1px solid #363636',
outline: 'none', outline: 'none',
background: '#94d601', background: '#94d601',

View File

@ -368,3 +368,21 @@ export async function sendNewOrderAlert(params: {
text: lines.join('\n'), text: lines.join('\n'),
}) })
} }
export async function sendAdminErrorAlert(params: {
subject: string
message: string
context?: Record<string, unknown>
}): Promise<void> {
const to = 'admin@beachpartyballoons.com'
const lines = [
params.message,
'',
...(params.context
? Object.entries(params.context).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
: []),
'',
`Time: ${new Date().toISOString()}`,
]
await send({ to, subject: `⚠️ ${params.subject}`, text: lines.join('\n') })
}

View File

@ -181,7 +181,7 @@
"colors": [ "colors": [
{ {
"name": "Chrome Rose Gold", "name": "Chrome Rose Gold",
"hex": "#B76E79", "hex": "#C17F87",
"metallic": true, "metallic": true,
"chromeType": "rosegold", "chromeType": "rosegold",
"image": "images/chrome-rosegold.webp" "image": "images/chrome-rosegold.webp"