🔒 Topher-only. Internal architecture + the field inventory I owe you. Key handling, write surface, intake-page opportunity. Don't share endpoint URLs externally yet — studio is the build surface, not customer-facing.
✅ What's live right now (studio env)
One edge Worker on Cloudflare Workers (the @astrojs/cloudflare adapter). Same Worker serves both the API and the server-rendered UI, single origin, single deploy.
- API —
/api/inventory,/api/inventory/[id],/api/inventory/stats - UI —
/boatslisting (stats card, filter form, grid),/boats/[documentId]detail (gallery, specs, sticky inquire CTA) - Caching — CF Cache API, edge-scoped. Lists 5 min · single records 10 min · stats 15 min. Pass
?fresh=1to bypass (staff/admin only). - Secret —
BOATS_COM_UJ_KEYbound on the studio Worker only. Never reaches the browser. Launch + production envs still need their own bind before promotion. - Deploy — git push to
studiobranch → GH Actions runsnpm run ci:deploy:studio→ wrangler deploys. Verified end-to-end at 19:19 UTC.
Live URLs to click around:
→ ugly-johns.studio.madebyaspire.com/boats (listing)
→ /boats/9473572 (2009 Supra Launch 21 detail)
→ /api/inventory/stats (raw JSON)
Field inventory — 110-field schema, scored on 117 records
Knowing what's safe to design around versus what shows up rarely changes how the UI behaves. The Boat detail page already does this — every section is conditional on data presence so we don't render "Beam: undefined".
100% — Always populated (51 fields)
- DocumentID
- SalesStatus
- MakeString
- Model
- ModelYear
- SaleClassCode
- BoatCategoryCode
- BoatClassCode
- BoatHullMaterialCode
- BoatHullID
- NominalLength
- LengthOverall
- BeamMeasure
- NumberOfEngines
- BoatLocation.{City,State}
- Office.{Email,Phone,City,State,…}
- GeneralBoatDescription (HTML)
- Images (CDN URLs)
- ItemReceivedDate
- LastModificationDate
- Owner.PartyId
- CompanyName
- CoOpIndicator
- PriceHideInd
- + 26 boolean / measurement flags
97–98% — Reliable (3 fields)
- SalesRep.{PartyId,Name}
- YachtWorldID
- AdditionalDetailDescription
70–96% — Most listings (6 fields)
- Engines (with Hours) — 91%
- TotalEnginePowerQuantity — 85%
- Price + NormPrice + OriginalPrice — 74–75%
- TotalEngineHoursNumeric — 70%
30–45% — Half-filled (8 fields)
- FuelTankCount + FuelTankCapacity — 44%
- DriveTypeCode — 43%
- DryWeightMeasure — 41%
- EmbeddedVideo + Videos.{title,desc,thumbnailUrl,url} — 31%
- ListingTitle — 28%
15–22% — Sparse (6 fields)
- MaxDraft — 22%
- WaterTank{Count,Capacity} — 21%
- DriveUp — 21%
- StockNumber — 18%
- Heads/HoldingTank — 17%
- ExternalLink (mfr URL) — 16%
≤12% — Rare (10+ fields)
- BridgeClearance, Deadrise, MaximumPassengers, Cruising/MaxSpeed, Windlass, LengthOfDeck, TaxStatus, SalesRep.Message, BuilderName, RangeMeasure, BoatName, CabinHeadroom, Ballast, CabinsCount, Designer, Displacement
- Most are detail-page nice-to-haves; useful when present, never assume
Dark fields — exposed in schema, never populated
PropellerCruisingSpeed · FreeBoardMeasure · BoatKeelCode · ElectricalCircuitMeasure · WaterTankMaterialCode · FuelTankMaterialCode · HoldingTankMaterialCode
Boats Group ships these fields in every record's envelope but UJ has never filled them. We render nothing for them. Safe to ignore until proven otherwise.
🎯 The intake-page opportunity (your question)
Can we add to inventory ourselves? The probe says yes — writes exist. When I POST'd to /inventory/search, the upstream returned "Bad Request: no method or ID provided" — that's their PHP framework's __call() magic-method router leaking a routing hint. The write API exists; it's keyed by a method= param + an ID= param.
What you'd build: a salesperson takes a new boat in trade, walks out to it with the iPad, snaps a few photos (or runs the Insta360 X5 once we land that), types in the basics, hits "Submit to inventory." Within minutes the boat appears on uglyjohns.com (via the new pipe) AND on boats.com / YachtWorld (via Boats Group's syndication). Mid-sized dealers normally pay $700/listing/month for the back-office tool that does this.
Where the sparse fields become the opportunity:
- StockNumber (18% fill rate) — invisible discipline gap. An intake page can require it. Suddenly UJ has consistent internal SKUs.
- ListingTitle (28%) — most listings fall back to "Year Make Model". An intake form with a "Hook headline" field unlocks "Babied freshwater — turnkey" copy that converts.
- EmbeddedVideo (31%) + Image360 (0%) — intake page makes uploading a video / 360 walkaround the default step. Within 90 days UJ has the rich-media moat that nobody else has.
- StreetableTitle: from the customer-facing side — when a salesperson types the description, they're guided ("highlights for buyers", "what makes this one different") instead of staring at a blank box. Better copy ships everywhere syndication reaches.
What I'd want to ship Phase 1 of the intake page: internal-only URL (CF Access on the route), form covers the 100%-fill fields + the 5 highest-leverage sparse ones (StockNumber, ListingTitle, primary photos, video URL, 360 URL), POST to a new API endpoint /api/inventory/intake that I implement after mapping the upstream write API (a 1-day spike). If the upstream writes don't pan out, fallback is to write into our own D1 / Airtable, run a nightly CSV sync into BoatWizard, still net positive.
API contract — the surface Jaime designs against
Three endpoints. All return JSON. All cached at the edge. All safe to call from anywhere on the same origin (and from elsewhere by enabling CORS if needed later).
/api/inventory cache: 5 min edge List boats (with filters)
| Param | Allowed |
|---|---|
| status | Active (default) | sale pending | Inactive | on-order |
| make | exact match (e.g. Nautique, Sea Ray) |
| category | Power (only value currently in use) |
| start | pagination offset, default 0 |
| rows | page size, default 500 (full pull) |
| fresh=1 | bypass cache (admin / staff use) |
→ /api/inventory?status=Active&make=Nautique&rows=10 /api/inventory/[documentId] cache: 10 min edge Single boat by DocumentID
| Param | Allowed |
|---|---|
| documentId | numeric (e.g. 9473572) |
→ /api/inventory/9473572 /api/inventory/stats cache: 15 min edge Aggregated counts (top brands, locations, rich-media, co-op)
| Param | Allowed |
|---|---|
| fresh=1 | bypass cache |
→ /api/inventory/stats Boat shape (normalized — lean projection of the 110-field raw)
{
documentId: string, // primary identifier
yachtWorldId: string?,
stockNumber: string?,
status: "Active" | "sale pending" | ...,
saleClass: "New" | "Used",
title: string, // ListingTitle or "year make model"
year: number?, make: string, model: string,
boatName: string?, // e.g. "Wine Down"
category: string?, // "Power" (all current)
classes: string[], // e.g. ["Bowrider", "Ski and Wakeboard"]
hull: { material?, id? }, // material, HIN
price: { // null if PriceHideInd=true
display: string, // raw "36000.00 USD"
numeric: number, // 36000
original?, hidden: boolean
} | null,
length: { nominal?, overall?, beam?, meters? },
engines: { count, hours?, power?, drive? },
location: { city?, state? }, // actual boat slip (not office)
rep: { name?, email?, phone? },
description: { primary?, legalNotice? }, // HTML strings
media: {
primaryImage?: string,
images: [{ url, caption? }],
hasVideo: boolean, has360: boolean, hasImmersiveTour: boolean,
embeddedVideos: [{ url, title?, thumbnail? }]
},
coOp: boolean,
externalLinks: [{ url, text, type }],
meta: { itemReceivedDate?, lastModified? },
raw: { /* full upstream record — escape hatch */ }
} Defined in src/lib/boats-api.ts. The raw field is an escape hatch — anything not yet projected is reachable.
Open follow-ups — what's NOT done yet
- Write-API mapping (1-day spike). Find the upstream
method=values (create/update/delete). Crawl4ai timed out onapi.boats.com/docsearlier — re-try with a longer wait, or just ask Jason Smith for the docs PDF. - Launch + production secret binding. When Jaime's design lands and we promote to launch, I have to
wrangler secret put BOATS_COM_UJ_KEY --env launchbefore the first launch deploy. Same for production at cutover. Documented in the deploy notes; won't forget. - HTML sanitization. The
description.primaryfield is HTML from BoatWizard. XSS surface is low (UJ staff don't have motive) but I want DOMPurify (or rehype-sanitize) on the server side before public launch. Tracked. - Email obfuscation. Currently the sales-rep email + phone show as plain text on the detail page (matches what Boats Group already exposes publicly). Confirm with you whether we proxy it through a Worker form to fight scrapers, or leave it (UJ may want the white-glove "call our team" signal).
- SEO depth. Detail pages get OG image (primary boat photo) and meta description (first 200 chars of description). JSON-LD
Productschema is next — boats.com may have a Google rich-result advantage UJ doesn't because the syndication strips it. - Image lazy-loading already on; image optimization not yet. Boats Group's CDN serves 2MB XLARGE images. Pages Worker can pipe through
@astrojs/cloudflare's Image binding (already configured) for resize + WebP — cuts page weight ~70%. Phase 2. - Filter UX. Currently a basic GET-submit form. Once Jaime's design is in, this becomes a left-rail facet panel with brand + length + price + location + new/used.
⌛ What I'm waiting on you for
- Reply to Jason Smith. Draft is on the 6/10 page. Send when you're ready.
- Intake page — green light? Asking before I burn the 1-day spike on the write-API mapping. If yes, I'll also need to know whether the intake page lives at
/staff/intake(CF Access-gated) or somewhere else. - Sales-rep email/phone — public or proxied? Default is matching what Boats Group already exposes (public).
Everything else I'll handle. Continuing with the next layer of polish (sanitization, JSON-LD, image optimization, image fallbacks) while Jaime's design takes shape.