2026-04-30 — Emporium follow-ups: covers, related products, sitemap, Payload-driven footer
Summary
Four PRs stacked on top of yesterday’s foundation. Real product covers and three new books, related-products renderer on blog posts, native Next sitemap + branded 404, and a fully Payload-driven footer. The footer work crystallized a guiding principle for the stack: anything staff would change without a deploy lives in Payload, and Medusa is the ecommerce layer only. Stack is open, descriptions written, Copilot comments addressed in-thread on the two earlier PRs.
What shipped
PR #10 — Real product covers + 3 new books (feat/seed-product-images)
Twelve seeded books had thumbnail: null and rendered as gray placeholders. Now they fetch from Open Library at seed-time, upload through Medusa’s file module so runtime images are served from our own storage, and land on the thumbnail field. Source URLs are documented in the seed; runtime images are ours.
Three new products added so blog posts can reference real product handles: The Mountain in the Sea (Ray Nayler), The Will of the Many (James Islington), Mausritter (Isaac Williams).
Built add-product-thumbnails.ts as a one-shot, idempotent script for applying changes to an already-seeded DB. The seed itself is non-idempotent (re-running fails on “Countries with codes ‘us’ are already assigned to a region”), and a full reset would wipe orders/customers/blog content. The one-shot script:
- Skips products with thumbnails already set
- Skips products that already exist
- Repairs sales-channel links: products created by the script initially landed on
Default Sales Channel, but the storefront publishable key is bound toOnline Store. The script auto-relinks them. - Handles inventory levels for newly-created variants (otherwise they aren’t sellable)
Cover-source map extracted to backend/src/scripts/lib/cover-sources.ts so seed and the one-shot script share constants and can’t drift on ISBN choices.
Mörk Borg has no Open Library cover under any ISBN or ASIN we tried. Confirmed with curl probes against several editions. RPGs especially indie ones (Free League, Losing Games, itch.io publishers) are outside the publisher pipeline that feeds Open Library / Google Books. Logged this as a structural gap — long-term we need a path that doesn’t depend on ISBN: drop covers in backend/seed-assets/covers/<handle>.jpg and seed reads from disk, or upload through the Medusa admin. Mörk Borg is left without a thumbnail for now.
PR #11 — Related products on blog posts (feat/blog-related-products)
The Payload BlogPosts.relatedProductHandles field has been there since Phase 1 but nothing was reading it. Wired up a new RelatedProducts server component on BlogPostTemplate:
- Fetches each handle from Medusa in one batch via
sdk.store.product.list(with?handle[]=...) - Dedupes input handles (Payload’s array doesn’t enforce uniqueness, and React keys collide otherwise)
- Preserves the editor’s order — the
byHandlemap is iterated against the original handle array - Drops missing handles silently (typo-tolerant)
- Uses an explicit
limit: handles.lengthso default pagination can’t truncate - Renders as a 2/3-aspect grid (book-cover proportions) titled “Featured in this post” — purposely generic since posts will reference more than just books
Built a custom card instead of reusing <ProductTile> because the existing tile is fixed at h-[504px] (Solace’s chair-shop sizing). Book covers got stretched and narrow inside that slot — a card with aspect-[2/3] and a flexible content area was the right shape.
Hover-zoom got added unprompted in the first pass and was removed when flagged. We don’t have hover transforms anywhere else — staying consistent.
PR #12 — Native sitemap + branded 404 (feat/sitemap-and-404)
Closed the SEO half of brief 2.7. Three pieces:
app/sitemap.ts(Next 14 native): home,/shop,/blog, every published Payload Page, every BlogPost, every Medusa product, every category. Hourly revalidation. 42 entries on the local DB, no duplicates.app/robots.ts: points at/sitemap.xml, disallows/checkout,/account/,/cart,/order/.- Branded 404: “Lost in the dungeon. This page wandered off into the stacks. Maybe a book ate it.” Two paths back: shop, blog. Updated both
app/not-found.tsx(fall-through) andapp/(main)/not-found.tsx(in-groupnotFound()calls).
Removed the dead next-sitemap.js config. The next-sitemap package wasn’t installed and no script invoked it — leftover Solace config that did nothing.
Known gap (out of scope, deferred): the sitemap lists every Payload Page slug, but the storefront only has hardcoded page.tsx files for about-us, faq, privacy-policy, terms-and-conditions. A Payload page with any other slug would 404 even though the sitemap advertises it. Wiring up a /[slug]/page.tsx catch-all is a separate task.
PR #13 — Payload-driven footer (feat/footer-payload)
This is the PR that established the rule. Mid-build, on whether the 404 page copy should live in Medusa or Payload, the conversation went: long-term vision is for this stack to be usable by other bookstores, and adding an Instagram link to the footer shouldn’t require a code deploy. So the rule is:
Anything staff would change without a deploy lives in Payload. Medusa is pure ecommerce.
Saved as a memory entry — it shapes future PRs.
New Payload Footer global:
- Address (group): structured fields, also feeds future
LocalBusinessJSON-LD - Hours (array): per-day with
isClosedcheckbox + 24-houropenTime/closeTimetext fields, plus a free-texthoursNotefor exceptions - Contact: email + phone
- Social links (array): enum-platform select with Instagram, Bluesky, Mastodon, TikTok, YouTube, Discord, Facebook, Twitter, plus URL
- Affiliate links (array): same shape with Bookshop.org, Libro.fm, Itch.io, DriveThruRPG, Patreon, Ko-fi
- Newsletter blurb: optional text shown above the signup form
- Copyright line:
{year}placeholder is replaced with current year
Storefront footer reads via getFooter(), hides sections that aren’t configured, falls back gracefully if Payload is down. Newsletter signup is a small client component that captures email + shows a confirmation state — backend wiring (Beehiiv / Buttondown / etc.) is deferred to its own PR.
Layout went through several rounds of styling notes:
- Initial layout had Visit Us on the left, then nav, affiliates, social. User asked to move Visit Us right — moved to the right column.
- Footer felt crowded — Visit Us moved to its own band at the top of the footer (Visit Us / Hours / Follow as three columns horizontally), divider below, then nav row with affiliates.
- “Follow” heading dropped (self-explanatory), social buttons promoted to their own column next to “Also find us at.”
- Address compacted from 4 lines (street, street2, city/region/postal, country) to 2 lines (street + street2 on line 1, city/region postal on line 2). Country dropped — assumes USA.
- Hours converted from 24-hour (
12:00–19:00) to 12-hour with AM/PM (12:00 PM – 7:00 PM). Standard in USA. New util atlib/util/format-time.ts. - Phone numbers now formatted as
(XXX) XXX-XXXXfor US 10-digit input.tel:href uses raw digits. New util atlib/util/format-phone.ts. - Tried adding the store logo at the top of the footer — user pulled it because it ate too much vertical space. Removed.
- Newsletter section briefly disappeared because I gated it on
newsletterBlurb !== null— Payload returnsnullfor unset fields. The signup should always render; the blurb is optional copy. Fixed.
Added 6 brand SVG icons (Instagram, Bluesky, Mastodon, TikTok, YouTube, Discord) under modules/common/icons/ to match the existing Facebook/Twitter/LinkedIn pattern. Hand-rolled, no new package.
Social buttons reuse Solace’s circle-button styling: 48×48 rounded-full, hover-fills with bg-action-secondary. Wrapped in SocialButtons component.
Future asks queued during this PR:
- Google Maps embed for Visit Us (single iframe URL field on the Footer global, probably)
- Email-provider wiring for newsletter
- Platform icons for affiliates (currently text labels only)
Decisions
Payload vs Medusa scope rule
Saved as a feedback memory. Concrete applications:
- Footer (this PR): all of it in Payload
- 404 page copy: hardcoded today, but should move to a
NotFoundPageglobal later. Cheap to swap, ~10 min. - Anything in the future that has shop voice (about page, homepage hero, category descriptions, marketing copy) → Payload
- Hardcode only when structural (route layout, component scaffolding) or behavioral (Stripe webhook secret name)
This isn’t multi-tenant work — we’re nowhere near multi-tenant. But treating Payload as the staff-edit surface keeps the boundary clean for when it matters.
Footer layout iteration
Worth noting that “make it Payload-driven” was the easy part. The styling iteration consumed most of the PR — moving Visit Us, compacting address, switching time format, dropping the logo, fixing the newsletter gate. Each round was small but the total compounded. For future Payload-driven UI work, expect the data layer to land fast and the layout/copy polish to take the rest.
One-shot scripts vs full re-seed
The seed script is non-idempotent (region creation in particular). Re-seeding requires dropdb && createdb && db:migrate && seed — destructive. For follow-up changes that need to reach an already-seeded DB without wiping it, write a one-shot idempotent script under backend/src/scripts/ and run with medusa exec. add-product-thumbnails.ts is the template: lookup-by-handle, skip-if-set, repair-on-mismatch.
Lessons
- Open Library has good ISBN coverage for trade-published books, near-zero for indie RPGs. Mörk Borg, Mausritter, anything from Free League / itch.io / direct-to-consumer publishers won’t be on OL. RPGs are a structural product-data gap — they often don’t even have ISBNs. Long-term we need a non-ISBN path (admin upload or seed-asset folder).
- The seed script renames the default sales channel to
Online Storeand links it to the publishable key. New products must go onOnline Store, notDefault Sales Channel, or the storefront API won’t return them. The one-shot script learned this the hard way and now prefersOnline Storewith a fallback toDefault Sales Channel. - Payload
nullvsundefined: Payload returnsnullfor unset fields, notundefined. Don’t gate UI onfield !== nullif you mean “field has a meaningful value” — the field will be null when nothing’s filled in, and you’ll hide content that should always show. - Solace’s
ProductTileis 504px tall because Solace was a chair shop. Reusing it for book covers (2:3 portrait) makes them look stretched and narrow. For book/magazine/zine grids, build a card withaspect-[2/3]instead of forcing the existing tile. - Dark Reader extension causes hydration mismatches by injecting
--darkreader-inline-*style attributes after server render. Not a code bug — the user just needs to disable Dark Reader on the dev origin or test in incognito. - Copilot’s “race condition” comments on
Promise.all+pushwere technically wrong but worth applying anyway. The original code keyed thumbnails byf.handle(not source-array index), so completion-order vs source-order didn’t affect correctness. Map+filter pattern is cleaner regardless. Replied in-thread explaining the original wasn’t buggy; applied the suggestion for readability.
Outstanding
- PRs #10 and #11 had Copilot rounds, addressed
- #12 and #13 are awaiting Copilot review at end of session
- Footer needs real shop data filled in via Payload admin (hours, address, social, affiliate URLs, newsletter blurb, copyright line) — that’s a content task, not a dev task
Tomorrow
Resume from: review/merge state of the four open PRs (#10–#13). Likely follow-ups:
- Address any new Copilot comments on #12 / #13
- Fill in real Footer data via Payload admin (Carrie may want to do this herself — it’s the whole point)
- Staff Picks page (deferred from yesterday’s brief 2.7 list — the only original ask not yet shipped)
- Email-provider wiring for newsletter
- Google Maps embed on Footer
- Move 404 copy to a Payload
NotFoundPageglobal (reuses the same pattern as Footer) - Wire up
/[slug]/page.tsxcatch-all so arbitrary Payload Page slugs render
Stack is healthy, ~4 commits in flight, no blockers. Resumable.