Skip to content

Offers Targeting + Web App Wiring โ€” Design โ€‹

Date: 2026-04-30 Scope: services/api/merchants/, apps/web/, apps/admin/, packages/shared/, tooling/scripts/Closes: #139, #321, #347Builds on: 2026-04-25-offers-crud-admin-design.md (sub-project #1)

Goal โ€‹

Wire real merchant offers from Firestore into the consumer web app with server-side targeting (geofence + audience), consume all four placement types (hero, inline, chat, feed) on the user-facing surfaces, and clean up the stub-era plumbing left behind from sub-project #1.

After this change:

  • The web app reads offers from a new public-auth endpoint on the merchants-api, not from a mock generator.
  • Offers are filtered server-side by geofence and audience before reaching the client.
  • chat and feed placements render in Chat.jsx and RecentActivityView.jsx (AdSlot components are already built; they're just not yet wired).
  • AdSlot, OfferCards, and offerNormalizer move to @lantern/shared so admin and web stop maintaining duplicates.
  • The stub OfferForm and setMerchantFakeOffers plumbing in the web app are removed.
  • The merchants-api openapi.json correctly documents all routes (existing and new).
  • A separate venue-picker bug surfaced during scoping is fixed.

Why now โ€‹

Sub-project #1 (2026-04-25-offers-crud-admin-design.md:296-303) explicitly deferred this work as sub-project #2 with three named items: wire web app to real API, extract shared components, implement targeting enforcement. All three are still outstanding and #139 is the canonical tracking issue. Doing this now also clears spec drift in the merchants-api openapi.json (discovered while scoping; only /health was documented even though five offer routes are live) and a venue-picker bug that's blocking merchant onboarding for venues whose Firestore document IDs sort beyond the picker's hard limit(500) cap.

Architecture โ€‹

1. New public-auth route on merchants-api โ€‹

Add a sibling route to the existing merchant-scoped routes. The current service mounts everything under /merchants/:merchantId with both verifyFirebaseToken and requireMerchantAccess middleware. The new route runs only verifyFirebaseToken โ€” any signed-in app user can read offers nearby โ€” but must not be reachable through the merchant-scoped chain.

Updated mount in services/api/merchants/src/index.js:

js
// Public read route โ€” auth only, no merchant scoping
app.use('/offers', verifyFirebaseToken, publicOffersRouter)
// Existing merchant-scoped CRUD
app.use('/merchants/:merchantId', verifyFirebaseToken, requireMerchantAccess, offersRouter)

New route file: services/api/merchants/src/routes/publicOffers.js

MethodPathPurpose
GET/offers/activeList active offers near the user, filtered by audience

Query parameters:

ParamTypeRequiredNotes
latnumberyesUser's latitude
lngnumberyesUser's longitude
placementstringnoFilter by placement (hero/inline/chat/feed); omitted โ†’ all

Response shape:

json
{
  "offers": [
    {
      "id": "abc123",
      "merchantId": "...",
      "venueId": "...",
      "title": "20% off brunch",
      "description": "Show this offer...",
      "placement": "hero",
      "targetAudience": "nearby",
      "radius": 800,
      "expiresAt": "2026-05-15T00:00:00.000Z",
      "showDisclaimerWhileSuppliesLast": false,
      "venue": { "id": "...", "name": "...", "lat": 32.7, "lng": -117.1, "category": "restaurant", "lanternCount": 3 },
      "distanceMeters": 412
    }
  ],
  "total": 1
}

Per-offer venue is hydrated server-side from the venues collection so the client doesn't need a second roundtrip. distanceMeters is the computed haversine distance for client-side sorting/display. lanternCount is read from the venue document's existing aggregate counter.

Response excludes:

  • merchantId is included (useful for analytics and future surfaces).
  • budget, per_user_limit, createdBy are not included โ€” internal fields not meant for the consumer surface.

2. Server-side filtering โ€‹

The endpoint computes the visible offer set in this order:

  1. Status filter: status === 'active' AND expiresAt > now.
  2. Geofence filter: haversine distance from (lat, lng) to venue.{lat,lng} โ‰ค offer.radius.
  3. Audience filter: evaluate per-offer (see below).
  4. Optional placement filter: if ?placement=โ€ฆ provided, retain only matching offers.

The filter pipeline is server-side because:

  • It centralizes audience semantics (one place to evolve).
  • It avoids leaking inactive/expired offers and other merchants' draft data over the wire.
  • Future redemption logic (per-user-limit, fraud) will live in the same service โ€” keeping reads here matches that direction.

Implementation note: for the pilot scale (~hundreds of active offers in San Diego), a single Firestore query for status == 'active' followed by in-process geofence + audience filtering is acceptable. A geohash index on offer venues is out of scope; revisit if active-offer count exceeds ~5,000.

3. Audience semantics โ€‹

ValueUI labelv1 server check
nearbyNearby Users (1.5mi)Geofence only โ€” already enforced upstream by step 2
lanternActive Lantern HoldersGeofence AND user has an active lantern at offer.venueId (queries lanterns where userId == req.user.uid AND venueId == offer.venueId AND status == 'active')
newNew UsersGeofence AND users/{uid}.createdAt > now โˆ’ 7 days (threshold is a constant in audience.js, can be tuned per-pilot)
frequentFrequent Visitorsv1 fallback: enforced as nearby. Proper enforcement requires a per-(user, venue) lantern counter that doesn't exist today (the lanterns collection has a 48-hour TTL, so it can't answer "lit โ‰ฅ3 times historically"). Filed as a follow-up issue (see Out of Scope).

Why frequent is deferred to a follow-up: building a counter requires either a Cloud Function trigger on lanterns.onCreate to increment users/{uid}.lanternCounts[venueId], or a denormalized aggregate. Both expand scope meaningfully (deploy job, backfill migration, ongoing maintenance). The scope-discipline call is to ship nearby/lantern/new enforcement now โ€” covering the audiences that map cleanly to existing data โ€” and track the frequent counter separately so it gets the design attention it deserves.

frequent-audience offers will still be visible (treated like nearby); they just won't be filtered to repeat visitors yet. Merchant-facing copy doesn't promise "only frequent users will see this" so this is a tightening that won't break expectations. Documented in the audience helper file's JSDoc.

4. Audience filter helper โ€‹

New module: services/api/merchants/src/lib/audience.js

js
/**
 * Returns true if the user qualifies for an offer's targetAudience.
 * Geofence is enforced upstream; this helper covers identity-based filters.
 *
 * @param {string} audience - 'nearby' | 'lantern' | 'frequent' | 'new'
 * @param {Object} userContext - { uid, accountAgeDays, hasActiveLanternAtVenue }
 * @param {Object} offer - The offer document
 * @returns {boolean}
 */
export function passesAudience(audience, userContext, offer) { ... }

The endpoint pre-computes userContext once per request:

  • accountAgeDays: derived from users/{uid}.createdAt
  • hasActiveLanternAtVenue: a single batched query for all (uid, offer.venueId) pairs in the candidate set

The batched lantern query avoids N+1 reads across offers.

5. Web app: replace offerService.js with API consumer โ€‹

apps/web/src/lib/offerService.js is rewritten as a thin client.

Removed:

  • setMerchantFakeOffers (and the singleton it manages)
  • The category-matched mock offer generator
  • Random lanternCount generation
  • Hardcoded mockOffers.slice(0, 3) logic

New:

  • fetchActiveOffers({ lat, lng, placement? }) โ€” calls GET /offers/active via apiClient.authRequest, returns the offer list directly
  • getHeroOffer(offers) โ€” picks highest-priority offer with placement === 'hero' from the fetched list (priority ordering preserved if the field exists; falls back to expiry-soonest)
  • getOffersByVenue(offers) โ€” groups offers by venueId for inline rendering
  • getVenueOffer(venueId, offers) โ€” returns the highest-priority offer for a specific venue

The functions take an already-fetched offer list as input rather than refetching, so the caller controls when network requests happen.

Caller changes:

FileChange
apps/web/src/components/dashboard/HomeView.jsxReplace getActiveOffers(venues) mock call with a useEffect that calls fetchActiveOffers() on mount and on user-location change > 100m. Memoize heroOffer and offersByVenue from the fetched list.
apps/web/src/components/dashboard/VenueView.jsxPull from the same fetched offer list (lifted to a parent or accessed via a small offers context).
apps/web/src/screens/merchant/MerchantDashboard.jsxRemove the setMerchantFakeOffers(fakeAds) call (line 243).

The fetch happens once per dashboard mount with subsequent refetches gated on:

  • User location moved > 100m (if a location-watcher is already in place; otherwise this gate is dropped and we rely on visibility refetch only โ€” verify at implementation time)
  • Tab returns to foreground (visibilitychange event)

No onSnapshot real-time listener โ€” offers don't change minute-to-minute and onSnapshot would require client-readable Firestore rules that we'd rather avoid.

6. Wire chat and feed placements โ€‹

ChatOfferPill and FeedOfferCard exist in apps/web/src/components/dashboard/OfferCards.jsx (lines 19, 40), shipped by PR #322, but unused on consumer surfaces. This work imports them where they belong.

Chat surface:

  • File: identify the chat screen (likely apps/web/src/screens/chats/Chat.jsx or similar; locate at implementation time).
  • Behavior: when a user is in a chat tied to a specific venue (via chat.venueId or the connection's anchor venue), render an AdSlot for any active offer at that venue with placement === 'chat'. One offer max โ€” pick the highest priority.
  • Position: top of the chat thread, above the message list, with sticky positioning matching the existing chat header pattern.
  • Empty case: render nothing (no slot reservation).

Feed surface:

  • File: apps/web/src/components/dashboard/RecentActivityView.jsx
  • Behavior: when activity rows are tied to a venue with an active placement === 'feed' offer, interleave a FeedOfferCard after the activity row. At most one FeedOfferCard per scroll session per venue (track via a Set of seen venueIds in component state).
  • Empty case: render nothing.

Both surfaces fetch from the same offer list returned by fetchActiveOffers() โ€” no separate API calls. The placement query param is used when the surface only needs one type, to keep response payloads tight.

7. Extract to @lantern/shared โ€‹

Three modules currently duplicated between apps/admin/ and apps/web/ move to packages/shared/:

Source A (admin)Source B (web)New location
apps/admin/src/components/offers/OfferCards.jsx (admin-restyled copy)apps/web/src/components/dashboard/OfferCards.jsxpackages/shared/components/OfferCards.jsx
apps/admin/src/components/offers/AdSlot.jsxapps/web/src/components/dashboard/AdSlot.jsxpackages/shared/components/AdSlot.jsx
apps/admin/src/shared/lib/offerNormalizer.jsapps/web/src/lib/offerNormalizer.jspackages/shared/lib/offerNormalizer.js

Styling reconciliation: the admin copy uses the admin portal's CSS variables; the web copy uses Tailwind. Resolution: the shared component accepts a theme prop or uses CSS variable names that both apps define. Concrete approach picked at implementation time after comparing the two copies side-by-side; if reconciliation is non-trivial, fall back to keeping admin and web copies separate and only extracting offerNormalizer.js (pure logic, no styles) for now. A pragmatic note in the implementation plan calls this out.

Storybook: existing stories in apps/web/src/screens/dashboard/ (HeroOfferCard.stories.jsx, OfferPill.stories.jsx) are updated to import from the shared package.

8. Stub cleanup in web app โ€‹

  • Delete apps/web/src/screens/merchant/OfferForm.jsx (functionality lives in the admin Offers tab now per sub-project #1).
  • Delete apps/web/src/screens/merchant/OfferForm.stories.jsx.
  • Remove the setMerchantFakeOffers export from offerService.js and its only caller in MerchantDashboard.jsx.
  • Remove the merchant-form route from apps/web/src/App.jsx if it exists (e.g., #/merchant/new).

9. Venue picker bug fix โ€‹

apps/admin/src/components/venues/AdminVenuePicker.jsx:41 does qLimit(500) with no orderBy, so any venue past doc-ID position 500 is invisible (e.g., "Swan Bar"). Fix:

  1. Schema: add nameLower (string, lowercased) to all venue documents.
  2. Backfill: one-shot script tooling/scripts/backfill-venue-name-lower.js that reads all venues, sets nameLower on documents missing it. Idempotent.
  3. Write path: every code path that writes a venue's name also writes nameLower. Inventory and update:
    • apps/web/src/lib/seedVenues.js
    • services/api/venues/src/routes/import.js
    • apps/web/src/lib/osmImportService.js
    • Any direct addDoc(collection(db, 'venues')) call (search at implementation time)
  4. Picker query rewrite: replace the limit(500) fetch-all-and-filter approach with a Firestore range query:
    js
    const q = query(
      collection(db, 'venues'),
      orderBy('nameLower'),
      startAt(searchTerm.toLowerCase()),
      endAt(searchTerm.toLowerCase() + '\uf8ff'),  // High Unicode codepoint bounds the prefix range
      qLimit(50)
    )
  5. Empty-search behavior: preserve current "show nothing until user types" โ€” Firestore prefix query naturally returns nothing for empty input.

The denormalization adds maintenance cost (two fields to keep in sync) but unblocks scaling to thousands of venues without a search service. CLAUDE.md gets a one-line note in the venue-onboarding section reminding writers to update nameLower alongside name.

10. Merchants-api openapi.json populated โ€‹

Add paths entries to services/api/merchants/openapi.json for all six routes that exist or are being added:

PathMethod
/healthGET (already documented)
/offers/activeGET (new โ€” public read)
/merchants/{merchantId}/offersGET, POST
/merchants/{merchantId}/offers/{offerId}GET, PUT, DELETE

Schema definitions for the offer object, error responses, and the audience enum are added to components.schemas. The admin portal's API Reference page picks these up automatically via the registered service entry.

11. OpenAPI sync linter (fulfills #347) โ€‹

New script: tooling/scripts/lint.openapi-sync.js

For every service registered in packages/shared/services/index.js:

  1. Load the service's openapi.json and collect declared (method, path) pairs.
  2. Walk the service's src/index.js for app.use('<prefix>', router) mounts and src/routes/** for router.<method>('<subpath>', โ€ฆ) declarations. Combine into full (method, fullPath) pairs.
  3. Compare and fail with a per-direction report:
    • Implemented but undocumented: routes in code missing from spec
    • Documented but unimplemented: spec paths not served by code
  4. Path-parameter normalization: :id and {id} are equivalent for the comparison.

Wiring into validation:

  • Add a section to tooling/scripts/validate.js that runs the linter as part of the default npm run validate.
  • Support npm run validate -- --scope openapi for targeted runs.

Out of scope for this script: validating response shapes, status codes, or request bodies. Path ร— method is the v1 contract.

CLAUDE.md "Linter Organization" section gets a one-line entry pointing at the new script.

Data flow โ€‹

โ”Œโ”€ Web app dashboard mount โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   useGeolocation โ†’ { lat, lng }                              โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   fetchActiveOffers({ lat, lng })                            โ”‚
โ”‚   โ†“ HTTP GET /offers/active?lat=&lng=                        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
              โ”‚
              โ–ผ
โ”Œโ”€ merchants-api: GET /offers/active โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   verifyFirebaseToken โ†’ req.user.uid                         โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   Firestore: offers where status='active' AND expiresAt>now  โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   Hydrate venue data (batch get from venues by venueId)      โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   Filter: distance(user, venue) โ‰ค offer.radius               โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   Build userContext (accountAge, activeLanterns batch)       โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   Filter: passesAudience(offer.targetAudience, ctx, offer)   โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   Optional ?placement= filter                                โ”‚
โ”‚   โ†“                                                          โ”‚
โ”‚   Return [{ ...offer, venue, distanceMeters }, ...]          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
              โ”‚
              โ–ผ
โ”Œโ”€ Web app render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   HomeView:                                                  โ”‚
โ”‚     getHeroOffer(offers) โ†’ <HeroOfferCard>                   โ”‚
โ”‚     getOffersByVenue(offers) โ†’ per-venue <OfferPill>         โ”‚
โ”‚   Chat.jsx (when in venue-anchored chat):                    โ”‚
โ”‚     filter placement='chat' for this.venueId โ†’ <AdSlot>      โ”‚
โ”‚   RecentActivityView:                                        โ”‚
โ”‚     interleave placement='feed' rows โ†’ <FeedOfferCard>       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Error handling โ€‹

CaseBehavior
Missing lat/lng query paramsAPI returns 400 { error: 'INVALID_INPUT', message: 'lat and lng required' }
User signed out / token invalidAPI returns 401; web app shows nothing (no offers panel)
API unreachable / 5xxWeb app caches the most recent successful response in memory; shows it for the rest of the session. Logs devLog.warn in development. No user-facing error toast โ€” offers are not critical UI.
Firestore query error inside the APIAPI returns 500 with a generic message; pino logs the actual error
User location unavailable (geolocation denied)Web app does not call fetchActiveOffers โ€” no offers visible (consistent with the "outside the geofence = nothing" decision)
Empty offer setAPI returns { offers: [], total: 0 }; web app renders no hero/inline/chat/feed slots

Testing plan โ€‹

Automated tests:

SurfaceTest fileCoverage
Public offers routeservices/api/merchants/src/__tests__/publicOffers.test.jsAuth required, geofence filtering, each audience filter (nearby, lantern, new, frequent-as-nearby), placement filter, status/expiry exclusion, response shape, batched lantern query
Audience helperservices/api/merchants/src/lib/__tests__/audience.test.jsEach audience value, edge cases (account age = exactly 7 days, no lanterns, etc.)
offerService.jsapps/web/src/lib/__tests__/offerService.test.jsfetchActiveOffers happy path, error fallback, getHeroOffer priority ordering, getOffersByVenue grouping, getVenueOffer per-venue lookup
AdminVenuePickerapps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsxRange query produces results past the old 500 cap, empty input shows nothing, prefix matching works
OpenAPI sync lintertooling/scripts/__tests__/lint.openapi-sync.test.jsDetects undocumented route, detects unimplemented path, handles path-param normalization, exits non-zero on drift

Manual verification:

  1. Seed an offer with placement: 'hero', targetAudience: 'nearby', radius: 800 against a known venue. Spoof location to within 800m โ†’ hero card visible with real merchant data. Spoof outside โ†’ nothing.
  2. Repeat with each placement value, confirming each surface (HomeView hero, HomeView per-venue inline, Chat, RecentActivity) renders correctly.
  3. Repeat with each audience value:
    • lantern: light a lantern at the venue first, confirm offer appears; extinguish, confirm it disappears.
    • new: test as a fresh account (createdAt < 7 days ago) and as an old account.
    • frequent: confirm it currently behaves identically to nearby (v1 fallback) and that this is documented.
  4. In admin portal, search for "Swan Bar" โ†’ appears in picker. Verify other previously-invisible venues now appear.
  5. In admin portal API Reference page, confirm all merchants-api routes are now documented and clickable.
  6. Run npm run validate -- --scope openapi โ†’ green. Manually break the openapi.json (delete a path) โ†’ linter fails with clear report.
  7. Confirm apps/web/src/screens/merchant/OfferForm.jsx is gone and #/merchant/new either redirects to admin or 404s gracefully.
  8. Confirm storybook stories still render after the @lantern/shared extraction.
  9. npm run validate passes for the full suite.

Files touched โ€‹

New files โ€‹

PathResponsibility
services/api/merchants/src/routes/publicOffers.jsGET /offers/active route handler
services/api/merchants/src/lib/audience.jspassesAudience helper + userContext builder
services/api/merchants/src/__tests__/publicOffers.test.jsRoute tests
services/api/merchants/src/lib/__tests__/audience.test.jsHelper tests
tooling/scripts/lint.openapi-sync.jsOpenAPI sync linter
tooling/scripts/__tests__/lint.openapi-sync.test.jsLinter tests
tooling/scripts/backfill-venue-name-lower.jsOne-shot venue nameLower backfill
packages/shared/components/OfferCards.jsxExtracted from web/admin (or partial if styling reconciliation defers)
packages/shared/components/AdSlot.jsxExtracted from web/admin
packages/shared/lib/offerNormalizer.jsExtracted from web/admin
apps/web/src/lib/__tests__/offerService.test.jsWeb-side fetch + selector tests
apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsxPicker tests after the fix

Modified files โ€‹

PathChange
services/api/merchants/src/index.jsMount publicOffersRouter under /offers with verifyFirebaseToken only
services/api/merchants/openapi.jsonDocument all six routes + schemas
services/api/merchants/src/routes/offers.js(No logic change; possibly minor refactor to share the serializeOffer helper with the public route)
apps/web/src/lib/offerService.jsRewrite as API client; remove mock generator, setMerchantFakeOffers
apps/web/src/components/dashboard/HomeView.jsxUse fetchActiveOffers + useEffect instead of mock import
apps/web/src/components/dashboard/VenueView.jsxUse shared offer list (via prop or context)
apps/web/src/components/dashboard/RecentActivityView.jsxWire feed placement
apps/web/src/screens/chats/Chat.jsx (or actual path)Wire chat placement
apps/web/src/screens/merchant/MerchantDashboard.jsxRemove setMerchantFakeOffers call
apps/web/src/components/dashboard/OfferCards.jsxRe-export from shared, or delete after extraction
apps/web/src/components/dashboard/AdSlot.jsxSame
apps/web/src/lib/offerNormalizer.jsRe-export or delete
apps/web/src/screens/dashboard/HeroOfferCard.stories.jsxUpdate import path
apps/web/src/screens/dashboard/OfferPill.stories.jsxUpdate import path
apps/admin/src/components/offers/OfferCards.jsxRe-export from shared, or delete
apps/admin/src/components/offers/AdSlot.jsxSame
apps/admin/src/shared/lib/offerNormalizer.jsRe-export or delete
apps/admin/src/components/venues/AdminVenuePicker.jsxRange query on nameLower, drop limit(500) fetch-all
apps/web/src/lib/seedVenues.jsWrite nameLower alongside name
services/api/venues/src/routes/import.jsSame
apps/web/src/lib/osmImportService.jsSame
tooling/scripts/validate.jsAdd openapi-sync section + --scope openapi support
CLAUDE.mdOne-line note in Linter Organization re: openapi-sync; one-line note in venue-onboarding re: nameLower
packages/shared/package.jsonExport new component paths

Deleted files โ€‹

PathReason
apps/web/src/screens/merchant/OfferForm.jsxMoved to admin in sub-project #1
apps/web/src/screens/merchant/OfferForm.stories.jsxSame
apps/web/src/screens/merchant/OfferForm.stories-D6b1dms_.js (storybook-static, regenerated)Build artifact, regenerates

Out of scope โ€‹

  • frequent audience real enforcement โ€” file follow-up issue: "Add per-(user, venue) lantern counter for frequent audience targeting". Requires a Cloud Function trigger and backfill; documented in ยง3.
  • per_user_limit enforcement โ€” already deferred per 2026-04-25 spec line 302. Tied to redemption flow.
  • Redemption flow (QR codes, code delivery, merchant verification) โ€” separate project per 2026-04-25 spec line 301.
  • Server-side location validation โ€” anti-spoofing checks tracked in #155. For this work we trust the client's lat/lng.
  • Offer analytics dashboard โ€” sub-project #3 of the offers migration; analytics-api getOfferAnalytics is scaffolded but not wired here.
  • Hero card visual updates (#323) โ€” independent visual work.
  • onSnapshot real-time offers โ€” not needed; offers are stable on the timescale of dashboard sessions.
  • OpenAPI request body / response shape validation in the linter โ€” v1 covers path ร— method only.
  • Geohash index on offer venues โ€” pilot scale doesn't need it.

Risks โ€‹

  • Styling reconciliation between admin and web copies of AdSlot/OfferCards may be larger than expected. Mitigation: if non-trivial, ship offerNormalizer.js to @lantern/shared only (pure logic) and leave the styled components in their respective apps for this PR. Note the deferral in the PR body.
    • Implementation note (2026-04-30): Diff was 260 lines for OfferCards.jsx and 91 for AdSlot.jsx โ€” well past the abort threshold. Web uses Tailwind utility classes; admin uses inline-style objects. Plan B fallback executed: only offerNormalizer.js extracted to @lantern/shared/lib. Component extraction deferred to follow-up #350 โ€” needs a styleSet/theme prop pattern.
  • Venue nameLower backfill window โ€” if a writer adds a venue mid-migration without nameLower, the picker won't find it. Mitigation: deploy the write-path changes (1, then add nameLower to all writers) before running the backfill, then run the backfill, in that order.
  • Distance computation on every API call โ€” pilot scale is fine; flag for review if active-offer count exceeds a few hundred.
  • OpenAPI linter false positives on dynamic routes โ€” Express supports :param regex constraints that the linter's parser might miss. Mitigation: start with strict matching and only allow opt-out via a // openapi-sync-ignore comment if needed.
  • Chat.jsx location โ€” file path is uncertain at spec time. Resolved in the implementation plan after a concrete grep.
  • audience.js evolution โ€” adding frequent later means changing the audience helper. Designed as a single-file pure function so the change is contained.

Open questions resolved during scoping โ€‹

QuestionResolution
Where does the user-facing read live?merchants-api, new /offers/active route. See "Architecture ยง1".
What does each audience value enforce?nearby/lantern/new enforced; frequent fallback to nearby with follow-up issue. See ยง3.
Outside-radius behavior?Hidden entirely. No "you're not nearby" CTA.
Emulation?Existing setSpoofedLocation is sufficient. No new code.
Placement consumer surfaces?chat in Chat.jsx, feed in RecentActivityView. Components already exist (PR #322).
Venue picker bug?nameLower denormalization + Firestore range query. Backfill script in tooling.
OpenAPI doc gap?Fix merchants-api spec in this PR; ship cross-service linter from #347 in this PR.
Single PR or multiple?Single PR per CLAUDE.md Rule #12. Closes #139, #321, #347.

Built with VitePress