Major overhaul: shared nav, admin improvements, email enhancements, routing fixes

Navigation & layout
- Replace per-page hardcoded nav/footer with shared nav.js (client-side injection)
- Add nginx reverse proxy back to docker-compose for clean localhost routing
- Rename /color-picker/ to /color/ across nav, directory, and references

eStore admin
- Add variation hiding controls (mirrors existing modifier hiding)
- Add delivery rate editor (base fee + per-mile per tier, persisted to data/)
- Fix all missing BASE prefix on fetch calls (admin PATCH/DELETE, availability, slots, colors)
- Mount estore/data/ as a Docker volume so admin config survives rebuilds

Booking & calendar
- Set pickup calendar events to TRANSPARENT (free) so they don't block delivery slots
- Skip CANCELLED events in busy-time calculation
- Re-check slot availability at checkout before charging (409 on conflict)

Phone & email validation
- Auto-format phone as (XXX) XXX-XXXX as user types
- Require exactly 10 digits; tighten email regex

Confirmation emails (store alert + customer)
- Full item detail per line: name, price, add-ons, colors, note
- Charges breakdown: subtotal, delivery fee, tax, total
- Delivery window: simplified M/D/YY h:mm – h:mm AM/PM format
- .ics calendar attachment on customer confirmation

Delivery rates
- Extract configurable rates to delivery-rates.ts (server-only, no fs in client bundle)
- calcDelivery() accepts optional rates param; delivery-quote route passes configured rates

Content
- Change all "40+ latex colors" references to "70+"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-04-14 21:14:06 -04:00
parent 9f9f326af9
commit 50680a323f
84 changed files with 1112 additions and 701 deletions

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# ── Root .env (fallback for Docker Compose < v2.24) ───────────────────────────
#
# Docker Compose v2.24+ reads build env_file directly from estore/.env — you
# should not need this file at all on a modern install.
#
# If your Compose is older and the shop shows "Online payment is not
# configured", copy the four NEXT_PUBLIC_* lines from estore/.env into this
# file so Compose can bake them into the Next.js build:
#
# NEXT_PUBLIC_SQUARE_APP_ID=
# NEXT_PUBLIC_SQUARE_LOCATION_ID=
# NEXT_PUBLIC_SQUARE_ENVIRONMENT=production
# NEXT_PUBLIC_SITE_URL=https://shop.beachpartyballoons.com
#
# All other secrets (access tokens, passwords, etc.) belong only in estore/.env
# — never put them here.

2
.gitignore vendored
View File

@ -10,6 +10,8 @@ estore/out/
# Runtime data
estore/data/catalog-cache.json
estore/data/item-overrides.json
estore/data/hours.json
estore/data/delivery-rates.json
main-site/photo-gallery-app/backend/uploads/
mongodb_data/
osrm/data/*

View File

@ -1,11 +1,24 @@
services:
# ── Nginx reverse proxy ───────────────────────────────────────────────────────
nginx:
image: nginx:alpine
container_name: bpb-nginx
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- main-site
- estore
restart: always
# ── Main website ─────────────────────────────────────────────────────────────
main-site:
build: ./main-site
container_name: bpb-main
ports:
- "3052:3050"
expose:
- "3050"
environment:
NODE_ENV: production
ADMIN_PASSWORD: ${MAIN_ADMIN_PASSWORD}
@ -20,7 +33,7 @@ services:
build: ./main-site/photo-gallery-app/backend
container_name: bpb-gallery
ports:
- "5001:5000"
- "5002:5000"
environment:
MONGO_URI: mongodb://mongodb:27017/photogallery
WATERMARK_URL: http://watermarker:8000/watermark
@ -35,8 +48,6 @@ services:
watermarker:
build: ./main-site/photo-gallery-app/watermarker
container_name: bpb-watermarker
ports:
- "8000:8000"
restart: always
# ── MongoDB ───────────────────────────────────────────────────────────────────
@ -50,28 +61,56 @@ services:
restart: always
# ── eStore (Next.js / Square) ─────────────────────────────────────────────────
# NEXT_PUBLIC_* vars are baked into the JS bundle at build time.
# They are resolved from the root .env file (same dir as this compose file).
estore:
build: ./estore
build:
context: ./estore
args:
NEXT_PUBLIC_SQUARE_APP_ID: ${NEXT_PUBLIC_SQUARE_APP_ID}
NEXT_PUBLIC_SQUARE_LOCATION_ID: ${NEXT_PUBLIC_SQUARE_LOCATION_ID}
NEXT_PUBLIC_SQUARE_ENVIRONMENT: ${NEXT_PUBLIC_SQUARE_ENVIRONMENT}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
container_name: bpb-estore
ports:
- "3000:3000"
expose:
- "3000"
env_file: ./estore/.env
volumes:
- ./estore/data:/app/data
restart: unless-stopped
depends_on:
- osrm
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/catalog"]
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/shop/api/catalog').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
# ── OSRM (routing engine) ─────────────────────────────────────────────────────
osrm:
image: osrm/osrm-backend
container_name: bpb-osrm
ports:
- "5002:5000"
# ── OSRM download (runs once, exits) ─────────────────────────────────────────
# Downloads the PBF map file into the shared volume if not already present.
osrm-download:
build:
context: ./osrm
dockerfile: Dockerfile.download
container_name: bpb-osrm-download
volumes:
- ./osrm/data:/data
command: osrm-routed --algorithm mld /data/connecticut-latest.osrm
environment:
OSRM_REGION: connecticut-latest
restart: "no"
# ── OSRM (routing engine) ─────────────────────────────────────────────────────
osrm:
build: ./osrm
container_name: bpb-osrm
expose:
- "5000"
volumes:
- ./osrm/data:/data
environment:
OSRM_REGION: connecticut-latest
OSRM_PROFILE: /opt/car.lua
depends_on:
osrm-download:
condition: service_completed_successfully
restart: unless-stopped

View File

@ -10,6 +10,18 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_* vars are baked into the JS bundle at build time.
# Pass them as build args from your .env so they're available here.
ARG NEXT_PUBLIC_SQUARE_APP_ID
ARG NEXT_PUBLIC_SQUARE_LOCATION_ID
ARG NEXT_PUBLIC_SQUARE_ENVIRONMENT
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SQUARE_APP_ID=$NEXT_PUBLIC_SQUARE_APP_ID
ENV NEXT_PUBLIC_SQUARE_LOCATION_ID=$NEXT_PUBLIC_SQUARE_LOCATION_ID
ENV NEXT_PUBLIC_SQUARE_ENVIRONMENT=$NEXT_PUBLIC_SQUARE_ENVIRONMENT
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build
# ── Stage 3: Production runner ────────────────────────────────────────────────

View File

@ -3,6 +3,10 @@ const isDev = process.env.NODE_ENV === 'development'
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
basePath: '/shop',
env: {
NEXT_PUBLIC_BASE_PATH: '/shop',
},
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'items-images-production.s3.us-west-2.amazonaws.com' },

View File

@ -1,4 +1,5 @@
'use client'
import { BASE } from '@/lib/basepath'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
@ -14,13 +15,13 @@ export default function AdminLoginPage() {
e.preventDefault()
setLoading(true)
setError('')
const res = await fetch('/api/admin/login', {
const res = await fetch(BASE + '/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (res.ok) {
router.push('/shop/admin')
router.push('/admin')
} else {
setError('Invalid password')
setLoading(false)

View File

@ -1,4 +1,5 @@
'use client'
import { BASE } from '@/lib/basepath'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
@ -15,6 +16,7 @@ interface AdminItem extends CatalogItem {
_rawCategory: string
_rawCategoryLabel: string
_rawShowColors: boolean
_rawVariations: CatalogItem['variations']
_rawModifiers: ModifierList[]
_rawDescription: string
_override: ItemOverride
@ -47,7 +49,7 @@ function CategoryDisplayEditor({ items }: { items: AdminItem[] }) {
useEffect(() => {
if (!catalogCats.length) return
fetch('/api/admin/categories-display')
fetch(BASE + '/api/admin/categories-display')
.then((r) => r.json())
.then(({ order, hidden }: { order: string[]; hidden: string[] }) => {
const inOrder = order
@ -77,7 +79,7 @@ function CategoryDisplayEditor({ items }: { items: AdminItem[] }) {
async function handleSave() {
setSaving(true)
setMsg('')
const res = await fetch('/api/admin/categories-display', {
const res = await fetch(BASE + '/api/admin/categories-display', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -168,7 +170,7 @@ function HoursEditor() {
const [msg, setMsg] = useState('')
useEffect(() => {
fetch('/api/admin/hours')
fetch(BASE + '/api/admin/hours')
.then((r) => r.json())
.then(setConfig)
.catch(() => setConfig(DEFAULT_HOURS))
@ -185,7 +187,7 @@ function HoursEditor() {
if (!config) return
setSaving(true)
setMsg('')
const res = await fetch('/api/admin/hours', {
const res = await fetch(BASE + '/api/admin/hours', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@ -324,7 +326,7 @@ function OccasionsEditor() {
const [newOcc, setNewOcc] = useState(EMPTY_NEW)
useEffect(() => {
fetch('/api/admin/occasions')
fetch(BASE + '/api/admin/occasions')
.then((r) => r.json())
.then(({ occasions }: { occasions: OccasionRow[] }) => setRows(occasions))
.catch(() => {})
@ -387,7 +389,7 @@ function OccasionsEditor() {
if (Object.keys(ov).length) config[r.key] = ov
}
}
const res = await fetch('/api/admin/occasions', {
const res = await fetch(BASE + '/api/admin/occasions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@ -579,6 +581,133 @@ function OccasionsEditor() {
)
}
// ─── Delivery Rates Editor ────────────────────────────────────────────────────
interface TierRate { base: number; perMile: number; label: string }
interface DeliveryRatesConfig { dropoff: TierRate; classic: TierRate; organic: TierRate }
const TIER_LABELS: Record<string, string> = {
dropoff: 'Drop-off',
classic: 'Setup & strike',
organic: 'Organic setup & strike',
}
function DeliveryRatesEditor() {
const [rates, setRates] = useState<DeliveryRatesConfig | null>(null)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
fetch(BASE + '/api/admin/delivery-rates')
.then((r) => r.json())
.then(setRates)
.catch(() => {})
}, [])
function updateTier(tier: keyof DeliveryRatesConfig, field: keyof TierRate, value: string) {
setRates((prev) => {
if (!prev) return prev
const updated = { ...prev[tier] }
if (field === 'label') {
updated.label = value
} else {
updated[field] = Math.round(parseFloat(value) * 100) || 0
}
return { ...prev, [tier]: updated }
})
}
async function handleSave() {
if (!rates) return
setSaving(true)
setMsg('')
const res = await fetch(BASE + '/api/admin/delivery-rates', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rates),
})
setSaving(false)
setMsg(res.ok ? 'Saved' : 'Save failed')
setTimeout(() => setMsg(''), 3000)
}
if (!rates) return <p className="has-text-grey">Loading</p>
return (
<div>
<p className="is-size-7 has-text-grey" style={{ marginBottom: '1rem' }}>
Set the base fee and per-mile rate for each delivery type. Changes apply to new quotes immediately.
</p>
<table className="table is-narrow is-fullwidth" style={{ fontSize: '0.85rem' }}>
<thead>
<tr>
<th style={{ minWidth: 200 }}>Tier</th>
<th style={{ width: 130 }}>Base fee ($)</th>
<th style={{ width: 130 }}>Per mile ($)</th>
<th style={{ minWidth: 240 }}>Label</th>
</tr>
</thead>
<tbody>
{(['dropoff', 'classic', 'organic'] as const).map((tier) => (
<tr key={tier}>
<td style={{ verticalAlign: 'middle', fontWeight: 500 }}>{TIER_LABELS[tier]}</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="number"
min={0}
step={0.01}
className="input is-small"
value={(rates[tier].base / 100).toFixed(2)}
onChange={(e) => updateTier(tier, 'base', e.target.value)}
style={{ width: 100 }}
/>
</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="number"
min={0}
step={0.01}
className="input is-small"
value={(rates[tier].perMile / 100).toFixed(2)}
onChange={(e) => updateTier(tier, 'perMile', e.target.value)}
style={{ width: 100 }}
/>
</td>
<td style={{ verticalAlign: 'middle' }}>
<input
type="text"
className="input is-small"
value={rates[tier].label}
onChange={(e) => updateTier(tier, 'label', e.target.value)}
style={{ width: '100%' }}
/>
</td>
</tr>
))}
</tbody>
</table>
<p className="help" style={{ marginBottom: '0.75rem' }}>
Formula: <strong>base + ceil(miles) × per-mile</strong>. Example: drop-off to a 5-mile address =
{' '}base + 5 × per-mile.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
onClick={handleSave}
type="button"
>
Save rates
</button>
{msg && (
<span className={`is-size-7 ${msg === 'Saved' ? 'has-text-success' : 'has-text-danger'}`}>
{msg}
</span>
)}
</div>
</div>
)
}
// ─── Item Editor ──────────────────────────────────────────────────────────────
function ItemEditor({
@ -601,6 +730,7 @@ function ItemEditor({
const [showColors, setShowColors] = useState<boolean | null>(
ov.showColors != null ? ov.showColors : null
)
const [hiddenVars, setHiddenVars] = useState<string[]>(ov.hiddenVariationIds ?? [])
const [hiddenMods, setHiddenMods] = useState<string[]>(ov.hiddenModifierIds ?? [])
const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '')
const [saving, setSaving] = useState(false)
@ -630,6 +760,12 @@ function ItemEditor({
const [creatingCat, setCreatingCat] = useState(false)
const [showNewCat, setShowNewCat] = useState(false)
function toggleVar(id: string) {
setHiddenVars((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
function toggleMod(id: string) {
setHiddenMods((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
@ -641,6 +777,7 @@ function ItemEditor({
setError('')
const patch: Partial<ItemOverride> = {
hidden,
hiddenVariationIds: hiddenVars,
hiddenModifierIds: hiddenMods,
}
if (catOverride) patch.categoryOverride = catOverride
@ -655,7 +792,7 @@ function ItemEditor({
if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim()
else patch.quantityUnit = undefined
const res = await fetch(`/api/admin/items/${item.id}`, {
const res = await fetch(`${BASE}/api/admin/items/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
@ -672,7 +809,7 @@ function ItemEditor({
async function handleReset() {
if (!confirm('Reset all overrides for this item to Square defaults?')) return
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'DELETE' })
if (res.ok) {
setHidden(false)
setCatOverride('')
@ -696,7 +833,7 @@ function ItemEditor({
setUploadResults([])
const fd = new FormData()
files.forEach((f) => fd.append('images', f))
const res = await fetch(`/api/admin/items/${item.id}/images`, { method: 'POST', body: fd })
const res = await fetch(`${BASE}/api/admin/items/${item.id}/images`, { method: 'POST', body: fd })
const data = await res.json()
setUploadResults(data.results ?? [])
setUploading(false)
@ -858,6 +995,42 @@ function ItemEditor({
{/* Right column */}
<div className="column is-half">
{/* Variations */}
{item._rawVariations.length > 1 && (
<div className="field">
<label className="label is-small">Variations</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{item._rawVariations.map((v) => {
const visible = !hiddenVars.includes(v.id)
return (
<div key={v.id} style={{
border: '1px solid #e8e8e8',
borderRadius: 6,
padding: '8px 10px',
background: visible ? '#fff' : '#fafafa',
opacity: visible ? 1 : 0.5,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="checkbox" style={{ fontWeight: 500, fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={visible}
onChange={() => toggleVar(v.id)}
style={{ marginRight: 6 }}
/>
{v.name}
</label>
<span className="has-text-grey-light" style={{ fontSize: '0.75rem' }}>
${(v.priceCents / 100).toFixed(2)}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Modifiers */}
{item._rawModifiers.length > 0 && (
<div className="field">
@ -1204,7 +1377,7 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions'>('items')
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery'>('items')
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [refreshMsg, setRefreshMsg] = useState('')
@ -1218,11 +1391,11 @@ export default function AdminPage() {
setLoading(true)
setError('')
const [itemsRes, catsRes] = await Promise.all([
fetch('/api/admin/items'),
fetch('/api/admin/categories'),
fetch(BASE + '/api/admin/items'),
fetch(BASE + '/api/admin/categories'),
])
if (itemsRes.status === 401) {
router.push('/shop/admin/login')
router.push('/admin/login')
return
}
if (!itemsRes.ok) {
@ -1241,7 +1414,7 @@ export default function AdminPage() {
async function handleRefreshCatalog() {
setRefreshing(true)
setRefreshMsg('')
const res = await fetch('/api/admin/cache/refresh', { method: 'POST' })
const res = await fetch(BASE + '/api/admin/cache/refresh', { method: 'POST' })
const data = await res.json()
setRefreshing(false)
if (res.ok) {
@ -1257,12 +1430,12 @@ export default function AdminPage() {
useEffect(() => { load() }, [load])
async function handleLogout() {
await fetch('/api/admin/logout', { method: 'POST' })
router.push('/shop/admin/login')
await fetch(BASE + '/api/admin/logout', { method: 'POST' })
router.push('/admin/login')
}
async function handleCreateCategory(name: string): Promise<SquareCategory> {
const res = await fetch('/api/admin/categories', {
const res = await fetch(BASE + '/api/admin/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
@ -1354,6 +1527,9 @@ export default function AdminPage() {
<li className={tab === 'occasions' ? 'is-active' : ''}>
<a onClick={() => setTab('occasions')}>Holidays</a>
</li>
<li className={tab === 'delivery' ? 'is-active' : ''}>
<a onClick={() => setTab('delivery')}>Delivery rates</a>
</li>
</ul>
</div>
@ -1456,6 +1632,9 @@ export default function AdminPage() {
{/* Holidays tab */}
{tab === 'occasions' && <OccasionsEditor />}
{/* Delivery rates tab */}
{tab === 'delivery' && <DeliveryRatesEditor />}
</div>
</section>
)

View File

@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { readDeliveryRates, writeDeliveryRates } from '@/lib/delivery-rates'
import type { DeliveryRatesConfig } from '@/lib/delivery'
export async function GET() {
return NextResponse.json(readDeliveryRates())
}
export async function PUT(req: Request) {
try {
const body = (await req.json()) as DeliveryRatesConfig
const tiers = ['dropoff', 'classic', 'organic'] as const
for (const tier of tiers) {
const t = body[tier]
if (!t || typeof t.base !== 'number' || typeof t.perMile !== 'number' || typeof t.label !== 'string') {
return NextResponse.json({ error: `Invalid config for tier: ${tier}` }, { status: 400 })
}
}
writeDeliveryRates(body)
return NextResponse.json({ ok: true })
} catch (err) {
console.error('[admin/delivery-rates] error:', err)
return NextResponse.json({ error: 'Failed to save delivery rates' }, { status: 500 })
}
}

View File

@ -23,6 +23,8 @@ export async function GET() {
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
description: ov.descriptionOverride ?? item.description,
variations: item.variations
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
modifiers: item.modifiers
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
.map((m) => {
@ -33,6 +35,7 @@ export async function GET() {
_rawCategory: item.category,
_rawCategoryLabel: item.categoryLabel,
_rawShowColors: item.showColors,
_rawVariations: item.variations,
_rawModifiers: item.modifiers,
_rawDescription: item.description,
_override: ov,

View File

@ -20,6 +20,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
quantityUnit: ov.quantityUnit ?? item.quantityUnit,
description: ov.descriptionOverride ?? item.description,
variations: item.variations
.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)),
modifiers: item.modifiers
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))
.map((m) => {

View File

@ -364,6 +364,11 @@ export async function POST(req: NextRequest) {
if (!captured) throw new Error('Payment capture returned no result')
// ── Fire-and-forget: emails only (calendar already written above) ────────
const subtotalCents = lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0)
const slotEndISO = deliverySlotISO
? new Date(new Date(deliverySlotISO).getTime() + jobMin * 60_000).toISOString()
: undefined
void (async () => {
try {
const { sendNewOrderAlert } = await import('@/lib/notify')
@ -375,9 +380,12 @@ export async function POST(req: NextRequest) {
customerEmail: customerEmail ?? '',
fulfillment: deliveryAddress ? 'delivery' : 'pickup',
slotISO: (deliverySlotISO ?? pickupSlotISO)!,
slotEndISO,
address: deliveryAddress,
items: itemsSummary,
lineItems,
colors: selectedColors,
subtotalCents,
deliveryCents: deliveryCents ?? undefined,
totalCents,
})
} catch (err) {
@ -389,14 +397,17 @@ export async function POST(req: NextRequest) {
const { sendOrderConfirmationEmail } = await import('@/lib/notify')
await sendOrderConfirmationEmail({
shortRef,
orderId: order.id!,
customerName: customerName ?? 'Customer',
orderId: order.id!,
customerName: customerName ?? 'Customer',
customerEmail,
fulfillment: deliveryAddress ? 'delivery' : 'pickup',
slotISO: (deliverySlotISO ?? pickupSlotISO)!,
address: deliveryAddress,
items: itemsSummary,
colors: selectedColors,
fulfillment: deliveryAddress ? 'delivery' : 'pickup',
slotISO: (deliverySlotISO ?? pickupSlotISO)!,
slotEndISO,
address: deliveryAddress,
lineItems,
colors: selectedColors,
subtotalCents,
deliveryCents: deliveryCents ?? undefined,
totalCents,
})
} catch (err) {

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { geocode, calcDelivery, inferTier } from '@/lib/delivery'
import { readDeliveryRates } from '@/lib/delivery-rates'
export async function POST(request: Request) {
const { address, itemNames } = await request.json() as {
@ -17,7 +18,8 @@ export async function POST(request: Request) {
}
const tier = inferTier(itemNames ?? [])
const quote = await calcDelivery(coords.lat, coords.lng, tier)
const rates = readDeliveryRates()
const quote = await calcDelivery(coords.lat, coords.lng, tier, rates)
if (quote.miles > 40) {
return NextResponse.json(

View File

@ -135,11 +135,11 @@
position: relative;
z-index: 1;
transition: transform 0.2s ease;
-webkit-mask-image: url('/color-picker/images/balloon-mask.svg');
-webkit-mask-image: url('/shop/color-picker/images/balloon-mask.svg');
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-image: url('/color-picker/images/balloon-mask.svg');
mask-image: url('/shop/color-picker/images/balloon-mask.svg');
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;

View File

@ -37,10 +37,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
{/* Favicon */}
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
{/* Favicon — prefix with basePath so nginx routes them to the estore */}
<link rel="apple-touch-icon" sizes="180x180" href="/shop/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/shop/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/shop/favicon/favicon-16x16.png" />
</head>
<body>
<CartProvider>

View File

@ -1,15 +1,12 @@
import Hero from '@/components/Hero'
import type { Metadata } from 'next'
import FeaturedProducts from '@/components/FeaturedProducts'
import ReviewsSection from '@/components/ReviewsSection'
import TrustedBrands from '@/components/TrustedBrands'
export default function HomePage() {
return (
<>
<Hero />
<FeaturedProducts />
<ReviewsSection />
<TrustedBrands />
</>
)
export const metadata: Metadata = {
title: 'Shop',
description:
'Browse all balloon arrangements, arches, centerpieces, and installations by Beach Party Balloons.',
}
export default function ShopPage() {
return <FeaturedProducts />
}

View File

@ -1,3 +0,0 @@
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

View File

@ -1,12 +0,0 @@
import type { Metadata } from 'next'
import FeaturedProducts from '@/components/FeaturedProducts'
export const metadata: Metadata = {
title: 'Shop',
description:
'Browse all balloon arrangements, arches, centerpieces, and installations by Beach Party Balloons.',
}
export default function ShopPage() {
return <FeaturedProducts />
}

View File

@ -1,4 +1,5 @@
'use client'
import { BASE } from '@/lib/basepath'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useCart } from '@/context/CartContext'
@ -91,8 +92,9 @@ export default function CartDrawer() {
const [infoErrors, setInfoErrors] = useState<{ firstName?: string; lastName?: string; email?: string; phone?: string; balloon?: string }>({})
const [balloonAgreement, setBalloonAgreement] = useState(false)
const isValidEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())
const isValidPhone = (v: string) => v.replace(/\D/g, '').length >= 10
const isValidEmail = (v: string) =>
/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(v.trim())
const isValidPhone = (v: string) => v.replace(/\D/g, '').length === 10
const validateAndContinue = () => {
const errors: typeof infoErrors = {}
@ -212,7 +214,7 @@ export default function CartDrawer() {
setQuoteErr('')
setQuote(null)
try {
const res = await fetch('/api/delivery-quote', {
const res = await fetch(BASE + '/api/delivery-quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: fullAddress, itemNames: entries.map((e) => e.product.name) }),
@ -551,7 +553,15 @@ export default function CartDrawer() {
autoComplete="tel"
maxLength={17}
value={custPhone}
onChange={(e) => { setCustPhone(e.target.value); setInfoErrors((p) => ({ ...p, phone: undefined })) }}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 10)
let formatted = digits
if (digits.length > 6) formatted = `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`
else if (digits.length > 3) formatted = `(${digits.slice(0,3)}) ${digits.slice(3)}`
else if (digits.length > 0) formatted = `(${digits}`
setCustPhone(formatted)
setInfoErrors((p) => ({ ...p, phone: undefined }))
}}
/>
{infoErrors.phone && <p className="help is-danger">{infoErrors.phone}</p>}
</div>

View File

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react'
import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
import { useCart } from '@/context/CartContext'
import { BASE } from '@/lib/basepath'
import type { CartEntry } from '@/context/CartContext'
import { fmt } from '@/lib/format'
@ -50,7 +51,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
)
useEffect(() => {
fetch('/colors.json')
fetch(BASE + '/colors.json')
.then((r) => r.json())
.then((data: ColorFamily[]) => setFamilies(data))
}, [])
@ -255,7 +256,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
{/* Intro */}
<p className="is-size-7 has-text-grey mb-3">
We stock <strong>40+ latex colors</strong>. Tap a family below to browse shades,
We stock <strong>70+ latex colors</strong>. Tap a family below to browse shades,
then tap a balloon to add it to your palette.
{colorMin > 1 && maxColors !== null && colorMin === maxColors && ` Exactly ${colorMin} color${colorMin !== 1 ? 's' : ''} required.`}
{colorMin > 1 && (maxColors === null || colorMin !== maxColors) && ` At least ${colorMin} color${colorMin !== 1 ? 's' : ''} required.`}

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { BASE } from '@/lib/basepath'
import type { DeliveryTier } from '@/lib/delivery'
import type { TimeSlot } from '@/lib/slots'
import CalendarPicker from './CalendarPicker'
@ -40,7 +41,7 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
// Fetch busy dates once on mount
useEffect(() => {
if (!process.env.NEXT_PUBLIC_SITE_URL && typeof window === 'undefined') return
fetch(`/api/availability?days=90`)
fetch(`${BASE}/api/availability?days=90`)
.then(r => r.ok ? r.json() : null)
.then((data) => {
if (!data?.busy) return
@ -60,7 +61,7 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
onChange(null)
const params = new URLSearchParams({ date, address, tier })
fetch(`/api/slots?${params}`)
fetch(`${BASE}/api/slots?${params}`)
.then((r) => r.json())
.then((data) => {
if (data.error) { setErrMsg(data.error); setStatus('error'); return }

View File

@ -1,4 +1,5 @@
'use client'
import { BASE } from '@/lib/basepath'
import { useState, useEffect, useMemo } from 'react'
import ProductCard from './ProductCard'
@ -84,10 +85,10 @@ export default function FeaturedProducts() {
useEffect(() => {
Promise.all([
fetch('/api/catalog').then((r) => { if (!r.ok) throw new Error('catalog error'); return r.json() }),
fetch('/api/inventory').then((r) => r.ok ? r.json() : { counts: {} }).catch(() => ({ counts: {} })),
fetch('/api/occasions').then((r) => r.ok ? r.json() : { occasions: [] }).catch(() => ({ occasions: [] })),
fetch('/api/categories-display').then((r) => r.ok ? r.json() : { order: [], hidden: [] }).catch(() => ({ order: [], hidden: [] })),
fetch(BASE + '/api/catalog').then((r) => { if (!r.ok) throw new Error('catalog error'); return r.json() }),
fetch(BASE + '/api/inventory').then((r) => r.ok ? r.json() : { counts: {} }).catch(() => ({ counts: {} })),
fetch(BASE + '/api/occasions').then((r) => r.ok ? r.json() : { occasions: [] }).catch(() => ({ occasions: [] })),
fetch(BASE + '/api/categories-display').then((r) => r.ok ? r.json() : { order: [], hidden: [] }).catch(() => ({ order: [], hidden: [] })),
])
.then(([{ items }, { counts }, { occasions }, catDisplay]: [
{ items: CatalogItem[] },

View File

@ -44,11 +44,11 @@ export default function Footer() {
Use of images without written permission is prohibited.
</p>
<p style={{ fontSize: '0.8rem', marginTop: '0.5rem' }}>
<a href="/privacy">Privacy Policy</a>
<a href="/shop/privacy">Privacy Policy</a>
{' · '}
<a href="/terms">Terms of Service</a>
<a href="/shop/terms">Terms of Service</a>
{' · '}
<a href="/refund">Refund &amp; Cancellation Policy</a>
<a href="/shop/refund">Refund &amp; Cancellation Policy</a>
</p>
</div>
</footer>

View File

@ -8,7 +8,7 @@ export default function Hero() {
{/* Scrolling announcement bar */}
<div className="update">
<div id="message">
🎈 Walk-ins welcome! · Delivery available across CT · Over 40 latex colors in stock · Custom arrangements made while you wait · 203.283.5626
🎈 Walk-ins welcome! · Delivery available across CT · Over 70 latex colors in stock · Custom arrangements made while you wait · 203.283.5626
</div>
</div>
@ -63,7 +63,7 @@ export default function Hero() {
it while you wait!
</p>
<p className="is-size-6 has-text-centered">
We have hundreds of foil balloon choices in stock and over 40 latex colors!
We have hundreds of foil balloon choices in stock and over 70 latex colors!
</p>
<p className="is-size-5 has-text-centered">

View File

@ -2,7 +2,10 @@
import { useState } from 'react'
import Link from 'next/link'
import { BASE } from '@/lib/basepath'
// Use <a> for links that leave the estore (main site pages) so Next.js basePath
// is not prepended. Use <Link> only for internal estore routes.
export default function Navbar() {
const [isOpen, setIsOpen] = useState(false)
@ -13,14 +16,14 @@ export default function Navbar() {
aria-label="main navigation"
>
<div className="navbar-brand is-size-1">
<Link className="navbar-item" href="/">
<a className="navbar-item" href="/">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
style={{ backgroundColor: 'white' }}
src="/images/logo/BeachPartyBalloons-logo.webp"
src={`${BASE}/images/logo/BeachPartyBalloons-logo.webp`}
alt="Beach Party Balloons logo"
/>
</Link>
</a>
<a
role="button"
@ -38,49 +41,13 @@ export default function Navbar() {
<div className={`navbar-menu has-text-right ${isOpen ? 'is-active' : ''}`}>
<div className="navbar-end">
<Link className="navbar-item" href="https://beachpartyballoons.com/">
Home
</Link>
<Link className="navbar-item is-tab is-active" href="/shop">
Shop
</Link>
<Link
className="navbar-item"
href="https://beachpartyballoons.com/about/"
>
About Us
</Link>
<Link
className="navbar-item"
href="https://beachpartyballoons.com/faq/"
>
FAQ
</Link>
<Link
className="navbar-item"
href="https://beachpartyballoons.com/terms/"
>
Terms
</Link>
<Link
className="navbar-item"
href="https://beachpartyballoons.com/gallery/"
>
Gallery
</Link>
<Link
className="navbar-item"
href="https://beachpartyballoons.com/color/"
>
Colors
</Link>
<Link
className="navbar-item"
href="https://beachpartyballoons.com/contact/"
>
Contact
</Link>
<a className="navbar-item" href="/">Home</a>
<Link className="navbar-item is-tab is-active" href="/">Shop</Link>
<a className="navbar-item" href="/about/">About Us</a>
<a className="navbar-item" href="/faq/">FAQ</a>
<a className="navbar-item" href="/gallery/">Gallery</a>
<a className="navbar-item" href="/color/">Colors</a>
<a className="navbar-item" href="/contact/">Contact</a>
</div>
</div>
</nav>

View File

@ -1,4 +1,5 @@
'use client'
import { BASE } from '@/lib/basepath'
import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef
import { fmt } from '@/lib/format'
@ -153,7 +154,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
// wait 2 seconds and retry once automatically — the idempotency key ensures
// no double charge and will return success if the first attempt already
// captured payment but the response was lost.
const attemptCheckout = () => fetch('/api/checkout', {
const attemptCheckout = () => fetch(BASE + '/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: checkoutBody,

View File

@ -7,7 +7,7 @@ interface Props {
const HOW_IT_WORKS = [
{ emoji: '🎈', title: 'Browse arrangements', body: 'Filter by occasion or category and find your perfect setup.' },
{ emoji: '🎨', title: 'Pick your colors', body: 'Choose from 40+ latex colors and customize every detail.' },
{ emoji: '🎨', title: 'Pick your colors', body: 'Choose from 70+ latex colors and customize every detail.' },
{ emoji: '📅', title: 'Choose delivery or pickup', body: 'We deliver to your door, or you can pick up at our Milford, CT shop.' },
{ emoji: '✅', title: 'Pay securely', body: 'Powered by Square — your card info never touches our servers.' },
]

View File

@ -97,7 +97,7 @@ export const MOCK_CATALOG: CatalogItem[] = (([
id: 'cat-005',
name: 'Helium Bouquet',
description:
'Classic floating helium balloon bouquets — great for walk-ins or delivery. Over 40 latex colors and hundreds of foil shapes in stock.',
'Classic floating helium balloon bouquets — great for walk-ins or delivery. Over 70 latex colors and hundreds of foil shapes in stock.',
category: 'classic',
categoryLabel: 'Classic',
price: 2800,

View File

@ -0,0 +1,3 @@
/** Prefix for all client-side API fetches and absolute asset paths.
* Must match `basePath` in next.config.mjs. */
export const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? ''

View File

@ -210,7 +210,7 @@ export async function createPickupEvent(params: {
'LOCATION:Beach Party Balloons\\, 554 Boston Post Rd\\, Milford CT',
foldLine(`DESCRIPTION:${descParts}`),
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'TRANSP:TRANSPARENT',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
@ -286,6 +286,9 @@ async function _fetchBusyDates(rangeStart: Date, rangeEnd: Date): Promise<BusyBl
// Skip transparent (free) events
const transp = vevent.getFirstPropertyValue('transp')
if (transp === 'TRANSPARENT') continue
// Skip cancelled events
const status = vevent.getFirstPropertyValue('status')
if (status === 'CANCELLED') continue
const ev = new ICAL.Event(vevent)
const location = (vevent.getFirstPropertyValue('location') as string ?? '').trim() || undefined

View File

@ -0,0 +1,35 @@
/**
* Server-only: read/write delivery rate overrides from data/delivery-rates.json.
* Do NOT import this file from client components.
*/
import { readFileSync, existsSync } from 'fs'
import path from 'path'
import { atomicWriteJSON } from './file-utils'
import { RATES, type DeliveryRatesConfig, type DeliveryTier } from './delivery'
const RATES_PATH = path.join(process.cwd(), 'data', 'delivery-rates.json')
const TIERS: DeliveryTier[] = ['dropoff', 'classic', 'organic']
export function readDeliveryRates(): DeliveryRatesConfig {
const defaults: DeliveryRatesConfig = {
dropoff: { ...RATES.dropoff },
classic: { ...RATES.classic },
organic: { ...RATES.organic },
}
if (!existsSync(RATES_PATH)) return defaults
try {
const stored = JSON.parse(readFileSync(RATES_PATH, 'utf-8')) as Partial<DeliveryRatesConfig>
const merged = { ...defaults }
for (const tier of TIERS) {
if (stored[tier]) merged[tier] = { ...defaults[tier], ...stored[tier] }
}
return merged
} catch {
return defaults
}
}
export function writeDeliveryRates(config: DeliveryRatesConfig): void {
atomicWriteJSON(RATES_PATH, config)
}

View File

@ -30,6 +30,9 @@ export const JOB_MINUTES: Record<DeliveryTier, number> = {
organic: 240, // 4 hrs setup + strike
}
// ── Configurable rates type (file I/O lives in delivery-rates.ts, server only) ─
export type DeliveryRatesConfig = Record<DeliveryTier, { base: number; perMile: number; label: string }>
/** Straight-line distance fallback (haversine) with road overhead factor */
function haversineMiles(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3958.8 // Earth radius in miles
@ -111,8 +114,9 @@ export async function calcDelivery(
destLat: number,
destLng: number,
tier: DeliveryTier,
rates?: DeliveryRatesConfig,
): Promise<DeliveryQuote> {
const rate = RATES[tier]
const rate = (rates ?? RATES)[tier]
const { miles: rawMiles, minutes: driveMinutes } =
await drivingInfo(SHOP_LAT, SHOP_LNG, destLat, destLng)
const miles = Math.ceil(rawMiles * 10) / 10

View File

@ -12,6 +12,55 @@
import nodemailer from 'nodemailer'
// ── Shared helpers ─────────────────────────────────────────────────────────────
/** Format a slot as "4/14/26 2:00 PM" */
function fmtDate(iso: string): string {
const d = new Date(iso)
const { month, day, year, hour, minute, dayPeriod } = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
month: 'numeric', day: 'numeric', year: '2-digit',
hour: 'numeric', minute: '2-digit', hour12: true,
}).formatToParts(d).map(({ type, value }) => [type, value])
)
return `${month}/${day}/${year} ${hour}:${minute} ${dayPeriod}`
}
/** Format a window as "4/14/26 2:00 5:00 PM" (shared date, shared AM/PM if same) */
function fmtWindow(startISO: string, endISO: string): string {
const start = fmtDate(startISO) // "4/14/26 2:00 PM"
const endTime = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
hour: 'numeric', minute: '2-digit', hour12: true,
}).format(new Date(endISO)) // "5:00 PM"
// Strip the time portion from start and append range
const datePart = start.replace(/ \d+:\d+ [AP]M$/, '')
return `${datePart} ${start.match(/\d+:\d+ [AP]M$/)?.[0]} ${endTime}`
}
// ── Shared types ───────────────────────────────────────────────────────────────
export interface EmailLineItem {
name: string
quantity: number
priceCents: number
colors?: string[]
note?: string
modifiers?: Array<{ name: string }>
}
function formatLineItems(lineItems: EmailLineItem[]): string {
return lineItems.map((li) => {
const price = `$${(li.priceCents / 100).toFixed(2)}`
const lines = [`${li.quantity}× ${li.name}${price}`]
if (li.modifiers?.length) lines.push(` Add-ons: ${li.modifiers.map((m) => m.name).join(', ')}`)
if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`)
if (li.note) lines.push(` Note: ${li.note}`)
return lines.join('\n')
}).join('\n')
}
function getTransporter() {
const host = process.env.SMTP_HOST
const port = parseInt(process.env.SMTP_PORT ?? '587', 10)
@ -29,9 +78,10 @@ function getTransporter() {
}
async function send(params: {
to: string
subject: string
text: string
to: string
subject: string
text: string
attachments?: Array<{ filename: string; content: string; contentType: string }>
}): Promise<void> {
const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com'
const transporter = getTransporter()
@ -45,9 +95,10 @@ async function send(params: {
try {
await transporter.sendMail({
from,
to: params.to,
subject: params.subject,
text: params.text,
to: params.to,
subject: params.subject,
text: params.text,
attachments: params.attachments,
})
console.log('[notify] Email sent:', params.subject, '→', params.to)
} catch (err: unknown) {
@ -57,65 +108,158 @@ async function send(params: {
}
}
// ── ICS builder for customer calendar attachment ────────────────────────────
function buildCustomerICS(params: {
uid: string
startISO: string
endISO: string
summary: string
description: string
location?: string
}): string {
function toStamp(d: Date): string {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
}
function toET(d: Date): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).formatToParts(d)
const p: Record<string, string> = {}
for (const { type, value } of parts) p[type] = value
const h = p.hour === '24' ? '00' : p.hour
return `${p.year}${p.month}${p.day}T${h}${p.minute}${p.second}`
}
function fold(s: string): string {
const out: string[] = []
while (s.length > 73) { out.push(s.slice(0, 73)); s = ' ' + s.slice(73) }
out.push(s)
return out.join('\r\n')
}
const start = new Date(params.startISO)
const end = new Date(params.endISO)
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//BeachPartyBalloons//Shop//EN',
'BEGIN:VEVENT',
`UID:${params.uid}`,
`DTSTAMP:${toStamp(new Date())}`,
`DTSTART;TZID=America/New_York:${toET(start)}`,
`DTEND;TZID=America/New_York:${toET(end)}`,
fold(`SUMMARY:${params.summary}`),
...(params.location ? [fold(`LOCATION:${params.location}`)] : []),
fold(`DESCRIPTION:${params.description}`),
'STATUS:CONFIRMED',
'END:VEVENT',
'END:VCALENDAR',
]
return lines.join('\r\n')
}
// ── Public helpers ─────────────────────────────────────────────────────────────
export async function sendOrderConfirmationEmail(params: {
shortRef: string
orderId: string
customerName: string
customerEmail: string
fulfillment: 'delivery' | 'pickup'
slotISO: string
address?: string
items: string
colors: string[]
totalCents: bigint
shortRef: string
orderId: string
customerName: string
customerEmail: string
fulfillment: 'delivery' | 'pickup'
slotISO: string
slotEndISO?: string
address?: string
lineItems: EmailLineItem[]
colors: string[] // order-level color selection (when not per-item)
subtotalCents?: number
deliveryCents?: number
totalCents: bigint
}): Promise<void> {
const slot = new Date(params.slotISO).toLocaleString('en-US', {
timeZone: 'America/New_York',
dateStyle: 'full',
timeStyle: 'short',
})
const total = `$${(Number(params.totalCents) / 100).toFixed(2)}`
const isDelivery = params.fulfillment === 'delivery'
const slotStr = params.slotEndISO
? fmtWindow(params.slotISO, params.slotEndISO)
: fmtDate(params.slotISO)
const total = `$${(Number(params.totalCents) / 100).toFixed(2)}`
// Charges breakdown (only shown when subtotal is provided)
const chargesLines: string[] = []
if (params.subtotalCents != null) {
chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`)
if (params.deliveryCents) {
chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`)
}
const impliedTax = Number(params.totalCents) - (params.subtotalCents) - (params.deliveryCents ?? 0)
if (impliedTax > 0) {
chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`)
}
chargesLines.push(`Total: ${total}`)
} else {
chargesLines.push(`Total: ${total}`)
}
const itemsBlock = formatLineItems(params.lineItems)
// Order-level colors (when colors aren't set per-item)
const hasPerItemColors = params.lineItems.some((li) => li.colors?.length)
const orderColors = !hasPerItemColors && params.colors.length ? params.colors : []
const lines = [
`Hi ${params.customerName.split(' ')[0]},`,
``,
`Your order is confirmed! Here's a summary:`,
``,
`Order #: ${params.shortRef}`,
`Items: ${params.items}`,
...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []),
`Order #: ${params.shortRef}`,
``,
itemsBlock,
...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []),
``,
isDelivery ? `Delivery: ${slotStr}` : `Pickup: ${slotStr}`,
...(params.address ? [`Address: ${params.address}`] : []),
``,
...chargesLines,
``,
isDelivery
? `Delivery: ${slot}`
: `Pickup: ${slot}`,
...(params.address ? [`Address: ${params.address}`] : []),
``,
`Total: ${total}`,
``,
isDelivery
? [
`We'll be in touch to confirm your delivery window. If you need to make any changes,`,
`please call or text us as soon as possible.`,
].join('\n')
? `If you need to make any changes, please call or text us as soon as possible.`
: [
`Pick up at 554 Boston Post Rd, Milford CT 06460.`,
`Please bring this confirmation. If you need to reschedule, give us a call!`,
].join('\n'),
``,
`Thank you for choosing Beach Party Balloons! 🎈`,
`Thank you for choosing Beach Party Balloons!`,
``,
`— The Beach Party Balloons Team`,
`beachpartyballoons.com`,
]
// Build ICS calendar attachment
const icsEndISO = params.slotEndISO ?? new Date(new Date(params.slotISO).getTime() + 60 * 60_000).toISOString()
const itemsSummary = params.lineItems.map((li) => `${li.quantity}× ${li.name}`).join(', ')
const icsDesc = [
`Order #${params.shortRef}`,
`Items: ${itemsSummary}`,
...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []),
...(params.address ? [`Address: ${params.address}`] : []),
].join('\\n')
const icsSummary = isDelivery ? 'Balloon Delivery — Beach Party Balloons' : 'Balloon Pickup — Beach Party Balloons'
const icsContent = buildCustomerICS({
uid: `customer-${params.orderId}@beachpartyballoons.com`,
startISO: params.slotISO,
endISO: icsEndISO,
summary: icsSummary,
description: icsDesc,
location: params.address ?? (isDelivery ? undefined : '554 Boston Post Rd, Milford CT 06460'),
})
await send({
to: params.customerEmail,
subject: `Your Beach Party Balloons order is confirmed! 🎈 (#${params.shortRef})`,
subject: `Your Beach Party Balloons order is confirmed! (#${params.shortRef})`,
text: lines.join('\n'),
attachments: [{
filename: 'appointment.ics',
content: icsContent,
contentType: 'text/calendar; method=REQUEST',
}],
})
}
@ -131,12 +275,6 @@ export async function sendSlotConflictAlert(params: {
const to = process.env.ALERT_EMAIL_TO
if (!to) { console.warn('[notify] ALERT_EMAIL_TO not set'); return }
const slot = new Date(params.slotISO).toLocaleString('en-US', {
timeZone: 'America/New_York',
dateStyle: 'full',
timeStyle: 'short',
})
await send({
to,
subject: `🚨 ACTION REQUIRED: Slot not blocked — Order #${params.shortRef}`,
@ -147,7 +285,7 @@ export async function sendSlotConflictAlert(params: {
``,
`Order: #${params.shortRef} (${params.orderId})`,
`Customer: ${params.customerName} ${params.customerPhone}`,
`Slot: ${slot}`,
`Slot: ${fmtDate(params.slotISO)}`,
`Address: ${params.address}`,
`Items: ${params.items}`,
``,
@ -160,28 +298,52 @@ export async function sendSlotConflictAlert(params: {
}
export async function sendNewOrderAlert(params: {
shortRef: string
orderId: string
customerName: string
customerPhone: string
customerEmail: string
fulfillment: 'delivery' | 'pickup'
slotISO: string
address?: string
items: string
colors: string[]
totalCents: bigint
shortRef: string
orderId: string
customerName: string
customerPhone: string
customerEmail: string
fulfillment: 'delivery' | 'pickup'
slotISO: string
slotEndISO?: string
address?: string
lineItems: EmailLineItem[]
colors: string[]
subtotalCents?: number
deliveryCents?: number
totalCents: bigint
}): Promise<void> {
const to = process.env.ALERT_EMAIL_TO
if (!to) return
const slot = new Date(params.slotISO).toLocaleString('en-US', {
timeZone: 'America/New_York',
dateStyle: 'full',
timeStyle: 'short',
})
const slotStr = params.slotEndISO
? fmtWindow(params.slotISO, params.slotEndISO)
: fmtDate(params.slotISO)
const total = `$${(Number(params.totalCents) / 100).toFixed(2)}`
const chargesLines: string[] = []
if (params.subtotalCents != null) {
chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`)
if (params.deliveryCents) {
chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`)
}
const impliedTax = Number(params.totalCents) - params.subtotalCents - (params.deliveryCents ?? 0)
if (impliedTax > 0) {
chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`)
}
chargesLines.push(`Total: ${total}`)
} else {
chargesLines.push(`Total: ${total}`)
}
const slotLine = params.fulfillment === 'delivery'
? `Delivery: ${slotStr}`
: `Pickup: ${slotStr}`
const itemsBlock = formatLineItems(params.lineItems)
const hasPerItemColors = params.lineItems.some((li) => li.colors?.length)
const orderColors = !hasPerItemColors && params.colors.length ? params.colors : []
const total = `$${(Number(params.totalCents) / 100).toFixed(2)}`
const lines = [
`New ${params.fulfillment} order — #${params.shortRef}`,
``,
@ -189,13 +351,13 @@ export async function sendNewOrderAlert(params: {
`Phone: ${params.customerPhone}`,
`Email: ${params.customerEmail}`,
``,
`${params.fulfillment === 'delivery' ? 'Delivery' : 'Pickup'}: ${slot}`,
slotLine,
...(params.address ? [`Address: ${params.address}`] : []),
``,
`Items: ${params.items}`,
...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []),
itemsBlock,
...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []),
``,
`Total: ${total}`,
...chargesLines,
``,
`View in Square: https://squareup.com/dashboard/orders`,
]

View File

@ -9,6 +9,7 @@ export interface ItemOverride {
sortOrder?: number
showColors?: boolean
hiddenModifierIds?: string[]
hiddenVariationIds?: string[]
descriptionOverride?: string
/** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */
modifierMinSelected?: Record<string, number>

View File

@ -18,6 +18,7 @@
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="/nav.js" defer></script>
<style>
@ -43,57 +44,7 @@
</style>
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="../">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item" href="../">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item is-tab is-active" href="#">
About Us
</a>
<a class="navbar-item" href="../faq/">
FAQ
</a>
<a class="navbar-item" href="../terms/">
Terms
</a>
<!-- <div class="navbar-item "> -->
<a class="navbar-item" href="../gallery/">
Gallery
</a>
<a class="navbar-item" href="../color/">Colors</a>
<a class="navbar-item" href="../contact/">
Contact
</a>
</div>
</div>
<div class="navbar-end">
</div>
</div>
</nav>
<div id="site-nav"></div>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
@ -158,24 +109,7 @@ Our expertise lies in crafting memorable experiences through a diverse array of
</div> -->
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<div id="site-footer"></div>
<script defer src="../script.js"></script>
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
<script async data-nf='{"formurl":"https://forms.beachpartyballoons.com/forms/contact-us-vjz40v","emoji":"💬","position":"left","bgcolor":"#0dc9ba","width":"500"}' src='https://forms.beachpartyballoons.com/widgets/embed-min.js'></script>

View File

@ -1,20 +1,141 @@
#clearSelection:hover {
color: #f14668;
/* ── Hero override: slim down the header ───────────────────────────────────── */
.admin-hero.admin-hero-slim {
padding: 0.85rem 1.5rem;
margin-bottom: 0;
}
.admin-hero.admin-hero-slim .title {
line-height: 1.2;
}
/* ── File input ─────────────────────────────────────────────────────────────── */
.admin-file-input .file-cta {
background: #e8f4fb;
border-color: #b5d9ef;
color: #2e7dbf;
font-weight: 600;
}
.admin-file-input .file-name {
max-width: none;
flex: 1;
color: #555;
border-color: #b5d9ef;
}
.admin-file-input:hover .file-cta {
background: #cde8f5;
}
/* ── Gallery cards ──────────────────────────────────────────────────────────── */
.low-tag-card {
box-shadow: 0 0 0 2px #ffdd57 inset;
}
.admin-gallery-grid .card {
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.admin-gallery-grid .card:hover {
box-shadow: 0 6px 20px rgba(24, 40, 72, 0.13);
transform: translateY(-2px);
}
/* Selected card: blue ring */
.admin-gallery-grid .card:has(.select-photo-checkbox:checked) {
box-shadow: 0 0 0 2.5px #2e7dbf inset, 0 6px 20px rgba(24, 40, 72, 0.1);
}
/* Card top bar (checkbox + tag count row) */
.admin-gallery-grid .card-content.py-2 {
background: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid #ebe5d2;
}
/* Action buttons in card footer */
.admin-gallery-grid .card-footer-item {
font-weight: 600;
transition: background 0.12s ease, color 0.12s ease;
gap: 0.35em;
}
.admin-gallery-grid .card-footer-item.edit-button {
color: #2e7dbf;
}
.admin-gallery-grid .card-footer-item.edit-button:hover {
background: #e8f4fb;
color: #1a5a8a;
}
.admin-gallery-grid .card-footer-item.delete-button {
color: #cc3333;
}
.admin-gallery-grid .card-footer-item.delete-button:hover {
background: #fdf0f0;
color: #991111;
}
/* ── Bulk panel ─────────────────────────────────────────────────────────────── */
#bulkPanel {
position: sticky;
top: 16px;
z-index: 10;
box-shadow: 0 12px 24px rgba(17, 17, 17, 0.08);
}
@media (max-width: 768px) {
#bulkPanel {
top: 8px;
}
}
#clearSelection:hover {
color: #f14668;
}
/* ── Store status: toggle switch ────────────────────────────────────────────── */
.admin-toggle {
display: inline-flex;
align-items: center;
gap: 0.65rem;
cursor: pointer;
user-select: none;
}
.admin-toggle input[type="checkbox"] {
display: none;
}
.admin-toggle-track {
display: inline-block;
width: 42px;
height: 24px;
border-radius: 12px;
background: #ccc;
position: relative;
transition: background 0.2s ease;
flex-shrink: 0;
}
.admin-toggle-track::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
transition: transform 0.2s ease;
}
.admin-toggle input:checked ~ .admin-toggle-track {
background: #e53e3e;
}
.admin-toggle input:checked ~ .admin-toggle-track::after {
transform: translateX(18px);
}
.admin-toggle-label {
font-size: 0.875rem;
color: #555;
}
.admin-toggle input:checked ~ .admin-toggle-track + .admin-toggle-label,
.admin-toggle input:checked ~ .admin-toggle-label {
color: #c53030;
font-weight: 600;
}
/* Response notification */
#response {
border-radius: 10px;
}

View File

@ -258,7 +258,6 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="card-content py-2 px-3 is-flex is-align-items-center is-justify-content-space-between">
<label class="checkbox is-size-7">
<input type="checkbox" class="select-photo-checkbox" data-photo-id="${photo._id}" ${selectedPhotoIds.has(photo._id) ? 'checked' : ''}>
Select
</label>
<span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span>
</div>
@ -272,8 +271,8 @@ document.addEventListener('DOMContentLoaded', () => {
<p class="has-text-dark"><strong class="has-text-dark">Tags:</strong> ${readableTags.join(', ')}</p>
</div>
<footer class="card-footer">
<a href="#" class="card-footer-item edit-button">Edit</a>
<a href="#" class="card-footer-item delete-button">Delete</a>
<a href="#" class="card-footer-item edit-button"><i class="fas fa-pencil mr-1"></i>Edit</a>
<a href="#" class="card-footer-item delete-button"><i class="fas fa-trash mr-1"></i>Delete</a>
</footer>
</div>
</div>
@ -737,6 +736,7 @@ document.addEventListener('DOMContentLoaded', () => {
const currentStatus = data[0];
messageInput.value = currentStatus.message;
isClosedCheckbox.checked = currentStatus.isClosed;
isClosedCheckbox.dispatchEvent(new Event('change'));
closedMessageInput.value = currentStatus.closedMessage;
} catch (error) {
console.error('Error fetching current status:', error);

View File

@ -11,7 +11,6 @@
</head>
<body class="admin-page">
<div id="login-modal" class="modal is-active">
<!-- <div class="modal-background"></div> -->
<div class="modal-content">
<div class="box admin-login-card has-background-light">
<div class="has-text-centered mb-4">
@ -47,19 +46,26 @@
</div>
</nav>
<div class="admin-hero">
<div class="admin-hero admin-hero-slim">
<div class="container">
<p class="tag is-light admin-kicker">Control Center</p>
<h1 class="title is-2 has-text-white">Admin Panel</h1>
<p class="subtitle is-5 has-text-white-bis">Upload new work, curate your gallery, and update the store status in one place.</p>
<div class="is-flex is-align-items-center" style="gap: 0.75rem;">
<div>
<h1 class="title is-5 has-text-white mb-0">Admin Panel</h1>
<p class="is-size-7 has-text-white-ter">Beach Party Balloons</p>
</div>
</div>
</div>
</div>
<div class="container padding">
<div class="tabs is-boxed">
<ul>
<li class="is-active" data-tab="photo-tab"><a>Photo Gallery</a></li>
<li data-tab="status-tab"><a>Store Status</a></li>
<li class="is-active" data-tab="photo-tab">
<a><span class="icon is-small"><i class="fas fa-images"></i></span><span>Photo Gallery</span></a>
</li>
<li data-tab="status-tab">
<a><span class="icon is-small"><i class="fas fa-bell"></i></span><span>Store Status</span></a>
</li>
</ul>
</div>
@ -67,15 +73,23 @@
<div class="columns is-variable is-5 photo-columns">
<div class="column upload-column">
<div class="box admin-card">
<p class="is-size-5 has-text-weight-semibold mb-3">Upload new photo</p>
<p class="is-size-5 has-text-weight-semibold mb-1">Upload new photo</p>
<p class="is-size-7 has-text-grey mb-4">Add a caption and tags to keep the gallery searchable.</p>
<form id="uploadForm" novalidate>
<div class="field">
<label class="label">Photo</label>
<div class="control">
<input class="input has-background-light has-text-black" type="file" id="photoInput" accept="image/*,.heic,.heif" multiple required>
<p class="help is-size-7 has-text-grey">Select one or many images (including HEIC/HEIF); each will be converted to WebP automatically.</p>
<div class="file has-name is-fullwidth admin-file-input">
<label class="file-label">
<input class="file-input" type="file" id="photoInput" accept="image/*,.heic,.heif" multiple required
onchange="var n=this.files.length; document.getElementById('photoFileName').textContent = n>1 ? n+' files selected' : (this.files[0]?.name || 'No files selected')">
<span class="file-cta">
<span class="file-icon"><i class="fas fa-cloud-upload-alt"></i></span>
<span class="file-label">Choose photos…</span>
</span>
<span class="file-name" id="photoFileName">No files selected</span>
</label>
</div>
<p class="help is-size-7 has-text-grey mt-1">HEIC/HEIF auto-converted to WebP.</p>
</div>
<div class="field">
<label class="label has-text-dark">Caption</label>
@ -85,17 +99,20 @@
</div>
</div>
<div class="field">
<label class="label has-text-dark">Tags (comma-separated)</label>
<label class="label has-text-dark">Tags <span class="has-text-grey has-text-weight-normal">(comma-separated)</span></label>
<div class="control">
<input class="input has-background-light has-text-black" type="text" id="tagsInput" placeholder="classic, birthday" list="tagSuggestions" required>
<datalist id="tagSuggestions"></datalist>
<p class="help is-size-7 has-text-grey">Pick from the curated list or presets; up to 8 tags per photo.</p>
<p class="help is-size-7 has-text-grey">Up to 8 tags per photo.</p>
</div>
<div class="buttons are-small mt-2" id="quickTagButtons" aria-label="Quick tag suggestions">
</div>
</div>
<div class="control">
<button class="button is-primary is-fullwidth" id="uploadButton">Upload</button>
<button class="button is-primary is-fullwidth" id="uploadButton">
<span class="icon"><i class="fas fa-upload"></i></span>
<span>Upload</span>
</button>
</div>
<progress id="uploadProgress" class="progress is-primary mt-3" value="0" max="100" style="display: none;"></progress>
<p id="uploadStatus" class="help mt-3"></p>
@ -106,15 +123,14 @@
<div class="box admin-card">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-3">
<div>
<p class="is-size-5 has-text-weight-semibold mb-1">Manage existing photos</p>
<p class="is-size-7 has-text-grey">Edit captions/tags or delete images you no longer want visible.</p>
<p class="is-size-5 has-text-weight-semibold mb-1">Manage photos</p>
<p class="is-size-7 has-text-grey">Edit captions/tags or delete images.</p>
</div>
<span class="tag is-info is-light"><i class="fas fa-images mr-2"></i>Gallery</span>
</div>
<div class="field mb-4">
<label class="label is-size-7 has-text-dark">Search by caption or tag</label>
<div class="control">
<input class="input is-small has-background-light has-text-dark" type="text" id="manageSearchInput" placeholder="e.g. classic, wedding, arch">
<div class="control has-icons-left">
<input class="input is-small has-background-light has-text-dark" type="text" id="manageSearchInput" placeholder="Search by caption or tag…">
<span class="icon is-small is-left"><i class="fas fa-search"></i></span>
</div>
</div>
<div class="box has-background-light mb-4" id="bulkPanel" style="display: none;">
@ -131,14 +147,14 @@
</div>
<div class="columns is-multiline">
<div class="column is-full-mobile is-half-tablet">
<label class="label is-size-7 has-text-dark">New caption (optional)</label>
<label class="label is-size-7 has-text-dark">New caption <span class="has-text-grey has-text-weight-normal">(optional)</span></label>
<input class="input is-small has-background-white has-text-dark" type="text" id="bulkCaption" placeholder="Leave blank to keep captions">
</div>
<div class="column is-full-mobile is-half-tablet">
<label class="label is-size-7 has-text-dark">Tags (comma-separated, optional)</label>
<label class="label is-size-7 has-text-dark">Tags <span class="has-text-grey has-text-weight-normal">(optional)</span></label>
<input class="input is-small has-background-white has-text-dark" type="text" id="bulkTags" placeholder="e.g. arch, pastel">
<label class="checkbox is-size-7 mt-1 has-text-dark">
<input type="checkbox" id="bulkAppendTags"> Append to existing tags (unchecked = replace)
<input type="checkbox" id="bulkAppendTags"> Append to existing tags
</label>
</div>
<div class="column is-full-mobile">
@ -159,40 +175,58 @@
<div id="status-tab" class="tab-content" style="display: none;">
<div class="box admin-card">
<div class="is-flex is-align-items-center is-justify-content-space-between mb-3">
<div>
<p class="is-size-5 has-text-weight-semibold mb-1">Store status & scrolling message</p>
<p class="is-size-7 has-text-grey">Update the homepage banner and closed message.</p>
</div>
<span class="tag is-warning is-light"><i class="fas fa-bell mr-2"></i>Status</span>
</div>
<p class="is-size-5 has-text-weight-semibold mb-1">Store status</p>
<p class="is-size-7 has-text-grey mb-5">Update the homepage banner and closed message.</p>
<div class="field">
<label class="label has-text-dark">Scrolling Message</label>
<div class="control">
<input class="input has-background-light has-text-dark" id="scrollingMessageInput" type="text" placeholder="Enter message for the scrolling bar">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="isClosedCheckbox">
Is the store closed?
<label class="label has-text-dark">
<span class="icon-text">
<span class="icon has-text-info"><i class="fas fa-bullhorn"></i></span>
<span>Scrolling announcement</span>
</span>
</label>
</div>
<div class="field">
<label class="label has-text-dark">Closed Message</label>
<div class="control">
<input class="input has-background-light has-text-dark" id="closedMessageInput" type="text" placeholder="Enter message to display when closed">
<input class="input has-background-light has-text-dark" id="scrollingMessageInput" type="text" placeholder="e.g. Now booking for summer events!">
</div>
</div>
<div class="control">
<button id="updateButton" class="button is-primary">Update</button>
<div class="field">
<label class="label has-text-dark">
<span class="icon-text">
<span class="icon has-text-warning"><i class="fas fa-store-slash"></i></span>
<span>Store closed?</span>
</span>
</label>
<div class="control">
<label class="admin-toggle">
<input type="checkbox" id="isClosedCheckbox">
<span class="admin-toggle-track"></span>
<span class="admin-toggle-label" id="closedToggleLabel">Store is open</span>
</label>
</div>
</div>
<div id="response" class="notification is-light mt-4"></div>
<div class="field">
<label class="label has-text-dark">
<span class="icon-text">
<span class="icon has-text-danger"><i class="fas fa-exclamation-circle"></i></span>
<span>Closed message</span>
</span>
</label>
<div class="control">
<input class="input has-background-light has-text-dark" id="closedMessageInput" type="text" placeholder="e.g. We're closed for the season. Back in spring!">
</div>
<p class="help has-text-grey">Shown to visitors when the store is marked closed.</p>
</div>
<div class="control mt-5">
<button id="updateButton" class="button is-primary">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save changes</span>
</button>
</div>
<div id="response" class="notification is-light mt-4" style="display:none;"></div>
</div>
</div>
</div>
@ -238,7 +272,7 @@
</header>
<section class="modal-card-body">
<p>Are you sure you want to delete the selected photos? This action cannot be undone.</p>
<p id="bulk-delete-count" class="has-text-weight-bold"></p>
<p id="bulk-delete-count" class="has-text-weight-bold mt-2"></p>
</section>
<footer class="modal-card-foot">
<button id="confirmBulkDelete" class="button is-danger">Delete</button>
@ -248,5 +282,25 @@
</div>
<script src="admin.js" defer></script>
<script>
// Keep "Store is open/closed" toggle label in sync
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('isClosedCheckbox');
const lbl = document.getElementById('closedToggleLabel');
if (cb && lbl) {
const sync = () => { lbl.textContent = cb.checked ? 'Store is closed' : 'Store is open'; };
cb.addEventListener('change', sync);
}
// Hide response div until there's a message
const resp = document.getElementById('response');
if (resp) {
const orig = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'textContent');
// Instead, just watch via MutationObserver
new MutationObserver(() => {
resp.style.display = resp.textContent.trim() ? '' : 'none';
}).observe(resp, { childList: true, subtree: true, characterData: true });
}
});
</script>
</body>
</html>

View File

@ -1,3 +0,0 @@
{
"liveServer.settings.port": 5504
}

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -56,28 +56,6 @@ body.modal-open > *:not(.palette-modal-backdrop):not(.preset-modal-backdrop):not
pointer-events: none;
}
.navbar {
background: rgba(14, 167, 160, 0.94) !important;
backdrop-filter: blur(6px);
}
.navbar .navbar-item,
.navbar .navbar-link {
color: #08383b;
font-weight: 700;
}
.navbar .navbar-item.is-active,
.navbar .navbar-item:hover {
background: rgba(255, 255, 255, 0.28) !important;
color: #072d2f !important;
}
.navbar-brand .navbar-item img {
max-height: 2.5rem;
border-radius: 8px;
padding: 4px;
}
.color-picker-app {
width: min(1240px, calc(100% - 20px));
@ -1430,9 +1408,6 @@ footer {
right: -8px;
}
.navbar-brand .navbar-item img {
max-height: 2rem;
}
#preset-palette-list {
grid-template-columns: 1fr;

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

View File

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View File

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 160 KiB

View File

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

View File

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

View File

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 225 KiB

View File

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 521 KiB

View File

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="color.css">
<script src="/nav.js" defer></script>
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png">
@ -20,34 +21,8 @@
<link rel="manifest" href="../assets/favicon/site.webmanifest">
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="/">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item is-tab" href="/">Home</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">Shop</a>
<a class="navbar-item" href="../about/">About Us</a>
<a class="navbar-item" href="../faq/">FAQ</a>
<a class="navbar-item" href="../terms/">Terms</a>
<a class="navbar-item" href="../gallery/">Gallery</a>
<a class="navbar-item is-active is-tab" href="../color/">Colors</a>
<a class="navbar-item" href="../contact/">Contact</a>
</div>
</div>
</nav>
<body>
<div id="site-nav"></div>
<main class="color-picker-app">
<section class="picker-layout">
@ -95,9 +70,7 @@
</section>
</main>
<footer>
<p>Made with 🎨 and 🎈 | Chris © 2025</p>
</footer>
<div id="site-footer"></div>
<div class="palette-modal-backdrop">
<div class="palette-modal">

View File

Before

Width:  |  Height:  |  Size: 515 B

After

Width:  |  Height:  |  Size: 515 B

View File

@ -19,6 +19,7 @@
<link href="https://fonts.googleapis.com/css2?family=Autour+One&family=Mogra&family=Rubik+Glitch&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="/nav.js" defer></script>
<style>
@ -44,57 +45,7 @@
</style>
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="../">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item " href="../">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item" href="../about/">
About Us
</a>
<a class="navbar-item" href="#">
FAQ
</a>
<a class="navbar-item" href="../terms/">
Terms
</a>
<!-- <div class="navbar-item "> -->
<a class="navbar-item" href="../gallery/">
Gallery
</a>
<a class="navbar-item" href="../color/">Colors</a>
<a class="navbar-item is-tab is-active" href="../contact/">
Contact
</a>
</div>
</div>
<div class="navbar-end">
</div>
</div>
</nav>
<div id="site-nav"></div>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
<div class="is-flex-direction-column is-dark">
@ -108,24 +59,7 @@
<iframe style="border:none;width:100%;" id="contact-us-vjz40v" src="https://forms.beachpartyballoons.com/forms/contact-us-vjz40v"></iframe><script type="text/javascript" onload="initEmbed('contact-us-vjz40v')" src="https://forms.beachpartyballoons.com/widgets/iframe.min.js"></script>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<div id="site-footer"></div>
<script src="../script.js"></script>
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
</body>

View File

@ -18,6 +18,7 @@
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="/nav.js" defer></script>
<style>
@ -59,57 +60,7 @@
</style>
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="../">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item " href="../">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item" href="../about/">
About Us
</a>
<a class="navbar-item is-tab is-active" href="#">
FAQ
</a>
<a class="navbar-item" href="../terms/">
Terms
</a>
<!-- <div class="navbar-item "> -->
<a class="navbar-item" href="../gallery/">
Gallery
</a>
<a class="navbar-item" href="../color/">Colors</a>
<a class="navbar-item" href="../contact/">
Contact
</a>
</div>
</div>
<div class="navbar-end">
</div>
</div>
</nav>
<div id="site-nav"></div>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
<section class="section theme-light">
@ -279,24 +230,7 @@
</div>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<div id="site-footer"></div>
<script src="../script.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {

View File

@ -17,58 +17,10 @@
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="gallery.css">
<script src="/nav.js" defer></script>
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="/">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item" href="/">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item" href="/about/">
About Us
</a>
<a class="navbar-item" href="/faq/">
FAQ
</a>
<a class="navbar-item" href="/terms/">
Terms
</a>
<a class="navbar-item is-tab is-active" href="/gallery/">
Gallery
</a>
<a class="navbar-item" href="/color/">
Colors
</a>
<a class="navbar-item" href="/contact/">
Contact
</a>
</div>
</div>
<div class="navbar-end">
</div>
</div>
</nav>
<div id="site-nav"></div>
<div class="update">
<div id="message"></div>
</div>
@ -149,24 +101,7 @@
</div>
</div>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<div id="site-footer"></div>
<script>
// Force gallery API to the hosted backend to avoid localhost/mixed-content issues.
window.GALLERY_API_URL = 'https://photobackend.beachpartyballoons.com';

View File

@ -18,60 +18,10 @@
<link href="https://fonts.googleapis.com/css2?family=Autour+One&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="/nav.js" defer></script>
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="/">
<img style="background-color: white;" src="assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item is-tab is-active" href="/">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item" href="about/">
About Us
</a>
<a class="navbar-item" href="faq/">
FAQ
</a>
<a class="navbar-item" href="terms/">
Terms
</a>
<a class="navbar-item" href="gallery/">
Gallery
</a>
<a class="navbar-item" href="color/">
Colors
</a>
<a class="navbar-item" href="contact/">
Contact
</a>
</div>
</div>
<div class="navbar-end">
</div>
</div>
</nav>
<div id="site-nav"></div>
<div class="update">
<div id="message"></div>
</div>
@ -101,7 +51,7 @@
<p class="is-size-5 has-text-centered">Walk-ins welcome!</p>
<p class="is-size-6 has-text-centered"> Pick up a balloon arrangement for birthdays or any occasion. We will make it while you wait! </p>
<p class="is-size-6 has-text-centered">We have hundreds of foil balloon choices in stock and over 40 latex colors! </p>
<p class="is-size-6 has-text-centered">We have hundreds of foil balloon choices in stock and over 70 latex colors! </p>
<p class="is-size-5 has-text-centered">...Or consult with one of our designers about your balloon decorating needs.</p>
@ -203,24 +153,7 @@
</div>
</section>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<div id="site-footer"></div>
<script src="script.js"></script>
<script src="update.js"></script>
<script src="reviews-data.js"></script>

77
main-site/nav.js Normal file
View File

@ -0,0 +1,77 @@
(function () {
var path = window.location.pathname;
var links = [
{ label: 'Home', href: '/' },
{ label: 'Shop', href: '/shop' },
{ label: 'About Us', href: '/about/' },
{ label: 'FAQ', href: '/faq/' },
{ label: 'Gallery', href: '/gallery/' },
{ label: 'Colors', href: '/color/' },
{ label: 'Contact', href: '/contact/' },
];
function isActive(href) {
if (href === '/') return path === '/';
return path === href || path.startsWith(href);
}
var items = links.map(function (l) {
var active = isActive(l.href) ? ' is-tab is-active' : '';
return '<a class="navbar-item' + active + '" href="' + l.href + '">' + l.label + '</a>';
}).join('\n ');
var navHTML = '<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">\n' +
' <div class="navbar-brand is-size-1">\n' +
' <a class="navbar-item" href="/">\n' +
' <img style="background-color:white" src="/assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">\n' +
' </a>\n' +
' <a role="button" class="navbar-burger" id="site-burger" aria-label="menu" aria-expanded="false" data-target="site-navbar-menu">\n' +
' <span aria-hidden="true"></span>\n' +
' <span aria-hidden="true"></span>\n' +
' <span aria-hidden="true"></span>\n' +
' <span aria-hidden="true"></span>\n' +
' </a>\n' +
' </div>\n' +
' <div id="site-navbar-menu" class="navbar-menu has-text-right">\n' +
' <div class="navbar-end">\n' +
' ' + items + '\n' +
' </div>\n' +
' </div>\n' +
'</nav>';
var footerHTML = '<footer class="footer has-background-primary-light">\n' +
' <div class="content has-text-centered">\n' +
' <div>\n' +
' <a target="_blank" rel="noopener noreferrer" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i></a>\n' +
' <a target="_blank" rel="noopener noreferrer" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i></a>\n' +
' <a target="_blank" rel="noopener noreferrer" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i></a>\n' +
' <a target="_blank" rel="noopener noreferrer" href="https://bsky.app/profile/beachpartyballoons.bsky.social"><i class="fa-brands fa-bluesky is-size-2"></i></a>\n' +
' </div>\n' +
' <p>Copyright &copy; ' + new Date().getFullYear() + ' Beach Party Balloons</p>\n' +
' <p>All images &amp; content are property of Beach Party Balloons. Use of images without written permission is prohibited.</p>\n' +
' <p style="font-size:0.8rem;margin-top:0.5rem">\n' +
' <a href="/privacy/">Privacy Policy</a>\n' +
' &nbsp;&middot;&nbsp;\n' +
' <a href="/terms/">Terms of Service</a>\n' +
' &nbsp;&middot;&nbsp;\n' +
' <a href="/refund/">Refund &amp; Cancellation Policy</a>\n' +
' </p>\n' +
' </div>\n' +
'</footer>';
var navEl = document.getElementById('site-nav');
if (navEl) navEl.outerHTML = navHTML;
var footerEl = document.getElementById('site-footer');
if (footerEl) footerEl.outerHTML = footerHTML;
// Burger toggle
document.addEventListener('click', function (e) {
var burger = e.target.closest('.navbar-burger');
if (!burger) return;
var target = document.getElementById(burger.dataset.target);
burger.classList.toggle('is-active');
if (target) target.classList.toggle('is-active');
});
})();

View File

@ -1,34 +1,6 @@
let statusInterval;
// Mobile NavBar
document.addEventListener('DOMContentLoaded', () => {
// Get all "navbar-burger" elements
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
// Add a click event on each of them
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
// Get the target from the "data-target" attribute
const target = el.dataset.target;
const $target = document.getElementById(target);
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
el.classList.toggle('is-active');
$target.classList.toggle('is-active');
});
});
});
// Footer copyright date
const copyDate = ( () => {
let date = new Date();
document.getElementById("year").innerHTML = "&nbsp" + date.getFullYear() + "&nbsp";
} );
copyDate();
// Burger toggle and footer year are handled by nav.js
document.addEventListener("load", function () {
// Your code goes here
document.querySelectorAll("formFooter").style.display = "none";

42
nginx/nginx.conf Normal file
View File

@ -0,0 +1,42 @@
events {}
http {
# ── eStore (Next.js) ─────────────────────────────────────────────────────────
# All estore routes live under /shop (Next.js basePath).
# This includes pages, API routes, and /_next/ static assets.
upstream estore {
server estore:3000;
}
# ── Main site (Express) ──────────────────────────────────────────────────────
upstream main_site {
server main-site:3050;
}
server {
listen 80;
server_name _;
client_max_body_size 20m;
# eStore: /shop and everything under it (pages, API, _next assets)
location ^~ /shop {
proxy_pass http://estore;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Main site: everything else
location / {
proxy_pass http://main_site;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

6
osrm/Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM osrm/osrm-backend:latest
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

5
osrm/Dockerfile.download Normal file
View File

@ -0,0 +1,5 @@
FROM alpine:3.19
RUN apk add --no-cache curl
COPY download.sh /download.sh
RUN chmod +x /download.sh
ENTRYPOINT ["/download.sh"]

25
osrm/download.sh Normal file
View File

@ -0,0 +1,25 @@
#!/bin/sh
set -e
REGION="${OSRM_REGION:-connecticut-latest}"
PBF="/data/${REGION}.osm.pbf"
URL="https://download.geofabrik.de/north-america/us/${REGION}.osm.pbf"
MIN_BYTES=10000000 # 10 MB — a valid PBF is always larger than this
# Check if a complete file already exists
if [ -f "$PBF" ]; then
SIZE=$(wc -c < "$PBF")
if [ "$SIZE" -gt "$MIN_BYTES" ]; then
echo "[osrm-download] Map data already present ($(( SIZE / 1024 / 1024 )) MB), skipping download."
exit 0
else
echo "[osrm-download] Existing file is too small (${SIZE} bytes), re-downloading..."
rm -f "$PBF"
fi
fi
echo "[osrm-download] Downloading ${REGION} map data from Geofabrik..."
curl --fail -L --progress-bar -o "$PBF" "$URL" || { rm -f "$PBF"; echo "[osrm-download] Download failed."; exit 1; }
SIZE=$(wc -c < "$PBF")
echo "[osrm-download] Download complete ($(( SIZE / 1024 / 1024 )) MB)."

27
osrm/entrypoint.sh Normal file
View File

@ -0,0 +1,27 @@
#!/bin/sh
set -e
REGION="${OSRM_REGION:-connecticut-latest}"
PBF="/data/${REGION}.osm.pbf"
OSRM="/data/${REGION}.osrm"
PROFILE="${OSRM_PROFILE:-/opt/car.lua}"
# The PBF is downloaded by the osrm-download service before this container starts.
if [ ! -f "$PBF" ]; then
echo "[osrm] ERROR: $PBF not found. The osrm-download service should have fetched it."
exit 1
fi
# Preprocess if .osrm files are missing
if [ ! -f "$OSRM" ]; then
echo "[osrm] Extracting road network (this takes a few minutes)..."
osrm-extract -p "$PROFILE" "$PBF"
echo "[osrm] Partitioning..."
osrm-partition "$OSRM"
echo "[osrm] Customizing..."
osrm-customize "$OSRM"
echo "[osrm] Preprocessing complete."
fi
echo "[osrm] Starting routing engine..."
exec osrm-routed --algorithm mld "$OSRM"