Skip to content

Offers CRUD + Admin Portal β€” Design ​

Date: 2026-04-25 Scope: services/api/merchants/ (new), apps/admin/Context: Sub-project #1 of the Offers Migration. Builds the backend API and admin portal UI for creating, editing, and managing merchant offers. Sub-project #2 (wire the web app to real data) and sub-project #3 (analytics) are sequenced after this lands.

Goal ​

Stand up a dedicated Merchants API service with offer CRUD endpoints, and add an Offers tab to MerchantDetail in the admin portal. After this change:

  • Admins can create, edit, view, and archive offers for any merchant.
  • Merchant-role users can create, edit, view, and archive their own offers.
  • Offers are persisted in Firestore (offers/{offerId}) with proper auth gating.
  • The create/edit form matches the web app's OfferForm field set β€” including a live placement preview sidebar.
  • Offer lifecycle: draft β†’ active β†’ expired (auto-expiration on read).
  • react-select is adopted as the standard dropdown component in the admin portal.

The web app's OfferForm will be removed in sub-project #2 once this admin version is live. The consumer-facing display components (AdSlot, OfferCards) stay in apps/web/ for now; copies are brought into apps/admin/ for the preview sidebar, to be extracted to @lantern/shared in sub-project #2.

Why now ​

The offer form currently lives in the web app with a stubbed backend β€” it submits to console.log and returns a hardcoded mock. The merchant nav refactor (2026-04-25) established the admin portal as the management surface for merchant operations. Building the real backend and placing the form in the admin portal before wiring the web app prevents the stub from propagating further and gives merchants a working tool immediately.

Architecture ​

1. New service: services/api/merchants/ ​

Express.js on Cloud Run, following the same pattern as services/api/auth/:

  • Firebase Admin SDK for Firestore access
  • Auth middleware validating Firebase ID tokens
  • OpenAPI spec at /openapi.json, Scalar docs at /api-docs
  • Registered in packages/shared/services/index.js
  • Port 8085 (next available after auth-api on 8084)

Routes:

MethodPathAuthPurpose
GET/merchants/:merchantId/offersadmin or ownerList offers, filterable by ?status=active|draft|expired|archived
POST/merchants/:merchantId/offersadmin or ownerCreate offer
GET/merchants/:merchantId/offers/:offerIdadmin or ownerGet single offer
PUT/merchants/:merchantId/offers/:offerIdadmin or ownerUpdate offer
DELETE/merchants/:merchantId/offers/:offerIdadmin or ownerHard delete if status === 'draft', soft delete (set status: 'archived') otherwise

Auth model: Every request is authenticated via Firebase Auth ID token. The middleware extracts the UID, checks custom claims and/or Firestore for role:

  • Admin β†’ can access any merchant's offers.
  • Merchant-role β†’ can only access offers where the route :merchantId matches users/{uid}.merchantId.

Venue validation on create: The API verifies the submitted venueId exists in the merchant's merchants/{merchantId}.venueIds[] array before creating the offer. Rejects with 400 if the venue doesn't belong to the merchant.

Auto-expiration on read: The list and get endpoints check expiresAt < now and return status: 'expired' for those offers, regardless of the stored status. No Cloud Function needed β€” expiration is computed at read time. A background function can be added later if we need to trigger events on expiry.

2. Offer schema (Firestore: offers/{offerId}) ​

Required fields (provided by the form):

FieldTypeDescription
merchantIdstringMerchant entity ID (from route param)
venueIdstringLinked venue ID (validated against merchant's venues)
titlestringShort headline shown to users, e.g. "20% off brunch"
descriptionstringRedemption rules and terms
placementstringhero | inline | chat | feed
targetAudiencestringnearby | lantern | frequent | new
radiusnumberGeofence radius in meters (10–2500)
per_user_limitnumberMax redemptions per user (default 1)
budgetnumberCampaign budget in USD
expiresAttimestampWhen the offer expires
showDisclaimerWhileSuppliesLastbooleanWhether to show "While supplies last" footer

Auto-set fields (managed by the API):

FieldTypeDescription
statusstringdraft | active | expired | archived
createdAttimestampServer timestamp on creation
createdBystringUID of the user who created the offer
updatedAttimestampServer timestamp on every update

Status lifecycle:

draft ──→ active ──→ expired (auto, on read when expiresAt < now)
  β”‚         β”‚
  β”‚         └──→ archived (soft delete via DELETE endpoint)
  β”‚
  └──→ (hard delete via DELETE endpoint β€” document removed)

Drafts can be published by updating status from draft to active. Active offers can be reverted to draft. Archived offers are hidden from the default list but queryable with ?status=archived.

3. Admin portal: Offers tab in MerchantDetail ​

A new tab added to the existing DETAIL_TABS array in MerchantDetail.jsx:

jsx
{ id: 'offers', label: 'Offers', icon: <Tag size={16} /> }

Positioned after Venues: Overview Β· Venues (n) Β· Offers (n) Β· Notes Β· Photos Β· Address.

The tab label includes a dynamic count of non-archived offers, matching the existing Venues (n) pattern.

New route in AdminDashboard.jsx:

jsx
<Route path="offers" element={<MerchantOffersTab />} />

This is a nested route inside the existing <Route path=":merchantId"> block, alongside the other tab routes.

4. Offers tab components ​

The tab manages its own internal view state (list / create / edit / detail) without adding URL routes β€” same pattern as the Venues tab's picker modal. State is managed via useState in MerchantOffersTab.

Component tree:

MerchantDetail.jsx (existing parent)
  └─ MerchantOffersTab.jsx (new β€” via useOutletContext)
       β”œβ”€ OffersList.jsx (status filters + offer rows)
       β”œβ”€ OfferForm.jsx (create / edit β€” all fields + preview sidebar)
       └─ OfferDetail.jsx (read-only view of a single offer)

File locations:

apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsx
apps/admin/src/components/merchants/tabs/OffersList.jsx
apps/admin/src/components/merchants/tabs/OfferForm.jsx
apps/admin/src/components/merchants/tabs/OfferDetail.jsx
apps/admin/src/components/offers/AdSlot.jsx          (copied from web app, restyled)
apps/admin/src/components/offers/OfferCards.jsx       (copied from web app, restyled)
apps/admin/src/lib/offerNormalizer.js                 (copied from web app)

5. Offers list view ​

Default view when the tab is active. Shows:

  • Status filter bar β€” sub-tabs: All (n) Β· Active (n) Β· Draft (n) Β· Expired (n). Follows the existing .sub-tabs pattern from UserManagement.jsx. "Archived" is not shown by default β€” accessible via a separate toggle or query.
  • Offer rows β€” clickable cards showing title, venue name, placement type, target audience, radius, expiry date, and status badge. Clicking a row opens the detail view.
  • Create Offer button β€” .btn-primary.btn-sm in the top-right, opens the create form.
  • Empty state β€” Lucide icon + "No offers yet" + "Create your first offer to start reaching nearby users." + Create Offer CTA.

6. Create / edit offer form ​

Two-column layout matching the web app's OfferForm:

Left column β€” form fields:

FieldInput typeValidation
Venuereact-select dropdown (merchant's linked venues)Required
TitleText inputRequired
DescriptionTextareaRequired
Placementreact-select dropdown (hero/inline/chat/feed)Required
Target Audiencereact-select dropdown (nearby/lantern/frequent/new)Required
Geofence RadiusNumber input (10–2500)Required
Per User LimitNumber input (default 1)Required
Budget ($)Number input (min 1)Required
ExpiresDate inputRequired
While Supplies LastCheckboxOptional

Right column β€” live placement preview:

Shows all 4 placement types (hero, inline, chat, feed) rendered with the current form data via offerNormalizer. The selected placement gets a highlighted amber border; unselected placements are dimmed to 35% opacity. Before any placement is selected, all show at equal opacity. Updates in real-time as the user types.

The preview uses copies of AdSlot and OfferCards from the web app, restyled for the admin portal's custom CSS (not Tailwind). These will be extracted to @lantern/shared in sub-project #2.

Form actions:

ButtonBehavior
CancelReturns to the offers list. Discards unsaved changes.
Save DraftSubmits with status: 'draft'. Not visible to app users.
PublishSubmits with status: 'active'. Immediately visible to app users.

When editing an existing offer, "Save Draft" becomes "Save" and "Publish" is shown only if the offer is currently a draft.

Venue selection behavior:

  • Dropdown lists only venues linked to this merchant (from merchants/{id}.venueIds[]).
  • If the merchant has exactly one venue, it's pre-selected.
  • If no venues are linked, the form shows: "Link a venue before creating an offer" with a link to the Venues tab.

7. Offer detail view ​

Read-only view of a single offer. Shows all fields in a structured layout. Actions in the header:

StateActions
Draft[Edit] (secondary) Β· [Publish] (primary) Β· [Delete] (danger)
Active[Edit] (secondary) Β· [Archive] (danger)
Expired[Edit] (secondary) Β· [Archive] (danger)
Archived[Edit] (secondary) β€” re-editing sets status back to draft

8. react-select as standard dropdown ​

Add react-select as a dependency in apps/admin/package.json. Create a thin wrapper component (apps/admin/src/components/StyledSelect.jsx) that applies the admin portal's dark-theme styling (matching the web app's amber-accent select styles but using CSS variables from the admin theme). All new dropdowns in the admin portal should use this component going forward.

9. Admin portal API client ​

Add a new API client module for the merchants service:

apps/admin/src/lib/merchantsApi.js

Follows the same pattern as apps/admin/src/lib/authApi.js β€” authenticated fetch wrapper that includes the Firebase ID token. Functions:

  • listOffers(merchantId, { status? }) β†’ GET
  • createOffer(merchantId, data) β†’ POST
  • getOffer(merchantId, offerId) β†’ GET
  • updateOffer(merchantId, offerId, data) β†’ PUT
  • deleteOffer(merchantId, offerId) β†’ DELETE

Also add convenience exports in apps/admin/src/firebase.js (matching the existing pattern where getMerchantData delegates to authApi).

Data flow ​

  • Form state is local to OfferForm.jsx via useState. No Firestore listeners β€” offers are fetched on tab mount and after mutations.
  • Preview reads form state via offerNormalizer (pure function, no side effects).
  • List fetches via GET /merchants/:merchantId/offers on mount and after create/edit/delete. Status filter is a client-side query param passed to the API.
  • Venue list for the dropdown comes from useOutletContext() β€” MerchantDetail already fetches and exposes venues to all tabs.

Error handling ​

CaseBehavior
No venues linked to merchantForm disabled, shows "Link a venue before creating an offer" with Venues tab link
Venue validation fails on createAPI returns 400 "Venue does not belong to this merchant" β†’ form shows inline error
Network error on saveInline error below form actions, offer data preserved in form state
Offer not found (detail view)"Offer not found" message with Back to list button
Unauthorized (merchant accessing another merchant's offers)API returns 403 β†’ redirect to own merchant detail
Duplicate titleNot enforced β€” merchants can have offers with the same title

Testing plan ​

Automated tests:

SurfaceTest fileCoverage
Merchants API routesservices/api/merchants/src/__tests__/offers.test.jsCRUD operations, auth gating (admin vs merchant vs unauthorized), venue validation, soft/hard delete, auto-expiration on read, status filtering
offerNormalizer.jsapps/admin/src/lib/__tests__/offerNormalizer.test.jsForm→display shape transformation (copy from web app tests if they exist, otherwise write new)
MerchantOffersTab.jsxapps/admin/src/components/merchants/__tests__/MerchantOffersTab.test.jsxList rendering, status filter switching, create/edit view transitions

Manual verification:

  1. Admin: navigate to any merchant β†’ Offers tab. Create an offer with all fields. Verify it appears in the list with correct status.
  2. Admin: edit an existing offer. Verify changes persist after save.
  3. Admin: delete a draft β†’ document removed from Firestore. Delete an active offer β†’ status set to archived.
  4. Admin: verify live preview updates as form fields change. Verify selected placement is highlighted.
  5. Merchant-role: log in β†’ navigate to own merchant β†’ Offers tab. Verify can create/edit/delete own offers.
  6. Merchant-role: attempt to access another merchant's offers via URL β†’ verify redirect.
  7. Offer with past expiresAt β†’ verify list shows as "Expired" even if Firestore document says "active".
  8. Merchant with no venues β†’ verify form shows "Link a venue first" message.
  9. npm run validate -- --workspace apps/admin passes.
  10. npm run validate -- --workspace services/api/merchants passes.

Files touched ​

New files ​

PathResponsibility
services/api/merchants/New Express.js API service (full scaffold)
services/api/merchants/src/index.jsServer entry point
services/api/merchants/src/middleware/auth.jsFirebase auth + role-checking middleware
services/api/merchants/src/routes/offers.jsOffer CRUD route handlers
services/api/merchants/openapi.jsonOpenAPI spec
services/api/merchants/package.jsonService dependencies
apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsxOffers tab root
apps/admin/src/components/merchants/tabs/OffersList.jsxList view with status filters
apps/admin/src/components/merchants/tabs/OfferForm.jsxCreate/edit form + preview
apps/admin/src/components/merchants/tabs/OfferDetail.jsxRead-only detail view
apps/admin/src/components/offers/AdSlot.jsxPlacement renderer (copied from web app)
apps/admin/src/components/offers/OfferCards.jsxDisplay components (copied from web app)
apps/admin/src/components/StyledSelect.jsxreact-select wrapper with admin theme
apps/admin/src/lib/offerNormalizer.jsForm→display transformer (copied from web app)
apps/admin/src/lib/merchantsApi.jsAPI client for merchants service

Modified files ​

PathChange
apps/admin/src/components/merchants/MerchantDetail.jsxAdd offers to DETAIL_TABS with dynamic count. Add data fetching for offers count.
apps/admin/src/components/AdminDashboard.jsxAdd <Route path="offers" element={<MerchantOffersTab />} /> inside the :merchantId route block.
apps/admin/src/firebase.jsAdd convenience exports for offer API functions.
apps/admin/package.jsonAdd react-select dependency.
packages/shared/services/index.jsRegister the new merchants API service.

Out of scope ​

  • Wire web app to real API (sub-project #2). The web app's OfferForm stays stubbed until this lands and the shared extraction happens.
  • Extract AdSlot/OfferCards/offerNormalizer to @lantern/shared (sub-project #2). For now, copies live in both apps.
  • Analytics dashboard (sub-project #3). The analytics-api already has getOfferAnalytics scaffolded; wiring it to real data is a separate effort.
  • Redemption flow (QR codes, code delivery, merchant verification). Separate project entirely.
  • Offer targeting enforcement (geofence filtering, per-user limit tracking, audience segmentation). The form captures these fields; enforcement is a backend concern for sub-project #2.
  • Cloud Function for offer expiration events. Auto-expiration on read is sufficient for now.
  • Removing OfferForm from the web app. Happens in sub-project #2 after the admin version is validated.

Risks ​

  • react-select bundle size. Adding a new dependency to the admin app increases the bundle. The admin app is internal-facing so this is acceptable, but worth noting. The dependency is ~25KB gzipped.
  • Preview component drift. Copies of AdSlot/OfferCards in admin will diverge from the web app versions until extracted to @lantern/shared. Mitigated by doing sub-project #2 soon after this lands.
  • Firestore rules mismatch. The existing offers rules gate on merchantId == request.auth.uid. After the merchant entity migration, this should be the merchant entity ID, not the user UID. The new API uses Firebase Admin SDK (bypasses rules), so this doesn't block us, but the rules should be updated for any future client-side writes.
  • Port 8085 collision. Verify 8085 is not in use by checking tooling/vscode-extension/src/config.js PORT_MAP during implementation.

Built with VitePress