fix: route gallery API through nginx, send Square receipts, unblock order completion

- nginx: add /photos and /uploads proxy routes to gallery-backend so the
  browser can reach the gallery API without needing direct port access
- gallery.js: drop hardcoded port/subdomain fallbacks; use same-origin path
  via the new nginx routes
- square.ts: pass buyerEmailAddress to createPayment so Square auto-sends
  a payment receipt to the customer on capture
- square.ts: create fulfillments in RESERVED state (was PROPOSED) so staff
  can mark orders complete/filled directly from the Square dashboard
- CartDrawer: merge Custom Vinyl into the Shape Balloon line item (one fewer
  Square line item per vinyl order); show modifier price deltas in cart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-20 14:32:00 -04:00
parent bb878c2a8a
commit 4a135a7919
5 changed files with 59 additions and 42 deletions

View File

@ -299,6 +299,7 @@ export async function POST(req: NextRequest) {
note, note,
idempotencyKey: paymentIdempotencyKey, idempotencyKey: paymentIdempotencyKey,
autocomplete: false, // hold only — capture after calendar write autocomplete: false, // hold only — capture after calendar write
buyerEmailAddress: customerEmail,
}) })
if (!payment?.id) throw new Error('Pre-authorization returned no payment ID') if (!payment?.id) throw new Error('Pre-authorization returned no payment ID')

View File

@ -272,19 +272,12 @@ export default function CartDrawer() {
}), }),
}, },
{ {
name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`, name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon w/ Vinyl`,
quantity: e.quantity, quantity: e.quantity,
priceCents: e.vinylShapePriceCents ?? 450, priceCents: (e.vinylShapePriceCents ?? 450) + vinylCents,
catalogItemId: e.vinylShapeVariationId, catalogItemId: e.vinylShapeVariationId,
note: `Vinyl add-on for: ${e.product.name}`,
},
{
name: 'Custom Vinyl',
quantity: e.quantity,
priceCents: vinylCents,
catalogItemId: e.product.variations[0]?.id ?? e.product.id,
note: [ note: [
`Add-on for: ${e.product.name}`, `Vinyl add-on for: ${e.product.name}`,
`Text: "${e.vinylText}"`, `Text: "${e.vinylText}"`,
e.vinylFontName ? `Font: ${e.vinylFontName}` : null, e.vinylFontName ? `Font: ${e.vinylFontName}` : null,
].filter(Boolean).join(' | ') || undefined, ].filter(Boolean).join(' | ') || undefined,
@ -423,10 +416,14 @@ export default function CartDrawer() {
if (!optIds.length) return null if (!optIds.length) return null
const ml = entry.product.modifiers?.find((m) => m.id === listId) const ml = entry.product.modifiers?.find((m) => m.id === listId)
if (!ml) return null if (!ml) return null
const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id) const labels = optIds.map((id) => {
const opt = ml.options.find((o) => o.id === id)
if (!opt) return id
return opt.priceDelta ? `${opt.name} (+${fmt(opt.priceDelta)})` : opt.name
})
return ( return (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}> <div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {names.join(', ')} {ml.name}: {labels.join(', ')}
</div> </div>
) )
})} })}

View File

@ -273,7 +273,7 @@ export async function createSquareOrder(params: {
? params.fulfillment.type === 'delivery' ? params.fulfillment.type === 'delivery'
? [{ ? [{
type: 'DELIVERY', type: 'DELIVERY',
state: 'PROPOSED', state: 'RESERVED',
deliveryDetails: { deliveryDetails: {
recipient: { recipient: {
displayName: params.fulfillment.recipientName, displayName: params.fulfillment.recipientName,
@ -288,7 +288,7 @@ export async function createSquareOrder(params: {
}] }]
: [{ : [{
type: 'PICKUP', type: 'PICKUP',
state: 'PROPOSED', state: 'RESERVED',
pickupDetails: { pickupDetails: {
recipient: { recipient: {
displayName: params.fulfillment.recipientName, displayName: params.fulfillment.recipientName,
@ -345,6 +345,7 @@ export async function createSquarePayment(params: {
note: string note: string
idempotencyKey: string idempotencyKey: string
autocomplete?: boolean // false = pre-authorize (hold) without capturing autocomplete?: boolean // false = pre-authorize (hold) without capturing
buyerEmailAddress?: string // triggers Square's automatic payment receipt email
}) { }) {
const client = getClient() const client = getClient()
const { result } = await client.paymentsApi.createPayment({ const { result } = await client.paymentsApi.createPayment({
@ -355,6 +356,7 @@ export async function createSquarePayment(params: {
locationId: process.env.SQUARE_LOCATION_ID!, locationId: process.env.SQUARE_LOCATION_ID!,
note: params.note, note: params.note,
autocomplete: params.autocomplete ?? true, autocomplete: params.autocomplete ?? true,
buyerEmailAddress: params.buyerEmailAddress,
}) })
return result.payment return result.payment
} }

View File

@ -78,16 +78,12 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
const apiBaseCandidates = (() => { const apiBaseCandidates = (() => {
const protocol = window.location.protocol;
const host = window.location.hostname;
const hints = [ const hints = [
window.GALLERY_API_URL || '', window.GALLERY_API_URL || '',
'https://photobackend.beachpartyballoons.com', '', // same-origin via nginx proxy
`${protocol}//${host}:5000`,
`${protocol}//${host}:5001`,
]; ];
// Remove duplicates/empties // Remove duplicates/empties — empty string means same-origin (/photos, /uploads)
return [...new Set(hints.filter(Boolean))]; return [...new Set(hints)];
})(); })();
let activeApiBase = ''; let activeApiBase = '';
@ -219,9 +215,7 @@ document.addEventListener('DOMContentLoaded', () => {
const resolveUrl = (p) => { const resolveUrl = (p) => {
if (typeof p !== 'string') return ''; if (typeof p !== 'string') return '';
if (p.startsWith('http') || p.startsWith('assets') || p.startsWith('/assets') || p.startsWith('../assets')) return p; if (p.startsWith('http') || p.startsWith('assets') || p.startsWith('/assets') || p.startsWith('../assets')) return p;
const base = activeApiBase const base = activeApiBase || '';
|| 'https://photobackend.beachpartyballoons.com'
|| `${window.location.protocol}//${window.location.hostname}:5000`;
const path = p.startsWith('/') ? p.slice(1) : p; const path = p.startsWith('/') ? p.slice(1) : p;
return `${base.replace(/\/$/, '')}/${path}`; return `${base.replace(/\/$/, '')}/${path}`;
}; };

View File

@ -16,6 +16,10 @@ http {
server main-site:3050; server main-site:3050;
} }
upstream gallery {
server gallery-backend:5000;
}
server { server {
listen 80; listen 80;
server_name _; server_name _;
@ -38,6 +42,25 @@ http {
location = /refund { return 301 /shop/refund; } location = /refund { return 301 /shop/refund; }
location = /refund/ { return 301 /shop/refund; } location = /refund/ { return 301 /shop/refund; }
# ── Gallery API and uploaded images ──────────────────────────────────────
location ^~ /photos {
proxy_pass http://gallery;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /uploads {
proxy_pass http://gallery;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── eStore: /shop and everything under it ──────────────────────────────── # ── eStore: /shop and everything under it ────────────────────────────────
location ^~ /shop { location ^~ /shop {
proxy_pass http://estore; proxy_pass http://estore;