Compare commits

..

5 Commits

Author SHA1 Message Date
dbd3589add Add Google Pay and Apple Pay support; fix catalog category filter
- PaymentForm now initialises Google Pay and Apple Pay via Square's Web
  Payments SDK alongside the existing card form; wallet buttons appear
  above the card with an "or pay with card" divider when available
- Apple Pay domain verification file added to public/.well-known/
- square.ts: fix online-category filter to show all items when the
  category doesn't exist; support multi-category display per item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:53:55 -04:00
e7fec9ea72 Merge beachPartyBalloons estore features into balloons-shop
- Multi-category support: CatalogItem gains categories/categoryLabels arrays;
  catalog route applies categoriesOverride; FeaturedProducts filters by array
- Featured sorting: featured items sort first in catalog route
- Admin panel: featured toggle, requiresDelivery with per-item rate overrides,
  multi-category checkboxes, variation visibility, AdminColorFilter modal,
  delivery rates tab (DeliveryRatesEditor)
- Per-item delivery rate overrides: delivery-quote route accepts rateOverride
  and reads from delivery-rates.json via readDeliveryRates()
- disabledColors, hiddenVariationIds applied in catalog and admin routes
- ScrollToTop button added to layout
- GuidedTour gains optional onStart prop; tourInit resets category/search
- Occasion tab deduplication in FeaturedProducts
- New components: ScrollToTop, AdminColorFilter, useLockBodyScroll,
  delivery-rates lib, admin/delivery-rates API route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:27:27 -04:00
b1606302b0 Restore osrm-init profile and fix host port mapping
Adds back the osrm-init service (profile: init) used by docker/osrm/update.sh
to extract, partition, and customize CT map data. Also documents that the host
port should be set to match the reverse proxy expectation (e.g. 8660:3000).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:02:33 -04:00
68bfe79db8 Expose vinylEnabled toggle in admin panel
item-overrides.json is gitignored (runtime data), so vinylEnabled must
be settable from the admin UI rather than committed directly. Adds the
"Enable vinyl configurator" checkbox alongside the existing vinyl promo
toggle so production overrides can be managed without touching files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:11:59 -04:00
1f1dabdb31 Add custom vinyl balloon configurator
Adds a per-letter vinyl text add-on tied to the Custom Vinyl Square item.
Customers pick a balloon shape (Heart/Star/Circle), type their message
(max 30 non-space chars, ASCII only — no emoji), and choose from 8 Google
Fonts rendered as live previews. Price updates in real time at $0.65/letter.

At checkout, vinyl orders expand to two Square line items: the 18" Shape
balloon at its catalog price and the Custom Vinyl service at the calculated
letter count price, with the font attached as a modifier.

Also adds a per-item admin toggle ("Suggest custom vinyl add-on") that shows
a promo note on any balloon's product modal pointing customers toward the
vinyl service.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:03:38 -04:00
2295 changed files with 1120 additions and 330651 deletions

View File

@ -1,16 +1,49 @@
# ── Root .env (fallback for Docker Compose < v2.24) ─────────────────────────── # ─── Square API ────────────────────────────────────────────────────────────────
# # Get these from https://developer.squareup.com/apps
# Docker Compose v2.24+ reads build env_file directly from estore/.env — you SQUARE_ACCESS_TOKEN=your_square_access_token_here
# should not need this file at all on a modern install. SQUARE_LOCATION_ID=your_square_location_id_here
# # "sandbox" or "production"
# If your Compose is older and the shop shows "Online payment is not SQUARE_ENVIRONMENT=sandbox
# configured", copy the four NEXT_PUBLIC_* lines from estore/.env into this
# file so Compose can bake them into the Next.js build: # These are exposed to the browser — use your Square Application ID (not access token)
# # and the same location ID as above.
# NEXT_PUBLIC_SQUARE_APP_ID= NEXT_PUBLIC_SQUARE_APP_ID=sandbox-sq0idb-your_app_id_here
# NEXT_PUBLIC_SQUARE_LOCATION_ID= NEXT_PUBLIC_SQUARE_LOCATION_ID=your_square_location_id_here
# NEXT_PUBLIC_SQUARE_ENVIRONMENT=production # "sandbox" or "production" — controls which Square JS SDK URL is loaded
# NEXT_PUBLIC_SITE_URL=https://shop.beachpartyballoons.com NEXT_PUBLIC_SQUARE_ENVIRONMENT=sandbox
# # Optional: ID of a Square category (Items > Categories) whose items appear in the shop.
# All other secrets (access tokens, passwords, etc.) belong only in estore/.env # If set, only items in this category are shown. Otherwise falls back to src/config/shop-items.json.
# — never put them here. SQUARE_SHOP_CATEGORY_ID=
# ─── CalDAV (Nextcloud) ────────────────────────────────────────────────────────
# Your Nextcloud CalDAV base URL — include trailing slash
CALDAV_URL=https://your-nextcloud.example.com/remote.php/dav/calendars/username/
CALDAV_USERNAME=your_nextcloud_username
# Use an app password (Settings > Security > Devices & sessions > App passwords)
CALDAV_PASSWORD=your_nextcloud_app_password
# Display name of the calendar to check for Busy blocks
CALDAV_CALENDAR_NAME=Deliveries
# ─── Email (SMTP — your mail server) ──────────────────────────────────────────
SMTP_HOST=mail.beachpartyballoons.com
SMTP_PORT=587
SMTP_USER=shop@beachpartyballoons.com
SMTP_PASS=your_email_password_here
# Address that receives new-order & slot-conflict alerts (you/staff)
ALERT_EMAIL_TO=you@beachpartyballoons.com
# Sender shown on outgoing emails
ALERT_EMAIL_FROM=shop@beachpartyballoons.com
# ─── Admin panel ───────────────────────────────────────────────────────────────
# Password to access /admin — keep this secret
ADMIN_PASSWORD=change_me_to_something_strong
# Secret token for the cron cache-refresh endpoint (POST /api/cache/refresh)
CACHE_REFRESH_SECRET=change_me_to_something_random
# ─── OSRM (self-hosted routing) ────────────────────────────────────────────────
# Leave blank to use the public demo server (unreliable). Self-host for production:
# https://hub.docker.com/r/osrm/osrm-backend
OSRM_URL=http://localhost:5000
# ─── Site ──────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_SITE_URL=http://localhost:3000

29
.gitignore vendored
View File

@ -1,28 +1,18 @@
# Dependencies # dependencies
node_modules/ node_modules/
.pnp
.pnp.js
# Next.js # Next.js build output
estore/.next/ .next/
estore/out/ out/
# Runtime data # Runtime data — cache and item overrides change at runtime, don't track them
estore/data/catalog-cache.json data/catalog-cache.json
estore/data/item-overrides.json data/item-overrides.json
estore/data/hours.json
estore/data/delivery-rates.json
main-site/photo-gallery-app/backend/uploads/
mongodb_data/
osrm/data/*
!osrm/data/.gitkeep
# Environment variables — never commit # Environment variables — never commit these
.env .env
.env.local .env.local
.env*.local .env*.local
*/.env
*/.env.local
# OS / editor # OS / editor
.DS_Store .DS_Store
@ -36,4 +26,7 @@ npm-debug.log*
# Misc # Misc
.eslintcache .eslintcache
# Raw/duplicate image files — use public/images/ directly
public/images/pics/
*.heic *.heic

View File

@ -10,18 +10,6 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 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 RUN npm run build
# ── Stage 3: Production runner ──────────────────────────────────────────────── # ── Stage 3: Production runner ────────────────────────────────────────────────
@ -38,11 +26,6 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Ensure the data directory exists and is writable by the nextjs user.
# For bind-mount deployments, the host directory must also be owned by uid 1001:
# sudo chown -R 1001:1001 estore/data
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000

22
data/vinyl-config.json Normal file
View File

@ -0,0 +1,22 @@
{
"vinylItemId": "7RLHYXSCI3QBXXCSZHZHVEMG",
"vinylVariationId": "6BORBAGKFLW3A6BREWMV3CB6",
"pricePerLetterCents": 65,
"maxCharacters": 30,
"shapeItemId": "46JGZU6MYPMQL7M6USGL3KKB",
"shapes": [
{ "name": "Heart", "variationId": "OOR23NGE53SDQY6UQRE2X353", "priceCents": 450 },
{ "name": "Star", "variationId": "7GQY7OXJ6HGNPD2PS7EMP7SM", "priceCents": 450 },
{ "name": "Circle", "variationId": "EVI4L5I5ZT3RRONMXXZ3KQ3U", "priceCents": 450 }
],
"fonts": [
{ "id": "HY4DFCCRUHIV5HLUBUNAFGQY", "name": "Anton", "family": "Anton" },
{ "id": "KMUNDFS4G6QFWCOS5KOBCKHL", "name": "Montserrat", "family": "Montserrat" },
{ "id": "TH7LNXBNV2NKJG5DOSJDOF4K", "name": "Indie Flower", "family": "Indie Flower" },
{ "id": "56CZ2GXWZU3OBBXMGS4FECNX", "name": "Pacifico", "family": "Pacifico" },
{ "id": "JVYX3XCQI2FRB23MOS3PXBJX", "name": "Style Script", "family": "Style Script" },
{ "id": "5I7ICFBD2KUJFH7J3SHU4HX3", "name": "MedievalSharp", "family": "MedievalSharp" },
{ "id": "73RT5RUWTAP4OKLCZZJHV47J", "name": "Luckiest Guy", "family": "Luckiest Guy" },
{ "id": "ST3M6TDB6PRN2JMJXA6EBEZ4", "name": "Playfair Display","family": "Playfair Display" }
]
}

View File

@ -1,116 +1,38 @@
services: services:
osrm-init:
# ── Nginx reverse proxy ─────────────────────────────────────────────────────── image: osrm/osrm-backend
nginx: container_name: osrm-init
image: nginx:alpine profiles: [init]
container_name: bpb-nginx
ports:
- "80:80"
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./docker/osrm/data:/data
depends_on: entrypoint: /bin/sh -c
- main-site command: >
- estore "osrm-extract -p /opt/car.lua /data/connecticut-latest.osm.pbf &&
restart: always osrm-partition /data/connecticut-latest.osrm &&
osrm-customize /data/connecticut-latest.osrm"
# ── Main website ───────────────────────────────────────────────────────────── osrm:
main-site: image: osrm/osrm-backend
build: ./main-site container_name: osrm
container_name: bpb-main restart: unless-stopped
expose:
- "3050"
environment:
NODE_ENV: production
ADMIN_PASSWORD: ${MAIN_ADMIN_PASSWORD}
volumes:
- ./main-site/update.json:/usr/src/app/update.json
restart: always
depends_on:
- gallery-backend
# ── Photo gallery backend ─────────────────────────────────────────────────────
gallery-backend:
build: ./main-site/photo-gallery-app/backend
container_name: bpb-gallery
ports: ports:
- "5002:5000" - "5002:5000"
environment:
MONGO_URI: mongodb://mongodb:27017/photogallery
WATERMARK_URL: http://watermarker:8000/watermark
volumes: volumes:
- ./main-site/photo-gallery-app/backend/uploads:/usr/src/app/uploads - ./docker/osrm/data:/data
depends_on: command: osrm-routed --algorithm mld /data/connecticut-latest.osrm
- mongodb
- watermarker
restart: always
# ── Watermarker ─────────────────────────────────────────────────────────────── balloons-shop:
watermarker: build: .
build: ./main-site/photo-gallery-app/watermarker container_name: balloons-shop
container_name: bpb-watermarker
restart: always
# ── MongoDB ───────────────────────────────────────────────────────────────────
mongodb:
image: mongo:latest
container_name: bpb-mongodb
ports: ports:
- "27017:27017" - "3000:3000"
volumes: env_file:
- ./mongodb_data:/data/db - .env
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:
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
expose:
- "3000"
env_file: ./estore/.env
volumes:
- ./estore/data:/app/data
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- osrm - osrm
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/shop/api/catalog').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/catalog"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
# ── 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
environment:
OSRM_REGION: connecticut-latest
restart: "no"
# ── OSRM (routing engine) ─────────────────────────────────────────────────────
osrm:
build: ./osrm
container_name: bpb-osrm
expose:
- "5000"
volumes:
- ./osrm/data:/data
environment:
OSRM_REGION: connecticut-latest
OSRM_PROFILE: /opt/car.lua
depends_on:
osrm-download:
condition: service_completed_successfully
restart: unless-stopped

View File

@ -1,49 +0,0 @@
# ─── Square API ────────────────────────────────────────────────────────────────
# Get these from https://developer.squareup.com/apps
SQUARE_ACCESS_TOKEN=your_square_access_token_here
SQUARE_LOCATION_ID=your_square_location_id_here
# "sandbox" or "production"
SQUARE_ENVIRONMENT=sandbox
# These are exposed to the browser — use your Square Application ID (not access token)
# and the same location ID as above.
NEXT_PUBLIC_SQUARE_APP_ID=sandbox-sq0idb-your_app_id_here
NEXT_PUBLIC_SQUARE_LOCATION_ID=your_square_location_id_here
# "sandbox" or "production" — controls which Square JS SDK URL is loaded
NEXT_PUBLIC_SQUARE_ENVIRONMENT=sandbox
# Optional: ID of a Square category (Items > Categories) whose items appear in the shop.
# If set, only items in this category are shown. Otherwise falls back to src/config/shop-items.json.
SQUARE_SHOP_CATEGORY_ID=
# ─── CalDAV (Nextcloud) ────────────────────────────────────────────────────────
# Your Nextcloud CalDAV base URL — include trailing slash
CALDAV_URL=https://your-nextcloud.example.com/remote.php/dav/calendars/username/
CALDAV_USERNAME=your_nextcloud_username
# Use an app password (Settings > Security > Devices & sessions > App passwords)
CALDAV_PASSWORD=your_nextcloud_app_password
# Display name of the calendar to check for Busy blocks
CALDAV_CALENDAR_NAME=Deliveries
# ─── Email (SMTP — your mail server) ──────────────────────────────────────────
SMTP_HOST=mail.beachpartyballoons.com
SMTP_PORT=587
SMTP_USER=shop@beachpartyballoons.com
SMTP_PASS=your_email_password_here
# Address that receives new-order & slot-conflict alerts (you/staff)
ALERT_EMAIL_TO=you@beachpartyballoons.com
# Sender shown on outgoing emails
ALERT_EMAIL_FROM=shop@beachpartyballoons.com
# ─── Admin panel ───────────────────────────────────────────────────────────────
# Password to access /admin — keep this secret
ADMIN_PASSWORD=change_me_to_something_strong
# Secret token for the cron cache-refresh endpoint (POST /api/cache/refresh)
CACHE_REFRESH_SECRET=change_me_to_something_random
# ─── OSRM (self-hosted routing) ────────────────────────────────────────────────
# Leave blank to use the public demo server (unreliable). Self-host for production:
# https://hub.docker.com/r/osrm/osrm-backend
OSRM_URL=http://localhost:5000
# ─── Site ──────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_SITE_URL=http://localhost:3000

32
estore/.gitignore vendored
View File

@ -1,32 +0,0 @@
# dependencies
node_modules/
# Next.js build output
.next/
out/
# Runtime data — cache and item overrides change at runtime, don't track them
data/catalog-cache.json
data/item-overrides.json
# Environment variables — never commit these
.env
.env.local
.env*.local
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Logs
*.log
npm-debug.log*
# Misc
.eslintcache
# Raw/duplicate image files — use public/images/ directly
public/images/pics/
*.heic

View File

@ -1,11 +0,0 @@
{
"order": [
"latex",
"birthday",
"mylar-bouquets",
"graduation",
"letters-and-numbers",
"other"
],
"hidden": []
}

View File

@ -1,8 +0,0 @@
{
"mothers-day": {
"windowStart": "04-10"
},
"graduation": {
"windowStart": "04-01"
}
}

View File

@ -1,26 +0,0 @@
services:
osrm:
image: osrm/osrm-backend
container_name: osrm
restart: unless-stopped
ports:
- "5002:5000"
volumes:
- ./docker/osrm/data:/data
command: osrm-routed --algorithm mld /data/connecticut-latest.osrm
balloons-shop:
build: .
container_name: balloons-shop
ports:
- "3000:3000"
env_file:
- .env
restart: unless-stopped
depends_on:
- osrm
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/catalog"]
interval: 30s
timeout: 10s
retries: 3

View File

@ -1,55 +0,0 @@
'use client'
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)
return (
<nav
className="navbar is-info is-spaced has-shadow"
role="navigation"
aria-label="main navigation"
>
<div className="navbar-brand is-size-1">
<a className="navbar-item" href="/">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
style={{ backgroundColor: 'white' }}
src={`${BASE}/images/logo/BeachPartyBalloons-logo.webp`}
alt="Beach Party Balloons logo"
/>
</a>
<a
role="button"
className={`navbar-burger ${isOpen ? 'is-active' : ''}`}
aria-label="menu"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</a>
</div>
<div className={`navbar-menu has-text-right ${isOpen ? 'is-active' : ''}`}>
<div className="navbar-end">
<a className="navbar-item" href="/">Home</a>
<Link className="navbar-item is-tab is-active" href="/">Shop</Link>
<a className="navbar-item" href="/about/">About Us</a>
<a className="navbar-item" href="/faq/">FAQ</a>
<a className="navbar-item" href="/gallery/">Gallery</a>
<a className="navbar-item" href="/color/">Colors</a>
<a className="navbar-item" href="/contact/">Contact</a>
</div>
</div>
</nav>
)
}

View File

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

View File

@ -1,388 +0,0 @@
/**
* Email notifications via your own SMTP server (nodemailer).
*
* Required env vars:
* SMTP_HOST your mail server hostname (e.g. mail.beachpartyballoons.com)
* SMTP_PORT 587 (STARTTLS) or 465 (SSL), defaults to 587
* SMTP_USER SMTP login username
* SMTP_PASS SMTP login password
* ALERT_EMAIL_TO address that receives alerts (e.g. chris@beachpartyballoons.com)
* ALERT_EMAIL_FROM sender address (e.g. shop@beachpartyballoons.com)
*/
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)
const user = process.env.SMTP_USER
const pass = process.env.SMTP_PASS
if (!host || !user || !pass) return null
return nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
})
}
async function send(params: {
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()
if (!transporter) {
const missing = ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS'].filter((k) => !process.env[k])
console.error('[notify] SMTP not configured — missing env vars:', missing.join(', '), '— email skipped:', params.subject)
return
}
try {
await transporter.sendMail({
from,
to: params.to,
subject: params.subject,
text: params.text,
attachments: params.attachments,
})
console.log('[notify] Email sent:', params.subject, '→', params.to)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
console.error('[notify] SMTP send failed:', msg, '| subject:', params.subject, '| to:', params.to)
throw err // re-throw so callers can handle/log it
}
}
// ── 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
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 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}`,
``,
itemsBlock,
...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []),
``,
isDelivery ? `Delivery: ${slotStr}` : `Pickup: ${slotStr}`,
...(params.address ? [`Address: ${params.address}`] : []),
``,
...chargesLines,
``,
isDelivery
? `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!`,
``,
`— 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})`,
text: lines.join('\n'),
attachments: [{
filename: 'appointment.ics',
content: icsContent,
contentType: 'text/calendar; method=REQUEST',
}],
})
}
export async function sendSlotConflictAlert(params: {
shortRef: string
orderId: string
customerName: string
customerPhone: string
slotISO: string
address: string
items: string
}): Promise<void> {
const to = process.env.ALERT_EMAIL_TO
if (!to) { console.warn('[notify] ALERT_EMAIL_TO not set'); return }
await send({
to,
subject: `🚨 ACTION REQUIRED: Slot not blocked — Order #${params.shortRef}`,
text: [
`URGENT — Payment succeeded but the delivery slot was NOT written to your calendar.`,
``,
`You must manually block this slot immediately to prevent a double-booking.`,
``,
`Order: #${params.shortRef} (${params.orderId})`,
`Customer: ${params.customerName} ${params.customerPhone}`,
`Slot: ${fmtDate(params.slotISO)}`,
`Address: ${params.address}`,
`Items: ${params.items}`,
``,
`Steps:`,
` 1. Block the slot in your calendar now.`,
` 2. Confirm the booking with the customer by phone.`,
` 3. Check Square dashboard for the full order details.`,
].join('\n'),
})
}
export async function sendNewOrderAlert(params: {
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 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 lines = [
`New ${params.fulfillment} order — #${params.shortRef}`,
``,
`Customer: ${params.customerName}`,
`Phone: ${params.customerPhone}`,
`Email: ${params.customerEmail}`,
``,
slotLine,
...(params.address ? [`Address: ${params.address}`] : []),
``,
itemsBlock,
...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []),
``,
...chargesLines,
``,
`View in Square: https://squareup.com/dashboard/orders`,
]
await send({
to,
subject: `🎈 New order #${params.shortRef}${params.customerName} (${params.fulfillment})`,
text: lines.join('\n'),
})
}
export async function sendAdminErrorAlert(params: {
subject: string
message: string
context?: Record<string, unknown>
}): Promise<void> {
const to = 'admin@beachpartyballoons.com'
const lines = [
params.message,
'',
...(params.context
? Object.entries(params.context).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
: []),
'',
`Time: ${new Date().toISOString()}`,
]
await send({ to, subject: `⚠️ ${params.subject}`, text: lines.join('\n') })
}

View File

@ -1,18 +0,0 @@
# Docker Build Ignore List
## Dependencies & Local State
node_modules
npm-debug.log*
mongodb_data/
*.swp
## Git & Docker
.git
.gitignore
Dockerfile
.dockerignore
## Development
.vscode/
README.md
.env

View File

@ -1 +0,0 @@
.webp filter=lfs diff=lfs merge=lfs -text

45
main-site/.gitignore vendored
View File

@ -1,45 +0,0 @@
# Dependencies
/node_modules
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs and editors
.idea
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.sublime-workspace
# Misc
.DS_Store
Thumbs.db
/assets/pics/gallery/centerpiece/
/assets/pics/gallery/sculpture/
/assets/pics/gallery/classic/
/assets/pics/gallery/organic/
gallery/centerpiece/index.html
gallery/organic/index.html
gallery/classic/index.html
gallery/sculpture/index.html
# Build artifacts and backups
public/build/
backups/
# Local database files
mongodb_data/
photo-gallery-app/backend/uploads/

View File

@ -1,5 +0,0 @@
{
"recommendations": [
"eliutdev.bulma-css-class-completion"
]
}

View File

@ -1,4 +0,0 @@
{
"liveServer.settings.port": 5517,
"continue.telemetryEnabled": false
}

View File

@ -1,23 +0,0 @@
# Use an official Node.js runtime as a parent image
FROM node:18-alpine
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install any needed packages
RUN npm install
# Bundle app source
COPY . .
# Build optimized frontend assets
RUN npm run build
# Make port 3050 available to the world outside this container
EXPOSE 3050
# Define the command to run the app
CMD [ "node", "server.js" ]

View File

@ -1,9 +0,0 @@
MIT License
Copyright (c) 2025 chris
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,2 +0,0 @@
# bpb-website

View File

@ -1,118 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</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">
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
<link rel="manifest" href="../assets/favicon/site.webmanifest">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beach Party Balloons</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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>
h1 {
margin-bottom: 2.1rem;
}
.section-title {
font-size: 3rem;
margin-bottom: 2rem;
color: #2c3e50;
}
.content-container {
max-width: 850px;
margin: 2rem auto;
padding: 1rem;
}
.article {
margin-bottom: 3rem;
}
</style>
</head>
<body>
<div id="site-nav"></div>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
<div class="container is-justify-content-center padding">
<img src="../assets/pics/classic/ceiling.jpg" alt="balloon ceiling fill" >
</div>
<div class="content-container">
<h1 class="section-title has-text-centered">Beach Party Balloons: A celebration of joy and creativity</h1>
<div class="article">
<p class="has-text-centered">Since 2016, Beach Party Balloons has been steadfastly dedicated to providing exceptional family-friendly entertainment in Milford, Connecticut.
Our journey began with the establishment of our sister company, Painted You, which laid the foundation for crafting unforgettable experiences through creative party services.</p>
</div>
<div class="article">
<p class="has-text-centered">In 2016, Melissa spearheaded the opening of Beach Party Balloons at Walnut Beach, bringing her unwavering passion for joy and magic to the community. Fast forward to today, we have relocated to a new venue at 554 Boston Post Road, continuing our legacy with enhanced innovation and growth. In 2025, Chris and Alyssa, both long time employees, purchased the business from Melissa. </p>
</div>
<div class="article">
<p class="has-text-centered">
Our expertise lies in crafting memorable experiences through a diverse array of services: expert balloon decorations that add vibrant life to your events, custom designs tailored to reflect each client's unique style, interactive balloon art that engages and captivates. We are committed to reflecting the essence of our clients by creating cohesive atmospheres that align with their needs. </p>
</div>
<div class="article has-text-centered">
<p class="has-text-centered">
At Beach Party Balloons, we prioritize excellence in service and relationship-building with our local community.
Our creative solutions exceed expectations, ensuring your events are truly unforgettable. Let us help you turn every detail into a magical memory.</p>
</div>
</div>
</body>
<!-- <div class="article">
<p class="has-text-centered">At Beach Party Balloons, we specialize in a variety of party services designed to make every event memorable. Our expert team offers balloon decorations, custom designs, and interactive balloon art that brings your special moments to life.
</div>
<div class="article">
<p class="has-text-centered">
Committed to excellence, we ensure each event reflects the unique personality of our clients by creating a cohesive atmosphere with our wide range of styles and services. Our goal is to make every experience as magical as possible for you.
</p>
</div>
<div class="article">
<p class="has-text-centered">
We take pride in delivering outstanding service, maintaining strong relationships with local communities, and providing creative solutions that exceed our clients' expectations. At Beach Party Balloons, we believe every detail matters—so let us help you create unforgettable memories.
</p>
</div> -->
</div>
<!-- <div style="margin: auto;">
<script type="text/javascript" src="https://form.jotform.com/jsform/250083932725053"></script>
</div> -->
<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>
</body>
</html>

View File

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

View File

@ -1,784 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
// General Admin Elements
const loginModal = document.getElementById('login-modal');
const loginForm = document.getElementById('loginForm');
const passwordInput = document.getElementById('passwordInput');
const loginButton = document.getElementById('loginButton');
const adminContent = document.getElementById('admin-content');
// Tabs
const tabs = document.querySelectorAll('.tabs li');
const tabContents = document.querySelectorAll('.tab-content');
// Photo Gallery Elements
const uploadForm = document.getElementById('uploadForm');
const uploadButton = document.getElementById('uploadButton');
const uploadStatus = document.getElementById('uploadStatus');
const uploadProgress = document.getElementById('uploadProgress');
const tagsInput = document.getElementById('tagsInput');
const tagSuggestions = document.getElementById('tagSuggestions');
const quickTagButtons = document.getElementById('quickTagButtons');
const captionInput = document.getElementById('captionInput');
const captionToTagsButton = document.getElementById('captionToTags');
const manageGallery = document.getElementById('manage-gallery');
const manageSearchInput = document.getElementById('manageSearchInput');
const editModal = document.getElementById('editModal');
const editPhotoId = document.getElementById('editPhotoId');
const editCaption = document.getElementById('editCaption');
const editTags = document.getElementById('editTags');
const saveChanges = document.getElementById('saveChanges');
const modalCloseButton = editModal.querySelector('.delete');
const modalCancelButton = editModal.querySelector('.modal-card-foot .button:not(.is-success)');
// Bulk Delete Modal
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
const confirmBulkDeleteBtn = document.getElementById('confirmBulkDelete');
const cancelBulkDeleteBtn = document.getElementById('cancelBulkDelete');
const bulkDeleteModalCloseBtn = bulkDeleteModal.querySelector('.delete');
const bulkDeleteCountEl = document.getElementById('bulk-delete-count');
const bulkCaption = document.getElementById('bulkCaption');
const bulkTags = document.getElementById('bulkTags');
const bulkAppendTags = document.getElementById('bulkAppendTags');
const applyBulkEdits = document.getElementById('applyBulkEdits');
const bulkDelete = document.getElementById('bulkDelete');
const selectAllPhotosBtn = document.getElementById('selectAllPhotos');
const clearSelectionBtn = document.getElementById('clearSelection');
const selectedCountEl = document.getElementById('selectedCount');
const bulkPanel = document.getElementById('bulkPanel');
let selectedPhotoIds = new Set();
let photos = [];
// Store Status Elements
const messageInput = document.getElementById('scrollingMessageInput');
const isClosedCheckbox = document.getElementById('isClosedCheckbox');
const closedMessageInput = document.getElementById('closedMessageInput');
const updateButton = document.getElementById('updateButton');
const responseDiv = document.getElementById('response');
const backendUrl = (() => {
const { protocol, hostname } = window.location;
const productionHosts = new Set([
'beachpartyballoons.com',
'www.beachpartyballoons.com',
'preview.beachpartyballoons.com',
'photobackend.beachpartyballoons.com'
]);
const isProduction = productionHosts.has(hostname);
if (!isProduction) {
return 'http://localhost:5001';
}
const backendHostname = 'photobackend.beachpartyballoons.com';
return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443
})();
const LAST_TAGS_KEY = 'bpb-last-tags';
const DEFAULT_MAX_TAGS = 8;
let tagMeta = {
tags: [],
main: [],
other: [],
aliases: {},
presets: [],
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: [],
tagCounts: {}
};
let adminPassword = '';
const storedPassword = localStorage.getItem('bpb-admin-password');
const getAdminPassword = () => adminPassword || localStorage.getItem('bpb-admin-password') || '';
const slugifyTag = (tag) => String(tag || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').trim();
const canonicalizeTag = (tag) => {
const slug = slugifyTag(tag);
const mapped = tagMeta.aliases?.[slug] || slug;
return mapped;
};
const resolveSearchTag = (value) => {
const slug = slugifyTag(value);
if (!slug) return '';
return tagMeta.aliases?.[slug] || slug;
};
const displayTag = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
const canonicalToDisplayString = (canonicalArr) => canonicalArr.map(displayTag).join(', ');
const normalizeTagsInput = (value) => {
const raw = String(value || '')
.split(',')
.map(t => t.trim())
.filter(Boolean);
const seen = new Set();
const canonical = [];
raw.forEach(tag => {
const mapped = canonicalizeTag(tag);
if (mapped && !seen.has(mapped) && canonical.length < (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
seen.add(mapped);
canonical.push(mapped);
}
});
return canonical;
};
const showAdmin = () => {
adminContent.style.display = 'block';
loginModal.classList.remove('is-active');
};
const showLogin = (message) => {
if (message) {
passwordInput.value = '';
passwordInput.placeholder = message;
}
loginModal.classList.add('is-active');
};
const handleUnauthorized = () => {
localStorage.removeItem('bpb-admin-password');
adminPassword = '';
showLogin('Enter password to continue');
};
// --- Password Protection ---
function login(event) {
event.preventDefault();
const passwordVal = passwordInput.value.trim();
if (!passwordVal) return;
adminPassword = passwordVal;
localStorage.setItem('bpb-admin-password', adminPassword);
showAdmin();
fetchTagMeta();
fetchPhotos();
fetchStatus();
preloadLastTags();
}
loginForm.addEventListener('submit', login);
loginButton.addEventListener('click', login);
if (storedPassword) {
adminPassword = storedPassword;
passwordInput.value = storedPassword;
showAdmin();
fetchTagMeta();
fetchPhotos();
fetchStatus();
preloadLastTags();
} else {
showLogin();
}
async function fetchTagMeta() {
try {
const response = await fetch(`${backendUrl}/photos/tags`);
if (!response.ok) return;
const data = await response.json();
tagMeta = {
tags: [],
main: [],
other: [],
aliases: {},
presets: [],
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: [],
tagCounts: {},
...data
};
updateTagSuggestions();
updateQuickTags();
preloadLastTags();
} catch (error) {
console.error('Error fetching tag metadata:', error);
}
}
// --- Tab Switching ---
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(item => item.classList.remove('is-active'));
tab.classList.add('is-active');
const target = document.getElementById(tab.dataset.tab);
tabContents.forEach(content => content.style.display = 'none');
target.style.display = 'block';
});
});
// --- Photo Management ---
async function fetchPhotos() {
try {
const response = await fetch(`${backendUrl}/photos`);
if (response.status === 401) {
handleUnauthorized();
return;
}
photos = await response.json();
const validIds = new Set(photos.map(p => p._id));
selectedPhotoIds = new Set(Array.from(selectedPhotoIds).filter(id => validIds.has(id)));
updateTagSuggestions();
updateQuickTags();
renderManageGallery();
updateBulkUI();
} catch (error) {
console.error('Error fetching photos:', error);
}
}
function renderManageGallery() {
manageGallery.innerHTML = '';
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
const normalizedQuery = resolveSearchTag(query);
const filtered = query
? photos.filter(photo => {
const caption = String(photo.caption || '').toLowerCase();
const tags = Array.isArray(photo.tags) ? photo.tags : [];
const tagText = tags.map(displayTag).join(' ').toLowerCase();
return caption.includes(query)
|| tags.some(tag => String(tag || '').toLowerCase().includes(query))
|| (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery))
|| tagText.includes(query);
})
: photos;
if (!filtered.length) {
const message = query
? 'No photos match your search.'
: 'No photos yet. Upload a photo to get started.';
manageGallery.innerHTML = `<div class="column"><p class="has-text-grey">${message}</p></div>`;
return;
}
filtered.forEach(photo => {
const tagCount = Array.isArray(photo.tags) ? photo.tags.length : 0;
const tagStatusClass = tagCount <= 2 ? 'is-warning' : 'is-light';
const lowTagClass = tagCount <= 2 ? 'low-tag-card' : '';
const readableTags = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag);
const photoCard = `
<div class="column is-half-tablet is-one-third-desktop is-one-quarter-widescreen">
<div class="card has-background-light ${lowTagClass}" data-photo-id="${photo._id}">
<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' : ''}>
</label>
<span class="tag ${tagStatusClass}">${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}</span>
</div>
<div class="card-image">
<figure class="image is-3by2">
<img src="${backendUrl}/${photo.path}" alt="${photo.caption}">
</figure>
</div>
<div class="card-content">
<p class="has-text-dark"><strong class="has-text-dark">Caption:</strong> ${photo.caption}</p>
<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"><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>
`;
manageGallery.innerHTML += photoCard;
});
}
function openEditModal(photoId) {
const photo = photos.find(p => p._id === photoId);
if (photo) {
editPhotoId.value = photo._id;
editCaption.value = photo.caption;
const readable = (Array.isArray(photo.tags) ? photo.tags : []).map(displayTag).join(', ');
editTags.value = readable;
editModal.classList.add('is-active');
}
}
function closeEditModal() {
editModal.classList.remove('is-active');
}
function updateBulkUI() {
const count = selectedPhotoIds.size;
selectedCountEl.textContent = `${count} selected`;
const disabled = count === 0;
applyBulkEdits.disabled = disabled;
bulkDelete.disabled = disabled;
if (bulkPanel) {
bulkPanel.style.display = count ? 'block' : 'none';
}
}
function toggleSelectAll() {
if (selectedPhotoIds.size === photos.length) {
selectedPhotoIds.clear();
} else {
photos.forEach(p => selectedPhotoIds.add(p._id));
}
renderManageGallery();
updateBulkUI();
}
function clearSelection() {
selectedPhotoIds.clear();
renderManageGallery();
updateBulkUI();
}
async function handleSaveChanges() {
const photoId = editPhotoId.value;
const canonicalTags = normalizeTagsInput(editTags.value);
if (!canonicalTags.length) {
alert('Please include at least one valid tag.');
return;
}
if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
alert(`Keep tags under ${tagMeta.maxTags || DEFAULT_MAX_TAGS}.`);
return;
}
const updatedPhoto = {
caption: editCaption.value.trim(),
tags: canonicalTags.join(', ')
};
try {
const response = await fetch(`${backendUrl}/photos/update/${photoId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedPhoto)
});
if (response.ok) {
closeEditModal();
fetchPhotos(); // Refresh the gallery
fetchTagMeta();
} else {
alert('Failed to save changes.');
}
} catch (error) {
console.error('Error saving changes:', error);
alert('An error occurred while saving. Please try again.');
}
}
async function deletePhoto(id) {
if (confirm('Are you sure you want to delete this photo?')) {
try {
await fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' });
fetchPhotos();
} catch (error) {
console.error('Error deleting photo:', error);
}
}
}
function openBulkDeleteModal() {
const count = selectedPhotoIds.size;
if (count === 0) return;
bulkDeleteCountEl.textContent = `You are about to delete ${count} photo(s).`;
bulkDeleteModal.classList.add('is-active');
}
function closeBulkDeleteModal() {
bulkDeleteModal.classList.remove('is-active');
}
async function handleConfirmBulkDelete() {
const ids = Array.from(selectedPhotoIds);
if (ids.length === 0) {
closeBulkDeleteModal();
return;
}
confirmBulkDeleteBtn.classList.add('is-loading');
try {
await Promise.all(ids.map(id => fetch(`${backendUrl}/photos/${id}`, { method: 'DELETE' })));
clearSelection();
fetchPhotos();
closeBulkDeleteModal();
} catch (error) {
console.error('Error deleting photos:', error);
alert('Some deletions may have failed. Please refresh and check.');
} finally {
confirmBulkDeleteBtn.classList.remove('is-loading');
}
}
function bulkDeletePhotos() {
openBulkDeleteModal();
}
async function bulkApplyEdits() {
if (!selectedPhotoIds.size) return;
const newCaption = bulkCaption.value.trim();
const tagStr = bulkTags.value.trim();
const hasCaption = newCaption.length > 0;
const hasTags = tagStr.length > 0;
const maxTagsAllowed = tagMeta.maxTags || DEFAULT_MAX_TAGS;
const incomingCanonical = hasTags ? normalizeTagsInput(tagStr) : [];
if (hasTags && !incomingCanonical.length) {
alert('Bulk tags must include at least one valid option from the list.');
return;
}
if (incomingCanonical.length > maxTagsAllowed) {
alert(`Please keep bulk tags under ${maxTagsAllowed}.`);
return;
}
if (!hasCaption && !hasTags) {
alert('Enter a caption and/or tags to apply.');
return;
}
const ids = Array.from(selectedPhotoIds);
const append = bulkAppendTags.checked;
try {
await Promise.all(ids.map(async (id) => {
const photo = photos.find(p => p._id === id);
if (!photo) return;
const existingTags = Array.isArray(photo.tags) ? photo.tags : [];
let finalTags = existingTags;
if (hasTags) {
const merged = append ? Array.from(new Set([...existingTags, ...incomingCanonical])) : incomingCanonical;
if (!merged.length || merged.length > maxTagsAllowed) {
throw new Error('Tag limit exceeded or invalid.');
}
finalTags = merged;
}
const payload = {
caption: hasCaption ? newCaption : photo.caption,
tags: (hasTags ? finalTags : existingTags).join(', ')
};
await fetch(`${backendUrl}/photos/update/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}));
fetchPhotos();
fetchTagMeta();
clearSelection();
bulkCaption.value = '';
bulkTags.value = '';
bulkAppendTags.checked = false;
} catch (error) {
console.error('Error applying bulk edits:', error);
alert('Some edits may have failed. Please refresh and verify.');
}
}
manageGallery.addEventListener('click', (e) => {
if (e.target.classList.contains('edit-button')) {
e.preventDefault();
const photoId = e.target.closest('.card').dataset.photoId;
openEditModal(photoId);
}
if (e.target.classList.contains('delete-button')) {
e.preventDefault();
const photoId = e.target.closest('.card').dataset.photoId;
deletePhoto(photoId);
}
if (e.target.classList.contains('select-photo-checkbox')) {
const id = e.target.dataset.photoId;
if (e.target.checked) {
selectedPhotoIds.add(id);
} else {
selectedPhotoIds.delete(id);
}
updateBulkUI();
}
});
manageGallery.addEventListener('change', (e) => {
if (e.target.classList.contains('select-photo-checkbox')) {
const id = e.target.dataset.photoId;
if (e.target.checked) {
selectedPhotoIds.add(id);
} else {
selectedPhotoIds.delete(id);
}
updateBulkUI();
}
});
if (manageSearchInput) {
manageSearchInput.addEventListener('input', () => renderManageGallery());
}
selectAllPhotosBtn.addEventListener('click', (e) => {
e.preventDefault();
toggleSelectAll();
});
clearSelectionBtn.addEventListener('click', (e) => {
e.preventDefault();
clearSelection();
});
uploadForm.addEventListener('submit', (e) => {
e.preventDefault();
const photoInput = document.getElementById('photoInput');
const captionInput = document.getElementById('captionInput');
uploadStatus.textContent = '';
uploadStatus.className = 'help mt-3';
uploadProgress.style.display = 'none';
uploadProgress.value = 0;
const files = photoInput.files ? Array.from(photoInput.files) : [];
if (!files.length) {
uploadStatus.textContent = 'Please choose an image before uploading.';
uploadStatus.classList.add('has-text-danger');
return;
}
const formData = new FormData();
files.forEach(file => formData.append('photos', file));
formData.append('caption', captionInput.value);
const canonicalTags = normalizeTagsInput(tagsInput.value);
if (!canonicalTags.length) {
uploadStatus.textContent = 'Please choose at least one tag from the suggestions.';
uploadStatus.classList.add('has-text-danger');
return;
}
if (canonicalTags.length > (tagMeta.maxTags || DEFAULT_MAX_TAGS)) {
uploadStatus.textContent = `Use ${tagMeta.maxTags || DEFAULT_MAX_TAGS} tags or fewer.`;
uploadStatus.classList.add('has-text-danger');
return;
}
tagsInput.value = canonicalToDisplayString(canonicalTags);
formData.append('tags', canonicalTags.join(', '));
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
uploadProgress.value = percentComplete;
}
});
xhr.addEventListener('load', () => {
uploadButton.classList.remove('is-loading');
uploadProgress.style.display = 'none';
if (xhr.status === 401) {
handleUnauthorized();
uploadStatus.textContent = 'Session expired. Please log in again.';
uploadStatus.classList.add('has-text-danger');
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
const errorText = xhr.responseText;
uploadStatus.textContent = `Upload failed: ${errorText || xhr.statusText}`;
uploadStatus.classList.add('has-text-danger');
return;
}
try {
const result = JSON.parse(xhr.responseText);
const uploadedCount = Array.isArray(result?.uploaded) ? result.uploaded.length : 0;
const skippedCount = Array.isArray(result?.skipped) ? result.skipped.length : 0;
if (result?.success === false) {
uploadStatus.textContent = result?.error || 'Upload failed.';
uploadStatus.classList.add('has-text-danger');
return;
}
uploadStatus.textContent = result?.message || `Uploaded ${uploadedCount || files.length} photo${(uploadedCount || files.length) === 1 ? '' : 's'} successfully!` + (skippedCount ? ` Skipped ${skippedCount} duplicate${skippedCount === 1 ? '' : 's'}.` : '');
uploadStatus.classList.add('has-text-success');
localStorage.setItem(LAST_TAGS_KEY, canonicalTags.join(', '));
fetchPhotos();
fetchTagMeta();
uploadForm.reset();
preloadLastTags();
} catch (jsonError) {
console.error('Error parsing upload response:', jsonError);
uploadStatus.textContent = 'Received an invalid response from the server.';
uploadStatus.classList.add('has-text-danger');
}
});
xhr.addEventListener('error', () => {
uploadButton.classList.remove('is-loading');
uploadProgress.style.display = 'none';
uploadStatus.textContent = 'An unexpected error occurred during upload.';
uploadStatus.classList.add('has-text-danger');
});
xhr.addEventListener('abort', () => {
uploadButton.classList.remove('is-loading');
uploadProgress.style.display = 'none';
uploadStatus.textContent = 'Upload cancelled.';
uploadStatus.classList.add('has-text-grey');
});
xhr.open('POST', `${backendUrl}/photos/upload`);
uploadButton.classList.add('is-loading');
uploadProgress.style.display = 'block';
xhr.send(formData);
});
const getTagCount = (slug) => Number(tagMeta.tagCounts?.[slug] || 0);
const sortTagsByCount = (a, b) => {
const countDiff = getTagCount(b.slug) - getTagCount(a.slug);
if (countDiff !== 0) return countDiff;
return (a.label || '').localeCompare(b.label || '');
};
const sortSlugsByCount = (a, b) => {
const countDiff = getTagCount(b) - getTagCount(a);
if (countDiff !== 0) return countDiff;
return displayTag(a).localeCompare(displayTag(b));
};
function updateTagSuggestions() {
if (!tagSuggestions) return;
tagSuggestions.innerHTML = '';
const mainSorted = [...(tagMeta.main || [])].sort(sortTagsByCount);
const otherSorted = [...(tagMeta.other || [])].sort(sortTagsByCount);
const existingSorted = [...(tagMeta.existing || [])].sort(sortSlugsByCount);
const suggestions = [
...mainSorted,
...otherSorted,
...existingSorted.map(slug => ({ slug, label: displayTag(slug) }))
];
const seen = new Set();
suggestions.forEach(tag => {
if (!tag || !tag.slug || seen.has(tag.slug)) return;
seen.add(tag.slug);
const option = document.createElement('option');
option.value = tag.label;
option.dataset.slug = tag.slug;
tagSuggestions.appendChild(option);
});
}
function updateQuickTags() {
if (!quickTagButtons) return;
const presetButtons = (tagMeta.presets || []).map(preset => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
const mainButtons = [...(tagMeta.main || [])]
.sort(sortTagsByCount)
.map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
const otherButtons = [...(tagMeta.other || [])]
.sort(sortTagsByCount)
.map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
}
function addTagToInput(tag) {
const canonical = canonicalizeTag(tag);
if (!canonical) return;
const existing = normalizeTagsInput(tagsInput.value);
if (!existing.includes(canonical)) {
existing.push(canonical);
}
tagsInput.value = canonicalToDisplayString(existing);
}
function preloadLastTags() {
const last = localStorage.getItem(LAST_TAGS_KEY);
if (last && tagsInput && !tagsInput.value) {
const canonical = normalizeTagsInput(last);
tagsInput.value = canonicalToDisplayString(canonical);
}
}
function applyPresetTags(presetName) {
const preset = (tagMeta.presets || []).find(p => p.name === presetName);
if (!preset) return;
const canonical = normalizeTagsInput((preset.tags || []).join(','));
tagsInput.value = canonicalToDisplayString(canonical);
}
if (quickTagButtons) {
quickTagButtons.addEventListener('click', (e) => {
const presetBtn = e.target.closest('button[data-preset]');
const tagBtn = e.target.closest('button[data-tag]');
if (presetBtn) {
applyPresetTags(presetBtn.dataset.preset);
return;
}
if (tagBtn) {
addTagToInput(tagBtn.dataset.tag);
}
});
}
if (captionToTagsButton) {
captionToTagsButton.addEventListener('click', () => {
const caption = captionInput.value || '';
const words = (caption.match(/[A-Za-z0-9]+/g) || [])
.map(w => w.toLowerCase())
.filter(w => w.length > 2);
const unique = Array.from(new Set(words));
unique.forEach(addTagToInput);
uploadStatus.textContent = unique.length ? 'Tags pulled from caption.' : 'No words found to convert to tags.';
uploadStatus.className = 'help mt-3 ' + (unique.length ? 'has-text-success' : 'has-text-grey');
});
}
updateBulkUI();
saveChanges.addEventListener('click', handleSaveChanges);
modalCloseButton.addEventListener('click', closeEditModal);
modalCancelButton.addEventListener('click', closeEditModal);
applyBulkEdits.addEventListener('click', bulkApplyEdits);
bulkDelete.addEventListener('click', bulkDeletePhotos);
confirmBulkDeleteBtn.addEventListener('click', handleConfirmBulkDelete);
cancelBulkDeleteBtn.addEventListener('click', closeBulkDeleteModal);
bulkDeleteModalCloseBtn.addEventListener('click', closeBulkDeleteModal);
// --- Store Status Management ---
async function fetchStatus() {
try {
const response = await fetch('../update.json');
const data = await response.json();
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);
responseDiv.textContent = 'Error fetching current status.';
responseDiv.classList.add('is-danger');
}
}
updateButton.addEventListener('click', async () => {
const data = [
{
message: messageInput.value,
isClosed: isClosedCheckbox.checked,
closedMessage: closedMessageInput.value
}
];
try {
const response = await fetch('/api/update-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ data })
});
const result = await response.json();
if (result.success) {
responseDiv.textContent = 'Status updated successfully!';
responseDiv.classList.remove('is-danger');
responseDiv.classList.add('is-success');
} else {
responseDiv.textContent = `Error: ${result.message}`;
responseDiv.classList.remove('is-success');
responseDiv.classList.add('is-danger');
}
} catch (error) {
console.error('Error updating status:', error);
responseDiv.textContent = 'An unexpected error occurred.';
responseDiv.classList.remove('is-success');
responseDiv.classList.add('is-danger');
}
});
});

View File

@ -1,306 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<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="admin.css">
</head>
<body class="admin-page">
<div id="login-modal" class="modal is-active">
<div class="modal-content">
<div class="box admin-login-card has-background-light">
<div class="has-text-centered mb-4">
<p class="tag is-info is-light">Beach Party Balloons</p>
<h1 class="title is-4 mt-2">Admin Access</h1>
<p class="subtitle is-6 has-text-grey">Sign in to manage gallery photos and store status.</p>
</div>
<form id="loginForm">
<div class="field">
<p class="control has-icons-left">
<input class="input" id="passwordInput" type="password" placeholder="Password" autocomplete="current-password" required>
<span class="icon is-small is-left">
<i class="fas fa-lock"></i>
</span>
</p>
</div>
<div class="field">
<p class="control">
<button class="button is-info is-fullwidth" id="loginButton">Log in</button>
</p>
</div>
</form>
</div>
</div>
</div>
<div id="admin-content" style="display: none;">
<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>
</div>
</nav>
<div class="admin-hero admin-hero-slim">
<div class="container">
<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><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>
<div id="photo-tab" class="tab-content">
<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-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="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>
<div class="control">
<input class="input has-background-light has-text-dark" type="text" id="captionInput" placeholder="A beautiful balloon arrangement." required>
<p class="help is-size-7 mt-1"><button id="captionToTags" type="button" class="button is-ghost is-small p-0">Use caption words as tags</button></p>
</div>
</div>
<div class="field">
<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">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">
<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>
</form>
</div>
</div>
<div class="column manage-column">
<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 photos</p>
<p class="is-size-7 has-text-grey">Edit captions/tags or delete images.</p>
</div>
</div>
<div class="field mb-4">
<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;">
<div class="columns is-vcentered is-mobile">
<div class="column is-narrow">
<button class="button is-small has-text-dark has-background-primary" id="selectAllPhotos">Select all</button>
</div>
<div class="column">
<p class="is-size-7 has-text-dark" id="selectedCount">0 selected</p>
</div>
<div class="column is-narrow">
<button class="button is-text is-small has-text-dark" id="clearSelection">Clear</button>
</div>
</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 <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 <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
</label>
</div>
<div class="column is-full-mobile">
<div class="buttons are-small">
<button class="button is-primary" id="applyBulkEdits" disabled>Apply to selected</button>
<button class="button is-danger is-light" id="bulkDelete" disabled>Delete selected</button>
</div>
</div>
</div>
</div>
<div id="manage-gallery" class="columns is-multiline is-variable is-4 admin-gallery-grid">
<!-- Existing photos will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<div id="status-tab" class="tab-content" style="display: none;">
<div class="box admin-card">
<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">
<span class="icon-text">
<span class="icon has-text-info"><i class="fas fa-bullhorn"></i></span>
<span>Scrolling announcement</span>
</span>
</label>
<div class="control">
<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="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 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>
</div>
<!-- Edit Photo Modal -->
<div id="editModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card has-background-light">
<header class="modal-card-head">
<p class="modal-card-title has-text-dark has-background-light">Edit Photo</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<input type="hidden" id="editPhotoId">
<div class="field">
<label class="label">Caption</label>
<div class="control">
<input class="input has-background-light has-text-black" type="text" id="editCaption">
</div>
</div>
<div class="field">
<label class="label">Tags</label>
<div class="control">
<input class="input has-background-light has-text-black" type="text" id="editTags">
</div>
</div>
</section>
<footer class="modal-card-foot">
<button id="saveChanges" class="button is-success">Save changes</button>
<button class="button">Cancel</button>
</footer>
</div>
</div>
<!-- Bulk Delete Confirmation Modal -->
<div id="bulkDeleteModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Confirm Deletion</p>
<button class="delete" aria-label="close"></button>
</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 mt-2"></p>
</section>
<footer class="modal-card-foot">
<button id="confirmBulkDelete" class="button is-danger">Delete</button>
<button class="button" id="cancelBulkDelete">Cancel</button>
</footer>
</div>
</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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1 +0,0 @@
Ceiling Fill

View File

@ -1 +0,0 @@
30ft Areopole Arch

Binary file not shown.

Before

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -1 +0,0 @@
Cocktail Arrangements

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

View File

@ -1 +0,0 @@
20' Classic Arch

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -1 +0,0 @@
Classic Columns

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,10 +0,0 @@
#!/bin/bash
if [ $# -ne 1 ]; then
echo "Usage: $0 <image_file>"
exit 1
fi
IMAGE_FILE=$1
convert "$IMAGE_FILE" -rotate 90 "$IMAGE_FILE"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1 +0,0 @@
Ceiling Fill

View File

@ -1 +0,0 @@
30ft Areopole Arch

Binary file not shown.

Before

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -1 +0,0 @@
20' Classic Arch

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -1 +0,0 @@
Column with Custom Vinyl and a Bouquet

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 KiB

View File

@ -1 +0,0 @@
Classic 25' Arch

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -1 +0,0 @@
Classic Columns

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

View File

@ -1,2 +0,0 @@
Classic 20' Arch with SIgnature Arrangement

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Some files were not shown because too many files have changed in this diff Show More