Compare commits

..

6 Commits

Author SHA1 Message Date
37ab070a8c Improve main-site image alt text, add homepage shop link
Descriptive alt text for accessibility/SEO, and a visible "Shop Now"
button in the hero so mobile visitors can find the shop without
opening the nav menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:27:52 -04:00
5b9524e1d9 Add scheduled Square catalog refresh via Docker sidecar
Catalog data only refreshed on first boot, admin button click, or
checkout retry — nothing kept it fresh on a schedule despite the
cache-refresh endpoint being built for cron use. A Next instrumentation
hook was tried first but broke the build (middleware.ts forces
instrumentation.ts into the same edge bundle, which can't resolve
fs/path/crypto). Using a lightweight cron sidecar in the compose stack
instead, so it ships with the deployment regardless of host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:27:49 -04:00
219256be55 Fix guided tour tooltip going off-screen on mobile
Body scroll is locked for the tour's duration, so scrollIntoView on the
step-2 target had nothing to scroll and its position was measured way
off-screen. Scroll the frozen body's offset directly instead, and clamp
tooltip vertical position to the viewport as a safety net.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:27:44 -04:00
f6960fd3ec Fix cart button overlapping scroll-to-top button, make it more compact
Both were fixed bottom-right with no awareness of each other. Scroll-to-top
now lifts above the cart pill when the cart has items; the pill itself
drops the redundant "View order" text and shrinks padding/font.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:27:40 -04:00
517e00aeb1 Fix admin auth gap from basePath stripping, add password reveal toggle
Middleware compared request.nextUrl.pathname (basePath already stripped
by Next.js) against paths still prefixed with /shop, so /shop/admin
pages loaded without server-side auth protection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:27:37 -04:00
ec2ebca836 Add Open Graph/Twitter metadata and improve shop page SEO copy
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:27:32 -04:00
11 changed files with 100 additions and 27 deletions

View File

@ -98,6 +98,27 @@ services:
timeout: 10s
retries: 3
# ── Scheduled Square catalog refresh ─────────────────────────────────────────
# Hits estore's cache-refresh endpoint on a timer so catalog/pricing edits made
# in Square show up without a redeploy or manual admin-panel refresh. Runs as
# part of the stack itself, so it works on any host this compose file is deployed to.
estore-cache-refresh:
image: alpine:3.20
container_name: bpb-estore-cache-refresh
env_file: ./estore/.env
depends_on:
estore:
condition: service_healthy
entrypoint: >
sh -c "
apk add --no-cache curl >/dev/null 2>&1;
while true; do
curl -sf -X POST http://estore:3000/shop/api/cache/refresh -H \"Authorization: Bearer $$CACHE_REFRESH_SECRET\" || echo 'cache refresh failed';
sleep 21600;
done
"
restart: unless-stopped
# ── OSRM download (runs once, exits) ─────────────────────────────────────────
# Downloads the PBF map file into the shared volume if not already present.
osrm-download:

View File

@ -10,6 +10,7 @@ export default function AdminLoginPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@ -34,16 +35,30 @@ export default function AdminLoginPage() {
<h1 className="title is-4" style={{ marginBottom: '1.5rem' }}>Admin Login</h1>
<form onSubmit={handleSubmit}>
<div className="field">
<div className="control">
<div className="control has-icons-right">
<input
className="input"
type="password"
type={showPassword ? 'text' : 'password'}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
required
/>
<button
type="button"
className="icon is-right"
style={{ pointerEvents: 'auto', cursor: 'pointer', background: 'none', border: 'none' }}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z"/><circle cx="12" cy="12" r="3"/></svg>
)}
</button>
</div>
</div>
{error && <p className="help is-danger">{error}</p>}

View File

@ -8,12 +8,35 @@ import { CartProvider } from '@/context/CartContext'
import ScrollToTop from '@/components/ScrollToTop'
export const metadata: Metadata = {
metadataBase: new URL('https://beachpartyballoons.com'),
title: {
default: 'Shop | Beach Party Balloons',
template: '%s | Beach Party Balloons',
},
description:
"Milford CT's premier balloon studio. Custom organic arrangements, arches, centerpieces, and event installations. 4.9★ Google rating. Walk-ins welcome.",
openGraph: {
title: 'Shop | Beach Party Balloons',
description: "Milford CT's premier balloon studio. Custom organic arrangements, arches, centerpieces, and event installations.",
url: 'https://beachpartyballoons.com/shop',
siteName: 'Beach Party Balloons',
images: [
{
url: 'https://beachpartyballoons.com/assets/pics/classic/hero.webp',
width: 1200,
height: 630,
alt: 'Beach Party Balloons',
},
],
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Shop | Beach Party Balloons',
description: "Milford CT's premier balloon studio. Custom organic arrangements, arches, centerpieces, and event installations.",
images: ['https://beachpartyballoons.com/assets/pics/classic/hero.webp'],
},
icons: {
icon: '/favicon/favicon.ico',
apple: '/favicon/apple-touch-icon.png',

View File

@ -2,9 +2,9 @@ import type { Metadata } from 'next'
import FeaturedProducts from '@/components/FeaturedProducts'
export const metadata: Metadata = {
title: 'Shop',
title: 'Balloon Shop & Delivery in Milford CT',
description:
'Browse all balloon arrangements, arches, centerpieces, and installations by Beach Party Balloons.',
'Browse all balloon arrangements, arches, centerpieces, and installations by Beach Party Balloons. Order online for delivery or pickup in Milford, CT.',
}
export default function ShopPage() {

View File

@ -20,13 +20,13 @@ export default function CartFab() {
color: '#fff',
border: 'none',
borderRadius: '999px',
padding: '0.75rem 1.25rem',
padding: '0.55rem 1rem',
display: 'flex',
alignItems: 'center',
gap: '0.6rem',
gap: '0.45rem',
boxShadow: '0 6px 20px rgba(0,0,0,0.22)',
cursor: 'pointer',
fontSize: '0.95rem',
fontSize: '0.85rem',
fontWeight: 'bold',
fontFamily: 'inherit',
transition: 'transform 0.15s, box-shadow 0.15s',
@ -42,7 +42,6 @@ export default function CartFab() {
>
<i className="fa-solid fa-bag-shopping" />
<span>{totalItems} {totalItems === 1 ? 'item' : 'items'}</span>
<span style={{ opacity: 0.75, fontWeight: 'normal', fontSize: '0.8rem' }}>· View order</span>
</button>
)
}

View File

@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLockBodyScroll } from '@/lib/useLockBodyScroll'
import { useLockBodyScroll, scrollLockedBodyTo } from '@/lib/useLockBodyScroll'
interface TourStep {
target: string | null // CSS selector, or null = centered modal
@ -56,6 +56,7 @@ const STEPS: TourStep[] = [
const PAD = 10 // px padding around spotlight
const TIP_WIDTH = 300 // tooltip width in px
const TIP_MAX_HEIGHT = 260 // conservative estimate, used to keep the tooltip on-screen
interface Props {
onDone: () => void
@ -103,7 +104,11 @@ export default function GuidedTour({ onDone, onStart }: Props) {
const elRect = el.getBoundingClientRect()
scrollParent.scrollTop = elRect.top - parentRect.top + scrollParent.scrollTop - 20
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Page scroll is locked (useLockBodyScroll), so scrollIntoView has nothing
// to scroll — shift the frozen body's offset instead so the target actually moves into view.
const lockedY = Math.abs(parseFloat(document.body.style.top || '0')) || 0
const elRect = el.getBoundingClientRect()
scrollLockedBodyTo(lockedY + elRect.top - 100)
}
// Measure after layout settles (double-rAF ensures paint is done)
requestAnimationFrame(() => {
@ -179,7 +184,7 @@ export default function GuidedTour({ onDone, onStart }: Props) {
)
tooltipStyle = {
position: 'fixed',
top: spot.top + spot.height + 12,
top: Math.max(8, Math.min(spot.top + spot.height + 12, vh - TIP_MAX_HEIGHT - 8)),
left,
width: Math.min(TIP_WIDTH, vw - 16),
zIndex: 10002,
@ -191,7 +196,7 @@ export default function GuidedTour({ onDone, onStart }: Props) {
)
tooltipStyle = {
position: 'fixed',
bottom: vh - spot.top + 12,
bottom: Math.max(8, Math.min(vh - spot.top + 12, vh - TIP_MAX_HEIGHT - 8)),
left,
width: Math.min(TIP_WIDTH, vw - 16),
zIndex: 10002,
@ -200,7 +205,7 @@ export default function GuidedTour({ onDone, onStart }: Props) {
// right
tooltipStyle = {
position: 'fixed',
top: Math.max(8, spot.top + spot.height / 2 - 80),
top: Math.max(8, Math.min(spot.top + spot.height / 2 - 80, vh - TIP_MAX_HEIGHT - 8)),
left: Math.min(spot.left + spot.width + 12, vw - TIP_WIDTH - 8),
width: Math.min(TIP_WIDTH, vw - 16),
zIndex: 10002,

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { useCart } from '@/context/CartContext'
export default function ScrollToTop() {
const { drawerOpen } = useCart()
const { drawerOpen, totalItems } = useCart()
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
@ -24,9 +24,10 @@ export default function ScrollToTop() {
onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })}
style={{
position: 'fixed',
bottom: '12px',
bottom: totalItems > 0 ? '76px' : '12px',
right: '10px',
zIndex: 98,
transition: 'bottom 0.15s',
border: '1px solid #363636',
outline: 'none',
background: '#94d601',

View File

@ -25,3 +25,9 @@ export function useLockBodyScroll() {
}
}, [])
}
/** While the page is frozen by useLockBodyScroll, there's no real scroll to move
* "scrolling" means shifting the fixed body's negative `top` offset instead. */
export function scrollLockedBodyTo(y: number) {
document.body.style.top = `-${Math.max(0, y)}px`
}

View File

@ -28,11 +28,11 @@ async function deriveSessionToken(password: string): Promise<string> {
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname === '/shop/admin/login' || pathname === '/api/admin/login') {
if (pathname === '/admin/login' || pathname === '/api/admin/login') {
return NextResponse.next()
}
if (pathname.startsWith('/shop/admin') || pathname.startsWith('/api/admin')) {
if (pathname.startsWith('/admin') || pathname.startsWith('/api/admin')) {
const token = request.cookies.get(COOKIE)?.value
const password = process.env.ADMIN_PASSWORD

File diff suppressed because one or more lines are too long

View File

@ -81,23 +81,26 @@
<div class="container is-justify-content-center padding">
<figure class="image">
<img src="assets/pics/classic/hero.webp" alt="Image 1" >
<img class="is-overlay" src="assets/logo/BeachPartyBalloons-logo.webp" alt="Image 2" style="width: 50%; height: auto; margin: auto;">
<img src="assets/pics/classic/hero.webp" alt="Beach Party Balloons Milford CT storefront" >
<img class="is-overlay" src="assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons Logo" style="width: 50%; height: auto; margin: auto;">
</figure>
</div>
<div class="is-flex-direction-column is-dark">
<h1 class="is-size-3" style="text-align: center;">Visit our store</h1>
<h2 class="is-size-4" style="text-align: center;"> <a target="_blank" href="https://maps.app.goo.gl/gRk6NztgMRUsSVJf9">554 Boston Post Road, Milford, CT 06460</a> </h2>
<h2 class="is-size-4" style="text-align: center;" ><a href="tel:203.283.5626">203.283.5626</a> </h2>
<div class="py-2 has-text-centered">
<div class="py-2 has-text-centered buttons is-centered">
<a href="/shop">
<button class="button is-info">Shop Now</button>
</a>
<a href="#contact-form">
<button class="button is-info">Contact Us</button>
<button class="button is-light">Contact Us</button>
</a>
</div>
</div>
<div class="container is-justify-content-center padding">
<img src="assets/pics/organic/img1.webp" alt="Image 1" >
<img src="assets/pics/organic/img1.webp" alt="Organic balloon arch decoration" >
</div>
<p class="is-size-5 has-text-centered">Walk-ins welcome!</p>
@ -107,13 +110,13 @@
<p class="is-size-5 has-text-centered">...Or consult with one of our designers about your balloon decorating needs.</p>
<div class="container is-justify-content-center padding">
<img src="assets/pics/organic/img2.webp" alt="Image 1" >
<img src="assets/pics/organic/img2.webp" alt="Custom organic balloon garland" >
</div>
<p class="is-size-5 has-text-centered">Delivery available!</p>
<p class="is-size-5 has-text-centered"> Have your balloons delivered! Our delivery staff will make sure your order arrives safe and sound!</p>
<div class="container is-justify-content-center padding">
<img src="assets/pics/helium/img1.webp" alt="Image 1" >
<img src="assets/pics/helium/img1.webp" alt="Helium balloon bouquet arrangement" >
</div>
<p class="has-text-centered is-size-1">Shop Hours</p>