diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5075b90 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# ── Root .env (fallback for Docker Compose < v2.24) ─────────────────────────── +# +# Docker Compose v2.24+ reads build env_file directly from estore/.env — you +# should not need this file at all on a modern install. +# +# If your Compose is older and the shop shows "Online payment is not +# configured", copy the four NEXT_PUBLIC_* lines from estore/.env into this +# file so Compose can bake them into the Next.js build: +# +# NEXT_PUBLIC_SQUARE_APP_ID= +# NEXT_PUBLIC_SQUARE_LOCATION_ID= +# NEXT_PUBLIC_SQUARE_ENVIRONMENT=production +# NEXT_PUBLIC_SITE_URL=https://shop.beachpartyballoons.com +# +# All other secrets (access tokens, passwords, etc.) belong only in estore/.env +# — never put them here. diff --git a/.gitignore b/.gitignore index 857c5fc..fc9ccec 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ estore/out/ # Runtime data estore/data/catalog-cache.json estore/data/item-overrides.json +estore/data/hours.json +estore/data/delivery-rates.json main-site/photo-gallery-app/backend/uploads/ mongodb_data/ osrm/data/* diff --git a/docker-compose.yml b/docker-compose.yml index 1154361..114f5a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,24 @@ services: + # ── Nginx reverse proxy ─────────────────────────────────────────────────────── + nginx: + image: nginx:alpine + container_name: bpb-nginx + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - main-site + - estore + restart: always + # ── Main website ───────────────────────────────────────────────────────────── main-site: build: ./main-site container_name: bpb-main - ports: - - "3052:3050" + expose: + - "3050" environment: NODE_ENV: production ADMIN_PASSWORD: ${MAIN_ADMIN_PASSWORD} @@ -20,7 +33,7 @@ services: build: ./main-site/photo-gallery-app/backend container_name: bpb-gallery ports: - - "5001:5000" + - "5002:5000" environment: MONGO_URI: mongodb://mongodb:27017/photogallery WATERMARK_URL: http://watermarker:8000/watermark @@ -35,8 +48,6 @@ services: watermarker: build: ./main-site/photo-gallery-app/watermarker container_name: bpb-watermarker - ports: - - "8000:8000" restart: always # ── MongoDB ─────────────────────────────────────────────────────────────────── @@ -50,28 +61,56 @@ services: restart: always # ── eStore (Next.js / Square) ───────────────────────────────────────────────── + # NEXT_PUBLIC_* vars are baked into the JS bundle at build time. + # They are resolved from the root .env file (same dir as this compose file). estore: - build: ./estore + build: + context: ./estore + args: + NEXT_PUBLIC_SQUARE_APP_ID: ${NEXT_PUBLIC_SQUARE_APP_ID} + NEXT_PUBLIC_SQUARE_LOCATION_ID: ${NEXT_PUBLIC_SQUARE_LOCATION_ID} + NEXT_PUBLIC_SQUARE_ENVIRONMENT: ${NEXT_PUBLIC_SQUARE_ENVIRONMENT} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL} container_name: bpb-estore - ports: - - "3000:3000" + expose: + - "3000" env_file: ./estore/.env + volumes: + - ./estore/data:/app/data restart: unless-stopped depends_on: - osrm healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/catalog"] + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/shop/api/catalog').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] interval: 30s timeout: 10s retries: 3 - # ── OSRM (routing engine) ───────────────────────────────────────────────────── - osrm: - image: osrm/osrm-backend - container_name: bpb-osrm - ports: - - "5002:5000" + # ── OSRM download (runs once, exits) ───────────────────────────────────────── + # Downloads the PBF map file into the shared volume if not already present. + osrm-download: + build: + context: ./osrm + dockerfile: Dockerfile.download + container_name: bpb-osrm-download volumes: - ./osrm/data:/data - command: osrm-routed --algorithm mld /data/connecticut-latest.osrm + environment: + OSRM_REGION: connecticut-latest + restart: "no" + + # ── OSRM (routing engine) ───────────────────────────────────────────────────── + osrm: + build: ./osrm + container_name: bpb-osrm + expose: + - "5000" + volumes: + - ./osrm/data:/data + environment: + OSRM_REGION: connecticut-latest + OSRM_PROFILE: /opt/car.lua + depends_on: + osrm-download: + condition: service_completed_successfully restart: unless-stopped diff --git a/estore/Dockerfile b/estore/Dockerfile index 0eaf73f..73e0a4e 100644 --- a/estore/Dockerfile +++ b/estore/Dockerfile @@ -10,6 +10,18 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 + +# NEXT_PUBLIC_* vars are baked into the JS bundle at build time. +# Pass them as build args from your .env so they're available here. +ARG NEXT_PUBLIC_SQUARE_APP_ID +ARG NEXT_PUBLIC_SQUARE_LOCATION_ID +ARG NEXT_PUBLIC_SQUARE_ENVIRONMENT +ARG NEXT_PUBLIC_SITE_URL +ENV NEXT_PUBLIC_SQUARE_APP_ID=$NEXT_PUBLIC_SQUARE_APP_ID +ENV NEXT_PUBLIC_SQUARE_LOCATION_ID=$NEXT_PUBLIC_SQUARE_LOCATION_ID +ENV NEXT_PUBLIC_SQUARE_ENVIRONMENT=$NEXT_PUBLIC_SQUARE_ENVIRONMENT +ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL + RUN npm run build # ── Stage 3: Production runner ──────────────────────────────────────────────── diff --git a/estore/next.config.mjs b/estore/next.config.mjs index 773f7b6..6ffdbc4 100644 --- a/estore/next.config.mjs +++ b/estore/next.config.mjs @@ -3,6 +3,10 @@ const isDev = process.env.NODE_ENV === 'development' /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + basePath: '/shop', + env: { + NEXT_PUBLIC_BASE_PATH: '/shop', + }, images: { remotePatterns: [ { protocol: 'https', hostname: 'items-images-production.s3.us-west-2.amazonaws.com' }, diff --git a/estore/src/app/shop/admin/login/page.tsx b/estore/src/app/admin/login/page.tsx similarity index 93% rename from estore/src/app/shop/admin/login/page.tsx rename to estore/src/app/admin/login/page.tsx index 469b63b..6f16719 100644 --- a/estore/src/app/shop/admin/login/page.tsx +++ b/estore/src/app/admin/login/page.tsx @@ -1,4 +1,5 @@ 'use client' +import { BASE } from '@/lib/basepath' import { useState } from 'react' import { useRouter } from 'next/navigation' @@ -14,13 +15,13 @@ export default function AdminLoginPage() { e.preventDefault() setLoading(true) setError('') - const res = await fetch('/api/admin/login', { + const res = await fetch(BASE + '/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }) if (res.ok) { - router.push('/shop/admin') + router.push('/admin') } else { setError('Invalid password') setLoading(false) diff --git a/estore/src/app/shop/admin/page.tsx b/estore/src/app/admin/page.tsx similarity index 88% rename from estore/src/app/shop/admin/page.tsx rename to estore/src/app/admin/page.tsx index 97e200c..d94909d 100644 --- a/estore/src/app/shop/admin/page.tsx +++ b/estore/src/app/admin/page.tsx @@ -1,4 +1,5 @@ 'use client' +import { BASE } from '@/lib/basepath' import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useRouter } from 'next/navigation' @@ -15,6 +16,7 @@ interface AdminItem extends CatalogItem { _rawCategory: string _rawCategoryLabel: string _rawShowColors: boolean + _rawVariations: CatalogItem['variations'] _rawModifiers: ModifierList[] _rawDescription: string _override: ItemOverride @@ -47,7 +49,7 @@ function CategoryDisplayEditor({ items }: { items: AdminItem[] }) { useEffect(() => { if (!catalogCats.length) return - fetch('/api/admin/categories-display') + fetch(BASE + '/api/admin/categories-display') .then((r) => r.json()) .then(({ order, hidden }: { order: string[]; hidden: string[] }) => { const inOrder = order @@ -77,7 +79,7 @@ function CategoryDisplayEditor({ items }: { items: AdminItem[] }) { async function handleSave() { setSaving(true) setMsg('') - const res = await fetch('/api/admin/categories-display', { + const res = await fetch(BASE + '/api/admin/categories-display', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -168,7 +170,7 @@ function HoursEditor() { const [msg, setMsg] = useState('') useEffect(() => { - fetch('/api/admin/hours') + fetch(BASE + '/api/admin/hours') .then((r) => r.json()) .then(setConfig) .catch(() => setConfig(DEFAULT_HOURS)) @@ -185,7 +187,7 @@ function HoursEditor() { if (!config) return setSaving(true) setMsg('') - const res = await fetch('/api/admin/hours', { + const res = await fetch(BASE + '/api/admin/hours', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), @@ -324,7 +326,7 @@ function OccasionsEditor() { const [newOcc, setNewOcc] = useState(EMPTY_NEW) useEffect(() => { - fetch('/api/admin/occasions') + fetch(BASE + '/api/admin/occasions') .then((r) => r.json()) .then(({ occasions }: { occasions: OccasionRow[] }) => setRows(occasions)) .catch(() => {}) @@ -387,7 +389,7 @@ function OccasionsEditor() { if (Object.keys(ov).length) config[r.key] = ov } } - const res = await fetch('/api/admin/occasions', { + const res = await fetch(BASE + '/api/admin/occasions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), @@ -579,6 +581,133 @@ function OccasionsEditor() { ) } +// ─── Delivery Rates Editor ──────────────────────────────────────────────────── + +interface TierRate { base: number; perMile: number; label: string } +interface DeliveryRatesConfig { dropoff: TierRate; classic: TierRate; organic: TierRate } + +const TIER_LABELS: Record = { + dropoff: 'Drop-off', + classic: 'Setup & strike', + organic: 'Organic setup & strike', +} + +function DeliveryRatesEditor() { + const [rates, setRates] = useState(null) + const [saving, setSaving] = useState(false) + const [msg, setMsg] = useState('') + + useEffect(() => { + fetch(BASE + '/api/admin/delivery-rates') + .then((r) => r.json()) + .then(setRates) + .catch(() => {}) + }, []) + + function updateTier(tier: keyof DeliveryRatesConfig, field: keyof TierRate, value: string) { + setRates((prev) => { + if (!prev) return prev + const updated = { ...prev[tier] } + if (field === 'label') { + updated.label = value + } else { + updated[field] = Math.round(parseFloat(value) * 100) || 0 + } + return { ...prev, [tier]: updated } + }) + } + + async function handleSave() { + if (!rates) return + setSaving(true) + setMsg('') + const res = await fetch(BASE + '/api/admin/delivery-rates', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rates), + }) + setSaving(false) + setMsg(res.ok ? 'Saved' : 'Save failed') + setTimeout(() => setMsg(''), 3000) + } + + if (!rates) return

Loading…

+ + return ( +
+

+ Set the base fee and per-mile rate for each delivery type. Changes apply to new quotes immediately. +

+ + + + + + + + + + + {(['dropoff', 'classic', 'organic'] as const).map((tier) => ( + + + + + + + ))} + +
TierBase fee ($)Per mile ($)Label
{TIER_LABELS[tier]} + updateTier(tier, 'base', e.target.value)} + style={{ width: 100 }} + /> + + updateTier(tier, 'perMile', e.target.value)} + style={{ width: 100 }} + /> + + updateTier(tier, 'label', e.target.value)} + style={{ width: '100%' }} + /> +
+

+ Formula: base + ceil(miles) × per-mile. Example: drop-off to a 5-mile address = + {' '}base + 5 × per-mile. +

+
+ + {msg && ( + + {msg} + + )} +
+
+ ) +} + // ─── Item Editor ────────────────────────────────────────────────────────────── function ItemEditor({ @@ -601,6 +730,7 @@ function ItemEditor({ const [showColors, setShowColors] = useState( ov.showColors != null ? ov.showColors : null ) + const [hiddenVars, setHiddenVars] = useState(ov.hiddenVariationIds ?? []) const [hiddenMods, setHiddenMods] = useState(ov.hiddenModifierIds ?? []) const [descOverride, setDescOverride] = useState(ov.descriptionOverride ?? '') const [saving, setSaving] = useState(false) @@ -630,6 +760,12 @@ function ItemEditor({ const [creatingCat, setCreatingCat] = useState(false) const [showNewCat, setShowNewCat] = useState(false) + function toggleVar(id: string) { + setHiddenVars((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ) + } + function toggleMod(id: string) { setHiddenMods((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] @@ -641,6 +777,7 @@ function ItemEditor({ setError('') const patch: Partial = { hidden, + hiddenVariationIds: hiddenVars, hiddenModifierIds: hiddenMods, } if (catOverride) patch.categoryOverride = catOverride @@ -655,7 +792,7 @@ function ItemEditor({ if (quantityUnit.trim()) patch.quantityUnit = quantityUnit.trim() else patch.quantityUnit = undefined - const res = await fetch(`/api/admin/items/${item.id}`, { + const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), @@ -672,7 +809,7 @@ function ItemEditor({ async function handleReset() { if (!confirm('Reset all overrides for this item to Square defaults?')) return - const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' }) + const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'DELETE' }) if (res.ok) { setHidden(false) setCatOverride('') @@ -696,7 +833,7 @@ function ItemEditor({ setUploadResults([]) const fd = new FormData() files.forEach((f) => fd.append('images', f)) - const res = await fetch(`/api/admin/items/${item.id}/images`, { method: 'POST', body: fd }) + const res = await fetch(`${BASE}/api/admin/items/${item.id}/images`, { method: 'POST', body: fd }) const data = await res.json() setUploadResults(data.results ?? []) setUploading(false) @@ -858,6 +995,42 @@ function ItemEditor({ {/* Right column */}
+ {/* Variations */} + {item._rawVariations.length > 1 && ( +
+ +
+ {item._rawVariations.map((v) => { + const visible = !hiddenVars.includes(v.id) + return ( +
+
+ + + ${(v.priceCents / 100).toFixed(2)} + +
+
+ ) + })} +
+
+ )} + {/* Modifiers */} {item._rawModifiers.length > 0 && (
@@ -1204,7 +1377,7 @@ export default function AdminPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [search, setSearch] = useState('') - const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions'>('items') + const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery'>('items') const [fetchedAt, setFetchedAt] = useState(null) const [refreshing, setRefreshing] = useState(false) const [refreshMsg, setRefreshMsg] = useState('') @@ -1218,11 +1391,11 @@ export default function AdminPage() { setLoading(true) setError('') const [itemsRes, catsRes] = await Promise.all([ - fetch('/api/admin/items'), - fetch('/api/admin/categories'), + fetch(BASE + '/api/admin/items'), + fetch(BASE + '/api/admin/categories'), ]) if (itemsRes.status === 401) { - router.push('/shop/admin/login') + router.push('/admin/login') return } if (!itemsRes.ok) { @@ -1241,7 +1414,7 @@ export default function AdminPage() { async function handleRefreshCatalog() { setRefreshing(true) setRefreshMsg('') - const res = await fetch('/api/admin/cache/refresh', { method: 'POST' }) + const res = await fetch(BASE + '/api/admin/cache/refresh', { method: 'POST' }) const data = await res.json() setRefreshing(false) if (res.ok) { @@ -1257,12 +1430,12 @@ export default function AdminPage() { useEffect(() => { load() }, [load]) async function handleLogout() { - await fetch('/api/admin/logout', { method: 'POST' }) - router.push('/shop/admin/login') + await fetch(BASE + '/api/admin/logout', { method: 'POST' }) + router.push('/admin/login') } async function handleCreateCategory(name: string): Promise { - const res = await fetch('/api/admin/categories', { + const res = await fetch(BASE + '/api/admin/categories', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), @@ -1354,6 +1527,9 @@ export default function AdminPage() {
  • setTab('occasions')}>Holidays
  • +
  • + setTab('delivery')}>Delivery rates +
  • @@ -1456,6 +1632,9 @@ export default function AdminPage() { {/* Holidays tab */} {tab === 'occasions' && } + + {/* Delivery rates tab */} + {tab === 'delivery' && }
    ) diff --git a/estore/src/app/api/admin/delivery-rates/route.ts b/estore/src/app/api/admin/delivery-rates/route.ts new file mode 100644 index 0000000..33b7b7f --- /dev/null +++ b/estore/src/app/api/admin/delivery-rates/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import { readDeliveryRates, writeDeliveryRates } from '@/lib/delivery-rates' +import type { DeliveryRatesConfig } from '@/lib/delivery' + +export async function GET() { + return NextResponse.json(readDeliveryRates()) +} + +export async function PUT(req: Request) { + try { + const body = (await req.json()) as DeliveryRatesConfig + const tiers = ['dropoff', 'classic', 'organic'] as const + for (const tier of tiers) { + const t = body[tier] + if (!t || typeof t.base !== 'number' || typeof t.perMile !== 'number' || typeof t.label !== 'string') { + return NextResponse.json({ error: `Invalid config for tier: ${tier}` }, { status: 400 }) + } + } + writeDeliveryRates(body) + return NextResponse.json({ ok: true }) + } catch (err) { + console.error('[admin/delivery-rates] error:', err) + return NextResponse.json({ error: 'Failed to save delivery rates' }, { status: 500 }) + } +} diff --git a/estore/src/app/api/admin/items/route.ts b/estore/src/app/api/admin/items/route.ts index efc7a71..e363695 100644 --- a/estore/src/app/api/admin/items/route.ts +++ b/estore/src/app/api/admin/items/route.ts @@ -23,6 +23,8 @@ export async function GET() { colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, description: ov.descriptionOverride ?? item.description, + variations: item.variations + .filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), modifiers: item.modifiers .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .map((m) => { @@ -33,6 +35,7 @@ export async function GET() { _rawCategory: item.category, _rawCategoryLabel: item.categoryLabel, _rawShowColors: item.showColors, + _rawVariations: item.variations, _rawModifiers: item.modifiers, _rawDescription: item.description, _override: ov, diff --git a/estore/src/app/api/catalog/route.ts b/estore/src/app/api/catalog/route.ts index 1ccfc7e..17c8bc3 100644 --- a/estore/src/app/api/catalog/route.ts +++ b/estore/src/app/api/catalog/route.ts @@ -20,6 +20,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] { chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, quantityUnit: ov.quantityUnit ?? item.quantityUnit, description: ov.descriptionOverride ?? item.description, + variations: item.variations + .filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), modifiers: item.modifiers .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .map((m) => { diff --git a/estore/src/app/api/checkout/route.ts b/estore/src/app/api/checkout/route.ts index 8f11239..7579e97 100644 --- a/estore/src/app/api/checkout/route.ts +++ b/estore/src/app/api/checkout/route.ts @@ -364,6 +364,11 @@ export async function POST(req: NextRequest) { if (!captured) throw new Error('Payment capture returned no result') // ── Fire-and-forget: emails only (calendar already written above) ──────── + const subtotalCents = lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0) + const slotEndISO = deliverySlotISO + ? new Date(new Date(deliverySlotISO).getTime() + jobMin * 60_000).toISOString() + : undefined + void (async () => { try { const { sendNewOrderAlert } = await import('@/lib/notify') @@ -375,9 +380,12 @@ export async function POST(req: NextRequest) { customerEmail: customerEmail ?? '', fulfillment: deliveryAddress ? 'delivery' : 'pickup', slotISO: (deliverySlotISO ?? pickupSlotISO)!, + slotEndISO, address: deliveryAddress, - items: itemsSummary, + lineItems, colors: selectedColors, + subtotalCents, + deliveryCents: deliveryCents ?? undefined, totalCents, }) } catch (err) { @@ -389,14 +397,17 @@ export async function POST(req: NextRequest) { const { sendOrderConfirmationEmail } = await import('@/lib/notify') await sendOrderConfirmationEmail({ shortRef, - orderId: order.id!, - customerName: customerName ?? 'Customer', + orderId: order.id!, + customerName: customerName ?? 'Customer', customerEmail, - fulfillment: deliveryAddress ? 'delivery' : 'pickup', - slotISO: (deliverySlotISO ?? pickupSlotISO)!, - address: deliveryAddress, - items: itemsSummary, - colors: selectedColors, + fulfillment: deliveryAddress ? 'delivery' : 'pickup', + slotISO: (deliverySlotISO ?? pickupSlotISO)!, + slotEndISO, + address: deliveryAddress, + lineItems, + colors: selectedColors, + subtotalCents, + deliveryCents: deliveryCents ?? undefined, totalCents, }) } catch (err) { diff --git a/estore/src/app/api/delivery-quote/route.ts b/estore/src/app/api/delivery-quote/route.ts index b3a64f7..8018f1c 100644 --- a/estore/src/app/api/delivery-quote/route.ts +++ b/estore/src/app/api/delivery-quote/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { geocode, calcDelivery, inferTier } from '@/lib/delivery' +import { readDeliveryRates } from '@/lib/delivery-rates' export async function POST(request: Request) { const { address, itemNames } = await request.json() as { @@ -17,7 +18,8 @@ export async function POST(request: Request) { } const tier = inferTier(itemNames ?? []) - const quote = await calcDelivery(coords.lat, coords.lng, tier) + const rates = readDeliveryRates() + const quote = await calcDelivery(coords.lat, coords.lng, tier, rates) if (quote.miles > 40) { return NextResponse.json( diff --git a/estore/src/app/globals.css b/estore/src/app/globals.css index 8824054..726e335 100644 --- a/estore/src/app/globals.css +++ b/estore/src/app/globals.css @@ -135,11 +135,11 @@ position: relative; z-index: 1; transition: transform 0.2s ease; - -webkit-mask-image: url('/color-picker/images/balloon-mask.svg'); + -webkit-mask-image: url('/shop/color-picker/images/balloon-mask.svg'); -webkit-mask-size: contain; -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; - mask-image: url('/color-picker/images/balloon-mask.svg'); + mask-image: url('/shop/color-picker/images/balloon-mask.svg'); mask-size: contain; mask-repeat: no-repeat; mask-position: center; diff --git a/estore/src/app/layout.tsx b/estore/src/app/layout.tsx index 032c4e2..e8ca3d3 100644 --- a/estore/src/app/layout.tsx +++ b/estore/src/app/layout.tsx @@ -37,10 +37,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) crossOrigin="anonymous" referrerPolicy="no-referrer" /> - {/* Favicon */} - - - + {/* Favicon — prefix with basePath so nginx routes them to the estore */} + + + diff --git a/estore/src/app/page.tsx b/estore/src/app/page.tsx index 5ea19a5..8298942 100644 --- a/estore/src/app/page.tsx +++ b/estore/src/app/page.tsx @@ -1,15 +1,12 @@ -import Hero from '@/components/Hero' +import type { Metadata } from 'next' import FeaturedProducts from '@/components/FeaturedProducts' -import ReviewsSection from '@/components/ReviewsSection' -import TrustedBrands from '@/components/TrustedBrands' -export default function HomePage() { - return ( - <> - - - - - - ) +export const metadata: Metadata = { + title: 'Shop', + description: + 'Browse all balloon arrangements, arches, centerpieces, and installations by Beach Party Balloons.', +} + +export default function ShopPage() { + return } diff --git a/estore/src/app/shop/admin/layout.tsx b/estore/src/app/shop/admin/layout.tsx deleted file mode 100644 index feaab0e..0000000 --- a/estore/src/app/shop/admin/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function AdminLayout({ children }: { children: React.ReactNode }) { - return <>{children} -} diff --git a/estore/src/app/shop/page.tsx b/estore/src/app/shop/page.tsx deleted file mode 100644 index 8298942..0000000 --- a/estore/src/app/shop/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from 'next' -import FeaturedProducts from '@/components/FeaturedProducts' - -export const metadata: Metadata = { - title: 'Shop', - description: - 'Browse all balloon arrangements, arches, centerpieces, and installations by Beach Party Balloons.', -} - -export default function ShopPage() { - return -} diff --git a/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx index f967039..7b929f8 100644 --- a/estore/src/components/CartDrawer.tsx +++ b/estore/src/components/CartDrawer.tsx @@ -1,4 +1,5 @@ 'use client' +import { BASE } from '@/lib/basepath' import { useState, useEffect, useCallback, useMemo } from 'react' import { useCart } from '@/context/CartContext' @@ -91,8 +92,9 @@ export default function CartDrawer() { const [infoErrors, setInfoErrors] = useState<{ firstName?: string; lastName?: string; email?: string; phone?: string; balloon?: string }>({}) const [balloonAgreement, setBalloonAgreement] = useState(false) - const isValidEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) - const isValidPhone = (v: string) => v.replace(/\D/g, '').length >= 10 + const isValidEmail = (v: string) => + /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(v.trim()) + const isValidPhone = (v: string) => v.replace(/\D/g, '').length === 10 const validateAndContinue = () => { const errors: typeof infoErrors = {} @@ -212,7 +214,7 @@ export default function CartDrawer() { setQuoteErr('') setQuote(null) try { - const res = await fetch('/api/delivery-quote', { + const res = await fetch(BASE + '/api/delivery-quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: fullAddress, itemNames: entries.map((e) => e.product.name) }), @@ -551,7 +553,15 @@ export default function CartDrawer() { autoComplete="tel" maxLength={17} value={custPhone} - onChange={(e) => { setCustPhone(e.target.value); setInfoErrors((p) => ({ ...p, phone: undefined })) }} + onChange={(e) => { + const digits = e.target.value.replace(/\D/g, '').slice(0, 10) + let formatted = digits + if (digits.length > 6) formatted = `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}` + else if (digits.length > 3) formatted = `(${digits.slice(0,3)}) ${digits.slice(3)}` + else if (digits.length > 0) formatted = `(${digits}` + setCustPhone(formatted) + setInfoErrors((p) => ({ ...p, phone: undefined })) + }} /> {infoErrors.phone &&

    {infoErrors.phone}

    } diff --git a/estore/src/components/ColorPicker.tsx b/estore/src/components/ColorPicker.tsx index c130190..7686bf3 100644 --- a/estore/src/components/ColorPicker.tsx +++ b/estore/src/components/ColorPicker.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react' import type { CatalogItem, ModifierList } from '@/data/mock-catalog' import { useCart } from '@/context/CartContext' +import { BASE } from '@/lib/basepath' import type { CartEntry } from '@/context/CartContext' import { fmt } from '@/lib/format' @@ -50,7 +51,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry ) useEffect(() => { - fetch('/colors.json') + fetch(BASE + '/colors.json') .then((r) => r.json()) .then((data: ColorFamily[]) => setFamilies(data)) }, []) @@ -255,7 +256,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry {/* Intro */}

    - We stock 40+ latex colors. Tap a family below to browse shades, + We stock 70+ latex colors. Tap a family below to browse shades, then tap a balloon to add it to your palette. {colorMin > 1 && maxColors !== null && colorMin === maxColors && ` Exactly ${colorMin} color${colorMin !== 1 ? 's' : ''} required.`} {colorMin > 1 && (maxColors === null || colorMin !== maxColors) && ` At least ${colorMin} color${colorMin !== 1 ? 's' : ''} required.`} diff --git a/estore/src/components/DeliveryDatePicker.tsx b/estore/src/components/DeliveryDatePicker.tsx index 92399d6..63b2126 100644 --- a/estore/src/components/DeliveryDatePicker.tsx +++ b/estore/src/components/DeliveryDatePicker.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect } from 'react' +import { BASE } from '@/lib/basepath' import type { DeliveryTier } from '@/lib/delivery' import type { TimeSlot } from '@/lib/slots' import CalendarPicker from './CalendarPicker' @@ -40,7 +41,7 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P // Fetch busy dates once on mount useEffect(() => { if (!process.env.NEXT_PUBLIC_SITE_URL && typeof window === 'undefined') return - fetch(`/api/availability?days=90`) + fetch(`${BASE}/api/availability?days=90`) .then(r => r.ok ? r.json() : null) .then((data) => { if (!data?.busy) return @@ -60,7 +61,7 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P onChange(null) const params = new URLSearchParams({ date, address, tier }) - fetch(`/api/slots?${params}`) + fetch(`${BASE}/api/slots?${params}`) .then((r) => r.json()) .then((data) => { if (data.error) { setErrMsg(data.error); setStatus('error'); return } diff --git a/estore/src/components/FeaturedProducts.tsx b/estore/src/components/FeaturedProducts.tsx index 2d5cbfd..5bcbcf6 100644 --- a/estore/src/components/FeaturedProducts.tsx +++ b/estore/src/components/FeaturedProducts.tsx @@ -1,4 +1,5 @@ 'use client' +import { BASE } from '@/lib/basepath' import { useState, useEffect, useMemo } from 'react' import ProductCard from './ProductCard' @@ -84,10 +85,10 @@ export default function FeaturedProducts() { useEffect(() => { Promise.all([ - fetch('/api/catalog').then((r) => { if (!r.ok) throw new Error('catalog error'); return r.json() }), - fetch('/api/inventory').then((r) => r.ok ? r.json() : { counts: {} }).catch(() => ({ counts: {} })), - fetch('/api/occasions').then((r) => r.ok ? r.json() : { occasions: [] }).catch(() => ({ occasions: [] })), - fetch('/api/categories-display').then((r) => r.ok ? r.json() : { order: [], hidden: [] }).catch(() => ({ order: [], hidden: [] })), + fetch(BASE + '/api/catalog').then((r) => { if (!r.ok) throw new Error('catalog error'); return r.json() }), + fetch(BASE + '/api/inventory').then((r) => r.ok ? r.json() : { counts: {} }).catch(() => ({ counts: {} })), + fetch(BASE + '/api/occasions').then((r) => r.ok ? r.json() : { occasions: [] }).catch(() => ({ occasions: [] })), + fetch(BASE + '/api/categories-display').then((r) => r.ok ? r.json() : { order: [], hidden: [] }).catch(() => ({ order: [], hidden: [] })), ]) .then(([{ items }, { counts }, { occasions }, catDisplay]: [ { items: CatalogItem[] }, diff --git a/estore/src/components/Footer.tsx b/estore/src/components/Footer.tsx index d10befb..45ecfe7 100644 --- a/estore/src/components/Footer.tsx +++ b/estore/src/components/Footer.tsx @@ -44,11 +44,11 @@ export default function Footer() { Use of images without written permission is prohibited.

    - Privacy Policy + Privacy Policy {' · '} - Terms of Service + Terms of Service {' · '} - Refund & Cancellation Policy + Refund & Cancellation Policy

    diff --git a/estore/src/components/Hero.tsx b/estore/src/components/Hero.tsx index 05cb868..29923e8 100644 --- a/estore/src/components/Hero.tsx +++ b/estore/src/components/Hero.tsx @@ -8,7 +8,7 @@ export default function Hero() { {/* Scrolling announcement bar */}
    - 🎈 Walk-ins welcome! · Delivery available across CT · Over 40 latex colors in stock · Custom arrangements made while you wait · 203.283.5626 + 🎈 Walk-ins welcome! · Delivery available across CT · Over 70 latex colors in stock · Custom arrangements made while you wait · 203.283.5626
    @@ -63,7 +63,7 @@ export default function Hero() { it while you wait!

    - We have hundreds of foil balloon choices in stock and over 40 latex colors! + We have hundreds of foil balloon choices in stock and over 70 latex colors!

    diff --git a/estore/src/components/Navbar.tsx b/estore/src/components/Navbar.tsx index 102875e..ffa8805 100644 --- a/estore/src/components/Navbar.tsx +++ b/estore/src/components/Navbar.tsx @@ -2,7 +2,10 @@ import { useState } from 'react' import Link from 'next/link' +import { BASE } from '@/lib/basepath' +// Use for links that leave the estore (main site pages) so Next.js basePath +// is not prepended. Use only for internal estore routes. export default function Navbar() { const [isOpen, setIsOpen] = useState(false) @@ -13,14 +16,14 @@ export default function Navbar() { aria-label="main navigation" >

    diff --git a/estore/src/components/PaymentForm.tsx b/estore/src/components/PaymentForm.tsx index 4eb72ee..1adf364 100644 --- a/estore/src/components/PaymentForm.tsx +++ b/estore/src/components/PaymentForm.tsx @@ -1,4 +1,5 @@ 'use client' +import { BASE } from '@/lib/basepath' import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef import { fmt } from '@/lib/format' @@ -153,7 +154,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { // wait 2 seconds and retry once automatically — the idempotency key ensures // no double charge and will return success if the first attempt already // captured payment but the response was lost. - const attemptCheckout = () => fetch('/api/checkout', { + const attemptCheckout = () => fetch(BASE + '/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: checkoutBody, diff --git a/estore/src/components/WelcomeModal.tsx b/estore/src/components/WelcomeModal.tsx index 7dba6a7..8e434d6 100644 --- a/estore/src/components/WelcomeModal.tsx +++ b/estore/src/components/WelcomeModal.tsx @@ -7,7 +7,7 @@ interface Props { const HOW_IT_WORKS = [ { emoji: '🎈', title: 'Browse arrangements', body: 'Filter by occasion or category and find your perfect setup.' }, - { emoji: '🎨', title: 'Pick your colors', body: 'Choose from 40+ latex colors and customize every detail.' }, + { emoji: '🎨', title: 'Pick your colors', body: 'Choose from 70+ latex colors and customize every detail.' }, { emoji: '📅', title: 'Choose delivery or pickup', body: 'We deliver to your door, or you can pick up at our Milford, CT shop.' }, { emoji: '✅', title: 'Pay securely', body: 'Powered by Square — your card info never touches our servers.' }, ] diff --git a/estore/src/data/mock-catalog.ts b/estore/src/data/mock-catalog.ts index bd443a7..2f3f611 100644 --- a/estore/src/data/mock-catalog.ts +++ b/estore/src/data/mock-catalog.ts @@ -97,7 +97,7 @@ export const MOCK_CATALOG: CatalogItem[] = (([ id: 'cat-005', name: 'Helium Bouquet', description: - 'Classic floating helium balloon bouquets — great for walk-ins or delivery. Over 40 latex colors and hundreds of foil shapes in stock.', + 'Classic floating helium balloon bouquets — great for walk-ins or delivery. Over 70 latex colors and hundreds of foil shapes in stock.', category: 'classic', categoryLabel: 'Classic', price: 2800, diff --git a/estore/src/lib/basepath.ts b/estore/src/lib/basepath.ts new file mode 100644 index 0000000..910f4c3 --- /dev/null +++ b/estore/src/lib/basepath.ts @@ -0,0 +1,3 @@ +/** Prefix for all client-side API fetches and absolute asset paths. + * Must match `basePath` in next.config.mjs. */ +export const BASE = process.env.NEXT_PUBLIC_BASE_PATH ?? '' diff --git a/estore/src/lib/caldav.ts b/estore/src/lib/caldav.ts index 097b5fe..807515b 100644 --- a/estore/src/lib/caldav.ts +++ b/estore/src/lib/caldav.ts @@ -210,7 +210,7 @@ export async function createPickupEvent(params: { 'LOCATION:Beach Party Balloons\\, 554 Boston Post Rd\\, Milford CT', foldLine(`DESCRIPTION:${descParts}`), 'STATUS:CONFIRMED', - 'TRANSP:OPAQUE', + 'TRANSP:TRANSPARENT', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n') @@ -286,6 +286,9 @@ async function _fetchBusyDates(rangeStart: Date, rangeEnd: Date): Promise + const merged = { ...defaults } + for (const tier of TIERS) { + if (stored[tier]) merged[tier] = { ...defaults[tier], ...stored[tier] } + } + return merged + } catch { + return defaults + } +} + +export function writeDeliveryRates(config: DeliveryRatesConfig): void { + atomicWriteJSON(RATES_PATH, config) +} diff --git a/estore/src/lib/delivery.ts b/estore/src/lib/delivery.ts index 80825b5..5e5d908 100644 --- a/estore/src/lib/delivery.ts +++ b/estore/src/lib/delivery.ts @@ -30,6 +30,9 @@ export const JOB_MINUTES: Record = { organic: 240, // 4 hrs setup + strike } +// ── Configurable rates type (file I/O lives in delivery-rates.ts, server only) ─ +export type DeliveryRatesConfig = Record + /** Straight-line distance fallback (haversine) with road overhead factor */ function haversineMiles(lat1: number, lng1: number, lat2: number, lng2: number): number { const R = 3958.8 // Earth radius in miles @@ -111,8 +114,9 @@ export async function calcDelivery( destLat: number, destLng: number, tier: DeliveryTier, + rates?: DeliveryRatesConfig, ): Promise { - const rate = RATES[tier] + const rate = (rates ?? RATES)[tier] const { miles: rawMiles, minutes: driveMinutes } = await drivingInfo(SHOP_LAT, SHOP_LNG, destLat, destLng) const miles = Math.ceil(rawMiles * 10) / 10 diff --git a/estore/src/lib/notify.ts b/estore/src/lib/notify.ts index 6e5883a..82e44c9 100644 --- a/estore/src/lib/notify.ts +++ b/estore/src/lib/notify.ts @@ -12,6 +12,55 @@ import nodemailer from 'nodemailer' +// ── Shared helpers ───────────────────────────────────────────────────────────── + +/** Format a slot as "4/14/26 2:00 PM" */ +function fmtDate(iso: string): string { + const d = new Date(iso) + const { month, day, year, hour, minute, dayPeriod } = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + month: 'numeric', day: 'numeric', year: '2-digit', + hour: 'numeric', minute: '2-digit', hour12: true, + }).formatToParts(d).map(({ type, value }) => [type, value]) + ) + return `${month}/${day}/${year} ${hour}:${minute} ${dayPeriod}` +} + +/** Format a window as "4/14/26 2:00 – 5:00 PM" (shared date, shared AM/PM if same) */ +function fmtWindow(startISO: string, endISO: string): string { + const start = fmtDate(startISO) // "4/14/26 2:00 PM" + const endTime = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + hour: 'numeric', minute: '2-digit', hour12: true, + }).format(new Date(endISO)) // "5:00 PM" + // Strip the time portion from start and append range + const datePart = start.replace(/ \d+:\d+ [AP]M$/, '') + return `${datePart} ${start.match(/\d+:\d+ [AP]M$/)?.[0]} – ${endTime}` +} + +// ── Shared types ─────────────────────────────────────────────────────────────── + +export interface EmailLineItem { + name: string + quantity: number + priceCents: number + colors?: string[] + note?: string + modifiers?: Array<{ name: string }> +} + +function formatLineItems(lineItems: EmailLineItem[]): string { + return lineItems.map((li) => { + const price = `$${(li.priceCents / 100).toFixed(2)}` + const lines = [`${li.quantity}× ${li.name} — ${price}`] + if (li.modifiers?.length) lines.push(` Add-ons: ${li.modifiers.map((m) => m.name).join(', ')}`) + if (li.colors?.length) lines.push(` Colors: ${li.colors.join(', ')}`) + if (li.note) lines.push(` Note: ${li.note}`) + return lines.join('\n') + }).join('\n') +} + function getTransporter() { const host = process.env.SMTP_HOST const port = parseInt(process.env.SMTP_PORT ?? '587', 10) @@ -29,9 +78,10 @@ function getTransporter() { } async function send(params: { - to: string - subject: string - text: string + to: string + subject: string + text: string + attachments?: Array<{ filename: string; content: string; contentType: string }> }): Promise { const from = process.env.ALERT_EMAIL_FROM ?? 'shop@beachpartyballoons.com' const transporter = getTransporter() @@ -45,9 +95,10 @@ async function send(params: { try { await transporter.sendMail({ from, - to: params.to, - subject: params.subject, - text: params.text, + to: params.to, + subject: params.subject, + text: params.text, + attachments: params.attachments, }) console.log('[notify] Email sent:', params.subject, '→', params.to) } catch (err: unknown) { @@ -57,65 +108,158 @@ async function send(params: { } } +// ── ICS builder for customer calendar attachment ──────────────────────────── + +function buildCustomerICS(params: { + uid: string + startISO: string + endISO: string + summary: string + description: string + location?: string +}): string { + function toStamp(d: Date): string { + return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') + } + function toET(d: Date): string { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).formatToParts(d) + const p: Record = {} + for (const { type, value } of parts) p[type] = value + const h = p.hour === '24' ? '00' : p.hour + return `${p.year}${p.month}${p.day}T${h}${p.minute}${p.second}` + } + function fold(s: string): string { + const out: string[] = [] + while (s.length > 73) { out.push(s.slice(0, 73)); s = ' ' + s.slice(73) } + out.push(s) + return out.join('\r\n') + } + + const start = new Date(params.startISO) + const end = new Date(params.endISO) + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BeachPartyBalloons//Shop//EN', + 'BEGIN:VEVENT', + `UID:${params.uid}`, + `DTSTAMP:${toStamp(new Date())}`, + `DTSTART;TZID=America/New_York:${toET(start)}`, + `DTEND;TZID=America/New_York:${toET(end)}`, + fold(`SUMMARY:${params.summary}`), + ...(params.location ? [fold(`LOCATION:${params.location}`)] : []), + fold(`DESCRIPTION:${params.description}`), + 'STATUS:CONFIRMED', + 'END:VEVENT', + 'END:VCALENDAR', + ] + return lines.join('\r\n') +} + // ── Public helpers ───────────────────────────────────────────────────────────── export async function sendOrderConfirmationEmail(params: { - shortRef: string - orderId: string - customerName: string - customerEmail: string - fulfillment: 'delivery' | 'pickup' - slotISO: string - address?: string - items: string - colors: string[] - totalCents: bigint + shortRef: string + orderId: string + customerName: string + customerEmail: string + fulfillment: 'delivery' | 'pickup' + slotISO: string + slotEndISO?: string + address?: string + lineItems: EmailLineItem[] + colors: string[] // order-level color selection (when not per-item) + subtotalCents?: number + deliveryCents?: number + totalCents: bigint }): Promise { - const slot = new Date(params.slotISO).toLocaleString('en-US', { - timeZone: 'America/New_York', - dateStyle: 'full', - timeStyle: 'short', - }) - - const total = `$${(Number(params.totalCents) / 100).toFixed(2)}` const isDelivery = params.fulfillment === 'delivery' + const slotStr = params.slotEndISO + ? fmtWindow(params.slotISO, params.slotEndISO) + : fmtDate(params.slotISO) + const total = `$${(Number(params.totalCents) / 100).toFixed(2)}` + + // Charges breakdown (only shown when subtotal is provided) + const chargesLines: string[] = [] + if (params.subtotalCents != null) { + chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`) + if (params.deliveryCents) { + chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`) + } + const impliedTax = Number(params.totalCents) - (params.subtotalCents) - (params.deliveryCents ?? 0) + if (impliedTax > 0) { + chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`) + } + chargesLines.push(`Total: ${total}`) + } else { + chargesLines.push(`Total: ${total}`) + } + + const itemsBlock = formatLineItems(params.lineItems) + // Order-level colors (when colors aren't set per-item) + const hasPerItemColors = params.lineItems.some((li) => li.colors?.length) + const orderColors = !hasPerItemColors && params.colors.length ? params.colors : [] const lines = [ `Hi ${params.customerName.split(' ')[0]},`, ``, `Your order is confirmed! Here's a summary:`, ``, - `Order #: ${params.shortRef}`, - `Items: ${params.items}`, - ...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []), + `Order #: ${params.shortRef}`, + ``, + itemsBlock, + ...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []), + ``, + isDelivery ? `Delivery: ${slotStr}` : `Pickup: ${slotStr}`, + ...(params.address ? [`Address: ${params.address}`] : []), + ``, + ...chargesLines, ``, isDelivery - ? `Delivery: ${slot}` - : `Pickup: ${slot}`, - ...(params.address ? [`Address: ${params.address}`] : []), - ``, - `Total: ${total}`, - ``, - isDelivery - ? [ - `We'll be in touch to confirm your delivery window. If you need to make any changes,`, - `please call or text us as soon as possible.`, - ].join('\n') + ? `If you need to make any changes, please call or text us as soon as possible.` : [ `Pick up at 554 Boston Post Rd, Milford CT 06460.`, `Please bring this confirmation. If you need to reschedule, give us a call!`, ].join('\n'), ``, - `Thank you for choosing Beach Party Balloons! 🎈`, + `Thank you for choosing Beach Party Balloons!`, ``, `— The Beach Party Balloons Team`, `beachpartyballoons.com`, ] + // Build ICS calendar attachment + const icsEndISO = params.slotEndISO ?? new Date(new Date(params.slotISO).getTime() + 60 * 60_000).toISOString() + const itemsSummary = params.lineItems.map((li) => `${li.quantity}× ${li.name}`).join(', ') + const icsDesc = [ + `Order #${params.shortRef}`, + `Items: ${itemsSummary}`, + ...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []), + ...(params.address ? [`Address: ${params.address}`] : []), + ].join('\\n') + const icsSummary = isDelivery ? 'Balloon Delivery — Beach Party Balloons' : 'Balloon Pickup — Beach Party Balloons' + const icsContent = buildCustomerICS({ + uid: `customer-${params.orderId}@beachpartyballoons.com`, + startISO: params.slotISO, + endISO: icsEndISO, + summary: icsSummary, + description: icsDesc, + location: params.address ?? (isDelivery ? undefined : '554 Boston Post Rd, Milford CT 06460'), + }) + await send({ to: params.customerEmail, - subject: `Your Beach Party Balloons order is confirmed! 🎈 (#${params.shortRef})`, + subject: `Your Beach Party Balloons order is confirmed! (#${params.shortRef})`, text: lines.join('\n'), + attachments: [{ + filename: 'appointment.ics', + content: icsContent, + contentType: 'text/calendar; method=REQUEST', + }], }) } @@ -131,12 +275,6 @@ export async function sendSlotConflictAlert(params: { const to = process.env.ALERT_EMAIL_TO if (!to) { console.warn('[notify] ALERT_EMAIL_TO not set'); return } - const slot = new Date(params.slotISO).toLocaleString('en-US', { - timeZone: 'America/New_York', - dateStyle: 'full', - timeStyle: 'short', - }) - await send({ to, subject: `🚨 ACTION REQUIRED: Slot not blocked — Order #${params.shortRef}`, @@ -147,7 +285,7 @@ export async function sendSlotConflictAlert(params: { ``, `Order: #${params.shortRef} (${params.orderId})`, `Customer: ${params.customerName} ${params.customerPhone}`, - `Slot: ${slot}`, + `Slot: ${fmtDate(params.slotISO)}`, `Address: ${params.address}`, `Items: ${params.items}`, ``, @@ -160,28 +298,52 @@ export async function sendSlotConflictAlert(params: { } export async function sendNewOrderAlert(params: { - shortRef: string - orderId: string - customerName: string - customerPhone: string - customerEmail: string - fulfillment: 'delivery' | 'pickup' - slotISO: string - address?: string - items: string - colors: string[] - totalCents: bigint + shortRef: string + orderId: string + customerName: string + customerPhone: string + customerEmail: string + fulfillment: 'delivery' | 'pickup' + slotISO: string + slotEndISO?: string + address?: string + lineItems: EmailLineItem[] + colors: string[] + subtotalCents?: number + deliveryCents?: number + totalCents: bigint }): Promise { const to = process.env.ALERT_EMAIL_TO if (!to) return - const slot = new Date(params.slotISO).toLocaleString('en-US', { - timeZone: 'America/New_York', - dateStyle: 'full', - timeStyle: 'short', - }) + const slotStr = params.slotEndISO + ? fmtWindow(params.slotISO, params.slotEndISO) + : fmtDate(params.slotISO) + const total = `$${(Number(params.totalCents) / 100).toFixed(2)}` + + const chargesLines: string[] = [] + if (params.subtotalCents != null) { + chargesLines.push(`Subtotal: $${(params.subtotalCents / 100).toFixed(2)}`) + if (params.deliveryCents) { + chargesLines.push(`Delivery: $${(params.deliveryCents / 100).toFixed(2)}`) + } + const impliedTax = Number(params.totalCents) - params.subtotalCents - (params.deliveryCents ?? 0) + if (impliedTax > 0) { + chargesLines.push(`Tax: $${(impliedTax / 100).toFixed(2)}`) + } + chargesLines.push(`Total: ${total}`) + } else { + chargesLines.push(`Total: ${total}`) + } + + const slotLine = params.fulfillment === 'delivery' + ? `Delivery: ${slotStr}` + : `Pickup: ${slotStr}` + + const itemsBlock = formatLineItems(params.lineItems) + const hasPerItemColors = params.lineItems.some((li) => li.colors?.length) + const orderColors = !hasPerItemColors && params.colors.length ? params.colors : [] - const total = `$${(Number(params.totalCents) / 100).toFixed(2)}` const lines = [ `New ${params.fulfillment} order — #${params.shortRef}`, ``, @@ -189,13 +351,13 @@ export async function sendNewOrderAlert(params: { `Phone: ${params.customerPhone}`, `Email: ${params.customerEmail}`, ``, - `${params.fulfillment === 'delivery' ? 'Delivery' : 'Pickup'}: ${slot}`, + slotLine, ...(params.address ? [`Address: ${params.address}`] : []), ``, - `Items: ${params.items}`, - ...(params.colors.length ? [`Colors: ${params.colors.join(', ')}`] : []), + itemsBlock, + ...(orderColors.length ? [``, `Colors: ${orderColors.join(', ')}`] : []), ``, - `Total: ${total}`, + ...chargesLines, ``, `View in Square: https://squareup.com/dashboard/orders`, ] diff --git a/estore/src/lib/overrides.ts b/estore/src/lib/overrides.ts index 0525a79..715a44f 100644 --- a/estore/src/lib/overrides.ts +++ b/estore/src/lib/overrides.ts @@ -9,6 +9,7 @@ export interface ItemOverride { sortOrder?: number showColors?: boolean hiddenModifierIds?: string[] + hiddenVariationIds?: string[] descriptionOverride?: string /** Per-modifier minimum selections override. Key = modifier list ID, value = min count. */ modifierMinSelected?: Record diff --git a/main-site/about/index.html b/main-site/about/index.html index 31f5e11..e3b00b8 100644 --- a/main-site/about/index.html +++ b/main-site/about/index.html @@ -18,6 +18,7 @@ + - + @@ -158,24 +109,7 @@ Our expertise lies in crafting memorable experiences through a diverse array of --> -
    -
    -
    - - - - - - - - - -
    - - Copyright © Beach Party Balloons - All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited. -
    -
    + diff --git a/main-site/admin/admin.css b/main-site/admin/admin.css index 37a01fd..c2b9e23 100644 --- a/main-site/admin/admin.css +++ b/main-site/admin/admin.css @@ -1,20 +1,141 @@ -#clearSelection:hover { - color: #f14668; +/* ── Hero override: slim down the header ───────────────────────────────────── */ +.admin-hero.admin-hero-slim { + padding: 0.85rem 1.5rem; + margin-bottom: 0; +} +.admin-hero.admin-hero-slim .title { + line-height: 1.2; } +/* ── File input ─────────────────────────────────────────────────────────────── */ +.admin-file-input .file-cta { + background: #e8f4fb; + border-color: #b5d9ef; + color: #2e7dbf; + font-weight: 600; +} +.admin-file-input .file-name { + max-width: none; + flex: 1; + color: #555; + border-color: #b5d9ef; +} +.admin-file-input:hover .file-cta { + background: #cde8f5; +} + +/* ── Gallery cards ──────────────────────────────────────────────────────────── */ .low-tag-card { box-shadow: 0 0 0 2px #ffdd57 inset; } +.admin-gallery-grid .card { + transition: box-shadow 0.15s ease, transform 0.15s ease; +} +.admin-gallery-grid .card:hover { + box-shadow: 0 6px 20px rgba(24, 40, 72, 0.13); + transform: translateY(-2px); +} + +/* Selected card: blue ring */ +.admin-gallery-grid .card:has(.select-photo-checkbox:checked) { + box-shadow: 0 0 0 2.5px #2e7dbf inset, 0 6px 20px rgba(24, 40, 72, 0.1); +} + +/* Card top bar (checkbox + tag count row) */ +.admin-gallery-grid .card-content.py-2 { + background: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid #ebe5d2; +} + +/* Action buttons in card footer */ +.admin-gallery-grid .card-footer-item { + font-weight: 600; + transition: background 0.12s ease, color 0.12s ease; + gap: 0.35em; +} +.admin-gallery-grid .card-footer-item.edit-button { + color: #2e7dbf; +} +.admin-gallery-grid .card-footer-item.edit-button:hover { + background: #e8f4fb; + color: #1a5a8a; +} +.admin-gallery-grid .card-footer-item.delete-button { + color: #cc3333; +} +.admin-gallery-grid .card-footer-item.delete-button:hover { + background: #fdf0f0; + color: #991111; +} + +/* ── Bulk panel ─────────────────────────────────────────────────────────────── */ #bulkPanel { position: sticky; top: 16px; z-index: 10; box-shadow: 0 12px 24px rgba(17, 17, 17, 0.08); } - @media (max-width: 768px) { #bulkPanel { top: 8px; } } + +#clearSelection:hover { + color: #f14668; +} + +/* ── Store status: toggle switch ────────────────────────────────────────────── */ +.admin-toggle { + display: inline-flex; + align-items: center; + gap: 0.65rem; + cursor: pointer; + user-select: none; +} +.admin-toggle input[type="checkbox"] { + display: none; +} +.admin-toggle-track { + display: inline-block; + width: 42px; + height: 24px; + border-radius: 12px; + background: #ccc; + position: relative; + transition: background 0.2s ease; + flex-shrink: 0; +} +.admin-toggle-track::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); + transition: transform 0.2s ease; +} +.admin-toggle input:checked ~ .admin-toggle-track { + background: #e53e3e; +} +.admin-toggle input:checked ~ .admin-toggle-track::after { + transform: translateX(18px); +} +.admin-toggle-label { + font-size: 0.875rem; + color: #555; +} +.admin-toggle input:checked ~ .admin-toggle-track + .admin-toggle-label, +.admin-toggle input:checked ~ .admin-toggle-label { + color: #c53030; + font-weight: 600; +} + +/* Response notification */ +#response { + border-radius: 10px; +} diff --git a/main-site/admin/admin.js b/main-site/admin/admin.js index f1871f8..b2f3de8 100644 --- a/main-site/admin/admin.js +++ b/main-site/admin/admin.js @@ -258,7 +258,6 @@ document.addEventListener('DOMContentLoaded', () => {
    ${tagCount} tag${tagCount === 1 ? '' : 's'}${tagCount <= 2 ? ' • add more' : ''}
    @@ -272,8 +271,8 @@ document.addEventListener('DOMContentLoaded', () => {

    Tags: ${readableTags.join(', ')}

    @@ -737,6 +736,7 @@ document.addEventListener('DOMContentLoaded', () => { const currentStatus = data[0]; messageInput.value = currentStatus.message; isClosedCheckbox.checked = currentStatus.isClosed; + isClosedCheckbox.dispatchEvent(new Event('change')); closedMessageInput.value = currentStatus.closedMessage; } catch (error) { console.error('Error fetching current status:', error); diff --git a/main-site/admin/index.html b/main-site/admin/index.html index b56748c..34495e9 100644 --- a/main-site/admin/index.html +++ b/main-site/admin/index.html @@ -11,7 +11,6 @@