Compare commits

..

No commits in common. "37ab070a8cc66f57dcfd7f7857cb05af73655747" and "92e9abdb84cc121e473a93887a2bfbdd54eef11e" have entirely different histories.

11 changed files with 27 additions and 100 deletions

View File

@ -98,27 +98,6 @@ services:
timeout: 10s timeout: 10s
retries: 3 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) ───────────────────────────────────────── # ── OSRM download (runs once, exits) ─────────────────────────────────────────
# Downloads the PBF map file into the shared volume if not already present. # Downloads the PBF map file into the shared volume if not already present.
osrm-download: osrm-download:

View File

@ -10,7 +10,6 @@ export default function AdminLoginPage() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@ -35,30 +34,16 @@ export default function AdminLoginPage() {
<h1 className="title is-4" style={{ marginBottom: '1.5rem' }}>Admin Login</h1> <h1 className="title is-4" style={{ marginBottom: '1.5rem' }}>Admin Login</h1>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="field"> <div className="field">
<div className="control has-icons-right"> <div className="control">
<input <input
className="input" className="input"
type={showPassword ? 'text' : 'password'} type="password"
placeholder="Password" placeholder="Password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoFocus autoFocus
required 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>
</div> </div>
{error && <p className="help is-danger">{error}</p>} {error && <p className="help is-danger">{error}</p>}

View File

@ -8,35 +8,12 @@ import { CartProvider } from '@/context/CartContext'
import ScrollToTop from '@/components/ScrollToTop' import ScrollToTop from '@/components/ScrollToTop'
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL('https://beachpartyballoons.com'),
title: { title: {
default: 'Shop | Beach Party Balloons', default: 'Shop | Beach Party Balloons',
template: '%s | Beach Party Balloons', template: '%s | Beach Party Balloons',
}, },
description: description:
"Milford CT's premier balloon studio. Custom organic arrangements, arches, centerpieces, and event installations. 4.9★ Google rating. Walk-ins welcome.", "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: { icons: {
icon: '/favicon/favicon.ico', icon: '/favicon/favicon.ico',
apple: '/favicon/apple-touch-icon.png', apple: '/favicon/apple-touch-icon.png',

View File

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

View File

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

View File

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

View File

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

View File

@ -25,9 +25,3 @@ 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) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
if (pathname === '/admin/login' || pathname === '/api/admin/login') { if (pathname === '/shop/admin/login' || pathname === '/api/admin/login') {
return NextResponse.next() return NextResponse.next()
} }
if (pathname.startsWith('/admin') || pathname.startsWith('/api/admin')) { if (pathname.startsWith('/shop/admin') || pathname.startsWith('/api/admin')) {
const token = request.cookies.get(COOKIE)?.value const token = request.cookies.get(COOKIE)?.value
const password = process.env.ADMIN_PASSWORD const password = process.env.ADMIN_PASSWORD

File diff suppressed because one or more lines are too long

View File

@ -81,26 +81,23 @@
<div class="container is-justify-content-center padding"> <div class="container is-justify-content-center padding">
<figure class="image"> <figure class="image">
<img src="assets/pics/classic/hero.webp" alt="Beach Party Balloons Milford CT storefront" > <img src="assets/pics/classic/hero.webp" alt="Image 1" >
<img class="is-overlay" src="assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons Logo" style="width: 50%; height: auto; margin: auto;"> <img class="is-overlay" src="assets/logo/BeachPartyBalloons-logo.webp" alt="Image 2" style="width: 50%; height: auto; margin: auto;">
</figure> </figure>
</div> </div>
<div class="is-flex-direction-column is-dark"> <div class="is-flex-direction-column is-dark">
<h1 class="is-size-3" style="text-align: center;">Visit our store</h1> <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 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> <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 buttons is-centered"> <div class="py-2 has-text-centered">
<a href="/shop">
<button class="button is-info">Shop Now</button>
</a>
<a href="#contact-form"> <a href="#contact-form">
<button class="button is-light">Contact Us</button> <button class="button is-info">Contact Us</button>
</a> </a>
</div> </div>
</div> </div>
<div class="container is-justify-content-center padding"> <div class="container is-justify-content-center padding">
<img src="assets/pics/organic/img1.webp" alt="Organic balloon arch decoration" > <img src="assets/pics/organic/img1.webp" alt="Image 1" >
</div> </div>
<p class="is-size-5 has-text-centered">Walk-ins welcome!</p> <p class="is-size-5 has-text-centered">Walk-ins welcome!</p>
@ -110,13 +107,13 @@
<p class="is-size-5 has-text-centered">...Or consult with one of our designers about your balloon decorating needs.</p> <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"> <div class="container is-justify-content-center padding">
<img src="assets/pics/organic/img2.webp" alt="Custom organic balloon garland" > <img src="assets/pics/organic/img2.webp" alt="Image 1" >
</div> </div>
<p class="is-size-5 has-text-centered">Delivery available!</p> <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> <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"> <div class="container is-justify-content-center padding">
<img src="assets/pics/helium/img1.webp" alt="Helium balloon bouquet arrangement" > <img src="assets/pics/helium/img1.webp" alt="Image 1" >
</div> </div>
<p class="has-text-centered is-size-1">Shop Hours</p> <p class="has-text-centered is-size-1">Shop Hours</p>