perf+fix: lazy images, API caching, iOS scroll lock, color name wrapping

Performance:
- Add loading="lazy" decoding="async" to product card images
- Preconnect to Square S3 image CDN and fonts.googleapis.com in layout
- Cache-Control headers on catalog (20s), inventory (10s), occasions/categories (5min)

Scroll lock:
- Update useLockBodyScroll to use position:fixed + scroll-restore for iOS Safari
- Apply same fix to CartDrawer's inline scroll lock

Color names:
- Remove word-break:break-word so single words never split across lines;
  multi-word names still wrap at spaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-05 10:13:50 -04:00
parent 0d95cf93b3
commit ec748c75a9
9 changed files with 55 additions and 11 deletions

View File

@ -70,11 +70,15 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
})
}
const CACHE = 'public, max-age=20, stale-while-revalidate=40'
export async function GET() {
try {
const { items: rawItems } = await getCatalog()
const items = applyOverrides(rawItems)
return NextResponse.json({ items, source: 'square' })
return NextResponse.json({ items, source: 'square' }, {
headers: { 'Cache-Control': CACHE },
})
} catch (err) {
console.error('[catalog] error:', err)
return NextResponse.json({ items: [], source: 'error' }, { status: 500 })

View File

@ -2,5 +2,7 @@ import { NextResponse } from 'next/server'
import { getCategoryDisplayConfig } from '@/lib/categories-display'
export function GET() {
return NextResponse.json(getCategoryDisplayConfig())
return NextResponse.json(getCategoryDisplayConfig(), {
headers: { 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600' },
})
}

View File

@ -21,7 +21,9 @@ export async function GET() {
const counts: Record<string, number> = {}
countsMap.forEach((qty, id) => { counts[id] = qty })
return NextResponse.json({ counts })
return NextResponse.json({ counts }, {
headers: { 'Cache-Control': 'public, max-age=10, stale-while-revalidate=20' },
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
console.error('[inventory] error:', msg)

View File

@ -8,5 +8,7 @@ export async function GET() {
const payload = active.map(({ key, label, emoji, blurb, squareCategorySlug }) => ({
key, label, emoji, blurb, squareCategorySlug,
}))
return NextResponse.json({ occasions: payload })
return NextResponse.json({ occasions: payload }, {
headers: { 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600' },
})
}

View File

@ -191,7 +191,8 @@
color: #334854;
width: 100%;
white-space: normal;
word-break: break-word;
overflow-wrap: normal;
word-break: normal;
}
.color-family-heading {

View File

@ -24,7 +24,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en">
<head>
{/* Preconnect to Square product image CDN */}
<link rel="preconnect" href="https://items-images-production.s3.us-west-2.amazonaws.com" crossOrigin="anonymous" />
{/* Google Fonts — same as beachpartyballoons.com */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap"

View File

@ -56,9 +56,22 @@ export default function CartDrawer() {
useEffect(() => {
if (!drawerOpen) return
const prev = document.body.style.overflow
const scrollY = window.scrollY
const prevOverflow = document.body.style.overflow
const prevPosition = document.body.style.position
const prevTop = document.body.style.top
const prevWidth = document.body.style.width
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollY}px`
document.body.style.width = '100%'
return () => {
document.body.style.overflow = prevOverflow
document.body.style.position = prevPosition
document.body.style.top = prevTop
document.body.style.width = prevWidth
window.scrollTo(0, scrollY)
}
}, [drawerOpen])
const [editingEntry, setEditingEntry] = useState<CartEntry | null>(null)

View File

@ -51,7 +51,7 @@ export default function ProductCard({ item }: Props) {
<div className="card-image" style={{ position: 'relative' }}>
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imageUrl} alt={item.name} />
<img src={item.imageUrl} alt={item.name} loading="lazy" decoding="async" />
) : (
<div className="no-image">🎈</div>
)}

View File

@ -1,10 +1,27 @@
import { useEffect } from 'react'
/** Locks body scroll while the calling component is mounted. */
/** Locks body scroll while the calling component is mounted.
* Uses position:fixed + scroll-position restore so iOS Safari
* rubber-band scrolling is also prevented. */
export function useLockBodyScroll() {
useEffect(() => {
const prev = document.body.style.overflow
const scrollY = window.scrollY
const prevOverflow = document.body.style.overflow
const prevPosition = document.body.style.position
const prevTop = document.body.style.top
const prevWidth = document.body.style.width
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollY}px`
document.body.style.width = '100%'
return () => {
document.body.style.overflow = prevOverflow
document.body.style.position = prevPosition
document.body.style.top = prevTop
document.body.style.width = prevWidth
window.scrollTo(0, scrollY)
}
}, [])
}