Offers CRUD + Admin Portal 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: Build a dedicated Merchants API service with offer CRUD endpoints and add an Offers tab to the admin portal's MerchantDetail page โ including a create/edit form with live placement preview.
Architecture: Two subsystems. (A) A new Express.js API service at services/api/merchants/ (port 8085) handling offer CRUD with Firebase Auth gating. (B) A new Offers tab in MerchantDetail with list, create/edit, and detail views. The create/edit form includes all fields from the web app's OfferForm plus a live placement preview using copies of AdSlot/OfferCards/offerNormalizer from apps/web/, restyled for the admin portal's custom CSS. react-select is added as the standard dropdown component.
Tech Stack: Express.js 5, Firebase Admin SDK, Zod validation, Pino logging, Scalar API docs, React 19, react-select, lucide-react, Vitest + Testing Library React.
Spec: docs/superpowers/specs/2026-04-25-offers-crud-admin-design.md
Before you start โ
- Branch. Work on a new branch off
dev:claude/offers-crud-admin. - Reference files. The service scaffold follows
services/api/auth/exactly. The admin portal follows existing patterns inapps/admin/src/components/merchants/tabs/. - Port 8085 is confirmed available in
tooling/vscode-extension/src/config.jsPORT_MAP. - Dependencies. You'll need to install
react-selectinapps/admin/(Task 6) and set upservices/api/merchants/with its ownpackage.json(Task 1).
File structure โ
New files โ Backend (services/api/merchants/) โ
| Path | Responsibility |
|---|---|
services/api/merchants/package.json | Service manifest, ESM, Node โฅ22 |
services/api/merchants/src/index.js | Express server: CORS, Pino, Firebase Admin init, route mounting, Scalar docs |
services/api/merchants/src/middleware/auth.js | verifyFirebaseToken + requireMerchantAccess(req, res, next) โ checks admin role or merchantId ownership |
services/api/merchants/src/routes/offers.js | 5 route handlers: list, create, get, update, delete |
services/api/merchants/src/routes/health.js | GET /health โ { status: 'ok' } |
services/api/merchants/openapi.json | OpenAPI 3.0.3 spec |
services/api/merchants/src/__tests__/offers.test.js | Integration tests for all 5 endpoints |
New files โ Frontend (apps/admin/) โ
| Path | Responsibility |
|---|---|
apps/admin/src/lib/merchantsApi.js | API client: listOffers, createOffer, getOffer, updateOffer, deleteOffer |
apps/admin/src/lib/offerNormalizer.js | Formโdisplay transformer (copied from apps/web/src/lib/offerNormalizer.js) |
apps/admin/src/components/StyledSelect.jsx | react-select wrapper with admin dark theme |
apps/admin/src/components/offers/AdSlot.jsx | Placement renderer (adapted from apps/web/, restyled for admin CSS) |
apps/admin/src/components/offers/OfferCards.jsx | 4 display components (adapted from apps/web/, restyled for admin CSS) |
apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsx | Tab root: view state management (list / create / edit / detail) |
apps/admin/src/components/merchants/tabs/OffersList.jsx | Status filters + offer rows |
apps/admin/src/components/merchants/tabs/OfferForm.jsx | Create/edit form + preview sidebar |
apps/admin/src/components/merchants/tabs/OfferDetail.jsx | Read-only view with actions |
Modified files โ
| Path | Change |
|---|---|
packages/shared/services/index.js | Add merchants-api entry (slug, name, port 8085) |
tooling/vscode-extension/src/config.js | Add Merchants API to PORT_MAP |
apps/admin/package.json | Add react-select dependency |
apps/admin/src/firebase.js | Add offer API convenience exports |
apps/admin/src/components/merchants/MerchantDetail.jsx | Add offers to DETAIL_TABS, fetch offer count |
apps/admin/src/components/AdminDashboard.jsx | Add <Route path="offers"> inside :merchantId block |
apps/admin/src/styles.css | Add offer list, form, detail, preview, and StyledSelect styles |
Task 1: Scaffold the Merchants API service โ
Files:
Create:
services/api/merchants/package.jsonCreate:
services/api/merchants/src/index.jsCreate:
services/api/merchants/src/routes/health.jsCreate:
services/api/merchants/src/middleware/auth.jsCreate:
services/api/merchants/openapi.jsonModify:
packages/shared/services/index.jsModify:
tooling/vscode-extension/src/config.js[ ] Step 1: Create
package.json
Create services/api/merchants/package.json:
{
"name": "lantern-merchants-api",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"start": "node src/index.js",
"dev": "node --env-file=../../../.env.local --watch src/index.js",
"test": "vitest run"
},
"dependencies": {
"@lantern/shared": "file:../../../packages/shared",
"@scalar/express-api-reference": "^0.8.40",
"express": "^5.2.1",
"firebase-admin": "^13.6.1",
"pino": "^10.3.0",
"pino-http": "^11.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"vitest": "^3.2.1"
}
}- [ ] Step 2: Create the health route
Create services/api/merchants/src/routes/health.js:
import { Router } from 'express'
const router = Router()
router.get('/health', (_req, res) => {
res.json({ status: 'ok', service: 'merchants-api', timestamp: new Date().toISOString() })
})
export default router- [ ] Step 3: Create the auth middleware
Create services/api/merchants/src/middleware/auth.js:
import { getAuth } from 'firebase-admin/auth'
import { getFirestore } from 'firebase-admin/firestore'
/**
* Verify the Firebase ID token from the Authorization header.
* Attaches { uid, email } to req.user on success.
*/
export async function verifyFirebaseToken(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' })
}
try {
const token = authHeader.split('Bearer ')[1]
const decoded = await getAuth().verifyIdToken(token)
req.user = { uid: decoded.uid, email: decoded.email || null }
next()
} catch (err) {
req.log?.error({ err }, 'Token verification failed')
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid or expired token' })
}
}
/**
* Check that the authenticated user is either:
* - An admin (can access any merchant's resources), or
* - A merchant whose merchantId matches the route :merchantId param.
*
* Attaches req.isAdmin (boolean) for downstream use.
*/
export async function requireMerchantAccess(req, res, next) {
const { uid } = req.user
const routeMerchantId = req.params.merchantId
try {
// Check admin via custom claims first, then Firestore fallback
const userRecord = await getAuth().getUser(uid)
const claims = userRecord.customClaims || {}
if (claims.role === 'admin') {
req.isAdmin = true
return next()
}
// Check merchant role โ claims or Firestore
const isMerchant = claims.role === 'merchant'
if (isMerchant || !claims.role) {
// Verify via Firestore: user's merchantId must match route param
const db = getFirestore()
const userDoc = await db.collection('users').doc(uid).get()
const userData = userDoc.data()
if (userData?.role === 'admin') {
req.isAdmin = true
return next()
}
if ((userData?.role === 'merchant' || isMerchant) && userData?.merchantId === routeMerchantId) {
req.isAdmin = false
return next()
}
}
return res.status(403).json({ error: 'FORBIDDEN', message: 'You do not have access to this merchant' })
} catch (err) {
req.log?.error({ err }, 'Access check failed')
return res.status(500).json({ error: 'INTERNAL', message: 'Failed to verify access' })
}
}- [ ] Step 4: Create the Express server
Create services/api/merchants/src/index.js:
import express from 'express'
import { readFileSync } from 'fs'
import { initializeApp, getApps } from 'firebase-admin/app'
import pino from 'pino'
import pinoHttp from 'pino-http'
import { apiReference } from '@scalar/express-api-reference'
import { ALLOWED_ORIGINS } from '@lantern/shared/services'
import healthRouter from './routes/health.js'
import offersRouter from './routes/offers.js'
import { verifyFirebaseToken, requireMerchantAccess } from './middleware/auth.js'
// Firebase Admin โ idempotent init
if (getApps().length === 0) initializeApp()
const app = express()
const logger = pino({ level: process.env.LOG_LEVEL || 'info' })
const PORT = process.env.PORT || 8085
// Trust Cloud Run load balancer
app.set('trust proxy', true)
// Logging
app.use(pinoHttp({ logger, autoLogging: { ignore: (req) => req.url === '/health' } }))
// CORS
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
}
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
})
// Body parsing
app.use(express.json())
// Public routes
app.use(healthRouter)
// OpenAPI spec
const spec = JSON.parse(readFileSync(new URL('../openapi.json', import.meta.url), 'utf-8'))
app.get('/openapi.json', (_req, res) => {
const liveSpec = { ...spec, servers: [{ url: `http://localhost:${PORT}`, description: 'Local' }] }
res.json(liveSpec)
})
app.use('/api-docs', apiReference({ spec: { url: '/openapi.json' } }))
// Authenticated routes
app.use('/merchants/:merchantId', verifyFirebaseToken, requireMerchantAccess, offersRouter)
// Error handler
app.use((err, req, res, _next) => {
req.log?.error({ err }, 'Unhandled error')
res.status(err.status || 500).json({
error: err.code || 'INTERNAL',
message: err.message || 'Internal server error',
})
})
app.listen(PORT, () => logger.info(`Merchants API listening on :${PORT}`))- [ ] Step 5: Create a minimal OpenAPI spec
Create services/api/merchants/openapi.json:
{
"openapi": "3.0.3",
"info": {
"title": "Lantern Merchants API",
"description": "Cloud Run service for merchant business operations โ offers, campaigns, and merchant-scoped resources.",
"version": "1.0.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", "description": "Offer CRUD scoped to a merchant. Requires authentication and merchant access." }
],
"paths": {
"/health": {
"get": {
"tags": ["Health"],
"summary": "Health check",
"responses": {
"200": {
"description": "Service is healthy",
"content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string" }, "service": { "type": "string" } } } } }
}
}
}
}
}
}OpenAPI paths for the offer endpoints will be added as we build them in Task 2. The spec is functional for Scalar docs now.
- [ ] Step 6: Register the service
Add to packages/shared/services/index.js in the API_SERVICES array:
{
slug: 'merchants-api',
name: 'Merchants API',
description: 'Merchant business operations โ offer CRUD, campaigns, and merchant-scoped resources',
devUrl: null,
prodUrl: null,
localDevPort: 8085,
},Add to tooling/vscode-extension/src/config.js in the PORT_MAP.apis array:
{ label: 'Merchants API', port: 8085, script: 'merchants-api:dev' },- [ ] Step 7: Install dependencies and verify server starts
Run:
cd services/api/merchants && npm install
npm run devExpected: server starts on port 8085. In another terminal:
curl http://localhost:8085/healthExpected: {"status":"ok","service":"merchants-api",...}
Stop the dev server (Ctrl+C).
- [ ] Step 8: Commit
git add services/api/merchants/ packages/shared/services/index.js tooling/vscode-extension/src/config.js
git commit -m "feat(merchants-api): scaffold new Merchants API service on port 8085
Express.js service with Firebase Admin, Pino logging, CORS, Scalar docs,
and auth middleware (verifyFirebaseToken + requireMerchantAccess). Health
endpoint at GET /health. Registered in shared services index."Task 2: Offer CRUD route handlers (TDD) โ
Files:
- Create:
services/api/merchants/src/routes/offers.js - Create:
services/api/merchants/src/__tests__/offers.test.js
This is the core backend logic. TDD approach: write tests first, then implement.
- [ ] Step 1: Create the offers route stub
Create services/api/merchants/src/routes/offers.js:
import { Router } from 'express'
import { getFirestore, FieldValue } from 'firebase-admin/firestore'
import { z } from 'zod'
const router = Router()
const db = () => getFirestore()
const VALID_PLACEMENTS = ['hero', 'inline', 'chat', 'feed']
const VALID_AUDIENCES = ['nearby', 'lantern', 'frequent', 'new']
const VALID_STATUSES = ['draft', 'active', 'expired', 'archived']
const CreateOfferSchema = z.object({
venueId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().min(1).max(2000),
placement: z.enum(VALID_PLACEMENTS),
targetAudience: z.enum(VALID_AUDIENCES),
radius: z.number().int().min(10).max(2500),
per_user_limit: z.number().int().min(1).default(1),
budget: z.number().min(1),
expiresAt: z.string().datetime(),
showDisclaimerWhileSuppliesLast: z.boolean().default(false),
status: z.enum(['draft', 'active']).default('draft'),
})
const UpdateOfferSchema = CreateOfferSchema.partial().extend({
status: z.enum(['draft', 'active']).optional(),
})
// POST /merchants/:merchantId/offers
router.post('/offers', async (req, res, next) => {
try {
const merchantId = req.params.merchantId
const parsed = CreateOfferSchema.parse(req.body)
// Validate venue belongs to merchant
const merchantDoc = await db().collection('merchants').doc(merchantId).get()
if (!merchantDoc.exists) {
return res.status(404).json({ error: 'NOT_FOUND', message: 'Merchant not found' })
}
const merchantData = merchantDoc.data()
if (!merchantData.venueIds?.includes(parsed.venueId)) {
return res.status(400).json({ error: 'INVALID_VENUE', message: 'Venue does not belong to this merchant' })
}
const offerData = {
...parsed,
merchantId,
expiresAt: new Date(parsed.expiresAt),
createdAt: FieldValue.serverTimestamp(),
createdBy: req.user.uid,
updatedAt: FieldValue.serverTimestamp(),
}
const docRef = await db().collection('offers').add(offerData)
res.status(201).json({ id: docRef.id, ...offerData, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })
} catch (err) {
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'VALIDATION', message: 'Invalid offer data', details: err.errors })
}
next(err)
}
})
// GET /merchants/:merchantId/offers
router.get('/offers', async (req, res, next) => {
try {
const merchantId = req.params.merchantId
const statusFilter = req.query.status
let query = db().collection('offers').where('merchantId', '==', merchantId)
if (statusFilter && VALID_STATUSES.includes(statusFilter) && statusFilter !== 'expired') {
query = query.where('status', '==', statusFilter)
}
const snapshot = await query.orderBy('createdAt', 'desc').get()
const now = new Date()
const offers = snapshot.docs
.map((doc) => {
const data = doc.data()
// Auto-expire: if expiresAt is in the past and status is active, report as expired
const expiresAt = data.expiresAt?.toDate?.() || new Date(data.expiresAt)
const effectiveStatus = (data.status === 'active' && expiresAt < now) ? 'expired' : data.status
return {
id: doc.id,
...data,
status: effectiveStatus,
expiresAt: expiresAt.toISOString(),
createdAt: data.createdAt?.toDate?.()?.toISOString() || null,
updatedAt: data.updatedAt?.toDate?.()?.toISOString() || null,
}
})
.filter((offer) => {
// If filtering by expired, only include auto-expired offers
if (statusFilter === 'expired') return offer.status === 'expired'
// If filtering by active, exclude auto-expired
if (statusFilter === 'active') return offer.status === 'active'
// Default (no filter or 'all'): exclude archived
if (!statusFilter) return offer.status !== 'archived'
return true
})
res.json({ offers, total: offers.length })
} catch (err) {
next(err)
}
})
// GET /merchants/:merchantId/offers/:offerId
router.get('/offers/:offerId', async (req, res, next) => {
try {
const { offerId } = req.params
const doc = await db().collection('offers').doc(offerId).get()
if (!doc.exists) {
return res.status(404).json({ error: 'NOT_FOUND', message: 'Offer not found' })
}
const data = doc.data()
if (data.merchantId !== req.params.merchantId) {
return res.status(404).json({ error: 'NOT_FOUND', message: 'Offer not found' })
}
const expiresAt = data.expiresAt?.toDate?.() || new Date(data.expiresAt)
const effectiveStatus = (data.status === 'active' && expiresAt < new Date()) ? 'expired' : data.status
res.json({
id: doc.id,
...data,
status: effectiveStatus,
expiresAt: expiresAt.toISOString(),
createdAt: data.createdAt?.toDate?.()?.toISOString() || null,
updatedAt: data.updatedAt?.toDate?.()?.toISOString() || null,
})
} catch (err) {
next(err)
}
})
// PUT /merchants/:merchantId/offers/:offerId
router.put('/offers/:offerId', async (req, res, next) => {
try {
const { merchantId, offerId } = req.params
const parsed = UpdateOfferSchema.parse(req.body)
const docRef = db().collection('offers').doc(offerId)
const doc = await docRef.get()
if (!doc.exists || doc.data().merchantId !== merchantId) {
return res.status(404).json({ error: 'NOT_FOUND', message: 'Offer not found' })
}
// If updating venueId, validate it belongs to merchant
if (parsed.venueId) {
const merchantDoc = await db().collection('merchants').doc(merchantId).get()
if (!merchantDoc.data()?.venueIds?.includes(parsed.venueId)) {
return res.status(400).json({ error: 'INVALID_VENUE', message: 'Venue does not belong to this merchant' })
}
}
// Convert expiresAt string to Date if provided
const updateData = { ...parsed, updatedAt: FieldValue.serverTimestamp() }
if (parsed.expiresAt) {
updateData.expiresAt = new Date(parsed.expiresAt)
}
await docRef.update(updateData)
const updated = await docRef.get()
const updatedData = updated.data()
const expiresAt = updatedData.expiresAt?.toDate?.() || new Date(updatedData.expiresAt)
res.json({
id: offerId,
...updatedData,
expiresAt: expiresAt.toISOString(),
createdAt: updatedData.createdAt?.toDate?.()?.toISOString() || null,
updatedAt: updatedData.updatedAt?.toDate?.()?.toISOString() || null,
})
} catch (err) {
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'VALIDATION', message: 'Invalid offer data', details: err.errors })
}
next(err)
}
})
// DELETE /merchants/:merchantId/offers/:offerId
router.delete('/offers/:offerId', async (req, res, next) => {
try {
const { merchantId, offerId } = req.params
const docRef = db().collection('offers').doc(offerId)
const doc = await docRef.get()
if (!doc.exists || doc.data().merchantId !== merchantId) {
return res.status(404).json({ error: 'NOT_FOUND', message: 'Offer not found' })
}
const data = doc.data()
if (data.status === 'draft') {
// Hard delete drafts
await docRef.delete()
res.json({ deleted: true, hard: true })
} else {
// Soft delete: set status to archived
await docRef.update({ status: 'archived', updatedAt: FieldValue.serverTimestamp() })
res.json({ deleted: true, hard: false, status: 'archived' })
}
} catch (err) {
next(err)
}
})
export default router- [ ] Step 2: Verify the server starts with the offers route
Run:
cd services/api/merchants && npm run devExpected: starts without import errors. Stop the server.
- [ ] Step 3: Commit
git add services/api/merchants/src/routes/offers.js
git commit -m "feat(merchants-api): add offer CRUD route handlers
Five endpoints: POST (create), GET list (with status filtering and auto-
expiration), GET single, PUT (update), DELETE (hard delete for drafts,
soft delete/archive for published). Zod validation on create/update.
Venue ownership validated against the merchant's venueIds array."Task 3: Register service in admin and add API client โ
Files:
Create:
apps/admin/src/lib/merchantsApi.jsModify:
apps/admin/src/firebase.js[ ] Step 1: Create the API client
Create apps/admin/src/lib/merchantsApi.js:
const API_BASE_URL = import.meta.env.VITE_MERCHANTS_API_URL || 'http://localhost:8085'
async function parseResponse(response) {
const text = await response.text()
let data
try {
data = text ? JSON.parse(text) : {}
} catch {
data = { message: text }
}
if (!response.ok) {
const error = new Error(data.message || `Merchants API request failed (${response.status})`)
error.status = response.status
error.details = data
throw error
}
return data
}
async function authRequest(path, options = {}) {
const { getAuth } = await import('firebase/auth')
const { auth } = await import('../firebase')
const user = getAuth(auth.app).currentUser
if (!user) throw new Error('Not authenticated')
const token = await user.getIdToken()
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
})
return parseResponse(response)
}
export async function listOffers(merchantId, { status } = {}) {
const params = status ? `?status=${status}` : ''
return authRequest(`/merchants/${merchantId}/offers${params}`)
}
export async function createOffer(merchantId, data) {
return authRequest(`/merchants/${merchantId}/offers`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function getOffer(merchantId, offerId) {
return authRequest(`/merchants/${merchantId}/offers/${offerId}`)
}
export async function updateOffer(merchantId, offerId, data) {
return authRequest(`/merchants/${merchantId}/offers/${offerId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteOffer(merchantId, offerId) {
return authRequest(`/merchants/${merchantId}/offers/${offerId}`, {
method: 'DELETE',
})
}- [ ] Step 2: Add convenience exports in
firebase.js
Open apps/admin/src/firebase.js. Add at the end of the file, near the existing getMerchantData export:
/**
* Offer CRUD โ delegates to the Merchants API.
*/
export async function listMerchantOffers(merchantId, opts) {
const api = await import('./lib/merchantsApi')
return api.listOffers(merchantId, opts)
}
export async function createMerchantOffer(merchantId, data) {
const api = await import('./lib/merchantsApi')
return api.createOffer(merchantId, data)
}
export async function getMerchantOffer(merchantId, offerId) {
const api = await import('./lib/merchantsApi')
return api.getOffer(merchantId, offerId)
}
export async function updateMerchantOffer(merchantId, offerId, data) {
const api = await import('./lib/merchantsApi')
return api.updateOffer(merchantId, offerId, data)
}
export async function deleteMerchantOffer(merchantId, offerId) {
const api = await import('./lib/merchantsApi')
return api.deleteOffer(merchantId, offerId)
}- [ ] Step 3: Commit
git add apps/admin/src/lib/merchantsApi.js apps/admin/src/firebase.js
git commit -m "feat(admin): add merchants API client for offer CRUD
Authenticated fetch wrapper following the authApi.js pattern. Convenience
exports in firebase.js match the existing getMerchantData delegation
pattern. Configurable via VITE_MERCHANTS_API_URL, defaults to localhost:8085."Task 4: Add react-select and StyledSelect wrapper โ
Files:
Modify:
apps/admin/package.jsonCreate:
apps/admin/src/components/StyledSelect.jsxModify:
apps/admin/src/styles.css[ ] Step 1: Install
react-select
Run:
cd /home/mechelle/repos/lantern_app && npm install react-select -w apps/admin- [ ] Step 2: Create
StyledSelectwrapper
Create apps/admin/src/components/StyledSelect.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import Select from 'react-select'
const darkStyles = {
control: (base, state) => ({
...base,
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderColor: state.isFocused ? 'var(--accent-600)' : 'rgba(255, 255, 255, 0.1)',
borderRadius: '6px',
padding: '2px 4px',
color: 'var(--text)',
boxShadow: 'none',
minHeight: '38px',
'&:hover': { borderColor: 'rgba(255, 255, 255, 0.2)' },
}),
menu: (base) => ({
...base,
backgroundColor: 'var(--surface)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '6px',
zIndex: 20,
}),
option: (base, state) => ({
...base,
backgroundColor: state.isSelected
? 'rgba(245, 158, 11, 0.2)'
: state.isFocused
? 'rgba(255, 255, 255, 0.05)'
: 'transparent',
color: 'var(--text)',
cursor: 'pointer',
'&:active': { backgroundColor: 'rgba(245, 158, 11, 0.15)' },
}),
singleValue: (base) => ({ ...base, color: 'var(--text)' }),
placeholder: (base) => ({ ...base, color: 'var(--muted)' }),
input: (base) => ({ ...base, color: 'var(--text)' }),
indicatorSeparator: () => ({ display: 'none' }),
dropdownIndicator: (base) => ({ ...base, color: 'var(--muted)', '&:hover': { color: 'var(--text)' } }),
}
export default function StyledSelect(props) {
return (
<Select
styles={darkStyles}
{...props}
/>
)
}- [ ] Step 3: Commit
git add apps/admin/package.json apps/admin/src/components/StyledSelect.jsx package-lock.json
git commit -m "feat(admin): add react-select with dark-themed StyledSelect wrapper
Adopted as the standard dropdown component for the admin portal. Themed
to match the admin dark UI with amber accent on selection and focus."Task 5: Copy and adapt offer preview components โ
Files:
- Create:
apps/admin/src/lib/offerNormalizer.js - Create:
apps/admin/src/components/offers/AdSlot.jsx - Create:
apps/admin/src/components/offers/OfferCards.jsx - Modify:
apps/admin/src/styles.css
These are adapted copies of apps/web/ components, restyled from Tailwind to admin CSS.
- [ ] Step 1: Copy
offerNormalizer.js
Read apps/web/src/lib/offerNormalizer.js and create apps/admin/src/lib/offerNormalizer.js with identical logic. This is a pure function โ no styling, no React, no imports that need changing. Copy it verbatim.
- [ ] Step 2: Create
OfferCards.jsxfor admin
Create apps/admin/src/components/offers/OfferCards.jsx. This adapts the 4 card components from the web app's Tailwind classes to the admin portal's custom CSS. Read apps/web/src/components/dashboard/OfferCards.jsx for the full structure, then rewrite each component using admin CSS classes and inline styles matching the admin dark theme.
Key differences from the web app version:
Replace all Tailwind classes with admin CSS classes or inline styles
Remove
flash.track()analytics calls (admin preview doesn't track)Remove
onSelectclick handlers (preview only, no navigation)Keep the same visual structure and amber color scheme
Export all 4 components:
OfferPill,ChatOfferPill,FeedOfferCard,HeroOfferCard[ ] Step 3: Create
AdSlot.jsxfor admin
Create apps/admin/src/components/offers/AdSlot.jsx. Simplified version of the web app's AdSlot โ preview-only mode (no click handlers, no analytics). Read apps/web/src/components/dashboard/AdSlot.jsx for the factory pattern, then rewrite using admin CSS.
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { OfferPill, ChatOfferPill, FeedOfferCard, HeroOfferCard } from './OfferCards'
const PLACEMENT_LABELS = {
hero: 'Dashboard Hero Rail',
inline: 'Inline Venue Card',
chat: 'Chat Assist Pill',
feed: 'Feed Insertion',
}
const RENDERERS = {
hero: (offer) => <HeroOfferCard offer={offer} />,
inline: (offer) => <OfferPill offer={offer} />,
chat: (offer) => <ChatOfferPill offer={offer} />,
feed: (offer) => <FeedOfferCard offer={offer} />,
}
export default function AdSlot({ offer }) {
const placement = offer?.placement || 'hero'
const Render = RENDERERS[placement]
return (
<div className="offer-preview-slot">
<span className="offer-preview-slot__label">{PLACEMENT_LABELS[placement]}</span>
{Render ? Render(offer) : null}
</div>
)
}- [ ] Step 4: Add preview CSS to styles.css
Add to apps/admin/src/styles.css:
/* โโ Offer Preview (placement slots) โโ */
.offer-preview-slot {
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: var(--space-3);
margin-bottom: var(--space-3);
}
.offer-preview-slot__label {
display: block;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: var(--space-2);
padding: 2px 6px;
background: rgba(255, 255, 255, 0.03);
border-radius: 3px;
display: inline-block;
}
/* Hero offer card */
.offer-card-hero {
padding: var(--space-4);
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(234, 88, 12, 0.08));
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 12px;
}
.offer-card-hero__sponsored {
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin-bottom: var(--space-1);
}
.offer-card-hero__headline {
font-size: 1rem;
font-weight: 700;
margin-bottom: 2px;
}
.offer-card-hero__body {
font-size: 0.8125rem;
color: var(--muted);
}
.offer-card-hero__incentive {
display: inline-block;
margin-top: var(--space-2);
padding: 3px 8px;
background: rgba(245, 158, 11, 0.2);
border-radius: 4px;
font-size: 0.6875rem;
color: var(--accent-600);
font-weight: 600;
}
/* Inline offer pill */
.offer-pill {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
}
.offer-pill__headline {
font-size: 0.8125rem;
font-weight: 600;
}
.offer-pill__venue {
font-size: 0.6875rem;
color: var(--muted);
}
/* Chat offer pill */
.offer-chat-pill {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
font-size: 0.8125rem;
}
/* Feed offer card */
.offer-card-feed {
padding: var(--space-3);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
}
.offer-card-feed__sponsored {
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin-bottom: var(--space-1);
}
.offer-card-feed__headline {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 2px;
}
.offer-card-feed__body {
font-size: 0.8125rem;
color: var(--muted);
}- [ ] Step 5: Commit
git add apps/admin/src/lib/offerNormalizer.js apps/admin/src/components/offers/ apps/admin/src/styles.css
git commit -m "feat(admin): add offer preview components for placement preview
Adapted from apps/web/ OfferCards, AdSlot, and offerNormalizer. Restyled
from Tailwind to admin CSS. Preview-only mode (no analytics tracking or
click handlers). Will be extracted to @lantern/shared in sub-project #2."Task 6: OffersList component โ
Files:
Create:
apps/admin/src/components/merchants/tabs/OffersList.jsxModify:
apps/admin/src/styles.css[ ] Step 1: Create
OffersList.jsx
Create apps/admin/src/components/merchants/tabs/OffersList.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useEffect, useState } from 'react'
import { Tag, Plus } from 'lucide-react'
import { listMerchantOffers } from '../../../firebase'
const STATUS_FILTERS = [
{ id: null, label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'draft', label: 'Draft' },
{ id: 'expired', label: 'Expired' },
]
const STATUS_STYLES = {
active: { background: 'rgba(34, 197, 94, 0.15)', color: '#22C55E' },
draft: { background: 'rgba(255, 255, 255, 0.08)', color: '#999' },
expired: { background: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' },
archived: { background: 'rgba(255, 255, 255, 0.05)', color: '#666' },
}
export default function OffersList({ merchantId, venues, onCreateClick, onOfferClick }) {
const [offers, setOffers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [statusFilter, setStatusFilter] = useState(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
setLoading(true)
setError(null)
const result = await listMerchantOffers(merchantId, { status: statusFilter })
if (!cancelled) setOffers(result.offers || [])
} catch (err) {
if (!cancelled) setError(err.message || 'Failed to load offers')
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [merchantId, statusFilter])
const venueMap = Object.fromEntries((venues || []).map((v) => [v.venueId || v.id, v]))
if (loading) return <p className="text-muted">Loading offersโฆ</p>
if (error) return <div className="form-error">{error}</div>
return (
<>
<div className="user-list-toolbar">
<div className="sub-tabs" role="tablist">
{STATUS_FILTERS.map((f) => (
<button
key={f.id || 'all'}
role="tab"
aria-selected={statusFilter === f.id}
className={`sub-tab ${statusFilter === f.id ? 'active' : ''}`}
onClick={() => setStatusFilter(f.id)}
>
<span>{f.label}</span>
{f.id === null && <span className="sub-tab__count">{offers.length}</span>}
</button>
))}
</div>
<button className="btn btn-primary btn-sm" onClick={onCreateClick}>
<Plus size={14} />
Create Offer
</button>
</div>
{offers.length === 0 ? (
<div className="feature-card" style={{ maxWidth: 480, marginTop: 'var(--space-4)' }}>
<div className="feature-card-icon"><Tag size={32} /></div>
<h3>{statusFilter ? `No ${statusFilter} offers` : 'No offers yet'}</h3>
<p>
{statusFilter
? 'Try a different filter to find offers.'
: 'Create your first offer to start reaching nearby users.'}
</p>
{!statusFilter && (
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 'var(--space-3)' }}
onClick={onCreateClick}
>
<Plus size={14} />
Create Offer
</button>
)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', marginTop: 'var(--space-3)' }}>
{offers.map((offer) => {
const venue = venueMap[offer.venueId]
const style = STATUS_STYLES[offer.status] || STATUS_STYLES.draft
return (
<button
key={offer.id}
type="button"
className="form-card"
style={{ textAlign: 'left', cursor: 'pointer', border: '1px solid rgba(255,255,255,0.08)', padding: 'var(--space-3) var(--space-4)', background: 'transparent', width: '100%' }}
onClick={() => onOfferClick(offer)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-3)' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '2px' }}>{offer.title}</div>
<div className="text-muted" style={{ fontSize: '12px' }}>
{venue?.name || offer.venueId} ยท {offer.placement} ยท {offer.status === 'expired' ? 'Expired' : offer.expiresAt ? `Expires ${new Date(offer.expiresAt).toLocaleDateString()}` : 'No expiry'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexShrink: 0 }}>
<div className="text-muted" style={{ fontSize: '12px', textAlign: 'right' }}>
{offer.targetAudience === 'nearby' ? 'Nearby Users' : offer.targetAudience === 'lantern' ? 'Lantern Holders' : offer.targetAudience === 'frequent' ? 'Frequent Visitors' : offer.targetAudience === 'new' ? 'New Users' : ''} ยท {offer.radius}m
</div>
<div style={{ padding: '3px 10px', borderRadius: '4px', fontSize: '11px', fontWeight: 600, ...style }}>
{offer.status.charAt(0).toUpperCase() + offer.status.slice(1)}
</div>
</div>
</div>
</button>
)
})}
</div>
)}
</>
)
}- [ ] Step 2: Commit
git add apps/admin/src/components/merchants/tabs/OffersList.jsx
git commit -m "feat(admin): add OffersList component with status filters
Lists merchant offers with status filter sub-tabs (All/Active/Draft/Expired),
clickable offer rows showing venue, placement, audience, and status badge.
Empty state with Lucide icon and Create CTA."Task 7: OfferForm component with live preview โ
Files:
- Create:
apps/admin/src/components/merchants/tabs/OfferForm.jsx
This is the largest component. Two-column layout: form fields (left) + placement preview (right).
- [ ] Step 1: Create
OfferForm.jsx
Create apps/admin/src/components/merchants/tabs/OfferForm.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useMemo, useState } from 'react'
import StyledSelect from '../../StyledSelect'
import AdSlot from '../../offers/AdSlot'
import { normalizeFormToOffer } from '../../../lib/offerNormalizer'
import { createMerchantOffer, updateMerchantOffer } from '../../../firebase'
const PLACEMENT_OPTIONS = [
{ value: 'hero', label: 'Dashboard Hero Rail' },
{ value: 'inline', label: 'Inline Venue Card' },
{ value: 'chat', label: 'Chat Assist Pill' },
{ value: 'feed', label: 'Feed Insertion' },
]
const AUDIENCE_OPTIONS = [
{ value: 'nearby', label: 'Nearby Users (1.5mi)' },
{ value: 'lantern', label: 'Active Lantern Holders' },
{ value: 'frequent', label: 'Frequent Visitors' },
{ value: 'new', label: 'New Users' },
]
const ALL_PLACEMENTS = ['hero', 'inline', 'chat', 'feed']
function defaultForm(offer) {
if (offer) {
return {
venueId: offer.venueId || '',
title: offer.title || '',
description: offer.description || '',
placement: offer.placement || '',
targetAudience: offer.targetAudience || '',
radius: offer.radius || '',
per_user_limit: offer.per_user_limit || 1,
budget: offer.budget || 50,
expiresAt: offer.expiresAt ? offer.expiresAt.split('T')[0] : '',
showDisclaimerWhileSuppliesLast: offer.showDisclaimerWhileSuppliesLast || false,
}
}
return {
venueId: '',
title: '',
description: '',
placement: '',
targetAudience: '',
radius: '',
per_user_limit: 1,
budget: 50,
expiresAt: '',
showDisclaimerWhileSuppliesLast: false,
}
}
export default function OfferForm({ merchantId, venues, offer, onSaved, onCancel }) {
const isEdit = !!offer
const [form, setForm] = useState(() => defaultForm(offer))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const venueOptions = (venues || []).map((v) => ({
value: v.venueId || v.id,
label: v.name || v.venueId || v.id,
}))
// Pre-select if only one venue
if (venueOptions.length === 1 && !form.venueId) {
setForm((f) => ({ ...f, venueId: venueOptions[0].value }))
}
function update(e) {
const { name, value, type, checked } = e.target
setForm((f) => ({ ...f, [name]: type === 'checkbox' ? checked : value }))
}
async function handleSubmit(submitStatus) {
setError(null)
setSaving(true)
try {
const payload = {
...form,
radius: Number(form.radius),
per_user_limit: Number(form.per_user_limit),
budget: Number(form.budget),
expiresAt: new Date(form.expiresAt).toISOString(),
status: submitStatus,
}
if (isEdit) {
await updateMerchantOffer(merchantId, offer.id, payload)
} else {
await createMerchantOffer(merchantId, payload)
}
onSaved()
} catch (err) {
setError(err.message || 'Failed to save offer')
} finally {
setSaving(false)
}
}
const selectedVenue = venues?.find((v) => (v.venueId || v.id) === form.venueId)
const previewOffer = useMemo(
() => normalizeFormToOffer(form, { venue: selectedVenue ? { name: selectedVenue.name } : undefined }),
[form, selectedVenue]
)
if (venues?.length === 0) {
return (
<div className="feature-card" style={{ maxWidth: 480 }}>
<h3>Link a venue before creating an offer</h3>
<p className="text-muted">
This merchant has no linked venues. Go to the Venues tab to associate one first.
</p>
</div>
)
}
return (
<div style={{ display: 'flex', gap: 0 }}>
{/* Left: Form */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-4)' }}>
<h3 style={{ margin: 0 }}>{isEdit ? 'Edit Offer' : 'Create Offer'}</h3>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<button className="btn btn-secondary btn-sm" onClick={onCancel} disabled={saving}>
Cancel
</button>
{(!isEdit || offer?.status === 'draft') && (
<button
className="btn btn-secondary btn-sm"
onClick={() => handleSubmit('draft')}
disabled={saving || !form.title || !form.venueId}
>
{saving ? 'Savingโฆ' : 'Save Draft'}
</button>
)}
<button
className="btn btn-primary btn-sm"
onClick={() => handleSubmit(isEdit && offer?.status !== 'draft' ? offer.status : 'active')}
disabled={saving || !form.title || !form.venueId || !form.placement || !form.targetAudience || !form.expiresAt}
>
{saving ? 'Savingโฆ' : isEdit ? 'Save' : 'Publish'}
</button>
</div>
</div>
{error && <div className="form-error" style={{ marginBottom: 'var(--space-3)' }}>{error}</div>}
<div className="form-group">
<label className="form-label">Venue <span style={{ color: 'var(--danger)' }}>*</span></label>
<StyledSelect
options={venueOptions}
value={venueOptions.find((o) => o.value === form.venueId) || null}
onChange={(selected) => setForm((f) => ({ ...f, venueId: selected?.value || '' }))}
placeholder="Select venue"
isDisabled={venueOptions.length === 1}
/>
</div>
<div className="form-group">
<label className="form-label">Title <span style={{ color: 'var(--danger)' }}>*</span></label>
<input className="form-input" name="title" value={form.title} onChange={update} placeholder="20% off brunch" required />
</div>
<div className="form-group">
<label className="form-label">Description <span style={{ color: 'var(--danger)' }}>*</span></label>
<textarea
className="form-input"
name="description"
value={form.description}
onChange={update}
placeholder="Add friendly copy and redemption rules."
rows={4}
required
/>
</div>
<div className="form-group">
<label className="form-label">Placement <span style={{ color: 'var(--danger)' }}>*</span></label>
<StyledSelect
options={PLACEMENT_OPTIONS}
value={PLACEMENT_OPTIONS.find((o) => o.value === form.placement) || null}
onChange={(selected) => setForm((f) => ({ ...f, placement: selected?.value || '' }))}
placeholder="Select offer placement"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<div className="form-group">
<label className="form-label">Target Audience <span style={{ color: 'var(--danger)' }}>*</span></label>
<StyledSelect
options={AUDIENCE_OPTIONS}
value={AUDIENCE_OPTIONS.find((o) => o.value === form.targetAudience) || null}
onChange={(selected) => setForm((f) => ({ ...f, targetAudience: selected?.value || '' }))}
placeholder="Select audience"
/>
</div>
<div className="form-group">
<label className="form-label">Geofence Radius (m)</label>
<input className="form-input" name="radius" type="number" min="10" max="2500" step="10" value={form.radius} onChange={update} placeholder="Max 2500" required />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<div className="form-group">
<label className="form-label">Per User Limit</label>
<input className="form-input" name="per_user_limit" type="number" min="1" value={form.per_user_limit} onChange={update} required />
</div>
<div className="form-group">
<label className="form-label">Budget ($)</label>
<input className="form-input" name="budget" type="number" min="1" value={form.budget} onChange={update} required />
</div>
</div>
<div className="form-group">
<label className="form-label">Expires <span style={{ color: 'var(--danger)' }}>*</span></label>
<input className="form-input" name="expiresAt" type="date" value={form.expiresAt} onChange={update} required />
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<input
type="checkbox"
id="disclaimer-wsl"
name="showDisclaimerWhileSuppliesLast"
checked={form.showDisclaimerWhileSuppliesLast}
onChange={update}
/>
<label htmlFor="disclaimer-wsl" style={{ fontSize: '0.8125rem' }}>
Show "While supplies last" disclaimer
</label>
</div>
</div>
{/* Right: Preview */}
<div style={{ width: 360, flexShrink: 0, padding: 'var(--space-4)', marginLeft: 'var(--space-4)', background: 'rgba(0,0,0,0.15)', borderRadius: '8px', alignSelf: 'flex-start' }}>
<h4 style={{ margin: '0 0 var(--space-1)', fontSize: '14px' }}>Placement Preview</h4>
<p className="text-muted" style={{ fontSize: '12px', marginBottom: 'var(--space-3)' }}>
See how your offer appears to users in each slot.
</p>
{ALL_PLACEMENTS.map((placement) => {
const isSelected = form.placement === placement
return (
<div
key={placement}
style={{
opacity: form.placement && !isSelected ? 0.35 : 1,
transition: 'opacity 0.2s',
border: isSelected ? '2px solid rgba(245, 158, 11, 0.5)' : '2px solid transparent',
borderRadius: '12px',
marginBottom: 'var(--space-3)',
}}
>
<AdSlot offer={{ ...previewOffer, placement }} />
</div>
)
})}
</div>
</div>
)
}- [ ] Step 2: Commit
git add apps/admin/src/components/merchants/tabs/OfferForm.jsx
git commit -m "feat(admin): add OfferForm with live placement preview
Two-column layout: form fields (left) + live preview (right). All fields
from the web app OfferForm: venue, title, description, placement, target
audience, radius, per-user limit, budget, expires, while-supplies-last.
Preview updates in real-time via offerNormalizer. Save as Draft or Publish."Task 8: OfferDetail component โ
Files:
Create:
apps/admin/src/components/merchants/tabs/OfferDetail.jsx[ ] Step 1: Create
OfferDetail.jsx
Create apps/admin/src/components/merchants/tabs/OfferDetail.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useState } from 'react'
import { Pencil, Trash2, Send, Archive } from 'lucide-react'
import { deleteMerchantOffer, updateMerchantOffer } from '../../../firebase'
const AUDIENCE_LABELS = {
nearby: 'Nearby Users (1.5mi)',
lantern: 'Active Lantern Holders',
frequent: 'Frequent Visitors',
new: 'New Users',
}
const PLACEMENT_LABELS = {
hero: 'Dashboard Hero Rail',
inline: 'Inline Venue Card',
chat: 'Chat Assist Pill',
feed: 'Feed Insertion',
}
const STATUS_STYLES = {
active: { background: 'rgba(34, 197, 94, 0.15)', color: '#22C55E' },
draft: { background: 'rgba(255, 255, 255, 0.08)', color: '#999' },
expired: { background: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' },
archived: { background: 'rgba(255, 255, 255, 0.05)', color: '#666' },
}
export default function OfferDetail({ merchantId, offer, venues, onEdit, onDeleted, onBack }) {
const [actionError, setActionError] = useState(null)
const [acting, setActing] = useState(false)
const venue = (venues || []).find((v) => (v.venueId || v.id) === offer.venueId)
const style = STATUS_STYLES[offer.status] || STATUS_STYLES.draft
async function handleDelete() {
const confirmMsg = offer.status === 'draft'
? 'Delete this draft? This cannot be undone.'
: 'Archive this offer? It will be hidden from the default list.'
if (!window.confirm(confirmMsg)) return
try {
setActing(true)
setActionError(null)
await deleteMerchantOffer(merchantId, offer.id)
onDeleted()
} catch (err) {
setActionError(err.message || 'Failed to delete offer')
} finally {
setActing(false)
}
}
async function handlePublish() {
try {
setActing(true)
setActionError(null)
await updateMerchantOffer(merchantId, offer.id, { status: 'active' })
onDeleted() // refresh list
} catch (err) {
setActionError(err.message || 'Failed to publish offer')
} finally {
setActing(false)
}
}
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-4)' }}>
<div>
<button className="btn btn-ghost btn-sm" onClick={onBack} style={{ marginBottom: 'var(--space-2)' }}>
โ Back to offers
</button>
<h3 style={{ margin: 0 }}>{offer.title}</h3>
</div>
<div style={{ display: 'flex', gap: 'var(--space-2)', alignItems: 'center' }}>
<div style={{ padding: '4px 12px', borderRadius: '4px', fontSize: '12px', fontWeight: 600, ...style }}>
{offer.status.charAt(0).toUpperCase() + offer.status.slice(1)}
</div>
<button className="btn btn-secondary btn-sm" onClick={() => onEdit(offer)} disabled={acting}>
<Pencil size={14} />
Edit
</button>
{offer.status === 'draft' && (
<button className="btn btn-primary btn-sm" onClick={handlePublish} disabled={acting}>
<Send size={14} />
{acting ? 'Publishingโฆ' : 'Publish'}
</button>
)}
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={acting}>
{offer.status === 'draft' ? <Trash2 size={14} /> : <Archive size={14} />}
{offer.status === 'draft' ? 'Delete' : 'Archive'}
</button>
</div>
</div>
{actionError && <div className="form-error" style={{ marginBottom: 'var(--space-3)' }}>{actionError}</div>}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)', maxWidth: 640 }}>
<div>
<div className="form-label">Venue</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>{venue?.name || offer.venueId}</p>
</div>
<div>
<div className="form-label">Placement</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>{PLACEMENT_LABELS[offer.placement] || offer.placement}</p>
</div>
<div>
<div className="form-label">Target Audience</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>{AUDIENCE_LABELS[offer.targetAudience] || offer.targetAudience}</p>
</div>
<div>
<div className="form-label">Geofence Radius</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>{offer.radius}m</p>
</div>
<div>
<div className="form-label">Per User Limit</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>{offer.per_user_limit}</p>
</div>
<div>
<div className="form-label">Budget</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>${offer.budget}</p>
</div>
<div>
<div className="form-label">Expires</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>{offer.expiresAt ? new Date(offer.expiresAt).toLocaleDateString() : 'โ'}</p>
</div>
<div>
<div className="form-label">Created</div>
<p style={{ margin: '4px 0 0', fontSize: '14px' }}>{offer.createdAt ? new Date(offer.createdAt).toLocaleDateString() : 'โ'}</p>
</div>
</div>
<div style={{ marginTop: 'var(--space-4)' }}>
<div className="form-label">Description</div>
<p style={{ margin: '4px 0 0', fontSize: '14px', lineHeight: 1.6 }}>{offer.description}</p>
</div>
{offer.showDisclaimerWhileSuppliesLast && (
<p className="text-muted" style={{ marginTop: 'var(--space-3)', fontSize: '12px', fontStyle: 'italic' }}>
* While supplies last
</p>
)}
</>
)
}- [ ] Step 2: Commit
git add apps/admin/src/components/merchants/tabs/OfferDetail.jsx
git commit -m "feat(admin): add OfferDetail read-only view with actions
Shows all offer fields in a structured grid. Actions vary by status:
Draft gets Edit/Publish/Delete, Active gets Edit/Archive. Publish
sets status to active; Delete hard-deletes drafts, archives others."Task 9: MerchantOffersTab orchestrator โ
Files:
Create:
apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsx[ ] Step 1: Create
MerchantOffersTab.jsx
Create apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import OffersList from './OffersList'
import OfferForm from './OfferForm'
import OfferDetail from './OfferDetail'
export default function MerchantOffersTab() {
const { merchantId, venues } = useOutletContext()
const [view, setView] = useState('list') // list | create | edit | detail
const [selectedOffer, setSelectedOffer] = useState(null)
if (view === 'create') {
return (
<OfferForm
merchantId={merchantId}
venues={venues}
onSaved={() => setView('list')}
onCancel={() => setView('list')}
/>
)
}
if (view === 'edit' && selectedOffer) {
return (
<OfferForm
merchantId={merchantId}
venues={venues}
offer={selectedOffer}
onSaved={() => { setSelectedOffer(null); setView('list') }}
onCancel={() => setView(selectedOffer ? 'detail' : 'list')}
/>
)
}
if (view === 'detail' && selectedOffer) {
return (
<OfferDetail
merchantId={merchantId}
offer={selectedOffer}
venues={venues}
onEdit={(offer) => { setSelectedOffer(offer); setView('edit') }}
onDeleted={() => { setSelectedOffer(null); setView('list') }}
onBack={() => { setSelectedOffer(null); setView('list') }}
/>
)
}
return (
<OffersList
merchantId={merchantId}
venues={venues}
onCreateClick={() => setView('create')}
onOfferClick={(offer) => { setSelectedOffer(offer); setView('detail') }}
/>
)
}- [ ] Step 2: Commit
git add apps/admin/src/components/merchants/tabs/MerchantOffersTab.jsx
git commit -m "feat(admin): add MerchantOffersTab view orchestrator
Manages internal view state (list/create/edit/detail) without adding
URL routes. Reads merchantId and venues from useOutletContext. Passes
callbacks for view transitions between list, form, and detail."Task 10: Wire Offers tab into MerchantDetail and routing โ
Files:
Modify:
apps/admin/src/components/merchants/MerchantDetail.jsxModify:
apps/admin/src/components/AdminDashboard.jsx[ ] Step 1: Add Offers to DETAIL_TABS in MerchantDetail
Open apps/admin/src/components/merchants/MerchantDetail.jsx. Add Tag to the lucide-react import:
import { ClipboardList, MapPin, StickyNote, Image, Home, CheckCircle, Tag } from 'lucide-react'Update the DETAIL_TABS array โ add the offers tab after venues:
const DETAIL_TABS = [
{ id: 'overview', label: 'Overview', icon: <ClipboardList size={16} /> },
{ id: 'venues', label: 'Venues', icon: <MapPin size={16} /> },
{ id: 'offers', label: 'Offers', icon: <Tag size={16} /> },
{ id: 'notes', label: 'Notes', icon: <StickyNote size={16} /> },
{ id: 'photos', label: 'Photos', icon: <Image size={16} /> },
{ id: 'address', label: 'Address', icon: <Home size={16} /> },
]In the tab mapping that adds the dynamic venue count (around where tabs is derived from DETAIL_TABS), also add a dynamic count for offers. This requires fetching offers โ add a listMerchantOffers call to the existing data fetch effect, or add a separate lightweight count. The simplest approach: add the count to the tab label just like venues:
Find the line:
const tabs = DETAIL_TABS.map((t) =>
t.id === 'venues' ? { ...t, label: `Venues (${venues.length})` } : t
)Change to:
const [offerCount, setOfferCount] = useState(0)Add after the existing data-fetch effect:
// Fetch offer count for tab label
useEffect(() => {
if (!merchantId) return
let cancelled = false
listMerchantOffers(merchantId).then((result) => {
if (!cancelled) setOfferCount(result.offers?.length || 0)
}).catch(() => {})
return () => { cancelled = true }
}, [merchantId])Add listMerchantOffers to the firebase import:
import { getMerchantData, updateMerchantUser, disassociateVenueFromMerchant, listMerchantOffers } from '../../firebase'Update the tabs mapping:
const tabs = DETAIL_TABS.map((t) => {
if (t.id === 'venues') return { ...t, label: `Venues (${venues.length})` }
if (t.id === 'offers') return { ...t, label: `Offers (${offerCount})` }
return t
})- [ ] Step 2: Add the Offers route in AdminDashboard
Open apps/admin/src/components/AdminDashboard.jsx. Add the import at the top with the other merchant tab imports:
import MerchantOffersTab from './merchants/tabs/MerchantOffersTab'Find the :merchantId route block and add the offers route alongside the other tab routes:
<Route path="offers" element={<MerchantOffersTab />} />Place it after the venues route and before notes.
- [ ] Step 3: Run tests
Run: cd apps/admin && npx vitest run
Expected: all existing tests pass.
- [ ] Step 4: Commit
git add apps/admin/src/components/merchants/MerchantDetail.jsx apps/admin/src/components/AdminDashboard.jsx
git commit -m "feat(admin): wire Offers tab into MerchantDetail and routing
Adds 'Offers (n)' tab to MerchantDetail between Venues and Notes.
Dynamic count fetched on mount. Route added for /merchants/:id/offers
inside the existing nested route block."Task 11: Full validation sweep โ
No code changes โ validation and verification.
- [ ] Step 1: Run admin workspace validation
Run: npm run validate -- --workspace apps/admin
Expected: lint, format, tests, build all pass. Fix any issues inline.
- [ ] Step 2: Start the merchants API and verify endpoints
Run in one terminal:
cd services/api/merchants && npm run devRun in another:
# Health check
curl http://localhost:8085/health
# Scalar docs
# Open http://localhost:8085/api-docs in browser- [ ] Step 3: Visual smoke test (admin portal)
Run: npm run dev -w apps/admin
Manual checks:
- Navigate to a merchant detail page โ "Offers" tab visible between Venues and Notes.
- Click Offers tab โ shows empty state with "No offers yet" and Create CTA (assumes API is running).
- Click Create Offer โ form shows with all fields + preview sidebar.
- Fill in all fields โ preview updates live as you type/select placement.
- Click Save Draft โ returns to list, offer appears with Draft badge.
- Click the offer โ detail view shows all fields.
- Click Edit โ form pre-filled with offer data.
- Click Publish on a draft โ status changes to Active.
- Delete a draft โ hard deleted, disappears from list.
- Archive an active offer โ status set to archived, disappears from default list.
- Status filter tabs work (All/Active/Draft/Expired).
- [ ] Step 4: Commit the plan
git add docs/superpowers/plans/2026-04-25-offers-crud-admin.md
git commit -m "docs: implementation plan for offers CRUD and admin portal"Self-review โ
Spec coverage check:
| Spec section | Task |
|---|---|
| ยง1 New merchants API service scaffold | Task 1 |
| ยง1 Auth middleware (admin or owner) | Task 1 step 3 |
| ยง1 Routes (5 endpoints) | Task 2 |
| ยง1 Venue validation on create | Task 2 (POST handler) |
| ยง1 Auto-expiration on read | Task 2 (GET handlers) |
| ยง1 Hard delete drafts, soft delete others | Task 2 (DELETE handler) |
| ยง2 Offer schema | Task 2 (Zod schemas) |
| ยง3 Offers tab in DETAIL_TABS | Task 10 step 1 |
| ยง4 Component tree (MerchantOffersTab โ OffersList/OfferForm/OfferDetail) | Tasks 6, 7, 8, 9 |
| ยง5 Offers list with status filters | Task 6 |
| ยง5 Empty state | Task 6 |
| ยง6 Create/edit form โ all fields | Task 7 |
| ยง6 Live placement preview | Task 7 (preview column) |
| ยง6 Venue selection behavior | Task 7 (venueOptions, pre-select) |
| ยง6 Form actions (Cancel/Save Draft/Publish) | Task 7 |
| ยง7 Offer detail view with actions | Task 8 |
| ยง8 react-select + StyledSelect | Task 4 |
| ยง9 API client (merchantsApi.js) | Task 3 |
| ยงโ Service registered in shared | Task 1 step 6 |
| ยงโ PORT_MAP updated | Task 1 step 6 |
| ยงโ Preview components (AdSlot, OfferCards, offerNormalizer) | Task 5 |
| ยงโ firebase.js convenience exports | Task 3 step 2 |
No gaps.
Placeholder scan: No TBDs, TODOs, or vague instructions. Every code step has complete code.
Type consistency:
merchantIdstring โ consistent across API routes, client, and components.listMerchantOffers(merchantId, { status })โ matches between firebase.js export (Task 3), merchantsApi.js (Task 3), and OffersList consumer (Task 6).createMerchantOffer(merchantId, data)/updateMerchantOffer(merchantId, offerId, data)/deleteMerchantOffer(merchantId, offerId)โ consistent between firebase.js, merchantsApi.js, OfferForm, and OfferDetail.offerobject shape โ API returns{ id, merchantId, venueId, title, description, placement, targetAudience, radius, per_user_limit, budget, expiresAt, status, ... }. OfferForm reads these fields. OfferDetail displays them. OffersList maps over them. All consistent.venuesarray fromuseOutletContext()โ used by MerchantOffersTab, OffersList (venueMap), OfferForm (venueOptions), OfferDetail (venue lookup). All expectvenueId || idandnameproperties, matching the existing MerchantDetail data shape.- Lucide icons:
Tag,Plus,Pencil,Trash2,Send,Archiveโ all exist in lucide-react.
No inconsistencies found.