From 50680a323f5b84514856a5a741c0759cadd632d6 Mon Sep 17 00:00:00 2001
From: chris
Date: Tue, 14 Apr 2026 21:14:06 -0400
Subject: [PATCH] Major overhaul: shared nav, admin improvements, email
enhancements, routing fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 16 +
.gitignore | 2 +
docker-compose.yml | 71 ++++-
estore/Dockerfile | 12 +
estore/next.config.mjs | 4 +
.../src/app/{shop => }/admin/login/page.tsx | 5 +-
estore/src/app/{shop => }/admin/page.tsx | 213 ++++++++++++-
.../src/app/api/admin/delivery-rates/route.ts | 25 ++
estore/src/app/api/admin/items/route.ts | 3 +
estore/src/app/api/catalog/route.ts | 2 +
estore/src/app/api/checkout/route.ts | 27 +-
estore/src/app/api/delivery-quote/route.ts | 4 +-
estore/src/app/globals.css | 4 +-
estore/src/app/layout.tsx | 8 +-
estore/src/app/page.tsx | 21 +-
estore/src/app/shop/admin/layout.tsx | 3 -
estore/src/app/shop/page.tsx | 12 -
estore/src/components/CartDrawer.tsx | 18 +-
estore/src/components/ColorPicker.tsx | 5 +-
estore/src/components/DeliveryDatePicker.tsx | 5 +-
estore/src/components/FeaturedProducts.tsx | 9 +-
estore/src/components/Footer.tsx | 6 +-
estore/src/components/Hero.tsx | 4 +-
estore/src/components/Navbar.tsx | 59 +---
estore/src/components/PaymentForm.tsx | 3 +-
estore/src/components/WelcomeModal.tsx | 2 +-
estore/src/data/mock-catalog.ts | 2 +-
estore/src/lib/basepath.ts | 3 +
estore/src/lib/caldav.ts | 5 +-
estore/src/lib/delivery-rates.ts | 35 +++
estore/src/lib/delivery.ts | 6 +-
estore/src/lib/notify.ts | 296 ++++++++++++++----
estore/src/lib/overrides.ts | 1 +
main-site/about/index.html | 72 +----
main-site/admin/admin.css | 127 +++++++-
main-site/admin/admin.js | 6 +-
main-site/admin/index.html | 152 ++++++---
main-site/color-picker/.vscode/settings.json | 3 -
main-site/{color-picker => color}/1shine.svg | Bin
.../assets/logo/BeachPartyBalloons-logo.webp | Bin
.../assets/pictures/asfalt-dark.png | Bin
main-site/{color-picker => color}/color.css | 25 --
main-site/{color-picker => color}/colors.json | 0
.../images/1balloon-mask.svg | 0
.../images/balloon-mask(1).svg | 0
.../images/balloon-mask.svg | 0
.../images/chrome-blue.webp | Bin
.../images/chrome-champagne.webp | Bin
.../images/chrome-gold.webp | Bin
.../images/chrome-green.webp | Bin
.../images/chrome-pink.webp | Bin
.../images/chrome-purple.webp | Bin
.../images/chrome-rosegold.webp | Bin
.../images/chrome-silver.webp | Bin
.../images/chrome-spacegrey.webp | Bin
.../images/chrome-truffle.webp | Bin
.../images/classic-gold.webp | Bin
.../images/classic-silver.webp | Bin
.../images/metalic-rosegold.webp | Bin
.../images/pearl-fuchsia.webp | Bin
.../images/pearl-lightblue.webp | Bin
.../images/pearl-lilac.webp | Bin
.../images/pearl-midnightblue.webp | Bin
.../images/pearl-peach.webp | Bin
.../images/pearl-periwinkle.webp | Bin
.../images/pearl-pink.webp | Bin
.../images/pearl-sapphire.webp | Bin
.../images/pearl-violet.webp | Bin
.../images/pearl-white.webp | Bin
main-site/{color-picker => color}/index.html | 35 +--
main-site/{color-picker => color}/list.txt | 0
main-site/{color-picker => color}/script.js | 0
main-site/{color-picker => color}/shine.svg | 0
main-site/contact/index.html | 72 +----
main-site/faq/index.html | 72 +----
main-site/gallery/index.html | 71 +----
main-site/index.html | 75 +----
main-site/nav.js | 77 +++++
main-site/script.js | 30 +-
nginx/nginx.conf | 42 +++
osrm/Dockerfile | 6 +
osrm/Dockerfile.download | 5 +
osrm/download.sh | 25 ++
osrm/entrypoint.sh | 27 ++
84 files changed, 1112 insertions(+), 701 deletions(-)
create mode 100644 .env.example
rename estore/src/app/{shop => }/admin/login/page.tsx (93%)
rename estore/src/app/{shop => }/admin/page.tsx (88%)
create mode 100644 estore/src/app/api/admin/delivery-rates/route.ts
delete mode 100644 estore/src/app/shop/admin/layout.tsx
delete mode 100644 estore/src/app/shop/page.tsx
create mode 100644 estore/src/lib/basepath.ts
create mode 100644 estore/src/lib/delivery-rates.ts
delete mode 100644 main-site/color-picker/.vscode/settings.json
rename main-site/{color-picker => color}/1shine.svg (100%)
rename main-site/{color-picker => color}/assets/logo/BeachPartyBalloons-logo.webp (100%)
rename main-site/{color-picker => color}/assets/pictures/asfalt-dark.png (100%)
rename main-site/{color-picker => color}/color.css (98%)
rename main-site/{color-picker => color}/colors.json (100%)
rename main-site/{color-picker => color}/images/1balloon-mask.svg (100%)
rename main-site/{color-picker => color}/images/balloon-mask(1).svg (100%)
rename main-site/{color-picker => color}/images/balloon-mask.svg (100%)
rename main-site/{color-picker => color}/images/chrome-blue.webp (100%)
rename main-site/{color-picker => color}/images/chrome-champagne.webp (100%)
rename main-site/{color-picker => color}/images/chrome-gold.webp (100%)
rename main-site/{color-picker => color}/images/chrome-green.webp (100%)
rename main-site/{color-picker => color}/images/chrome-pink.webp (100%)
rename main-site/{color-picker => color}/images/chrome-purple.webp (100%)
rename main-site/{color-picker => color}/images/chrome-rosegold.webp (100%)
rename main-site/{color-picker => color}/images/chrome-silver.webp (100%)
rename main-site/{color-picker => color}/images/chrome-spacegrey.webp (100%)
rename main-site/{color-picker => color}/images/chrome-truffle.webp (100%)
rename main-site/{color-picker => color}/images/classic-gold.webp (100%)
rename main-site/{color-picker => color}/images/classic-silver.webp (100%)
rename main-site/{color-picker => color}/images/metalic-rosegold.webp (100%)
rename main-site/{color-picker => color}/images/pearl-fuchsia.webp (100%)
rename main-site/{color-picker => color}/images/pearl-lightblue.webp (100%)
rename main-site/{color-picker => color}/images/pearl-lilac.webp (100%)
rename main-site/{color-picker => color}/images/pearl-midnightblue.webp (100%)
rename main-site/{color-picker => color}/images/pearl-peach.webp (100%)
rename main-site/{color-picker => color}/images/pearl-periwinkle.webp (100%)
rename main-site/{color-picker => color}/images/pearl-pink.webp (100%)
rename main-site/{color-picker => color}/images/pearl-sapphire.webp (100%)
rename main-site/{color-picker => color}/images/pearl-violet.webp (100%)
rename main-site/{color-picker => color}/images/pearl-white.webp (100%)
rename main-site/{color-picker => color}/index.html (76%)
rename main-site/{color-picker => color}/list.txt (100%)
rename main-site/{color-picker => color}/script.js (100%)
rename main-site/{color-picker => color}/shine.svg (100%)
create mode 100644 main-site/nav.js
create mode 100644 nginx/nginx.conf
create mode 100644 osrm/Dockerfile
create mode 100644 osrm/Dockerfile.download
create mode 100644 osrm/download.sh
create mode 100644 osrm/entrypoint.sh
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5075b90
--- /dev/null
+++ b/.env.example
@@ -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.
diff --git a/.gitignore b/.gitignore
index 857c5fc..fc9ccec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/*
diff --git a/docker-compose.yml b/docker-compose.yml
index 1154361..114f5a9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/estore/Dockerfile b/estore/Dockerfile
index 0eaf73f..73e0a4e 100644
--- a/estore/Dockerfile
+++ b/estore/Dockerfile
@@ -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 ────────────────────────────────────────────────
diff --git a/estore/next.config.mjs b/estore/next.config.mjs
index 773f7b6..6ffdbc4 100644
--- a/estore/next.config.mjs
+++ b/estore/next.config.mjs
@@ -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' },
diff --git a/estore/src/app/shop/admin/login/page.tsx b/estore/src/app/admin/login/page.tsx
similarity index 93%
rename from estore/src/app/shop/admin/login/page.tsx
rename to estore/src/app/admin/login/page.tsx
index 469b63b..6f16719 100644
--- a/estore/src/app/shop/admin/login/page.tsx
+++ b/estore/src/app/admin/login/page.tsx
@@ -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)
diff --git a/estore/src/app/shop/admin/page.tsx b/estore/src/app/admin/page.tsx
similarity index 88%
rename from estore/src/app/shop/admin/page.tsx
rename to estore/src/app/admin/page.tsx
index 97e200c..d94909d 100644
--- a/estore/src/app/shop/admin/page.tsx
+++ b/estore/src/app/admin/page.tsx
@@ -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 = {
+ dropoff: 'Drop-off',
+ classic: 'Setup & strike',
+ organic: 'Organic setup & strike',
+}
+
+function DeliveryRatesEditor() {
+ const [rates, setRates] = useState(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 Loading…
+
+ return (
+
+
+ Set the base fee and per-mile rate for each delivery type. Changes apply to new quotes immediately.
+
+
+
+ Formula: base + ceil(miles) × per-mile . Example: drop-off to a 5-mile address =
+ {' '}base + 5 × per-mile.
+
+
+
+ Save rates
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+
+ )
+}
+
// ─── Item Editor ──────────────────────────────────────────────────────────────
function ItemEditor({
@@ -601,6 +730,7 @@ function ItemEditor({
const [showColors, setShowColors] = useState(
ov.showColors != null ? ov.showColors : null
)
+ const [hiddenVars, setHiddenVars] = useState(ov.hiddenVariationIds ?? [])
const [hiddenMods, setHiddenMods] = useState(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 = {
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 */}
+ {/* Variations */}
+ {item._rawVariations.length > 1 && (
+
+
Variations
+
+ {item._rawVariations.map((v) => {
+ const visible = !hiddenVars.includes(v.id)
+ return (
+
+
+
+ toggleVar(v.id)}
+ style={{ marginRight: 6 }}
+ />
+ {v.name}
+
+
+ ${(v.priceCents / 100).toFixed(2)}
+
+
+
+ )
+ })}
+
+
+ )}
+
{/* Modifiers */}
{item._rawModifiers.length > 0 && (
@@ -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
(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 {
- 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() {
setTab('occasions')}>Holidays
+
+ setTab('delivery')}>Delivery rates
+
@@ -1456,6 +1632,9 @@ export default function AdminPage() {
{/* Holidays tab */}
{tab === 'occasions' &&
}
+
+ {/* Delivery rates tab */}
+ {tab === 'delivery' &&
}
)
diff --git a/estore/src/app/api/admin/delivery-rates/route.ts b/estore/src/app/api/admin/delivery-rates/route.ts
new file mode 100644
index 0000000..33b7b7f
--- /dev/null
+++ b/estore/src/app/api/admin/delivery-rates/route.ts
@@ -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 })
+ }
+}
diff --git a/estore/src/app/api/admin/items/route.ts b/estore/src/app/api/admin/items/route.ts
index efc7a71..e363695 100644
--- a/estore/src/app/api/admin/items/route.ts
+++ b/estore/src/app/api/admin/items/route.ts
@@ -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,
diff --git a/estore/src/app/api/catalog/route.ts b/estore/src/app/api/catalog/route.ts
index 1ccfc7e..17c8bc3 100644
--- a/estore/src/app/api/catalog/route.ts
+++ b/estore/src/app/api/catalog/route.ts
@@ -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) => {
diff --git a/estore/src/app/api/checkout/route.ts b/estore/src/app/api/checkout/route.ts
index 8f11239..7579e97 100644
--- a/estore/src/app/api/checkout/route.ts
+++ b/estore/src/app/api/checkout/route.ts
@@ -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) {
diff --git a/estore/src/app/api/delivery-quote/route.ts b/estore/src/app/api/delivery-quote/route.ts
index b3a64f7..8018f1c 100644
--- a/estore/src/app/api/delivery-quote/route.ts
+++ b/estore/src/app/api/delivery-quote/route.ts
@@ -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(
diff --git a/estore/src/app/globals.css b/estore/src/app/globals.css
index 8824054..726e335 100644
--- a/estore/src/app/globals.css
+++ b/estore/src/app/globals.css
@@ -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;
diff --git a/estore/src/app/layout.tsx b/estore/src/app/layout.tsx
index 032c4e2..e8ca3d3 100644
--- a/estore/src/app/layout.tsx
+++ b/estore/src/app/layout.tsx
@@ -37,10 +37,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
- {/* Favicon */}
-
-
-
+ {/* Favicon — prefix with basePath so nginx routes them to the estore */}
+
+
+
diff --git a/estore/src/app/page.tsx b/estore/src/app/page.tsx
index 5ea19a5..8298942 100644
--- a/estore/src/app/page.tsx
+++ b/estore/src/app/page.tsx
@@ -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 (
- <>
-
-
-
-
- >
- )
+export const metadata: Metadata = {
+ title: 'Shop',
+ description:
+ 'Browse all balloon arrangements, arches, centerpieces, and installations by Beach Party Balloons.',
+}
+
+export default function ShopPage() {
+ return
}
diff --git a/estore/src/app/shop/admin/layout.tsx b/estore/src/app/shop/admin/layout.tsx
deleted file mode 100644
index feaab0e..0000000
--- a/estore/src/app/shop/admin/layout.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function AdminLayout({ children }: { children: React.ReactNode }) {
- return <>{children}>
-}
diff --git a/estore/src/app/shop/page.tsx b/estore/src/app/shop/page.tsx
deleted file mode 100644
index 8298942..0000000
--- a/estore/src/app/shop/page.tsx
+++ /dev/null
@@ -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
-}
diff --git a/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx
index f967039..7b929f8 100644
--- a/estore/src/components/CartDrawer.tsx
+++ b/estore/src/components/CartDrawer.tsx
@@ -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 && {infoErrors.phone}
}
diff --git a/estore/src/components/ColorPicker.tsx b/estore/src/components/ColorPicker.tsx
index c130190..7686bf3 100644
--- a/estore/src/components/ColorPicker.tsx
+++ b/estore/src/components/ColorPicker.tsx
@@ -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 */}
- We stock 40+ latex colors . Tap a family below to browse shades,
+ We stock 70+ latex colors . 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.`}
diff --git a/estore/src/components/DeliveryDatePicker.tsx b/estore/src/components/DeliveryDatePicker.tsx
index 92399d6..63b2126 100644
--- a/estore/src/components/DeliveryDatePicker.tsx
+++ b/estore/src/components/DeliveryDatePicker.tsx
@@ -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 }
diff --git a/estore/src/components/FeaturedProducts.tsx b/estore/src/components/FeaturedProducts.tsx
index 2d5cbfd..5bcbcf6 100644
--- a/estore/src/components/FeaturedProducts.tsx
+++ b/estore/src/components/FeaturedProducts.tsx
@@ -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[] },
diff --git a/estore/src/components/Footer.tsx b/estore/src/components/Footer.tsx
index d10befb..45ecfe7 100644
--- a/estore/src/components/Footer.tsx
+++ b/estore/src/components/Footer.tsx
@@ -44,11 +44,11 @@ export default function Footer() {
Use of images without written permission is prohibited.
- Privacy Policy
+ Privacy Policy
{' · '}
- Terms of Service
+ Terms of Service
{' · '}
- Refund & Cancellation Policy
+ Refund & Cancellation Policy
diff --git a/estore/src/components/Hero.tsx b/estore/src/components/Hero.tsx
index 05cb868..29923e8 100644
--- a/estore/src/components/Hero.tsx
+++ b/estore/src/components/Hero.tsx
@@ -8,7 +8,7 @@ export default function Hero() {
{/* Scrolling announcement bar */}
- 🎈 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
@@ -63,7 +63,7 @@ export default function Hero() {
it while you wait!
- 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!
diff --git a/estore/src/components/Navbar.tsx b/estore/src/components/Navbar.tsx
index 102875e..ffa8805 100644
--- a/estore/src/components/Navbar.tsx
+++ b/estore/src/components/Navbar.tsx
@@ -2,7 +2,10 @@
import { useState } from 'react'
import Link from 'next/link'
+import { BASE } from '@/lib/basepath'
+// Use for links that leave the estore (main site pages) so Next.js basePath
+// is not prepended. Use 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"
>
diff --git a/estore/src/components/PaymentForm.tsx b/estore/src/components/PaymentForm.tsx
index 4eb72ee..1adf364 100644
--- a/estore/src/components/PaymentForm.tsx
+++ b/estore/src/components/PaymentForm.tsx
@@ -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,
diff --git a/estore/src/components/WelcomeModal.tsx b/estore/src/components/WelcomeModal.tsx
index 7dba6a7..8e434d6 100644
--- a/estore/src/components/WelcomeModal.tsx
+++ b/estore/src/components/WelcomeModal.tsx
@@ -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.' },
]
diff --git a/estore/src/data/mock-catalog.ts b/estore/src/data/mock-catalog.ts
index bd443a7..2f3f611 100644
--- a/estore/src/data/mock-catalog.ts
+++ b/estore/src/data/mock-catalog.ts
@@ -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,
diff --git a/estore/src/lib/basepath.ts b/estore/src/lib/basepath.ts
new file mode 100644
index 0000000..910f4c3
--- /dev/null
+++ b/estore/src/lib/basepath.ts
@@ -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 ?? ''
diff --git a/estore/src/lib/caldav.ts b/estore/src/lib/caldav.ts
index 097b5fe..807515b 100644
--- a/estore/src/lib/caldav.ts
+++ b/estore/src/lib/caldav.ts
@@ -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
+ 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)
+}
diff --git a/estore/src/lib/delivery.ts b/estore/src/lib/delivery.ts
index 80825b5..5e5d908 100644
--- a/estore/src/lib/delivery.ts
+++ b/estore/src/lib/delivery.ts
@@ -30,6 +30,9 @@ export const JOB_MINUTES: Record = {
organic: 240, // 4 hrs setup + strike
}
+// ── Configurable rates type (file I/O lives in delivery-rates.ts, server only) ─
+export type DeliveryRatesConfig = Record
+
/** 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 {
- 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
diff --git a/estore/src/lib/notify.ts b/estore/src/lib/notify.ts
index 6e5883a..82e44c9 100644
--- a/estore/src/lib/notify.ts
+++ b/estore/src/lib/notify.ts
@@ -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 {
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 = {}
+ 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 {
- 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 {
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`,
]
diff --git a/estore/src/lib/overrides.ts b/estore/src/lib/overrides.ts
index 0525a79..715a44f 100644
--- a/estore/src/lib/overrides.ts
+++ b/estore/src/lib/overrides.ts
@@ -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
diff --git a/main-site/about/index.html b/main-site/about/index.html
index 31f5e11..e3b00b8 100644
--- a/main-site/about/index.html
+++ b/main-site/about/index.html
@@ -18,6 +18,7 @@
+
-
-
-
-
-
-
-
-
-
-
+
Top
@@ -158,24 +109,7 @@ Our expertise lies in crafting memorable experiences through a diverse array of
-->
-
+
diff --git a/main-site/admin/admin.css b/main-site/admin/admin.css
index 37a01fd..c2b9e23 100644
--- a/main-site/admin/admin.css
+++ b/main-site/admin/admin.css
@@ -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;
+}
diff --git a/main-site/admin/admin.js b/main-site/admin/admin.js
index f1871f8..b2f3de8 100644
--- a/main-site/admin/admin.js
+++ b/main-site/admin/admin.js
@@ -258,7 +258,6 @@ document.addEventListener('DOMContentLoaded', () => {
- Select
${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}
@@ -272,8 +271,8 @@ document.addEventListener('DOMContentLoaded', () => {
Tags: ${readableTags.join(', ')}
@@ -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);
diff --git a/main-site/admin/index.html b/main-site/admin/index.html
index b56748c..34495e9 100644
--- a/main-site/admin/index.html
+++ b/main-site/admin/index.html
@@ -11,7 +11,6 @@
-
@@ -47,19 +46,26 @@
-
+
-
Control Center
-
Admin Panel
-
Upload new work, curate your gallery, and update the store status in one place.
+
+
+
Admin Panel
+
Beach Party Balloons
+
+
@@ -67,15 +73,23 @@
-
Upload new photo
+
Upload new photo
Add a caption and tags to keep the gallery searchable.