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

@ -293,12 +293,13 @@ export async function POST(req: NextRequest) {
// We capture only AFTER the calendar write succeeds. If the calendar fails,
// we void the hold — the customer is never charged without a confirmed booking.
const payment = await createSquarePayment({
sourceId: sourceId,
orderId: order.id!,
amountMoney: { amount: order.totalMoney.amount!, currency: 'USD' },
sourceId: sourceId,
orderId: order.id!,
amountMoney: { amount: order.totalMoney.amount!, currency: 'USD' },
note,
idempotencyKey: paymentIdempotencyKey,
autocomplete: false, // hold only — capture after calendar write
idempotencyKey: paymentIdempotencyKey,
autocomplete: false, // hold only — capture after calendar write
buyerEmailAddress: customerEmail,
})
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,
priceCents: e.vinylShapePriceCents ?? 450,
priceCents: (e.vinylShapePriceCents ?? 450) + vinylCents,
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: [
`Add-on for: ${e.product.name}`,
`Vinyl add-on for: ${e.product.name}`,
`Text: "${e.vinylText}"`,
e.vinylFontName ? `Font: ${e.vinylFontName}` : null,
].filter(Boolean).join(' | ') || undefined,
@ -423,10 +416,14 @@ export default function CartDrawer() {
if (!optIds.length) return null
const ml = entry.product.modifiers?.find((m) => m.id === listId)
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 (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {names.join(', ')}
{ml.name}: {labels.join(', ')}
</div>
)
})}

View File

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

View File

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

View File

@ -16,6 +16,10 @@ http {
server main-site:3050;
}
upstream gallery {
server gallery-backend:5000;
}
server {
listen 80;
server_name _;
@ -38,6 +42,25 @@ http {
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 ────────────────────────────────
location ^~ /shop {
proxy_pass http://estore;