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
OfferFormfield set β including a live placement preview sidebar. - Offer lifecycle:
draftβactiveβexpired(auto-expiration on read). react-selectis 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:
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /merchants/:merchantId/offers | admin or owner | List offers, filterable by ?status=active|draft|expired|archived |
POST | /merchants/:merchantId/offers | admin or owner | Create offer |
GET | /merchants/:merchantId/offers/:offerId | admin or owner | Get single offer |
PUT | /merchants/:merchantId/offers/:offerId | admin or owner | Update offer |
DELETE | /merchants/:merchantId/offers/:offerId | admin or owner | Hard 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
:merchantIdmatchesusers/{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):
| Field | Type | Description |
|---|---|---|
merchantId | string | Merchant entity ID (from route param) |
venueId | string | Linked venue ID (validated against merchant's venues) |
title | string | Short headline shown to users, e.g. "20% off brunch" |
description | string | Redemption rules and terms |
placement | string | hero | inline | chat | feed |
targetAudience | string | nearby | lantern | frequent | new |
radius | number | Geofence radius in meters (10β2500) |
per_user_limit | number | Max redemptions per user (default 1) |
budget | number | Campaign budget in USD |
expiresAt | timestamp | When the offer expires |
showDisclaimerWhileSuppliesLast | boolean | Whether to show "While supplies last" footer |
Auto-set fields (managed by the API):
| Field | Type | Description |
|---|---|---|
status | string | draft | active | expired | archived |
createdAt | timestamp | Server timestamp on creation |
createdBy | string | UID of the user who created the offer |
updatedAt | timestamp | Server 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:
{ 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:
<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-tabspattern fromUserManagement.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-smin 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:
| Field | Input type | Validation |
|---|---|---|
| Venue | react-select dropdown (merchant's linked venues) | Required |
| Title | Text input | Required |
| Description | Textarea | Required |
| Placement | react-select dropdown (hero/inline/chat/feed) | Required |
| Target Audience | react-select dropdown (nearby/lantern/frequent/new) | Required |
| Geofence Radius | Number input (10β2500) | Required |
| Per User Limit | Number input (default 1) | Required |
| Budget ($) | Number input (min 1) | Required |
| Expires | Date input | Required |
| While Supplies Last | Checkbox | Optional |
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:
| Button | Behavior |
|---|---|
| Cancel | Returns to the offers list. Discards unsaved changes. |
| Save Draft | Submits with status: 'draft'. Not visible to app users. |
| Publish | Submits 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:
| State | Actions |
|---|---|
| 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.jsFollows the same pattern as apps/admin/src/lib/authApi.js β authenticated fetch wrapper that includes the Firebase ID token. Functions:
listOffers(merchantId, { status? })β GETcreateOffer(merchantId, data)β POSTgetOffer(merchantId, offerId)β GETupdateOffer(merchantId, offerId, data)β PUTdeleteOffer(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.jsxviauseState. 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/offerson 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()βMerchantDetailalready fetches and exposesvenuesto all tabs.
Error handling β
| Case | Behavior |
|---|---|
| No venues linked to merchant | Form disabled, shows "Link a venue before creating an offer" with Venues tab link |
| Venue validation fails on create | API returns 400 "Venue does not belong to this merchant" β form shows inline error |
| Network error on save | Inline 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 title | Not enforced β merchants can have offers with the same title |
Testing plan β
Automated tests:
| Surface | Test file | Coverage |
|---|---|---|
| Merchants API routes | services/api/merchants/src/__tests__/offers.test.js | CRUD operations, auth gating (admin vs merchant vs unauthorized), venue validation, soft/hard delete, auto-expiration on read, status filtering |
offerNormalizer.js | apps/admin/src/lib/__tests__/offerNormalizer.test.js | Formβdisplay shape transformation (copy from web app tests if they exist, otherwise write new) |
MerchantOffersTab.jsx | apps/admin/src/components/merchants/__tests__/MerchantOffersTab.test.jsx | List rendering, status filter switching, create/edit view transitions |
Manual verification:
- Admin: navigate to any merchant β Offers tab. Create an offer with all fields. Verify it appears in the list with correct status.
- Admin: edit an existing offer. Verify changes persist after save.
- Admin: delete a draft β document removed from Firestore. Delete an active offer β status set to archived.
- Admin: verify live preview updates as form fields change. Verify selected placement is highlighted.
- Merchant-role: log in β navigate to own merchant β Offers tab. Verify can create/edit/delete own offers.
- Merchant-role: attempt to access another merchant's offers via URL β verify redirect.
- Offer with past
expiresAtβ verify list shows as "Expired" even if Firestore document says "active". - Merchant with no venues β verify form shows "Link a venue first" message.
npm run validate -- --workspace apps/adminpasses.npm run validate -- --workspace services/api/merchantspasses.
Files touched β
New files β
| Path | Responsibility |
|---|---|
services/api/merchants/ | New Express.js API service (full scaffold) |
services/api/merchants/src/index.js | Server entry point |
services/api/merchants/src/middleware/auth.js | Firebase auth + role-checking middleware |
services/api/merchants/src/routes/offers.js | Offer CRUD route handlers |
services/api/merchants/openapi.json | OpenAPI spec |
services/api/merchants/package.json | Service dependencies |
apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsx | Offers tab root |
apps/admin/src/components/merchants/tabs/OffersList.jsx | List view with status filters |
apps/admin/src/components/merchants/tabs/OfferForm.jsx | Create/edit form + preview |
apps/admin/src/components/merchants/tabs/OfferDetail.jsx | Read-only detail view |
apps/admin/src/components/offers/AdSlot.jsx | Placement renderer (copied from web app) |
apps/admin/src/components/offers/OfferCards.jsx | Display components (copied from web app) |
apps/admin/src/components/StyledSelect.jsx | react-select wrapper with admin theme |
apps/admin/src/lib/offerNormalizer.js | Formβdisplay transformer (copied from web app) |
apps/admin/src/lib/merchantsApi.js | API client for merchants service |
Modified files β
| Path | Change |
|---|---|
apps/admin/src/components/merchants/MerchantDetail.jsx | Add offers to DETAIL_TABS with dynamic count. Add data fetching for offers count. |
apps/admin/src/components/AdminDashboard.jsx | Add <Route path="offers" element={<MerchantOffersTab />} /> inside the :merchantId route block. |
apps/admin/src/firebase.js | Add convenience exports for offer API functions. |
apps/admin/package.json | Add react-select dependency. |
packages/shared/services/index.js | Register the new merchants API service. |
Out of scope β
- Wire web app to real API (sub-project #2). The web app's
OfferFormstays stubbed until this lands and the shared extraction happens. - Extract
AdSlot/OfferCards/offerNormalizerto@lantern/shared(sub-project #2). For now, copies live in both apps. - Analytics dashboard (sub-project #3). The
analytics-apialready hasgetOfferAnalyticsscaffolded; 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
OfferFormfrom the web app. Happens in sub-project #2 after the admin version is validated.
Risks β
react-selectbundle 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/OfferCardsin 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
offersrules gate onmerchantId == 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.jsPORT_MAPduring implementation.