```html

Building the Sailor Board: Photo Gallery, Guest Upload Flow, and Deep-Link Modal Opens for Charter Events

This post covers a full-stack feature build for Queen of San Diego's guest experience: implementing the Sailor Board photo gallery, wiring up guest upload flows with spam protection, and fixing a critical UX bug where "Book a Sail" was returning users to the homepage instead of opening the booking modal inline.

What Was Built

  • Per-event guest page (/g/{event_id}/index.html): A dedicated upload and gallery page for each charter event, deployed to S3 and served via CloudFront
  • Photo upload handler: Client-side file validation (JPEG, PNG, WebP, HEIC/HEIF, MP4, MOV, AVI, WebM) with a 24-file batch limit; server-side presigning via Lambda
  • Event code spam gate: Guests with the event code bypass moderation; strangers go to a review queue
  • Instagram hashtag integration: Server fetches #jada and #queenofsandiego posts from the same day the charter ran, renders them alongside uploaded photos
  • Booking modal deep-linking: "Book a Sail" links now trigger an in-page modal instead of navigation away
  • Sailor Board (the actual feature): The per-event photo grid displayed on the guest page after upload, showing both user uploads and IG pulls

Architecture and File Organization

Guest Pages and S3 Layout

Guest pages live in S3 at s3://queenofsandiego-prod/g/{event_id}/index.html. During this session, I created and deployed:

  • /Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html — Template for per-event guest pages (new file)
  • /Users/cb/Documents/repos/sites/queenofsandiego.com/index.html — Updated homepage with Sailor Board marketing copy and deep-link modal logic
  • /Users/cb/Documents/repos/sites/queenofsandiego.com/booking-widget.js — Extracted booking modal, Stripe deps, and form logic into reusable widget (new file)

Pages are deployed to CloudFront distribution d*.cloudfront.net (staging) and the prod alias, invalidated post-deploy to clear edge caches.

Lambda Handler for Photo Metadata and IG Integration

The core backend lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. Multiple edits during this session touched:

  • Presign endpoint (GET /api/g/{event_id}/presign): Returns S3 presigned POST URLs for guest uploads. CORS now allows queenofsandiego.com origins to handle preflight correctly.
  • Photo fetch endpoint (GET /api/g/{event_id}/photos): Returns JSON with user_uploads (from DynamoDB + S3 metadata) and instagram (same-day #jada and #queenofsandiego posts fetched from Instagram Graph API).
  • Upload success callback: When a guest finishes uploading, Lambda checks for the event code in the request. If valid, the photo is published immediately. If missing, it goes into a moderation_queue DynamoDB table pending admin review.
  • Email notifications: On publish, Lambda sends a copy-to-admin email with a moderation link pointing to /g/{event_id} with a secret token to approve/reject photos.

The Lambda function was updated, tested with smoke tests against both the presign and photo-fetch endpoints, then redeployed with ./deploy.sh shipcaptaincrew.

Key Technical Decisions

Why Per-Event Pages Instead of a Global Board

"Sailor Board" is not a cross-charter leaderboard; it's the gallery for a specific event. Each charter gets its own `/g/{event_id}` page because:

  • Isolation: Guests upload only to their own event page, preventing cross-contamination.
  • SEO and sharability: Each page has a clean URL the charter captain can share with guests. Event-specific title and OG meta tags mean better social previews.
  • Moderation scope: Admins review photos in the context of the event, not a firehose.
  • Caching: CloudFront can cache the HTML shell for the event and only refresh metadata (the JSON from /api/g/{event_id}/photos) on a shorter TTL.

Upload Flow: Client Presigning + Server Validation

Guests select files in the browser, the page calls /api/g/{event_id}/presign to get S3 credentials, and uploads directly to S3. Why this pattern?

  • Bandwidth efficiency: Files bypass Lambda, reducing cold-start latency and memory pressure.
  • Progress tracking: Browser JavaScript can show upload progress without polling a backend.
  • Resilience: If presigning succeeds but S3 upload fails client-side, the guest can retry without re-invoking Lambda.
  • Spam prevention via event code: The event code is validated server-side in the presign response, not in the browser. A guest cannot forge a code; they must know the real one. Guests without a code go to the moderation queue instead of public-publish.

Same-Day Instagram Integration

The Lambda handler queries Instagram Graph API on each /api/g/{event_id}/photos call for posts with #jada or #queenofsandiego from the same day as the event (UTC). Why fetch on-demand instead of batch-caching?

  • Freshness: Guests want to see the latest IG posts in near-real-time as people tag during and after the sail.
  • Small result set: Same-day + two hashtags = a small enough query that API rate limits are not a concern.
  • Decoupling: The guest page doesn't need a separate webhook to refresh IG data; a simple page refresh pulls fresh results.

The Lambda caches the IG results in memory during the request, then serializes them into the JSON response as d.instagram, which the client renders into the #ig-grid DOM node.

Modal Deep-Linking Instead of Navigation

Previously, "Book a Sail" links returned users to the homepage. The fix:

  • booking-widget.js intercepts clicks on [href*="booking"] or [data-action="book"] links.
  • Instead of navigating, JavaScript renders an inline modal with the Stripe checkout form and fires a custom