# Layer 7 — Seller Dashboard (Design)

**Status:** Draft · 2026-04-22
**Predecessors:** Layer 5 (Fulfillment & Shipping), Layer 6 (Notifications)
**Successors:** Layer 8 (Admin console + dispute resolution)

---

## Purpose

Build out the seller console that fills in the skeleton at `web/src/app/(seller)/`, giving store operators a working UI for the APIs and events shipped in Layers 1–6.

Two user surfaces were explicitly deferred to this layer:

- **Seller order/fulfillment UI** (Layer 5 design, lines 14, 21, 382)
- **Seller inbox UI** (Layer 6 design, lines 325, 459)

Both are in scope here, alongside a real dashboard, listings management (with item create/edit), and seller-facing settings.

## User model

Target user: a resale-store operator who checks in on the dashboard periodically through the day (not a power user living in it, not a mobile-first on-the-go user). The dashboard is a **triage hub** — its job is to surface what needs the seller's attention. Deep browsing and analytics are not the point.

Implications:

- Polling is sufficient; no WebSocket / SSE push
- Desktop-first (1280px+), graceful down to tablet (768px); no hand-tuned phone layouts
- Clarity over density; bulk actions only where they earn their weight (listings table)
- Onboarding flow is out of scope — assumes a seller with an already-provisioned store and Stripe Connect

## Scope

### In scope

- Dashboard (triage hub with KPIs + action widgets)
- Inbox (full notification history with category tabs + read filter)
- Listings (table/grid toggle, filters, search, bulk status actions, item create/edit form with full Snap parity)
- Orders (list with filters/search, detail with inline fulfillment, cancel flow)
- Settings (Store, Shipping, Notifications, Payments tabs)
- Seller top bar with NotificationBell
- No-store guard landing page

### Out of scope

- Seller onboarding / first-run flow (later layer)
- Phone-sized layouts (tablet+ only)
- Real-time push (polling + refocus invalidation only)
- Dispute resolution UI (Layer 8)
- Analytics beyond the 4 KPIs (no charts)
- Admin impersonation UI (existing middleware behavior preserved; no new UI)
- E2E test coverage (Playwright deferred)

## Information architecture

Left sidebar, preserving the existing seller layout pattern:

```
Dashboard      /seller
Inbox          /seller/inbox
Listings       /seller/listings
Orders         /seller/orders
Settings       /seller/settings    (→ /seller/settings/store by default)
```

Settings is a parent route with sub-routes so tab state is URL-addressable:

```
/seller/settings/store          (default)
/seller/settings/shipping
/seller/settings/notifications
/seller/settings/payments
```

Top bar (right side): NotificationBell (unread count, dropdown of 5 recent, "see all" → `/seller/inbox`), avatar menu, logout. Same pattern as the buyer layout's bell.

Sidebar collapses behind a toggle at viewport widths below 1024px.

## Section-by-section design

### Dashboard (`/seller`)

Layout: KPIs on top, triage widgets below (chosen over a triage-first layout because it matches the SaaS-conventional reading order and keeps numbers always visible).

```
┌─────────────────────────────────────────────────────────┐
│ KPI ROW — 4 equal-width cards                           │
│ Revenue this month · Active listings · Orders · Payout  │
├─────────────────────────────────────────────────────────┤
│ ORDERS NEEDING ACTION (2/3 width) │ UNREAD INBOX (1/3)  │
│ grouped: Overdue · Today · This week │ 5 most recent    │
├─────────────────────────────────────────────────────────┤
│ LISTINGS NEEDING ATTENTION (full width, compact)        │
└─────────────────────────────────────────────────────────┘
```

**KPI cards** — each shows a current-period value and a delta chip vs the prior equivalent period. "This month" means the current calendar month to date; "last month" is the full prior calendar month. "This week" on the listings card means the trailing 7 days.

| Card | Source |
|------|--------|
| Revenue this month | Sum of Purchase amounts where state is paid or further along, filtered to this seller's Orders; current calendar month to date vs full prior calendar month |
| Active listings | Count of Items in `published` status; delta is count of items that moved to `published` in the trailing 7 days |
| Orders this month | Count of Orders placed this calendar month to date; delta vs full prior calendar month |
| Next payout | Amount + arrival date from Stripe Connect (no delta) |

**Orders needing action**
- Source: Orders where `status = paid` (paid but not yet shipped)
- Groups: **Overdue** (ship-by < today), **Today** (ship-by = today), **This week** (ship-by within 7 days)
- Row: order ID, buyer name, item count, ship-by with color (red / amber / neutral)
- Cap 10 rows per group; "view all" links to `/seller/orders?status=paid`
- Row click → `/seller/orders/:id`

**Unread inbox widget**
- Reuses the existing `useNotifications({ filter: 'unread', limit: 5 })` hook
- "See all" → `/seller/inbox`
- "Mark all read" inline action

**Listings needing attention**
- Criteria (v1, tunable later):
  - Items in `draft` status (never published)
  - Items in `published` status with zero views in the last 30 days
- Renders as a single-row compact card: count + one-line summary
- Click-through → `/seller/listings?filter=needs-attention`
- The `filter=needs-attention` filter is server-side on the items list endpoint (new query param). Keeps the semantics consistent between the dashboard count and the filtered listings view.

**Refresh model**
- KPI card queries: refetch on mount only (coarse data)
- Orders-needing-action + listings-attention: `refetchOnWindowFocus: true` + 30 s interval
- Unread inbox widget: shares the NotificationBell cache (30 s polling)

### Inbox (`/seller/inbox`)

Pattern: category tabs + read filter (chosen over a flat list because sellers accumulate notifications from multiple event domains).

```
┌──────────────────────────────────────────────────────────┐
│ Tabs:  All  ·  Orders  ·  Shipping  ·  Payouts  · System │
├──────────────────────────────────────────────────────────┤
│ [All / Unread toggle]           [Mark all read]          │
├──────────────────────────────────────────────────────────┤
│ NotificationRow × N  (reuses existing component)         │
│ [Load more]                                              │
└──────────────────────────────────────────────────────────┘
```

**Category tabs**
- Map to the `category` column on the `notifications` table. This column does not yet exist — Layer 6 shipped category only as a preference-gate input during dispatch. Layer 7 adds a `category` column to the `notifications` table, backfills it from the notification class name, and includes it in `toDatabase` going forward.
- Category values:
  - **Orders** — OrderPaid, OrderAutoCancelled, OrderCancelled
  - **Shipping** — ShipByReminderDue, OrderDelayed, OrderShipped
  - **Payouts** — Stripe Connect payout events
  - **System** — fallback / platform announcements
- Active tab is URL-backed: `?category=shipping`
- Unread count badge per tab

**Row interaction**
- Click marks read (PATCH) and navigates to related entity:
  - Order category → `/seller/orders/:id`
  - Payout category → `/seller/settings/payments`
  - Others → stay on inbox, show body in an expanded inline state or leave as a no-op
- The existing `NotificationRow` (`src/components/notifications/notification-row.tsx`) powers both buyer and seller — no fork

**Backend changes needed**
- Migration: add `category` (nullable string, indexed) to `notifications` table; backfill from class-name → category map
- Update every `toDatabase()` implementation and/or the notification dispatch to record `category` alongside the `data` payload (column-level, not inside JSON)
- `GET /v1/me/notifications` gains a `category` query-string filter. Existing `filter=unread` support stays.

### Orders

**List (`/seller/orders`)**
- Columns: Order ID · Buyer · Items · Total · Status · Ship-by · Placed
- Filter chips: All · Paid · Shipped · Delivered · Cancelled (URL-backed as `?status=...`)
- Server-side search by order ID or buyer name (debounced 300 ms), URL-backed as `?q=...`
- Sort: Placed (default desc), Ship-by, Total
- Ship-by cell colored red/amber/neutral consistent with dashboard widget
- Row click → `/seller/orders/:id`
- No bulk actions (per-order workflow)

**Detail (`/seller/orders/:id`) — inline fulfillment**

```
┌─────────────────────────────────────────────────────────┐
│ Header: Order #A12 · Paid · placed 3d ago      [Cancel] │
├─────────────────────────────────────────────────────────┤
│ ORDER SUMMARY                  │ BUYER & SHIPPING       │
│ line items w/ thumbnails       │ buyer, ship-to,        │
│ subtotal / fees / total        │ ship-by (colored)      │
├─────────────────────────────────────────────────────────┤
│ SHIPPING LABEL (highlighted when action pending)        │
│ preset · carrier · cost · [Buy label]                   │
│                                                         │
│ After purchase: label PDF · tracking # · re-print       │
├─────────────────────────────────────────────────────────┤
│ TIMELINE (compact)                                      │
│ Placed → Paid → Label purchased → Shipped → Delivered   │
└─────────────────────────────────────────────────────────┘
```

**Shipping panel states**
- **Ready to ship** — preset picker (default = store's default `parcel_preset`), carrier/service select (USPS Priority default), cost preview (fetched from a rate-preview endpoint), "Buy label" CTA
- **Label purchased** — shows label PDF link, tracking number (clickable), re-print link, "Mark shipped" action if backend requires manual transition
- **Shipped / delivered** — shipping panel collapses to a summary line with tracking link

**Cancel flow**
- Header "Cancel order" → confirmation modal with required reason (dropdown: restock, damaged, other + optional note)
- Posts to existing seller-cancel endpoint; on success, UI shows refund state and disables further actions

**Backend endpoints**
- Existing: `GET /v1/stores/{store}/orders`, `GET /v1/stores/{store}/orders/{order}`, `POST .../labels`, `POST .../cancel`
- New, if not already present: `POST /v1/stores/{store}/orders/{order}/labels/preview` — returns EasyPost-rated cost without purchasing. During planning, verify whether preview is already on the controller; add only if missing.

### Listings

**Index (`/seller/listings`) — table + grid toggle**

```
┌──────────────────────────────────────────────────────────┐
│ Filter chips: All · Published · Draft · Sold · Removed   │
│ Special chip: "Needs attention"                          │
│ [🔍 search]  [▦│≡ view toggle]  [+ New item]             │
├──────────────────────────────────────────────────────────┤
│ TABLE VIEW (default):                                    │
│ □ Thumb · Title · Status · Price · Views · Listed        │
│ …                                                        │
│ Bulk bar (when rows selected):                           │
│ [Publish] [Remove] [Relist] [Export CSV]                 │
├──────────────────────────────────────────────────────────┤
│ GRID VIEW (toggle): 4-col image-first cards              │
└──────────────────────────────────────────────────────────┘
```

- Filters + search + view choice URL-backed (`?status=published&q=wool&view=grid`)
- View toggle also persisted in localStorage as the default when no `?view` in URL
- Bulk actions in table view only; grid has no checkboxes this layer
- Row/card click → `/seller/listings/:id` (edit form)
- "New item" button → `/seller/listings/new`

**Item create/edit form (`/seller/listings/new`, `/seller/listings/:id`) — single long form**

Same component powers create and edit. Sections stacked on the page in this order:

1. **Images** — drag-drop uploader, thumbnail strip, drag-reorder, primary-image marker
2. **Basics** — title, category (autocomplete over Category tree), condition (enum), description
3. **Details** — size, measurements (category-aware fields), colors (multi-select chips), notes
4. **Price** — listed price in cents with live dollar display
5. **Status/publish** — Save draft, Publish (disabled until required fields pass); for existing items: Remove, Relist

**Behavior**
- Autosave to draft on field blur; "Saved Xs ago" indicator in the header
- Publish validates required fields client-side (title, category, condition, ≥1 image, price > 0) then calls `POST /v1/stores/{store}/items/{item}/publish`
- Images via existing `POST /v1/stores/{store}/items/{item}/images` / `DELETE .../images/:id`
- Remove / Relist via existing endpoints

### Settings

Tabbed parent route. Each tab is a sub-route for shareable URLs and refresh-stable state.

**Tab: Store** (`/seller/settings/store`)
- Store name, slug (read-only once set), tagline/description
- Logo upload, banner upload (reuses image upload pattern)
- Public store-page preview link
- Save with optimistic UI + toast

**Tab: Shipping** (`/seller/settings/shipping`)
- Ship-from address (single, required for labels)
- Processing days (1–7 → drives ship-by calculation)
- Parcel presets table — name, dimensions, weight, default badge
  - Create / edit / delete inline via a modal (small surface)
  - Exactly one default; changing default un-sets the previous
- Uses existing `/v1/stores/{store}/parcel-presets` CRUD

**Tab: Notifications** (`/seller/settings/notifications`)
- Table of categories × channels (Email / Inbox) from the `NotificationPreference` model:
  ```
                           Email   Inbox
  Orders                    [🔒]    [🔒]   (transactional, always on)
  Shipping                  [🔒]    [🔒]   (transactional, always on)
  Payouts                   [✓]     [✓]
  System announcements      [✓]     [✓]
  Marketing / tips          [ ]     [ ]
  ```
- Transactional rows are locked on with a tooltip explanation
- Toggle flips the pref optimistically; mutation updates both channels of a row atomically (consistent with the Layer 6 transactional refactor in commit `6c3e3df`)
- No digest / quiet-hours settings in this layer

**Tab: Payments** (`/seller/settings/payments`)
- Stripe Connect status card: connected / not connected, account verified-at timestamp
- Not connected → "Connect with Stripe" button that launches existing OAuth flow
- Connected → Stripe dashboard link + "Payouts go to bank ending in …" summary
- Recent payouts table (5 most recent) — payout id, amount, arrival date, status — only if the Stripe API surface exposes it; otherwise just the connect status card

No platform-fee config here (stays admin-owned).

## Cross-cutting

### Data fetching

- TanStack Query for all server state (matches buyer patterns)
- Shared hooks under `web/src/lib/queries/`:
  - `use-seller-dashboard.ts` (new) — KPI metrics + action widget counts, single coarse query
  - `use-seller-orders.ts` (new)
  - `use-seller-items.ts` (new)
  - `use-notifications.ts` (extend) — accept `category` param
- Refetch policy:
  - Dashboard action widgets: `refetchOnWindowFocus: true` + 30 s interval
  - KPI cards: mount-only (coarse data)
  - NotificationBell: 30 s polling (matches buyer)
  - Lists: `refetchOnWindowFocus: true`, no interval
- Mutations invalidate relevant query keys and the seller-dashboard key when counts change

### API client

- Extend `@alqove/api-client` with a `seller` namespace that wraps `/v1/stores/{store}/*` endpoints, binding `storeId` from the authenticated user so call sites don't repeat it
- New endpoints needed:
  - `GET /v1/stores/{store}/dashboard/metrics` — returns 4 KPI tuples (current + prior) + action-widget counts
  - `POST /v1/stores/{store}/orders/{order}/labels/preview` — rate preview without purchase (add only if not already present in the OrderFulfillmentController)
  - Extend `GET /v1/stores/{store}/items` with a `filter=needs-attention` query param
  - Extend `GET /v1/notifications` with a `category` query param
- Contract-first: update `api/contracts/openapi.yaml`, run `npm run build:types`, write test, implement

### Styling

- Reuse the Layer-5 palette in `tailwind.config.ts` and `packages/design-tokens/tokens/colors.json` — forest / bone / ink / terracotta; no new tokens
- Promote reusable seller fragments into `web/src/components/seller/`:
  - `KpiCard`, `StatusPill`, `OrdersActionList`, `ListingsAttentionWidget`, `BulkActionBar`, `DataTable` (thin wrapper establishing column conventions), `EmptyState`, `SellerTopBar`

### Responsiveness

- Target breakpoints: 1280px+ primary, 768–1024px tablet with graceful reflow
- Collapsible sidebar below 1024px
- Tables horizontal-scroll below 768px; grid drops to 2 columns
- Small-viewport warning banner only on the item edit form (drag-drop image uploader is the one surface that genuinely needs a desktop/tablet)

### Testing

- Component tests: Vitest + React Testing Library, matching buyer-side conventions, for each new seller component
- Page-level smoke tests: each top-level seller route renders cleanly with a seeded seller user
- Backend: Pest feature tests for new endpoints (`dashboard/metrics`, `labels/preview` if added); follow Layer 5 order test patterns
- No E2E in this layer (Playwright still deferred)

### Auth & guards

- Seller layout calls a server-side check: if the authenticated user has no `store_id`, redirect to `/seller/no-store` (minimal explanatory page)
- `EnsureStoreOwner` middleware continues to gate all `/v1/stores/{store}/*` endpoints; admins bypass as today
- No new impersonation UI this layer

### File/module layout

New and modified under `web/`:

```
src/app/(seller)/
  layout.tsx                         (update — add top bar with NotificationBell)
  page.tsx                           (rewrite — dashboard)
  inbox/page.tsx                     (new)
  inbox/inbox-client.tsx             (new)
  listings/page.tsx                  (rewrite — table/grid + filters)
  listings/listings-client.tsx       (new)
  listings/new/page.tsx              (new — item form, create mode)
  listings/[id]/page.tsx             (new — item form, edit mode)
  listings/item-form.tsx             (new — shared form)
  orders/page.tsx                    (rewrite — filters + search)
  orders/orders-client.tsx           (new)
  orders/[id]/page.tsx               (new — order detail)
  orders/[id]/order-detail-client.tsx (new)
  settings/page.tsx                  (redirect → /seller/settings/store)
  settings/store/page.tsx            (move existing store-settings content)
  settings/shipping/page.tsx         (new)
  settings/notifications/page.tsx    (new)
  settings/payments/page.tsx         (move existing Stripe Connect content)
  no-store/page.tsx                  (new — no-store guard)
src/components/seller/
  kpi-card.tsx                       (new)
  orders-action-list.tsx             (new)
  listings-attention-widget.tsx      (new)
  bulk-action-bar.tsx                (new)
  seller-top-bar.tsx                 (new — wraps NotificationBell + avatar menu)
  data-table.tsx                     (new — thin wrapper)
  empty-state.tsx                    (new — shared empty states)
src/lib/queries/
  use-seller-dashboard.ts            (new)
  use-seller-orders.ts               (new)
  use-seller-items.ts                (new)
  use-notifications.ts               (extend — category filter)
```

Backend (`api/`):

- `app/Modules/Stores/Controllers/SellerDashboardController.php` (new) — `metrics` action
- `app/Modules/Orders/Controllers/OrderFulfillmentController.php` (extend if needed) — `labelsPreview` action
- `app/Modules/Notifications/Controllers/NotificationController.php` (extend) — accept `category` filter
- `routes/api.php` — wire new routes
- `contracts/openapi.yaml` — new endpoint definitions
- `tests/Feature/Seller/*` — Pest tests for the new surfaces

## Open items

These are flagged for the implementation plan, not to block this spec:

- **Label rate preview** — confirm whether `OrderFulfillmentController` already exposes a preview action. If yes, reuse; if no, add one as part of the orders work.
- **Stripe Connect payouts listing** — confirm whether the Stripe API integration already returns recent payouts. If the endpoint isn't there, the Payments tab ships the connect status card only and the payouts table is deferred.
- **"Listings needing attention" criteria** — v1 uses drafts + published-with-zero-views-30d. If the Item model doesn't yet track views, the criteria collapses to drafts only in v1, and views-based detection lands when the view-tracking infrastructure exists.
- **Item view tracking** — if not already in place, it's not added as part of this layer; the attention widget simply won't use that signal yet.
