Skip to content

Offers Targeting + Web App Wiring โ€” Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Wire real merchant offers from Firestore into the consumer web app with server-side targeting (geofence + audience), consume all four placement types on user-facing surfaces, fix a venue-picker bug surfaced during scoping, fill the merchants-api openapi.json drift, and add a cross-service openapi-sync linter.

Architecture: Add a public-auth GET /offers/active route to merchants-api that filters by geofence + audience server-side. Replace the web app's mock offerService.js with a thin client of this new endpoint. Wire chat/feed placements to existing-but-unused components. Extract shared offer modules to @lantern/shared.

Tech Stack: Express 5 + Firebase Admin SDK (server), React 19 + Vite (web), Vitest + Testing Library (tests), Firestore (data), npm workspaces.

Spec: docs/superpowers/specs/2026-04-30-offers-targeting-and-web-wiring-design.md

Closes: #139, #321, #347

Branch: feat/139-offers-targeting-web-wiring (off dev)


Pre-flight โ€‹

  • [ ] Step 1: Create the feature branch
bash
git checkout dev
git pull origin dev
git checkout -b feat/139-offers-targeting-web-wiring

Per CLAUDE.md Rule #12: branch in main repo, no git worktree.

  • [ ] Step 2: Verify clean starting state
bash
git status

Expected: untracked items from earlier sessions are fine; no staged or modified files for these tasks.


Task 1: Venue picker fix โ€” add nameLower write paths โ€‹

Independent foundation. Update every code path that writes a venue's name to also write nameLower. Backfill comes in Task 2; query rewrite in Task 3.

Files:

  • Modify: apps/web/src/lib/seedVenues.js

  • Modify: services/api/venues/src/services/venue.service.js

  • Modify: apps/web/src/lib/osmImportService.js

  • Create: packages/shared/venues/nameLower.js

  • Modify: packages/shared/venues/index.js

  • [ ] Step 1: Inventory venue writers

bash
grep -rn "addDoc(collection(db, 'venues')\|setDoc(doc(db, 'venues'\|venuesRef.add" apps/ services/ packages/ 2>/dev/null

Capture the file paths.

  • [ ] Step 2: Add nameLower helper to shared

Create packages/shared/venues/nameLower.js:

js
export function toNameLower(name) {
  return (name || '').toLowerCase()
}
  • [ ] Step 3: Export from @lantern/shared/venues

Read packages/shared/venues/index.js, append:

js
export { toNameLower } from './nameLower.js'
  • [ ] Step 4: Write a test

Create packages/shared/__tests__/venues/nameLower.test.js:

js
import { describe, it, expect } from 'vitest'
import { toNameLower } from '../../venues/nameLower.js'

describe('toNameLower', () => {
  it('lowercases an ASCII name', () => {
    expect(toNameLower('Swan Bar')).toBe('swan bar')
  })
  it('handles undefined/null safely', () => {
    expect(toNameLower(undefined)).toBe('')
    expect(toNameLower(null)).toBe('')
  })
  it('preserves whitespace and punctuation', () => {
    expect(toNameLower("O'Malley's Pub & Grill")).toBe("o'malley's pub & grill")
  })
})
  • [ ] Step 5: Run the test
bash
npx vitest run packages/shared/__tests__/venues/nameLower.test.js

Expected: 3 tests pass.

  • [ ] Step 6: Update each venue writer to include nameLower: toNameLower(venue.name)

For each path from Step 1, read the file, locate the venue document write, add the field, add the import:

js
import { toNameLower } from '@lantern/shared/venues'
  • [ ] Step 7: Run lint + format
bash
npm run validate -- --scope lint,format

Expected: green.

  • [ ] Step 8: Commit
bash
git add packages/shared/venues/nameLower.js \
        packages/shared/venues/index.js \
        packages/shared/__tests__/venues/nameLower.test.js \
        apps/web/src/lib/seedVenues.js \
        services/api/venues/src/services/venue.service.js \
        apps/web/src/lib/osmImportService.js
git commit -m "feat(venues): write nameLower on every venue write path

Single source of truth: @lantern/shared/venues/toNameLower.
Backfill of pre-existing venues in next commit; picker query rewrite follows.

Refs #139"

Task 2: Backfill nameLower for existing venue documents โ€‹

Files:

  • Create: tooling/scripts/backfill-venue-name-lower.js

  • Modify: docs/engineering/architecture/LOCATION_STACK.md

  • [ ] Step 1: Write the backfill script

Create tooling/scripts/backfill-venue-name-lower.js:

js
#!/usr/bin/env node
import { initializeApp, applicationDefault, getApps } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { toNameLower } from '../../packages/shared/venues/index.js'

const args = process.argv.slice(2)
const projectId = (args.find((a) => a.startsWith('--project=')) || '--project=lantern-app-dev').split('=')[1]
const dryRun = args.includes('--dry-run')

if (getApps().length === 0) {
  initializeApp({ credential: applicationDefault(), projectId })
}
const db = getFirestore()

async function main() {
  console.log(`[backfill] project=${projectId} dryRun=${dryRun}`)
  const snap = await db.collection('venues').get()
  let scanned = 0, updated = 0, alreadyOk = 0
  let batch = db.batch()
  let batchSize = 0

  for (const doc of snap.docs) {
    scanned++
    const data = doc.data()
    const expected = toNameLower(data.name)
    if (data.nameLower === expected) { alreadyOk++; continue }
    if (!dryRun) {
      batch.update(doc.ref, { nameLower: expected })
      batchSize++
      if (batchSize >= 450) {
        await batch.commit()
        batch = db.batch()
        batchSize = 0
      }
    }
    updated++
  }
  if (!dryRun && batchSize > 0) await batch.commit()
  console.log(`[backfill] scanned=${scanned} updated=${updated} alreadyOk=${alreadyOk}`)
}

main().catch((err) => { console.error('[backfill] failed:', err); process.exit(1) })
  • [ ] Step 2: Verify syntax
bash
chmod +x tooling/scripts/backfill-venue-name-lower.js
node --check tooling/scripts/backfill-venue-name-lower.js

Expected: no output (syntax OK).

  • [ ] Step 3: Document in LOCATION_STACK.md

Append to docs/engineering/architecture/LOCATION_STACK.md:

markdown
## Venue `nameLower` field

Every venue document carries a denormalized `nameLower` field โ€” the lowercased value of `name` โ€” used for case-insensitive prefix search in the merchant venue picker. All writers must include this field via `toNameLower` from `@lantern/shared/venues`.

Backfill runbook:

\`\`\`bash
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-dev --dry-run
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-dev
\`\`\`

The script is idempotent.
  • [ ] Step 4: Commit
bash
git add tooling/scripts/backfill-venue-name-lower.js docs/engineering/architecture/LOCATION_STACK.md
git commit -m "feat(venues): nameLower backfill script + docs

One-shot, idempotent, batched.

Refs #139"
  • [ ] Step 5: (Manual, post-merge) Run backfill against dev

Runbook line, not a code commit:

bash
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-dev --dry-run
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-dev

Task 3: Rewrite AdminVenuePicker to use Firestore prefix query โ€‹

Files:

  • Modify: apps/admin/src/components/venues/AdminVenuePicker.jsx

  • Create: apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx

  • [ ] Step 1: Write a failing test

Create apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx:

jsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'

const queryArgsLog = []
vi.mock('firebase/firestore', () => ({
  collection: vi.fn(() => ({ __collection: 'venues' })),
  query: vi.fn((...args) => { queryArgsLog.push(args); return { __query: args } }),
  orderBy: vi.fn((field) => ({ __orderBy: field })),
  startAt: vi.fn((v) => ({ __startAt: v })),
  endAt: vi.fn((v) => ({ __endAt: v })),
  limit: vi.fn((n) => ({ __limit: n })),
  getDocs: vi.fn(async () => ({ docs: [] })),
}))
vi.mock('../../../firebase', () => ({
  db: {},
  associateVenueWithMerchant: vi.fn(),
}))

import AdminVenuePicker from '../AdminVenuePicker.jsx'

describe('AdminVenuePicker', () => {
  beforeEach(() => { queryArgsLog.length = 0 })

  it('does not query while the search input is empty', async () => {
    render(<AdminVenuePicker merchantId="m1" />)
    expect(queryArgsLog.length).toBe(0)
  })

  it('issues a Firestore prefix query on nameLower when the user types', async () => {
    render(<AdminVenuePicker merchantId="m1" />)
    const input = screen.getByPlaceholderText(/search venues/i)
    fireEvent.change(input, { target: { value: 'Swan' } })
    await waitFor(() => expect(queryArgsLog.length).toBeGreaterThan(0))
    const constraints = queryArgsLog[0].slice(1)
    expect(constraints).toContainEqual({ __orderBy: 'nameLower' })
    expect(constraints).toContainEqual({ __startAt: 'swan' })
    const endAt = constraints.find((c) => '__endAt' in c)
    expect(endAt.__endAt.startsWith('swan')).toBe(true)
    expect(endAt.__endAt.length).toBeGreaterThan('swan'.length)
  })
})
  • [ ] Step 2: Run test, verify it fails
bash
npx vitest run apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx

Expected: failures referencing missing prefix query usage.

  • [ ] Step 3: Rewrite the picker

Read the current file first. Replace the imports + the fetch-all useEffect + the results useMemo with:

jsx
import { useEffect, useMemo, useState } from 'react'
import {
  collection, endAt, getDocs, limit as qLimit, orderBy, query, startAt,
} from 'firebase/firestore'
import { associateVenueWithMerchant, db } from '../../firebase'

// Inside the component, replace the existing fetch-all + filter logic:

const [allVenues, setAllVenues] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

useEffect(() => {
  const term = searchTerm.trim().toLowerCase()
  if (!term) { setAllVenues([]); return }
  let cancelled = false
  const handle = setTimeout(async () => {
    try {
      setLoading(true)
      setError(null)
      const q = query(
        collection(db, 'venues'),
        orderBy('nameLower'),
        startAt(term),
        endAt(term + '๏ฃฟ'),
        qLimit(50)
      )
      const snap = await getDocs(q)
      if (cancelled) return
      setAllVenues(
        snap.docs.map((d) => ({
          venueId: d.id,
          name: d.data().name,
          address: d.data().address,
          category: d.data().category,
          merchantId: d.data().merchantId || null,
        }))
      )
    } catch (err) {
      if (!cancelled) setError(err.message || 'Failed to load venues')
    } finally {
      if (!cancelled) setLoading(false)
    }
  }, 200)  // 200ms debounce
  return () => { cancelled = true; clearTimeout(handle) }
}, [searchTerm])

Update the JSX consumer of the previous results variable to use allVenues (or rename for clarity).

  • [ ] Step 4: Run test, verify it passes
bash
npx vitest run apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx

Expected: 2 tests pass.

  • [ ] Step 5: Manual smoke
bash
npm run dev -w apps/admin

Navigate to merchant โ†’ Venues โ†’ Associate venue โ†’ search "Swan". Verify result appears.

  • [ ] Step 6: Commit
bash
git add apps/admin/src/components/venues/AdminVenuePicker.jsx \
        apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx
git commit -m "fix(admin): venue picker uses Firestore prefix query on nameLower

Replaces the limit(500) fetch-all-and-filter approach that hid venues
sorting past doc-id position 500 (Swan Bar etc.). Uses orderBy +
startAt/endAt with U+F8FF prefix bound, debounced 200ms.

Refs #139"

Task 4: Establish merchants-api test infrastructure โ€‹

Files:

  • Modify: services/api/merchants/package.json

  • Create: services/api/merchants/vitest.config.js

  • [ ] Step 1: Add vitest scripts + devDep

Replace the scripts block in services/api/merchants/package.json with the auth-api pattern:

json
{
  "scripts": {
    "start": "node src/index.js",
    "predev": "lsof -ti :8085 | xargs kill -9 2>/dev/null || true",
    "dev": "node --env-file=../../../.env.local --watch src/index.js",
    "test": "vitest",
    "test:run": "vitest run",
    "deploy:dev": "cp -r ../../../packages/shared .shared-pkg && gcloud run deploy merchants-api --source . --region us-central1 --project lantern-app-dev --allow-unauthenticated; STATUS=$?; rm -rf .shared-pkg; exit $STATUS",
    "validate": "vitest run && echo 'Merchants API validation: OK'"
  },
  "devDependencies": {
    "vitest": "^4.0.18"
  }
}

(Match the vitest version with auth-api so the lockfile resolves cleanly.)

  • [ ] Step 2: Create vitest config

Create services/api/merchants/vitest.config.js:

js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/__tests__/**/*.test.js'],
    globals: false,
  },
})
  • [ ] Step 3: Install
bash
npm install
  • [ ] Step 4: Verify the runner boots
bash
npm test -w services/api/merchants -- --run

Expected: "No test files found" โ€” runner works, nothing to run yet.

  • [ ] Step 5: Commit
bash
git add services/api/merchants/package.json services/api/merchants/vitest.config.js package-lock.json
git commit -m "chore(merchants-api): add vitest infrastructure

Mirrors auth-api setup. Tests at src/**/__tests__/**/*.test.js.

Refs #139"

Task 5: Audience filter helper โ€” TDD โ€‹

Files:

  • Create: services/api/merchants/src/lib/audience.js

  • Create: services/api/merchants/src/lib/__tests__/audience.test.js

  • [ ] Step 1: Write failing tests

Create services/api/merchants/src/lib/__tests__/audience.test.js:

js
import { describe, it, expect } from 'vitest'
import { passesAudience, NEW_USER_THRESHOLD_DAYS } from '../audience.js'

describe('passesAudience', () => {
  const baseCtx = { uid: 'u1', accountAgeDays: 30, hasActiveLanternAtVenue: false }
  const baseOffer = { venueId: 'v1', targetAudience: 'nearby' }

  describe("audience 'nearby'", () => {
    it('always passes (geofence enforced upstream)', () => {
      expect(passesAudience('nearby', baseCtx, baseOffer)).toBe(true)
    })
  })

  describe("audience 'lantern'", () => {
    it('passes when user has active lantern at venue', () => {
      const ctx = { ...baseCtx, hasActiveLanternAtVenue: true }
      expect(passesAudience('lantern', ctx, baseOffer)).toBe(true)
    })
    it('fails when user has no active lantern at venue', () => {
      expect(passesAudience('lantern', baseCtx, baseOffer)).toBe(false)
    })
  })

  describe("audience 'new'", () => {
    it('passes when account age < threshold', () => {
      const ctx = { ...baseCtx, accountAgeDays: NEW_USER_THRESHOLD_DAYS - 1 }
      expect(passesAudience('new', ctx, baseOffer)).toBe(true)
    })
    it('fails at exactly the threshold', () => {
      const ctx = { ...baseCtx, accountAgeDays: NEW_USER_THRESHOLD_DAYS }
      expect(passesAudience('new', ctx, baseOffer)).toBe(false)
    })
    it('fails for older accounts', () => {
      expect(passesAudience('new', baseCtx, baseOffer)).toBe(false)
    })
  })

  describe("audience 'frequent'", () => {
    it('falls back to nearby in v1 (passes)', () => {
      // v1 limitation โ€” see audience.js JSDoc and the spec.
      expect(passesAudience('frequent', baseCtx, baseOffer)).toBe(true)
    })
  })

  describe('unknown audience', () => {
    it('rejects defensively', () => {
      expect(passesAudience('invalid', baseCtx, baseOffer)).toBe(false)
    })
  })

  describe('NEW_USER_THRESHOLD_DAYS', () => {
    it('is 7 for v1', () => {
      expect(NEW_USER_THRESHOLD_DAYS).toBe(7)
    })
  })
})
  • [ ] Step 2: Run test, verify failure
bash
npm test -w services/api/merchants -- --run

Expected: cannot find module ../audience.js.

  • [ ] Step 3: Implement

Create services/api/merchants/src/lib/audience.js:

js
/**
 * Audience filter for offer targeting.
 * Geofence is enforced upstream of this helper; this covers identity-based filters.
 *
 * v1 audience semantics:
 *   nearby   โ†’ always true (geofence already passed)
 *   lantern  โ†’ user has an active lantern at offer.venueId
 *   new      โ†’ user account age < NEW_USER_THRESHOLD_DAYS days
 *   frequent โ†’ returns true (v1 fallback). Real enforcement requires a per-(user,
 *              venue) lantern counter that doesn't exist yet โ€” the lanterns
 *              collection has a 48h TTL so it can't answer "lit โ‰ฅ 3 times
 *              historically." Tracked as a follow-up.
 *
 * Unknown audience values return false defensively.
 */

export const NEW_USER_THRESHOLD_DAYS = 7

export function passesAudience(audience, userContext, offer) {
  switch (audience) {
    case 'nearby': return true
    case 'lantern': return Boolean(userContext.hasActiveLanternAtVenue)
    case 'new': return Number(userContext.accountAgeDays) < NEW_USER_THRESHOLD_DAYS
    case 'frequent': return true // v1 fallback
    default: return false
  }
}
  • [ ] Step 4: Run test, verify pass
bash
npm test -w services/api/merchants -- --run

Expected: 9 tests pass.

  • [ ] Step 5: Commit
bash
git add services/api/merchants/src/lib/audience.js \
        services/api/merchants/src/lib/__tests__/audience.test.js
git commit -m "feat(merchants-api): add audience filter helper

Pure function with TDD coverage for nearby/lantern/new audiences.
'frequent' falls back to nearby in v1 โ€” proper enforcement needs a
per-(user, venue) lantern counter (follow-up issue).

Refs #139"

Task 6: Public offers route โ€” GET /offers/active โ€‹

Files:

  • Create: services/api/merchants/src/routes/publicOffers.js

  • Create: services/api/merchants/src/__tests__/publicOffers.test.js

  • Modify: services/api/merchants/src/index.js

  • Modify: services/api/merchants/package.json (add supertest devDep)

  • [ ] Step 1: Add supertest

bash
npm install --save-dev supertest -w services/api/merchants
  • [ ] Step 2: Write failing tests

Create services/api/merchants/src/__tests__/publicOffers.test.js:

js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import express from 'express'
import request from 'supertest'

const mockGetFirestore = vi.fn()
vi.mock('firebase-admin/firestore', () => ({
  getFirestore: () => mockGetFirestore(),
  FieldValue: { serverTimestamp: () => 'NOW' },
}))

import publicOffersRouter from '../routes/publicOffers.js'

function makeApp(uid = 'user-1') {
  const app = express()
  app.use(express.json())
  app.use((req, _res, next) => { req.user = { uid }; next() })
  app.use('/offers', publicOffersRouter)
  return app
}

function makeFirestoreFixture({ offers = [], venues = {}, users = {}, lanterns = [] } = {}) {
  const offersGet = vi.fn(async () => ({
    docs: offers.map((o) => ({ id: o.id, data: () => o })),
  }))
  const venuesGet = vi.fn(async (id) => ({
    exists: id in venues, id, data: () => venues[id],
  }))
  const usersGet = vi.fn(async (id) => ({
    exists: id in users, data: () => users[id],
  }))
  const lanternsGet = vi.fn(async () => ({
    docs: lanterns.map((l) => ({ id: l.id, data: () => l })),
  }))

  return {
    collection: (name) => {
      if (name === 'offers') return { where: () => ({ where: () => ({ get: offersGet }) }) }
      if (name === 'venues') return { doc: (id) => ({ get: () => venuesGet(id) }) }
      if (name === 'users') return { doc: (id) => ({ get: () => usersGet(id) }) }
      if (name === 'lanterns') return {
        where: () => ({ where: () => ({ where: () => ({ get: lanternsGet }) }) }),
      }
      throw new Error('unexpected collection: ' + name)
    },
  }
}

describe('GET /offers/active', () => {
  beforeEach(() => mockGetFirestore.mockReset())

  it('returns 400 when lat/lng are missing', async () => {
    mockGetFirestore.mockReturnValue(makeFirestoreFixture())
    const res = await request(makeApp()).get('/offers/active')
    expect(res.status).toBe(400)
    expect(res.body.error).toBe('INVALID_INPUT')
  })

  it('filters out offers outside the geofence radius', async () => {
    mockGetFirestore.mockReturnValue(
      makeFirestoreFixture({
        offers: [{
          id: 'o1', merchantId: 'm1', venueId: 'v1',
          status: 'active', expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
          targetAudience: 'nearby', radius: 500,
          placement: 'hero', title: 't', description: 'd',
        }],
        venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 2 } },
        users: { 'user-1': { createdAt: { toDate: () => new Date(Date.now() - 30 * 86400000) } } },
      })
    )
    const res = await request(makeApp()).get('/offers/active?lat=0&lng=10')
    expect(res.body.offers).toEqual([])
  })

  it('returns offers within radius with hydrated venue + distance', async () => {
    mockGetFirestore.mockReturnValue(
      makeFirestoreFixture({
        offers: [{
          id: 'o1', merchantId: 'm1', venueId: 'v1',
          status: 'active', expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
          targetAudience: 'nearby', radius: 1000,
          placement: 'hero', title: 'Brunch', description: 'd',
        }],
        venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 5, category: 'restaurant' } },
        users: { 'user-1': { createdAt: { toDate: () => new Date(Date.now() - 30 * 86400000) } } },
      })
    )
    const res = await request(makeApp()).get('/offers/active?lat=0&lng=0.001')
    expect(res.status).toBe(200)
    expect(res.body.offers).toHaveLength(1)
    expect(res.body.offers[0].id).toBe('o1')
    expect(res.body.offers[0].venue).toMatchObject({ name: 'V1', lanternCount: 5 })
    expect(res.body.offers[0].distanceMeters).toBeGreaterThan(0)
    expect(res.body.offers[0].budget).toBeUndefined()
  })

  it('filters out lantern-audience offers when no active lantern', async () => {
    mockGetFirestore.mockReturnValue(
      makeFirestoreFixture({
        offers: [{
          id: 'o1', merchantId: 'm1', venueId: 'v1',
          status: 'active', expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
          targetAudience: 'lantern', radius: 1000,
          placement: 'hero', title: 't', description: 'd',
        }],
        venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 0 } },
        users: { 'user-1': { createdAt: { toDate: () => new Date() } } },
        lanterns: [],
      })
    )
    const res = await request(makeApp()).get('/offers/active?lat=0&lng=0')
    expect(res.body.offers).toEqual([])
  })

  it('respects ?placement= filter', async () => {
    mockGetFirestore.mockReturnValue(
      makeFirestoreFixture({
        offers: [
          { id: 'h', merchantId: 'm1', venueId: 'v1', status: 'active',
            expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
            targetAudience: 'nearby', radius: 1000, placement: 'hero',
            title: 't', description: 'd' },
          { id: 'c', merchantId: 'm1', venueId: 'v1', status: 'active',
            expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
            targetAudience: 'nearby', radius: 1000, placement: 'chat',
            title: 't', description: 'd' },
        ],
        venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 0 } },
        users: { 'user-1': { createdAt: { toDate: () => new Date(Date.now() - 30 * 86400000) } } },
      })
    )
    const res = await request(makeApp()).get('/offers/active?lat=0&lng=0&placement=chat')
    expect(res.body.offers).toHaveLength(1)
    expect(res.body.offers[0].id).toBe('c')
  })

  it('excludes expired offers', async () => {
    mockGetFirestore.mockReturnValue(
      makeFirestoreFixture({
        offers: [{
          id: 'o1', merchantId: 'm1', venueId: 'v1', status: 'active',
          expiresAt: { toDate: () => new Date(Date.now() - 1000) },
          targetAudience: 'nearby', radius: 1000, placement: 'hero',
          title: 't', description: 'd',
        }],
        venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 0 } },
        users: { 'user-1': { createdAt: { toDate: () => new Date() } } },
      })
    )
    const res = await request(makeApp()).get('/offers/active?lat=0&lng=0')
    expect(res.body.offers).toEqual([])
  })
})
  • [ ] Step 3: Run, verify failure
bash
npm test -w services/api/merchants -- --run

Expected: cannot find ../routes/publicOffers.js.

  • [ ] Step 4: Implement the route

Create services/api/merchants/src/routes/publicOffers.js:

js
/**
 * Public offer reads โ€” user-facing endpoint.
 *
 * GET /offers/active?lat=&lng=&placement=
 *
 * Auth: any signed-in app user (verifyFirebaseToken upstream).
 * Filters: status=active, expiresAt>now, geofence (radius), audience.
 * Hydration: includes venue summary + distanceMeters per offer.
 */

import { Router } from 'express'
import { getFirestore } from 'firebase-admin/firestore'
import { passesAudience } from '../lib/audience.js'

const router = Router()
const MS_PER_DAY = 86_400_000
const EARTH_RADIUS_M = 6_371_000

function haversineMeters(lat1, lng1, lat2, lng2) {
  const toRad = (deg) => (deg * Math.PI) / 180
  const dLat = toRad(lat2 - lat1)
  const dLng = toRad(lng2 - lng1)
  const a = Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
  return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a))
}

function toIsoOrNull(ts) {
  if (!ts) return null
  if (ts.toDate) return ts.toDate().toISOString()
  if (ts instanceof Date) return ts.toISOString()
  return null
}

function serializeForClient(offer, venue, distanceMeters) {
  return {
    id: offer.id,
    merchantId: offer.merchantId,
    venueId: offer.venueId,
    title: offer.title,
    description: offer.description,
    placement: offer.placement,
    targetAudience: offer.targetAudience,
    radius: offer.radius,
    expiresAt: toIsoOrNull(offer.expiresAt),
    showDisclaimerWhileSuppliesLast: offer.showDisclaimerWhileSuppliesLast || false,
    venue: {
      id: venue.id,
      name: venue.name,
      lat: venue.lat,
      lng: venue.lng,
      category: venue.category || null,
      lanternCount: venue.lanternCount || 0,
    },
    distanceMeters,
  }
}

router.get('/active', async (req, res, next) => {
  try {
    const lat = Number(req.query.lat)
    const lng = Number(req.query.lng)
    if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
      return res.status(400).json({
        error: 'INVALID_INPUT',
        message: 'lat and lng query params are required and must be numbers',
      })
    }
    const placementFilter = req.query.placement || null
    const db = getFirestore()
    const now = new Date()

    // 1. Active offers.
    const offersSnap = await db.collection('offers').where('status', '==', 'active').get()
    const rawOffers = offersSnap.docs.map((d) => ({ id: d.id, ...d.data() }))

    // 2. Drop expired.
    const live = rawOffers.filter((o) => {
      const exp = o.expiresAt?.toDate ? o.expiresAt.toDate() : o.expiresAt
      return exp instanceof Date && exp > now
    })

    // 3. Hydrate venues (one read per unique venueId).
    const uniqueVenueIds = [...new Set(live.map((o) => o.venueId).filter(Boolean))]
    const venueDocs = await Promise.all(
      uniqueVenueIds.map((id) => db.collection('venues').doc(id).get())
    )
    const venueById = {}
    for (const snap of venueDocs) {
      if (snap.exists) venueById[snap.id] = { id: snap.id, ...snap.data() }
    }

    // 4. Geofence filter.
    const withinRadius = live
      .map((offer) => {
        const venue = venueById[offer.venueId]
        if (!venue || typeof venue.lat !== 'number' || typeof venue.lng !== 'number') return null
        const distance = haversineMeters(lat, lng, venue.lat, venue.lng)
        if (distance > (offer.radius || 0)) return null
        return { offer, venue, distance }
      })
      .filter(Boolean)

    // 5. User context.
    const userSnap = await db.collection('users').doc(req.user.uid).get()
    const userData = userSnap.exists ? userSnap.data() : {}
    const createdAtDate = userData.createdAt?.toDate
      ? userData.createdAt.toDate()
      : userData.createdAt instanceof Date ? userData.createdAt : new Date()
    const accountAgeDays = (now - createdAtDate) / MS_PER_DAY

    // Active lantern lookup โ€” batched per user.
    const candidateVenueIds = [...new Set(withinRadius.map((r) => r.offer.venueId))]
    let activeLanternVenueIds = new Set()
    if (candidateVenueIds.length > 0) {
      if (candidateVenueIds.length <= 10) {
        const lanternsSnap = await db.collection('lanterns')
          .where('userId', '==', req.user.uid)
          .where('status', '==', 'active')
          .where('venueId', 'in', candidateVenueIds)
          .get()
        activeLanternVenueIds = new Set(lanternsSnap.docs.map((d) => d.data().venueId))
      } else {
        // Firestore 'in' max = 10. Fall back to a single uid+status query.
        const allLanternsSnap = await db.collection('lanterns')
          .where('userId', '==', req.user.uid)
          .where('status', '==', 'active')
          .get()
        activeLanternVenueIds = new Set(allLanternsSnap.docs.map((d) => d.data().venueId))
      }
    }

    // 6. Audience filter.
    const audienced = withinRadius.filter(({ offer }) =>
      passesAudience(offer.targetAudience, {
        uid: req.user.uid,
        accountAgeDays,
        hasActiveLanternAtVenue: activeLanternVenueIds.has(offer.venueId),
      }, offer)
    )

    // 7. Optional placement filter.
    const placementFiltered = placementFilter
      ? audienced.filter(({ offer }) => offer.placement === placementFilter)
      : audienced

    const offers = placementFiltered.map(({ offer, venue, distance }) =>
      serializeForClient(offer, venue, Math.round(distance))
    )

    return res.json({ offers, total: offers.length })
  } catch (err) {
    next(err)
  }
})

export default router
  • [ ] Step 5: Run, verify pass
bash
npm test -w services/api/merchants -- --run

Expected: all 6 publicOffers tests + 9 audience tests pass.

  • [ ] Step 6: Mount the new router

Read services/api/merchants/src/index.js. Add the import and mount before the existing merchant-scoped mount:

js
import publicOffersRouter from './routes/publicOffers.js'

// ...later, replacing the section that mounts /merchants/:merchantId:
// Public read โ€” auth only, no merchant scoping
app.use('/offers', verifyFirebaseToken, publicOffersRouter)
// Existing merchant-scoped CRUD (unchanged)
app.use('/merchants/:merchantId', verifyFirebaseToken, requireMerchantAccess, offersRouter)
  • [ ] Step 7: Local smoke test
bash
npm run dev -w services/api/merchants

In another terminal:

bash
curl -i 'http://localhost:8085/offers/active?lat=32.7&lng=-117.1'
# Expected: 401 (auth middleware engaged)

With a valid ID token in the browser session, hit the endpoint via the web app DevTools fetch and confirm 200 response.

  • [ ] Step 8: Commit
bash
git add services/api/merchants/src/routes/publicOffers.js \
        services/api/merchants/src/__tests__/publicOffers.test.js \
        services/api/merchants/src/index.js \
        services/api/merchants/package.json \
        package-lock.json
git commit -m "feat(merchants-api): add GET /offers/active public route

User-facing endpoint with server-side geofence + audience filtering.
Hydrates venue data and computes distanceMeters. Internal fields
(budget, per_user_limit, createdBy) stripped from response. Mounts
under /offers with verifyFirebaseToken only โ€” no merchant scoping.

Refs #139"

Task 7: Document the merchants-api routes in openapi.json โ€‹

Files:

  • Modify: services/api/merchants/openapi.json

  • [ ] Step 1: Replace services/api/merchants/openapi.json entirely

Write the full spec. It documents /health (existing), /offers/active (new), and the four merchant-scoped CRUD paths that already exist in code but were undocumented.

json
{
  "openapi": "3.0.3",
  "info": {
    "title": "Lantern Merchants API",
    "description": "Cloud Run service for merchant business operations โ€” offers (CRUD + public read), and merchant-scoped resources.",
    "version": "1.1.0",
    "contact": { "name": "Lantern Engineering", "url": "https://ourlantern.app" }
  },
  "servers": [
    { "url": "http://localhost:8085", "description": "Local development" }
  ],
  "tags": [
    { "name": "Health", "description": "Service health โ€” no authentication required." },
    { "name": "Offers (Public)", "description": "User-facing read of active offers near a location. Requires authentication." },
    { "name": "Offers (Merchant)", "description": "Offer CRUD scoped to a merchant. Requires authentication and merchant access (admin or owning merchant)." }
  ],
  "paths": {
    "/health": {
      "get": {
        "tags": ["Health"], "summary": "Health check",
        "responses": { "200": { "description": "Healthy", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } } } }
      }
    },
    "/offers/active": {
      "get": {
        "tags": ["Offers (Public)"], "summary": "List active offers near a location",
        "description": "Returns active, non-expired offers within geofence radius and audience-filtered for the requesting user.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "lat", "in": "query", "required": true, "schema": { "type": "number" } },
          { "name": "lng", "in": "query", "required": true, "schema": { "type": "number" } },
          { "name": "placement", "in": "query", "required": false, "schema": { "type": "string", "enum": ["hero", "inline", "chat", "feed"] } }
        ],
        "responses": {
          "200": { "description": "Visible offers", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicOfferList" } } } },
          "400": { "description": "Missing/invalid lat or lng", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "Auth required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/merchants/{merchantId}/offers": {
      "get": {
        "tags": ["Offers (Merchant)"], "summary": "List a merchant's offers",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
          { "name": "status", "in": "query", "required": false, "schema": { "type": "string", "enum": ["active", "draft", "expired", "archived"] } }
        ],
        "responses": {
          "200": { "description": "Offers", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOfferList" } } } },
          "401": { "description": "Auth required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "Forbidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "post": {
        "tags": ["Offers (Merchant)"], "summary": "Create an offer",
        "security": [{ "bearerAuth": [] }],
        "parameters": [{ "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfferCreate" } } } },
        "responses": {
          "201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOffer" } } } },
          "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Merchant not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/merchants/{merchantId}/offers/{offerId}": {
      "get": {
        "tags": ["Offers (Merchant)"], "summary": "Get a single offer",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
          { "name": "offerId", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Offer", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOffer" } } } },
          "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "put": {
        "tags": ["Offers (Merchant)"], "summary": "Update an offer",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
          { "name": "offerId", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfferUpdate" } } } },
        "responses": {
          "200": { "description": "Updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOffer" } } } },
          "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "delete": {
        "tags": ["Offers (Merchant)"], "summary": "Delete (hard for drafts, archive otherwise)",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
          { "name": "offerId", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Deleted", "content": { "application/json": { "schema": { "type": "object", "properties": { "deleted": { "type": "boolean" }, "hard": { "type": "boolean" }, "status": { "type": "string" } } } } } },
          "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "Firebase ID token" }
    },
    "schemas": {
      "Health": { "type": "object", "properties": { "status": { "type": "string" }, "service": { "type": "string" } } },
      "Error": { "type": "object", "properties": { "error": { "type": "string" }, "message": { "type": "string" } } },
      "Audience": { "type": "string", "enum": ["nearby", "lantern", "frequent", "new"] },
      "Placement": { "type": "string", "enum": ["hero", "inline", "chat", "feed"] },
      "Status": { "type": "string", "enum": ["draft", "active", "expired", "archived"] },
      "VenueSummary": {
        "type": "object",
        "properties": {
          "id": { "type": "string" }, "name": { "type": "string" },
          "lat": { "type": "number" }, "lng": { "type": "number" },
          "category": { "type": "string", "nullable": true }, "lanternCount": { "type": "integer" }
        }
      },
      "PublicOffer": {
        "type": "object",
        "properties": {
          "id": { "type": "string" }, "merchantId": { "type": "string" }, "venueId": { "type": "string" },
          "title": { "type": "string" }, "description": { "type": "string" },
          "placement": { "$ref": "#/components/schemas/Placement" },
          "targetAudience": { "$ref": "#/components/schemas/Audience" },
          "radius": { "type": "integer" },
          "expiresAt": { "type": "string", "format": "date-time", "nullable": true },
          "showDisclaimerWhileSuppliesLast": { "type": "boolean" },
          "venue": { "$ref": "#/components/schemas/VenueSummary" },
          "distanceMeters": { "type": "integer" }
        }
      },
      "PublicOfferList": {
        "type": "object",
        "properties": {
          "offers": { "type": "array", "items": { "$ref": "#/components/schemas/PublicOffer" } },
          "total": { "type": "integer" }
        }
      },
      "MerchantOffer": {
        "type": "object",
        "properties": {
          "id": { "type": "string" }, "merchantId": { "type": "string" }, "venueId": { "type": "string" },
          "title": { "type": "string" }, "description": { "type": "string" },
          "placement": { "$ref": "#/components/schemas/Placement" },
          "targetAudience": { "$ref": "#/components/schemas/Audience" },
          "radius": { "type": "integer" }, "per_user_limit": { "type": "integer" }, "budget": { "type": "number" },
          "status": { "$ref": "#/components/schemas/Status" },
          "expiresAt": { "type": "string", "format": "date-time", "nullable": true },
          "showDisclaimerWhileSuppliesLast": { "type": "boolean" },
          "createdAt": { "type": "string", "format": "date-time", "nullable": true },
          "createdBy": { "type": "string" },
          "updatedAt": { "type": "string", "format": "date-time", "nullable": true }
        }
      },
      "MerchantOfferList": {
        "type": "object",
        "properties": {
          "offers": { "type": "array", "items": { "$ref": "#/components/schemas/MerchantOffer" } },
          "total": { "type": "integer" }
        }
      },
      "OfferCreate": {
        "type": "object",
        "required": ["venueId", "title", "description", "placement", "targetAudience", "radius", "budget", "expiresAt"],
        "properties": {
          "venueId": { "type": "string" },
          "title": { "type": "string", "maxLength": 200 },
          "description": { "type": "string", "maxLength": 2000 },
          "placement": { "$ref": "#/components/schemas/Placement" },
          "targetAudience": { "$ref": "#/components/schemas/Audience" },
          "radius": { "type": "integer", "minimum": 10, "maximum": 2500 },
          "per_user_limit": { "type": "integer", "minimum": 1, "default": 1 },
          "budget": { "type": "number", "minimum": 1 },
          "expiresAt": { "type": "string", "format": "date-time" },
          "showDisclaimerWhileSuppliesLast": { "type": "boolean", "default": false },
          "status": { "type": "string", "enum": ["draft", "active"], "default": "draft" }
        }
      },
      "OfferUpdate": {
        "type": "object",
        "description": "All fields optional โ€” only included fields are updated.",
        "properties": {
          "venueId": { "type": "string" },
          "title": { "type": "string", "maxLength": 200 },
          "description": { "type": "string", "maxLength": 2000 },
          "placement": { "$ref": "#/components/schemas/Placement" },
          "targetAudience": { "$ref": "#/components/schemas/Audience" },
          "radius": { "type": "integer", "minimum": 10, "maximum": 2500 },
          "per_user_limit": { "type": "integer", "minimum": 1 },
          "budget": { "type": "number", "minimum": 1 },
          "expiresAt": { "type": "string", "format": "date-time" },
          "showDisclaimerWhileSuppliesLast": { "type": "boolean" },
          "status": { "type": "string", "enum": ["draft", "active", "archived"] }
        }
      }
    }
  }
}
  • [ ] Step 2: Validate JSON
bash
node -e "JSON.parse(require('fs').readFileSync('services/api/merchants/openapi.json'))"

Expected: no error.

  • [ ] Step 3: Manual verify in admin portal API Reference
bash
npm run dev -w apps/admin
npm run dev -w services/api/merchants

Open admin portal โ†’ API Reference โ†’ Merchants API. Confirm all 6 paths render and the schemas resolve.

  • [ ] Step 4: Commit
bash
git add services/api/merchants/openapi.json
git commit -m "docs(merchants-api): document all routes in openapi.json

Fills the silent drift where only /health was documented despite five
offer routes being live. Adds GET /offers/active. Schemas for
PublicOffer, MerchantOffer, audience/placement/status enums.

Refs #139"

Task 8: Extract offerNormalizer.js to @lantern/shared โ€‹

Pure logic โ€” safest extraction first.

Files:

  • Create: packages/shared/lib/offerNormalizer.js

  • Create: packages/shared/lib/index.js

  • Create: packages/shared/__tests__/lib/offerNormalizer.test.js

  • Modify: packages/shared/package.json (add ./lib export)

  • Modify: apps/web/src/lib/offerNormalizer.js (re-export shim)

  • Modify: apps/admin/src/shared/lib/offerNormalizer.js (re-export shim)

  • [ ] Step 1: Diff the two existing copies

bash
diff apps/web/src/lib/offerNormalizer.js apps/admin/src/shared/lib/offerNormalizer.js

Reconcile to a single canonical version (admin copy is likely a superset; if identical, the move is trivial).

  • [ ] Step 2: Create shared copy

Create packages/shared/lib/offerNormalizer.js with the canonical content (start by copying apps/web/src/lib/offerNormalizer.js verbatim).

  • [ ] Step 3: Create lib index

Create packages/shared/lib/index.js:

js
export { normalizeFormToOffer } from './offerNormalizer.js'
  • [ ] Step 4: Add ./lib to package exports

Read packages/shared/package.json, add to exports:

json
"./lib": "./lib/index.js"
  • [ ] Step 5: Write a test

Create packages/shared/__tests__/lib/offerNormalizer.test.js:

js
import { describe, it, expect } from 'vitest'
import { normalizeFormToOffer } from '../../lib/offerNormalizer.js'

describe('normalizeFormToOffer', () => {
  it('maps title to headline and description to body', () => {
    const out = normalizeFormToOffer({ title: '20% off brunch', description: 'Show this' })
    expect(out.headline).toBe('20% off brunch')
    expect(out.body).toBe('Show this')
  })
  it('extracts the first 3 words of title as incentive', () => {
    expect(normalizeFormToOffer({ title: 'Free latte for friends today' }).incentive)
      .toBe('Free latte for')
  })
  it('falls back to placement="hero" if not provided', () => {
    expect(normalizeFormToOffer({ title: 't' }).placement).toBe('hero')
  })
  it('marks the result as a preview', () => {
    expect(normalizeFormToOffer({ title: 't' }).isPreview).toBe(true)
  })
  it('uses options.id when provided', () => {
    expect(normalizeFormToOffer({ title: 't' }, { id: 'fixed' }).id).toBe('fixed')
  })
})
  • [ ] Step 6: Run, verify pass
bash
npx vitest run packages/shared/__tests__/lib/offerNormalizer.test.js

Expected: 5 tests pass.

  • [ ] Step 7: Replace apps/web/src/lib/offerNormalizer.js with re-export

Replace entire file:

js
// Re-export from @lantern/shared/lib so existing imports keep working.
export { normalizeFormToOffer } from '@lantern/shared/lib'
  • [ ] Step 8: Replace apps/admin/src/shared/lib/offerNormalizer.js with re-export

Same pattern.

  • [ ] Step 9: Run web + admin test suites
bash
npm test -w apps/web -- --run
npm test -w apps/admin -- --run

Expected: green.

  • [ ] Step 10: Commit
bash
git add packages/shared/lib/ packages/shared/__tests__/lib/ \
        packages/shared/package.json \
        apps/web/src/lib/offerNormalizer.js \
        apps/admin/src/shared/lib/offerNormalizer.js
git commit -m "refactor(shared): extract offerNormalizer to @lantern/shared/lib

Pure logic, no styling. Web and admin become re-export shims.

Refs #321"

Task 9: Extract OfferCards.jsx + AdSlot.jsx to @lantern/shared โ€‹

Risk-aware: admin copy uses CSS variables; web uses Tailwind. Plan A: extract with styleSet prop. Plan B: defer if reconciliation is too costly.

Files (Plan A):

  • Create: packages/shared/components/OfferCards.jsx

  • Create: packages/shared/components/AdSlot.jsx

  • Create: packages/shared/components/index.js

  • Modify: packages/shared/package.json

  • Modify: web + admin copies โ†’ re-export shims

  • [ ] Step 1: Diff OfferCards.jsx

bash
diff apps/web/src/components/dashboard/OfferCards.jsx apps/admin/src/components/offers/OfferCards.jsx | head -100

If substantive style divergence > ~80 lines: abort to Plan B (Step 3b).

  • [ ] Step 2: Diff AdSlot.jsx
bash
diff apps/web/src/components/dashboard/AdSlot.jsx apps/admin/src/components/offers/AdSlot.jsx | head -100

Same decision.

  • [ ] Step 3a (Plan A): Move web copies to shared with styleSet prop

Move apps/web/src/components/dashboard/OfferCards.jsx content to packages/shared/components/OfferCards.jsx. Add an optional styles prop on each component:

jsx
const DEFAULT_STYLES = {
  heroCard: '...tailwind classes from web copy...',
  // ...other slot styles
}

export const HeroOfferCard = ({ offer, onSelect, styles = DEFAULT_STYLES }) => (
  <div className={styles.heroCard}>{/* ... */}</div>
)

Web app passes nothing โ†’ Tailwind defaults. Admin passes its own styles object โ†’ admin CSS class names.

Same approach for AdSlot.jsx.

  • [ ] Step 3b (Plan B fallback): Defer extraction

If Step 1/2 diffs were too divergent:

  1. Edit the spec at docs/superpowers/specs/2026-04-30-offers-targeting-and-web-wiring-design.md ยง7 Risks. Append:
markdown
**Implementation note (2026-XX-XX):** Style reconciliation between admin and web copies of `OfferCards`/`AdSlot` exceeded the cost-benefit threshold. Only `offerNormalizer.js` was extracted to `@lantern/shared`. Components remain in their respective apps. Tracked as a follow-up issue.
  1. File a follow-up:
bash
gh issue create --title "Extract OfferCards/AdSlot to @lantern/shared with style theming" \
  --label "enhancement,refactor" \
  --body "Deferred from #139 PR on 2026-04-30. Admin and web have substantively diverged styling; needs a styleSet/theme prop pattern."
  1. Skip to Task 10. Note the new issue # in the PR body.
  • [ ] Step 4 (Plan A): Add ./components to package exports

In packages/shared/package.json, add to exports:

json
"./components": "./components/index.js"

Create packages/shared/components/index.js:

js
export { HeroOfferCard, OfferPill, ChatOfferPill, FeedOfferCard } from './OfferCards.jsx'
export { AdSlot } from './AdSlot.jsx'
  • [ ] Step 5 (Plan A): Replace web + admin copies with re-export shims
jsx
// apps/web/src/components/dashboard/OfferCards.jsx
export { HeroOfferCard, OfferPill, ChatOfferPill, FeedOfferCard } from '@lantern/shared/components'
jsx
// apps/web/src/components/dashboard/AdSlot.jsx
export { AdSlot } from '@lantern/shared/components'

Same for admin copies.

  • [ ] Step 6 (Plan A): Run all tests + storybook spot-check
bash
npm test -- --run
npm run storybook -w apps/web

Expected: green tests; stories render identically.

  • [ ] Step 7: Commit

Plan A:

bash
git add packages/shared/components/ packages/shared/package.json \
        apps/web/src/components/dashboard/OfferCards.jsx \
        apps/web/src/components/dashboard/AdSlot.jsx \
        apps/admin/src/components/offers/OfferCards.jsx \
        apps/admin/src/components/offers/AdSlot.jsx
git commit -m "refactor(shared): extract OfferCards + AdSlot to @lantern/shared

Components accept a styles prop so admin and web each pass their theme.
Web defaults preserve Tailwind; admin passes CSS-var class names.

Refs #321"

Plan B:

bash
git add docs/superpowers/specs/2026-04-30-offers-targeting-and-web-wiring-design.md
git commit -m "docs: defer OfferCards/AdSlot extraction to follow-up

Style reconciliation cost exceeded plan budget.

Refs #321"

Task 10: Rewrite apps/web/src/lib/offerService.js as the API client โ€‹

Files:

  • Modify: apps/web/src/lib/offerService.js (full rewrite)

  • Create: apps/web/src/lib/__tests__/offerService.test.js

  • Modify: .env.local.example

  • [ ] Step 1: Write failing tests

Create apps/web/src/lib/__tests__/offerService.test.js:

js
import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.mock('../apiClient', () => ({ authRequest: vi.fn() }))
vi.mock('../../firebase', () => ({ auth: { currentUser: { uid: 'u1' } } }))

import { fetchActiveOffers, getHeroOffer, getOffersByVenue, getVenueOffer } from '../offerService.js'
import { authRequest } from '../apiClient'

const baseOffer = (over = {}) => ({
  id: 'o1', merchantId: 'm1', venueId: 'v1', title: 't', description: 'd',
  placement: 'hero', targetAudience: 'nearby', radius: 1000,
  expiresAt: new Date(Date.now() + 86400000).toISOString(),
  venue: { id: 'v1', name: 'V1', lat: 0, lng: 0, lanternCount: 0 },
  distanceMeters: 100,
  ...over,
})

describe('offerService', () => {
  beforeEach(() => authRequest.mockReset())

  describe('fetchActiveOffers', () => {
    it('hits /offers/active with lat/lng query', async () => {
      authRequest.mockResolvedValueOnce({
        ok: true,
        text: async () => JSON.stringify({ offers: [baseOffer()], total: 1 }),
      })
      const offers = await fetchActiveOffers({ lat: 32.7, lng: -117.1 })
      const url = authRequest.mock.calls[0][0]
      expect(url).toContain('/offers/active')
      expect(url).toContain('lat=32.7')
      expect(url).toContain('lng=-117.1')
      expect(offers).toHaveLength(1)
    })
    it('passes optional placement filter', async () => {
      authRequest.mockResolvedValueOnce({
        ok: true,
        text: async () => JSON.stringify({ offers: [], total: 0 }),
      })
      await fetchActiveOffers({ lat: 0, lng: 0, placement: 'chat' })
      expect(authRequest.mock.calls[0][0]).toContain('placement=chat')
    })
    it('returns [] on non-ok responses', async () => {
      authRequest.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'oops' })
      const offers = await fetchActiveOffers({ lat: 0, lng: 0 })
      expect(offers).toEqual([])
    })
  })

  describe('getHeroOffer', () => {
    it('returns the closest hero-placement offer', () => {
      const list = [
        baseOffer({ id: 'a', placement: 'hero', distanceMeters: 500 }),
        baseOffer({ id: 'b', placement: 'inline' }),
        baseOffer({ id: 'c', placement: 'hero', distanceMeters: 100 }),
      ]
      expect(getHeroOffer(list).id).toBe('c')
    })
    it('returns null when no hero offers', () => {
      expect(getHeroOffer([baseOffer({ placement: 'inline' })])).toBe(null)
    })
  })

  describe('getOffersByVenue', () => {
    it('groups by venueId, picks closest per venue', () => {
      const list = [
        baseOffer({ id: 'x', venueId: 'v1', distanceMeters: 200 }),
        baseOffer({ id: 'y', venueId: 'v1', distanceMeters: 50 }),
        baseOffer({ id: 'z', venueId: 'v2', distanceMeters: 300 }),
      ]
      const map = getOffersByVenue(list)
      expect(map.v1.id).toBe('y')
      expect(map.v2.id).toBe('z')
    })
  })

  describe('getVenueOffer', () => {
    it('returns closest offer for venueId', () => {
      const list = [
        baseOffer({ id: 'a', venueId: 'v1', distanceMeters: 100 }),
        baseOffer({ id: 'b', venueId: 'v1', distanceMeters: 50 }),
      ]
      expect(getVenueOffer('v1', list).id).toBe('b')
    })
    it('returns null when no offer for that venue', () => {
      expect(getVenueOffer('v9', [baseOffer()])).toBe(null)
    })
  })
})
  • [ ] Step 2: Run, verify failure
bash
npm test -w apps/web -- --run apps/web/src/lib/__tests__/offerService.test.js

Expected: failures referencing missing exports.

  • [ ] Step 3: Rewrite offerService.js

Replace apps/web/src/lib/offerService.js entirely:

js
/**
 * Offer Service โ€” thin client for merchants-api /offers/active.
 *
 * Replaces the prior mock generator + setMerchantFakeOffers singleton.
 * Geofence + audience filtering happens server-side; this module is fetch + select.
 *
 * @sdkCategory Merchant & Offers
 */
import { authRequest } from './apiClient'

const API_BASE_URL = import.meta.env.VITE_MERCHANTS_API_URL

/**
 * Fetch active offers visible to the current user near (lat, lng).
 * Returns [] on errors โ€” offers are non-critical UI.
 */
export async function fetchActiveOffers({ lat, lng, placement } = {}) {
  if (typeof lat !== 'number' || typeof lng !== 'number') return []
  const params = new URLSearchParams({ lat: String(lat), lng: String(lng) })
  if (placement) params.set('placement', placement)
  try {
    const res = await authRequest(`${API_BASE_URL}/offers/active?${params}`)
    if (!res.ok) return []
    const text = await res.text()
    const data = text ? JSON.parse(text) : {}
    return Array.isArray(data.offers) ? data.offers : []
  } catch {
    return []
  }
}

/**
 * Pick the hero offer for a global slot โ€” closest hero-placement offer wins.
 */
export function getHeroOffer(offers = []) {
  const heroes = offers.filter((o) => o.placement === 'hero')
  if (heroes.length === 0) return null
  return heroes.reduce((closest, o) =>
    o.distanceMeters < closest.distanceMeters ? o : closest
  )
}

/**
 * Group offers by venueId, picking the closest per venue.
 */
export function getOffersByVenue(offers = []) {
  const map = {}
  for (const o of offers) {
    if (!o.venueId) continue
    const existing = map[o.venueId]
    if (!existing || o.distanceMeters < existing.distanceMeters) {
      map[o.venueId] = o
    }
  }
  return map
}

/**
 * Closest offer for a single venue, or null.
 */
export function getVenueOffer(venueId, offers = []) {
  let best = null
  for (const o of offers) {
    if (o.venueId !== venueId) continue
    if (!best || o.distanceMeters < best.distanceMeters) best = o
  }
  return best
}
  • [ ] Step 4: Run, verify pass
bash
npm test -w apps/web -- --run apps/web/src/lib/__tests__/offerService.test.js

Expected: all tests pass.

  • [ ] Step 5: Add VITE_MERCHANTS_API_URL to .env.local.example

Read .env.local.example. Append near other VITE_*_API_URL entries:

# Merchants API (offers CRUD + public reads)
VITE_MERCHANTS_API_URL=http://localhost:8085

Also ensure apps/web/.env.local.example is updated if it exists separately.

  • [ ] Step 6: Commit
bash
git add apps/web/src/lib/offerService.js \
        apps/web/src/lib/__tests__/offerService.test.js \
        .env.local.example
git commit -m "feat(web): rewrite offerService to call /offers/active

Removes the mock generator entirely. setMerchantFakeOffers gone.
Selectors operate on server-filtered data; closest distance wins.

Refs #139"

Task 11: Update HomeView.jsx to fetch real offers โ€‹

Files:

  • Modify: apps/web/src/components/dashboard/HomeView.jsx

  • [ ] Step 1: Locate the integration points

bash
grep -n "getActiveOffers\|getHeroOffer\|getOffersByVenue\|userLocation" apps/web/src/components/dashboard/HomeView.jsx | head -20

Note the geolocation state name used by HomeView (likely userLocation or similar).

  • [ ] Step 2: Update the import

Replace:

js
import { getHeroOffer, getOffersByVenue } from '../../lib/offerService'

with:

js
import { fetchActiveOffers, getHeroOffer, getOffersByVenue } from '../../lib/offerService'
  • [ ] Step 3: Replace synchronous derivation with state + effect

Find the lines:

js
const offersByVenue = useMemo(() => getOffersByVenue(venues), [venues])
const heroOffer = useMemo(() => getHeroOffer(venues), [venues])

Replace with:

js
const [offers, setOffers] = useState([])

useEffect(() => {
  if (!userLocation) return
  let cancelled = false
  const load = () => fetchActiveOffers({
    lat: userLocation.latitude, lng: userLocation.longitude,
  }).then((list) => { if (!cancelled) setOffers(list) })
  load()
  const onVisible = () => {
    if (document.visibilityState === 'visible' && userLocation) load()
  }
  document.addEventListener('visibilitychange', onVisible)
  return () => {
    cancelled = true
    document.removeEventListener('visibilitychange', onVisible)
  }
}, [userLocation?.latitude, userLocation?.longitude])

const offersByVenue = useMemo(() => getOffersByVenue(offers), [offers])
const heroOffer = useMemo(() => getHeroOffer(offers), [offers])

(If userLocation is named differently, adjust.)

  • [ ] Step 4: Run HomeView tests
bash
npm test -w apps/web -- --run HomeView

Expected: green (or pre-existing failures unrelated to offers).

  • [ ] Step 5: Manual smoke
bash
npm run dev -w apps/web
npm run dev -w services/api/merchants

Spoof location near a venue with an active hero offer (created in admin). Confirm hero card shows real merchant data.

  • [ ] Step 6: Commit
bash
git add apps/web/src/components/dashboard/HomeView.jsx
git commit -m "feat(web): HomeView fetches real offers from /offers/active

Refetches on location change + tab visibility. Hero + per-venue inline
slots reflect live merchant data.

Refs #139"

Task 12: Update VenueView.jsx to consume the same offer list โ€‹

Files:

  • Modify: apps/web/src/components/dashboard/VenueView.jsx

  • Modify: parent (HomeView or wrapper) to pass offers prop

  • [ ] Step 1: Read current usage

bash
grep -n "getVenueOffer\|venueOffer" apps/web/src/components/dashboard/VenueView.jsx
  • [ ] Step 2: Take offers as a prop
jsx
import { getVenueOffer } from '../../lib/offerService'

export default function VenueView({ venue, offers = [], /* existing props */ }) {
  const venueOffer = getVenueOffer(venue.id, offers)
  // ... rest unchanged
}
  • [ ] Step 3: Pass offers from the parent that renders <VenueView>

Find the parent (HomeView or a wrapper). Pass the offers state from Task 11:

jsx
<VenueView venue={selectedVenue} offers={offers} /* existing props */ />
  • [ ] Step 4: Run tests
bash
npm test -w apps/web -- --run VenueView

Expected: green.

  • [ ] Step 5: Commit
bash
git add apps/web/src/components/dashboard/VenueView.jsx \
        apps/web/src/components/dashboard/HomeView.jsx
git commit -m "feat(web): VenueView reads offers from parent's fetched list

Avoids a second round-trip โ€” VenueView consumes the same /offers/active
result HomeView already fetched.

Refs #139"

Task 13: Wire chat placement in Chat.jsx โ€‹

Files:

  • Modify: apps/web/src/components/Chat.jsx

  • [ ] Step 1: Read Chat.jsx

bash
grep -n "venueId\|connection.venueId\|return (\|userLocation\|location" apps/web/src/components/Chat.jsx | head -20

Chat consumes connection.venueId (line ~161). Identify how Chat receives location (prop, context, or hook).

  • [ ] Step 2: Add offer fetch keyed on connection.venueId

Inside the component:

jsx
import { useEffect, useState } from 'react'
import { fetchActiveOffers } from '../lib/offerService'
import { AdSlot } from './dashboard/AdSlot'

// inside component, alongside existing state:
const [chatOffer, setChatOffer] = useState(null)

useEffect(() => {
  if (!connection?.venueId || !userLocation) return
  let cancelled = false
  fetchActiveOffers({
    lat: userLocation.latitude,
    lng: userLocation.longitude,
    placement: 'chat',
  }).then((offers) => {
    if (cancelled) return
    setChatOffer(offers.find((o) => o.venueId === connection.venueId) || null)
  })
  return () => { cancelled = true }
}, [connection?.venueId, userLocation?.latitude, userLocation?.longitude])

(Adjust userLocation to match Chat's actual state/prop name.)

  • [ ] Step 3: Render slot at top of chat thread

In the JSX return, before the messages list:

jsx
{chatOffer && (
  <div style={{ position: 'sticky', top: 0, zIndex: 5 }}>
    <AdSlot offer={chatOffer} />
  </div>
)}
  • [ ] Step 4: Storybook spot-check
bash
npm run storybook -w apps/web

Open the Chat story; confirm no console errors.

  • [ ] Step 5: Commit
bash
git add apps/web/src/components/Chat.jsx
git commit -m "feat(web): wire chat placement to AdSlot in Chat.jsx

When a chat is anchored to a venue with an active placement='chat'
offer, render a sticky banner above the message thread.

Refs #321"

Task 14: Wire feed placement in RecentActivityView.jsx โ€‹

Files:

  • Modify: apps/web/src/components/dashboard/RecentActivityView.jsx

  • [ ] Step 1: Read RecentActivityView

bash
grep -n "map\|return\|venueId\|userLocation\|activity" apps/web/src/components/dashboard/RecentActivityView.jsx | head -20

Find the activity-row map and how it receives location.

  • [ ] Step 2: Add feed-placement offer fetch
jsx
import { useEffect, useState } from 'react'
import { fetchActiveOffers } from '../../lib/offerService'
import { AdSlot } from './AdSlot'

// inside component:
const [feedOffers, setFeedOffers] = useState([])

useEffect(() => {
  if (!userLocation) return
  let cancelled = false
  fetchActiveOffers({
    lat: userLocation.latitude, lng: userLocation.longitude, placement: 'feed',
  }).then((offers) => { if (!cancelled) setFeedOffers(offers) })
  return () => { cancelled = true }
}, [userLocation?.latitude, userLocation?.longitude])
  • [ ] Step 3: Interleave AdSlots into the activity list

Replace the activity map with:

jsx
{(() => {
  const seen = new Set()
  const rendered = []
  for (const activity of activities) {
    rendered.push(<ActivityRow key={activity.id} activity={activity} />)
    const matching = feedOffers.find((o) => o.venueId === activity.venueId && !seen.has(o.venueId))
    if (matching) {
      seen.add(matching.venueId)
      rendered.push(<AdSlot key={`offer-${matching.id}`} offer={matching} />)
    }
  }
  return rendered
})()}

(Replace <ActivityRow ... /> with whatever the existing activity row component is named in the file.)

  • [ ] Step 4: Run tests
bash
npm test -w apps/web -- --run RecentActivityView

Expected: green.

  • [ ] Step 5: Commit
bash
git add apps/web/src/components/dashboard/RecentActivityView.jsx
git commit -m "feat(web): wire feed placement to AdSlot in RecentActivityView

Interleaves feed-placement offers after the first activity row for each
matching venue. One AdSlot per venue per render (Set-deduped).

Refs #321"

Task 15: Stub cleanup โ€” delete web OfferForm + setMerchantFakeOffers โ€‹

Files:

  • Delete: apps/web/src/screens/merchant/OfferForm.jsx

  • Delete: apps/web/src/screens/merchant/OfferForm.stories.jsx

  • Modify: apps/web/src/screens/merchant/MerchantDashboard.jsx

  • Modify: apps/web/src/App.jsx (if #/merchant/new route exists)

  • [ ] Step 1: Confirm no callers remain

bash
grep -rn "setMerchantFakeOffers\|from.*screens/merchant/OfferForm" apps/web/src

Expected: only the one site in MerchantDashboard.jsx.

  • [ ] Step 2: Remove import + call from MerchantDashboard.jsx

Delete the import:

js
import { setMerchantFakeOffers } from '../../lib/offerService'

Delete the call (around line 243):

js
setMerchantFakeOffers(fakeAds)

If fakeAds becomes unused, remove its declaration. If the surrounding block becomes a no-op, remove it.

  • [ ] Step 3: Remove #/merchant/new route from App.jsx (if present)
bash
grep -n "#/merchant/new\|merchant/new" apps/web/src/App.jsx

If found, remove the route check + the OfferForm import.

  • [ ] Step 4: Delete the OfferForm files
bash
rm apps/web/src/screens/merchant/OfferForm.jsx
rm apps/web/src/screens/merchant/OfferForm.stories.jsx
  • [ ] Step 5: Run validation
bash
npm run validate -- --workspace apps/web

Expected: green.

  • [ ] Step 6: Commit
bash
git add -A apps/web/src/screens/merchant/ apps/web/src/App.jsx
git commit -m "chore(web): remove stub OfferForm + setMerchantFakeOffers

OfferForm now lives in the admin portal (sub-project #1, PR #334).

Refs #139, #321"

Task 16: OpenAPI sync linter (closes #347) โ€‹

Files:

  • Create: tooling/scripts/lint.openapi-sync.js

  • Create: tooling/scripts/__tests__/lint.openapi-sync.test.js

  • Modify: tooling/scripts/validate.js

  • Modify: CLAUDE.md (Linter Organization note)

  • [ ] Step 1: Inspect validate.js section pattern

bash
grep -n "section\|scope\|--scope" tooling/scripts/validate.js | head -30

Note the structure for adding a new lint section.

  • [ ] Step 2: Inspect services index
bash
cat packages/shared/services/index.js | head -80

Note the service entry shape (ports, localPath, openapiPath, etc.).

  • [ ] Step 3: Write failing tests

Create tooling/scripts/__tests__/lint.openapi-sync.test.js:

js
import { describe, it, expect } from 'vitest'
import { compareRoutes, normalizePath } from '../lint.openapi-sync.js'

describe('normalizePath', () => {
  it('treats :param and {param} as equivalent', () => {
    expect(normalizePath('/users/:id')).toBe(normalizePath('/users/{id}'))
  })
  it('preserves literal segments', () => {
    expect(normalizePath('/users/:id/posts')).toBe('/users/{param}/posts')
  })
})

describe('compareRoutes', () => {
  it('returns no drift when sets match', () => {
    const r = compareRoutes(
      [{ method: 'GET', path: '/health' }],
      [{ method: 'GET', path: '/health' }]
    )
    expect(r.implementedNotDocumented).toEqual([])
    expect(r.documentedNotImplemented).toEqual([])
  })
  it('detects implemented-but-undocumented', () => {
    const r = compareRoutes(
      [{ method: 'GET', path: '/health' }, { method: 'POST', path: '/users' }],
      [{ method: 'GET', path: '/health' }]
    )
    expect(r.implementedNotDocumented).toEqual([{ method: 'POST', path: '/users' }])
  })
  it('detects documented-but-unimplemented', () => {
    const r = compareRoutes(
      [{ method: 'GET', path: '/health' }],
      [{ method: 'GET', path: '/health' }, { method: 'GET', path: '/ghost' }]
    )
    expect(r.documentedNotImplemented).toEqual([{ method: 'GET', path: '/ghost' }])
  })
  it('treats path-param syntax differences as a match', () => {
    const r = compareRoutes(
      [{ method: 'GET', path: '/users/:id' }],
      [{ method: 'GET', path: '/users/{id}' }]
    )
    expect(r.implementedNotDocumented).toEqual([])
    expect(r.documentedNotImplemented).toEqual([])
  })
})
  • [ ] Step 4: Implement the linter

Create tooling/scripts/lint.openapi-sync.js:

js
#!/usr/bin/env node
/**
 * OpenAPI sync linter.
 * For every Cloud Run API service registered in packages/shared/services/index.js,
 * compares (method, path) tuples between Express route declarations and openapi.json.
 * Exits 0 if all in sync; non-zero with per-service drift report otherwise.
 */
import { readFileSync, readdirSync, statSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join, relative } from 'path'

const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, '..', '..')

// โ”€โ”€ Helpers (exported for tests) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

export function normalizePath(p) {
  return p
    .replace(/:([A-Za-z_]+)/g, '{param}')
    .replace(/\{([A-Za-z_]+)\}/g, '{param}')
}

export function compareRoutes(implemented, documented) {
  const key = (r) => `${r.method.toUpperCase()} ${normalizePath(r.path)}`
  const implMap = new Map(implemented.map((r) => [key(r), r]))
  const docMap = new Map(documented.map((r) => [key(r), r]))
  const implementedNotDocumented = []
  const documentedNotImplemented = []
  for (const [k, r] of implMap) if (!docMap.has(k)) implementedNotDocumented.push(r)
  for (const [k, r] of docMap) if (!implMap.has(k)) documentedNotImplemented.push(r)
  return { implementedNotDocumented, documentedNotImplemented }
}

// โ”€โ”€ Service discovery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

async function loadServices() {
  const mod = await import(join(repoRoot, 'packages/shared/services/index.js'))
  const services = mod.default || mod.SERVICES || mod.services
  if (!Array.isArray(services)) {
    throw new Error('packages/shared/services/index.js does not have a default/SERVICES array export')
  }
  return services.filter((s) => s.localPath)
}

// โ”€โ”€ Express route extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

const ROUTE_RE = /router\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g

function walkJs(dir, out = []) {
  for (const entry of readdirSync(dir)) {
    const full = join(dir, entry)
    if (entry === 'node_modules' || entry === '__tests__') continue
    const st = statSync(full)
    if (st.isDirectory()) walkJs(full, out)
    else if (entry.endsWith('.js')) out.push(full)
  }
  return out
}

function extractImplementedRoutes(serviceDir) {
  const indexPath = join(serviceDir, 'src', 'index.js')
  let indexSrc = ''
  try { indexSrc = readFileSync(indexPath, 'utf8') } catch { return [] }
  const routes = []
  const routesDir = join(serviceDir, 'src', 'routes')
  let routeFiles = []
  try { routeFiles = walkJs(routesDir) } catch { return [] }
  for (const file of routeFiles) {
    const src = readFileSync(file, 'utf8')
    // Find this router file's mount prefix in index.js by import name โ†’ app.use match.
    const rel = relative(routesDir, file).replace(/\.js$/, '')
    const importLine = new RegExp(
      `import\\s+(\\w+)\\s+from\\s+['"\`]\\.\\./routes/${rel.replace(/\//g, '\\/')}\\.js['"\`]`
    )
    const importMatch = indexSrc.match(importLine)
    let prefix = ''
    if (importMatch) {
      const routerVar = importMatch[1]
      const useRe = new RegExp(`app\\.use\\(\\s*['"\`]([^'"\`]+)['"\`]\\s*,[^,]*${routerVar}`)
      const useMatch = indexSrc.match(useRe)
      if (useMatch) prefix = useMatch[1]
    }
    let m
    const re = new RegExp(ROUTE_RE.source, 'g')
    while ((m = re.exec(src))) {
      const subPath = m[2]
      const fullPath = (prefix + subPath).replace(/\/+/g, '/')
      routes.push({ method: m[1].toUpperCase(), path: fullPath })
    }
  }
  return routes
}

function extractDocumentedRoutes(openapiPath) {
  const spec = JSON.parse(readFileSync(openapiPath, 'utf8'))
  const out = []
  for (const [path, methods] of Object.entries(spec.paths || {})) {
    for (const method of Object.keys(methods)) {
      if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) {
        out.push({ method: method.toUpperCase(), path })
      }
    }
  }
  return out
}

// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

async function main() {
  const services = await loadServices()
  let drift = false
  for (const svc of services) {
    const dir = join(repoRoot, svc.localPath)
    const specPath = join(dir, 'openapi.json')
    const implemented = extractImplementedRoutes(dir)
    let documented = []
    try { documented = extractDocumentedRoutes(specPath) }
    catch { console.error(`[openapi-sync] ${svc.name}: missing/invalid openapi.json at ${specPath}`); drift = true; continue }
    const cmp = compareRoutes(implemented, documented)
    if (cmp.implementedNotDocumented.length || cmp.documentedNotImplemented.length) {
      drift = true
      console.error(`\n[openapi-sync] DRIFT in ${svc.name} (${svc.localPath}):`)
      if (cmp.implementedNotDocumented.length) {
        console.error('  Implemented but undocumented:')
        for (const r of cmp.implementedNotDocumented) console.error(`    ${r.method} ${r.path}`)
      }
      if (cmp.documentedNotImplemented.length) {
        console.error('  Documented but unimplemented:')
        for (const r of cmp.documentedNotImplemented) console.error(`    ${r.method} ${r.path}`)
      }
    } else {
      console.log(`[openapi-sync] ${svc.name}: OK`)
    }
  }
  process.exit(drift ? 1 : 0)
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error('[openapi-sync] failed:', err); process.exit(2) })
}
  • [ ] Step 5: Run unit tests
bash
npx vitest run tooling/scripts/__tests__/lint.openapi-sync.test.js

Expected: 5 tests pass.

  • [ ] Step 6: Run linter against the live tree
bash
node tooling/scripts/lint.openapi-sync.js

Expected: every service reports OK (merchants-api just fixed in Task 7). If another service has drift, that's a real finding โ€” file a follow-up issue, do not fix in this PR.

  • [ ] Step 7: Wire into validate.js

Read tooling/scripts/validate.js. Add a section entry mirroring an existing one (likely an array literal of { scope, label, command } objects):

js
{
  scope: 'openapi',
  label: 'OpenAPI sync',
  command: 'node tooling/scripts/lint.openapi-sync.js',
}

Then verify the --scope filter handles openapi.

  • [ ] Step 8: Test the validate scope filter
bash
npm run validate -- --scope openapi

Expected: runs only the openapi linter, green.

  • [ ] Step 9: Update CLAUDE.md Linter Organization

In CLAUDE.md, find the "Linter Organization" section, append:

markdown
- `tooling/scripts/lint.openapi-sync.js` โ€” verifies every Cloud Run API service's `openapi.json` matches its actual Express routes
  • [ ] Step 10: Commit
bash
git add tooling/scripts/lint.openapi-sync.js \
        tooling/scripts/__tests__/lint.openapi-sync.test.js \
        tooling/scripts/validate.js CLAUDE.md
git commit -m "feat(tooling): add openapi-sync linter (closes #347)

Compares (method, path) tuples between Express route declarations and
each service's openapi.json. Wired into npm run validate; supports
--scope openapi.

Closes #347"

Task 17: Final validation + manual smoke โ€‹

  • [ ] Step 1: Run the full validate suite
bash
npm run validate

Expected: all green.

  • [ ] Step 2: Manual end-to-end smoke

Spin up:

bash
npm run dev -w services/api/merchants
npm run dev -w services/api/auth
npm run dev -w apps/web
npm run dev -w apps/admin

Walk through:

  1. Admin portal: create a merchant offer with placement: 'hero', targetAudience: 'nearby', radius: 1000, against a real venue. Save as active.
  2. Web app: spoof location within the venue's radius via dev panel. Open dashboard โ†’ confirm hero card shows real merchant title.
  3. Spoof location 5km away โ†’ confirm hero card disappears (geofence works).
  4. Repeat with placement: 'inline' โ†’ confirm OfferPill renders on the venue card.
  5. Repeat with placement: 'chat' โ†’ enter a chat anchored to that venue โ†’ confirm AdSlot renders.
  6. Repeat with placement: 'feed' โ†’ open RecentActivity โ†’ confirm offer interleaves.
  7. Audience lantern: without lighting a lantern at the venue, offer is hidden. Light a lantern โ†’ offer appears.
  8. Audience new: test as fresh account (createdAt < 7d) โ€” visible. Test as old account โ€” invisible.
  9. Venue picker: in admin, search for "Swan Bar" โ€” appears.
  10. API docs: admin portal โ†’ API Reference โ†’ Merchants API. All 6 routes present.
  • [ ] Step 3: Push the branch + ASK before opening the PR

Per CLAUDE.md Rule #11, push the branch then stop and ask the user before opening the PR:

bash
git push -u origin feat/139-offers-targeting-web-wiring

Then prompt the user with the proposed PR title + body for approval.

Proposed PR body:

markdown
## Summary
- Wire real merchant offers into the web app with server-side geofence + audience filtering
- Add public `GET /offers/active` endpoint to merchants-api
- Wire `chat` and `feed` placement consumer surfaces (components shipped previously by #322 but unused)
- Extract shared offer modules to `@lantern/shared`
- Fix venue picker bug (limit-500 hid venues like "Swan Bar")
- Document all merchants-api routes in openapi.json (was: only `/health`)
- Add openapi-sync linter to prevent the same drift in any service

## Test plan
- [ ] `npm run validate` green
- [ ] Hero/inline/chat/feed placements render with real merchant data when within geofence
- [ ] Outside-radius hides offers entirely
- [ ] `lantern`/`new` audiences gate correctly
- [ ] Venue picker finds previously-hidden venues
- [ ] Admin portal API Reference shows all 6 merchants-api routes
- [ ] Backfill `nameLower` against `lantern-app-dev` after merge (runbook step in LOCATION_STACK.md)

Closes #139
Closes #321
Closes #347

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Self-review โ€‹

1. Spec coverage:

  • Spec ยง1 New public-auth route โ†’ Task 6 โœ“
  • Spec ยง2 Server-side filtering โ†’ Task 6 โœ“
  • Spec ยง3 Audience semantics โ†’ Task 5 + Task 6 โœ“
  • Spec ยง4 Audience filter helper โ†’ Task 5 โœ“
  • Spec ยง5 Web app offerService.js rewrite โ†’ Task 10 โœ“
  • Spec ยง6 Wire chat/feed placements โ†’ Tasks 13, 14 โœ“
  • Spec ยง7 Extract to @lantern/shared โ†’ Tasks 8, 9 (with Plan B fallback) โœ“
  • Spec ยง8 Stub cleanup โ†’ Task 15 โœ“
  • Spec ยง9 Venue picker bug โ†’ Tasks 1, 2, 3 โœ“
  • Spec ยง10 Merchants-api openapi.json โ†’ Task 7 โœ“
  • Spec ยง11 OpenAPI sync linter โ†’ Task 16 โœ“
  • HomeView/VenueView updates โ†’ Tasks 11, 12 โœ“
  • Test infrastructure (preq for Tasks 5โ€“6) โ†’ Task 4 โœ“

All spec sections covered. โœ“

2. Placeholder scan: No "TBD"/"TODO"/"implement later". Discovery steps that say "find the parent" or "adjust to match the actual state name" are environment-discovery steps with grep commands, not placeholders. โœ“

3. Type consistency:

  • passesAudience(audience, userContext, offer) โ€” Task 5 def, Task 6 call โœ“
  • userContext = { uid, accountAgeDays, hasActiveLanternAtVenue } โ€” consistent across Task 5 tests/impl, Task 6 impl โœ“
  • fetchActiveOffers({ lat, lng, placement? }) โ€” Task 10 def, Tasks 11/12/13/14 calls โœ“
  • getHeroOffer(offers) / getOffersByVenue(offers) / getVenueOffer(venueId, offers) โ€” Task 10 defs, Tasks 11/12 uses โœ“
  • Public offer response shape โ€” Task 6 serializeForClient matches Task 10 test fixtures matches Task 7 openapi schema โœ“
  • normalizeFormToOffer โ€” Task 8 extraction; signature unchanged โœ“
  • toNameLower โ€” Task 1 def, Tasks 1/2/3 uses โœ“

All consistent. โœ“

Built with VitePress