Compare commits
6 Commits
92e9abdb84
...
37ab070a8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 37ab070a8c | |||
| 5b9524e1d9 | |||
| 219256be55 | |||
| f6960fd3ec | |||
| 517e00aeb1 | |||
| ec2ebca836 |
@ -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:
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -54,8 +54,9 @@ const STEPS: TourStep[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const PAD = 10 // px padding around spotlight
|
||||
const TIP_WIDTH = 300 // tooltip width in px
|
||||
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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user