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>
16
.env.example
Normal 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
@ -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/*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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)
|
||||
@ -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>
|
||||
)
|
||||
25
estore/src/app/api/admin/delivery-rates/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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.`}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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[] },
|
||||
|
||||
@ -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 & Cancellation Policy</a>
|
||||
<a href="/shop/refund">Refund & Cancellation Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.' },
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
3
estore/src/lib/basepath.ts
Normal 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 ?? ''
|
||||
@ -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
|
||||
|
||||
35
estore/src/lib/delivery-rates.ts
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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`,
|
||||
]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 © <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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
3
main-site/color-picker/.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
||||
{
|
||||
"liveServer.settings.port": 5504
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@ -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;
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 521 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
@ -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">
|
||||
|
Before Width: | Height: | Size: 515 B After Width: | Height: | Size: 515 B |
@ -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 © <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>
|
||||
|
||||
@ -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 © <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', () => {
|
||||
|
||||
@ -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 © <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';
|
||||
|
||||
@ -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 © <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
@ -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 © ' + new Date().getFullYear() + ' Beach Party Balloons</p>\n' +
|
||||
' <p>All images & 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' +
|
||||
' · \n' +
|
||||
' <a href="/terms/">Terms of Service</a>\n' +
|
||||
' · \n' +
|
||||
' <a href="/refund/">Refund & 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');
|
||||
});
|
||||
})();
|
||||
@ -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 = " " + date.getFullYear() + " ";
|
||||
} );
|
||||
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
@ -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
@ -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
@ -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
@ -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
@ -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"
|
||||