feat/admin #2
@@ -1,4 +0,0 @@
|
|||||||
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
|
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
|
||||||
CLERK_SECRET_KEY=sk_test_...
|
|
||||||
CLERK_WEBHOOK_SECRET=whsec_...
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Order Processing Backend — Phased Implementation Plan
|
||||||
|
|
||||||
|
**Audience:** Senior software engineers
|
||||||
|
**Scope:** Backend only (Convex, Stripe, Shippo). No UI.
|
||||||
|
**Source:** [Admin Order Processing User Stories](docs/project-documentation/storefront/admin-order-processing-user-stories.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive summary
|
||||||
|
|
||||||
|
This plan breaks the order-processing backend into four phases:
|
||||||
|
|
||||||
|
| Phase | Focus | Key deliverables |
|
||||||
|
|-------|--------|-------------------|
|
||||||
|
| **1** | Label creation | Resolve rate from shipment, POST Shippo transaction, persist label data, status → `processing`, timeline |
|
||||||
|
| **2** | Shippo Track Updated webhook | HTTP route, verify & parse, update order + timeline, set `delivered` on DELIVERED |
|
||||||
|
| **3** | Return/cancel only when confirmed | Enforce `status === "confirmed"` for customer return/cancel; optional schema for `labelUrl` |
|
||||||
|
| **4** | Refund idempotency & polish | Idempotent Stripe refund, optional tracking refresh action, tests |
|
||||||
|
|
||||||
|
**Already in place (no work in this plan):**
|
||||||
|
|
||||||
|
- **Schema:** `orders` (including `shippoShipmentId`, `shippingServiceCode`, `carrier`, `trackingNumber`, `trackingUrl`, `estimatedDelivery`, `actualDelivery`, `shippedAt`, `returnRequestedAt`, `returnReceivedAt`); `orderTimelineEvents` with `by_order` and `by_order_and_created_at`.
|
||||||
|
- **Model:** `recordOrderTimelineEvent` in `convex/model/orders.ts`.
|
||||||
|
- **Queries:** `orders.getTimeline` (timeline by order).
|
||||||
|
- **Orders:** `fulfillFromCheckout` (stores Shippo shipment ID and rate identifiers); `updateStatus` (admin) with timeline; `cancel` (customer, only when allowed) with timeline and stock restore; `requestReturn`, `markReturnReceived`, `getOrderForRefund`, `applyRefund`.
|
||||||
|
- **Refunds:** `returnActions.issueRefund` (admin, Stripe refund + `applyRefund`).
|
||||||
|
- **HTTP:** Clerk and Stripe webhooks in `convex/http.ts`.
|
||||||
|
|
||||||
|
**Resources:** Convex MCP, Stripe MCP, Shippo MCP (e.g. `shipments-get`, `transactions-create`, `tracking-status-get`, `webhooks-create`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Phase 1 — Label creation (US-ADM-1, US-ADM-2)
|
||||||
|
|
||||||
|
**Goal:** Admin can create a shipping label for an order in status `confirmed`. On success, persist label data, set status to `processing` (or `shipped`), and record a timeline event. Enforce valid transitions and prevent duplicate labels.
|
||||||
|
|
||||||
|
### 2.1 Tasks
|
||||||
|
|
||||||
|
1. **Schema (optional)**
|
||||||
|
- Add `labelUrl: v.optional(v.string())` to `orders` if the team wants to store the label PDF/PNG URL from Shippo.
|
||||||
|
- If not stored, admin can open `tracking_url_provider` or use Shippo dashboard for the label.
|
||||||
|
|
||||||
|
2. **Shippo: GET shipment and resolve rate**
|
||||||
|
- In `convex/model/shippo.ts` (or a new internal helper used only by the label action):
|
||||||
|
- Add a function that calls Shippo **GET** `https://api.goshippo.com/shipments/{order.shippoShipmentId}` (or use Shippo MCP `shipments-get` from an action).
|
||||||
|
- From the response `rates` array, find the rate where `rate.servicelevel.token === order.shippingServiceCode` and `rate.provider === order.carrier`.
|
||||||
|
- Return that rate’s `object_id`.
|
||||||
|
- Handle: shipment not found, no matching rate, or multiple matches (pick first or fail explicitly).
|
||||||
|
|
||||||
|
3. **Shippo: Create transaction (purchase label)**
|
||||||
|
- Add a Convex **action** (e.g. in `convex/fulfillmentActions.ts` or `convex/orders.ts` with `"use node";` in a separate file if needed) that:
|
||||||
|
- Takes `orderId: v.id("orders")`.
|
||||||
|
- Runs with admin auth (e.g. resolve user via identity, then `requireAdmin`-style check via internal query).
|
||||||
|
- Loads order (internal query); validates `order.status === "confirmed"` and that the order has no `trackingNumber` yet (no label already created).
|
||||||
|
- Calls the new “resolve rate” helper to get the rate object ID from `order.shippoShipmentId`, `order.shippingServiceCode`, `order.carrier`.
|
||||||
|
- Calls Shippo **POST** `/transactions` with `{ rate: rateObjectId }` (and `async: true` for international if applicable).
|
||||||
|
- If Shippo returns async (e.g. status `QUEUED`), poll **GET** transaction until `SUCCESS` or `ERROR` (or schedule a follow-up action to poll).
|
||||||
|
- On success: extract `tracking_number`, `tracking_url_provider`, and optionally `label_url` from the transaction response.
|
||||||
|
|
||||||
|
4. **Persist label and update order**
|
||||||
|
- From the same action, call an **internal mutation** that:
|
||||||
|
- Receives `orderId`, `trackingNumber`, `trackingUrl` (from `tracking_url_provider`), optional `labelUrl`, and optional `eta` if Shippo provides it.
|
||||||
|
- Validates again that order is `confirmed` and has no existing `trackingNumber` (idempotency / duplicate-label guard).
|
||||||
|
- Patches order: `trackingNumber`, `trackingUrl`, `labelUrl` (if schema added), `estimatedDelivery` (if provided), `shippedAt: Date.now()`, `status: "processing"` (or `"shipped"` per team decision), `updatedAt: Date.now()`.
|
||||||
|
- Calls `recordOrderTimelineEvent` with `eventType: "label_created"`, `source: "admin"`, `fromStatus: "confirmed"`, `toStatus: "processing"` (or `"shipped"`), and optional `payload` (e.g. JSON with tracking number and carrier).
|
||||||
|
- On Shippo failure (rate expired, address issue, etc.): return a structured error (e.g. `{ success: false, code: "RATE_EXPIRED" | "SHIPPO_ERROR", message: string }`) so the client can show a clear message.
|
||||||
|
|
||||||
|
5. **Public API**
|
||||||
|
- Expose one public (or admin-only) **action** that orchestrates: resolve rate → create transaction → persist + timeline.
|
||||||
|
- Ensure only admins can call it (e.g. resolve user and check role in action, or call from a mutation that already enforces admin).
|
||||||
|
|
||||||
|
### 2.2 Acceptance (backend)
|
||||||
|
|
||||||
|
- Only orders in status `confirmed` and without an existing `trackingNumber` can receive a label.
|
||||||
|
- After a successful label creation, order has `trackingNumber`, `trackingUrl`, `shippedAt`, and status `processing` (or `shipped`); one `label_created` timeline event exists.
|
||||||
|
- Duplicate label creation for the same order is rejected.
|
||||||
|
- Rate resolution uses `shippoShipmentId` + `shippingServiceCode` + `carrier`; expired or missing rate returns a clear error.
|
||||||
|
|
||||||
|
### 2.3 Dependencies and references
|
||||||
|
|
||||||
|
- Shippo: [Transactions](https://docs.goshippo.com/docs/guides_general/transactions/) (POST with rate ID).
|
||||||
|
- Shippo MCP: `shipments-get` (ShipmentId = `order.shippoShipmentId`), `transactions-create` (request `{ rate: rateObjectId }`).
|
||||||
|
- Existing: `convex/model/shippo.ts` (GET shipment not present today; add or call from action via fetch/MCP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase 2 — Shippo Track Updated webhook (US-ADM-3)
|
||||||
|
|
||||||
|
**Goal:** Receive Shippo “Track Updated” webhooks, verify and parse payload, update the order (tracking status, ETA, and on DELIVERED set `status: "delivered"`, `actualDelivery`), and append a `tracking_update` event to `orderTimelineEvents`. No manual refresh required for live tracking.
|
||||||
|
|
||||||
|
### 3.1 Tasks
|
||||||
|
|
||||||
|
1. **HTTP route**
|
||||||
|
- In `convex/http.ts`, add a route, e.g. `POST /shippo/webhook`.
|
||||||
|
- Handler: `httpAction` that reads body, optionally verifies Shippo signature (if Shippo supports it; check [Shippo webhooks](https://docs.goshippo.com/docs/tracking/tracking)), then calls an internal action with the raw body (or parsed JSON).
|
||||||
|
|
||||||
|
2. **Webhook handler action**
|
||||||
|
- Implement an **internal action** (e.g. `internal.fulfillmentActions.handleShippoTrackUpdated` or `internal.shippoWebhook.handleTrackUpdated`) that:
|
||||||
|
- Parses the JSON payload (carrier, tracking_number, tracking_status, status_details, location, eta, etc.).
|
||||||
|
- Finds the order: query by `trackingNumber` + `carrier`. Add an index on `orders` e.g. `by_tracking_number_and_carrier: ["trackingNumber", "carrier"]` for efficient lookup (only orders with a label have these set).
|
||||||
|
- If no order found, return 200 anyway (avoid retries for bad data) and log.
|
||||||
|
- If order found: call an **internal mutation** that:
|
||||||
|
- Updates the order: set latest tracking state (e.g. store a minimal `trackingStatus` / `trackingStatusDetails` on `orders` if desired for display; otherwise derive from timeline).
|
||||||
|
- Sets `estimatedDelivery` from webhook `eta` if provided.
|
||||||
|
- If tracking status is **DELIVERED**: set `order.status = "delivered"`, `actualDelivery = Date.now()`, `updatedAt = Date.now()`.
|
||||||
|
- Inserts one `orderTimelineEvents` row: `eventType: "tracking_update"`, `source: "shippo_webhook"`, `payload: JSON.stringify(webhookPayload)`, `createdAt: Date.now()`.
|
||||||
|
|
||||||
|
3. **Idempotency / duplicates**
|
||||||
|
- Shippo may send duplicate events. Options: (a) store last processed `tracking_status` + timestamp on the order and skip if same status already applied; (b) or accept duplicate timeline events and treat UI as “last event wins” for status. Prefer (a) if you want a clean timeline.
|
||||||
|
|
||||||
|
4. **Shippo webhook registration**
|
||||||
|
- Document or script: register in Shippo dashboard (or via Shippo MCP `webhooks-create`) a webhook with `event: "track_updated"` and URL `https://<deployment>.convex.site/shippo/webhook`. Use production URL and live API key for live tracking.
|
||||||
|
|
||||||
|
### 3.2 Optional: “Refresh tracking” action
|
||||||
|
|
||||||
|
- Add an **action** (admin-only) that takes `orderId`, loads order, then calls Shippo **GET** tracking (e.g. `tracks/{carrier}/{tracking_number}` or Shippo MCP `tracking-status-get`) and then updates the order + appends a `tracking_update` timeline event from the response. Use for debugging or one-off backfills; primary source remains the webhook.
|
||||||
|
|
||||||
|
### 3.3 Acceptance (backend)
|
||||||
|
|
||||||
|
- POST to `/shippo/webhook` with a valid Track Updated payload updates the correct order and appends a `tracking_update` event.
|
||||||
|
- When status is DELIVERED, order `status` becomes `delivered` and `actualDelivery` is set.
|
||||||
|
- Unknown tracking numbers or missing orders do not cause 5xx (return 200 and log).
|
||||||
|
|
||||||
|
### 3.4 Dependencies and references
|
||||||
|
|
||||||
|
- Shippo: [Track live shipments](https://docs.goshippo.com/docs/tracking/tracking), webhook payload shape.
|
||||||
|
- Shippo MCP: `webhooks-create` (event `track_updated`, url = Convex HTTP URL); `tracking-status-get` for optional refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase 3 — Return / cancel only when confirmed (US-ADM-4)
|
||||||
|
|
||||||
|
**Goal:** Enforce in the backend that customer-initiated return and cancel are allowed **only** when `order.status === "confirmed"`. Align `canCustomerRequestReturn` (or equivalent) with this rule so that once the order moves to `processing`, `shipped`, or `delivered`, return/cancel from the storefront flow is rejected.
|
||||||
|
|
||||||
|
### 4.1 Tasks
|
||||||
|
|
||||||
|
1. **Cancel (already aligned)**
|
||||||
|
- `orders.cancel` already uses `canCustomerCancel(order)` which allows only `confirmed`. No change required unless you want to explicitly document or add a test that only `confirmed` is allowed.
|
||||||
|
|
||||||
|
2. **Return request**
|
||||||
|
- Today `canCustomerRequestReturn` allows return only when `order.status === "delivered"`. Per user stories, **return is only allowed while status is confirmed**.
|
||||||
|
- Update `canCustomerRequestReturn` in `convex/model/orders.ts` so that it returns `allowed: true` only when `order.status === "confirmed"` (and optionally not already cancelled/refunded/return requested).
|
||||||
|
- Adjust `orders.requestReturn` so that it uses this updated helper and rejects when not allowed.
|
||||||
|
- If the product decision is “return when confirmed = cancel”, you may keep a single cancel flow and treat “return” as cancel when confirmed; otherwise keep `requestReturn` for a distinct “return requested” state that stays in `confirmed` until admin processes it.
|
||||||
|
|
||||||
|
3. **Validation in mutations**
|
||||||
|
- Ensure any customer-facing mutation that cancels or requests a return checks the same rule: `status === "confirmed"`. Add an explicit guard at the start of `cancel` and `requestReturn` (e.g. “only confirmed orders can be cancelled/returned”) so the contract is clear.
|
||||||
|
|
||||||
|
4. **Timeline**
|
||||||
|
- When a return is requested (while confirmed), keep recording the existing `return_requested` (or equivalent) timeline event with `source: "customer_return"`.
|
||||||
|
|
||||||
|
### 4.2 Acceptance (backend)
|
||||||
|
|
||||||
|
- Customer cancel and customer return request succeed only when `order.status === "confirmed"`.
|
||||||
|
- For `processing`, `shipped`, or `delivered`, both flows return a clear error (e.g. “Return/cancel not available; contact support”).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 4 — Refund idempotency and polish (US-ADM-5)
|
||||||
|
|
||||||
|
**Goal:** Make refunds idempotent (no double Stripe refund if admin clicks twice), and add optional tracking refresh and tests.
|
||||||
|
|
||||||
|
### 5.1 Tasks
|
||||||
|
|
||||||
|
1. **Idempotent Stripe refund**
|
||||||
|
- In `returnActions.issueRefund` (or equivalent): before calling `stripe.refunds.create`, list existing refunds for the payment intent (e.g. `stripe.refunds.list({ payment_intent: order.stripePaymentIntentId })`).
|
||||||
|
- If a refund already exists for the full amount (or the intended amount), skip creating a new one and still call `internal.orders.applyRefund` only if the order is not yet `refunded` (so order and payment status are updated once).
|
||||||
|
- If partial refunds are in scope, define the rule (e.g. “only one full refund” or “sum of refunds < total”) and implement accordingly.
|
||||||
|
|
||||||
|
2. **Order guard in applyRefund**
|
||||||
|
- In `orders.applyRefund`, at the start: if `order.paymentStatus === "refunded"` (and optionally `order.status === "refunded"`), skip patching and timeline (idempotent). This protects against double application when the action is called twice.
|
||||||
|
|
||||||
|
3. **Optional: Tracking refresh action**
|
||||||
|
- Implement the optional “Refresh tracking” action described in Phase 2 (GET Shippo tracking by carrier + tracking number, update order and append one `tracking_update` event). Secure with admin-only check.
|
||||||
|
|
||||||
|
4. **Tests**
|
||||||
|
- Unit/integration tests for: (a) label creation — only `confirmed`, no duplicate label; (b) webhook handler — find order by tracking + carrier, set delivered on DELIVERED; (c) return/cancel only when confirmed; (d) refund idempotency (second call does not create a second Stripe refund, order updated once).
|
||||||
|
|
||||||
|
### 5.2 Acceptance (backend)
|
||||||
|
|
||||||
|
- Issuing refund twice for the same order does not create two Stripe refunds and does not double-restore stock or double-write timeline.
|
||||||
|
- Refund flow is documented (full vs partial, idempotency rule).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implementation order and dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Label creation) → no dependency on Phase 2/3/4
|
||||||
|
Phase 2 (Track webhook) → depends on orders having trackingNumber (from Phase 1 or manual)
|
||||||
|
Phase 3 (Return when confirmed) → independent; can run in parallel with 1 or 2
|
||||||
|
Phase 4 (Refund idempotency) → depends on existing refund flow; can run after or in parallel with 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended sequence: **Phase 1 → Phase 2 → Phase 3 → Phase 4**, with Phase 3 and 4 swappable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File and module checklist
|
||||||
|
|
||||||
|
| Area | File / module | Changes |
|
||||||
|
|------|----------------|---------|
|
||||||
|
| Schema | `convex/schema.ts` | Optional: `orders.labelUrl`; optional: `orders.trackingStatus` / `trackingStatusDetails`; index `by_tracking_number_and_carrier` on `["trackingNumber", "carrier"]` for webhook lookup. |
|
||||||
|
| Model | `convex/model/orders.ts` | `canCustomerRequestReturn`: allow only `confirmed`; keep `recordOrderTimelineEvent`. |
|
||||||
|
| Model | `convex/model/shippo.ts` | New: get shipment by ID; resolve rate by `shippingServiceCode` + `carrier`. |
|
||||||
|
| Orders | `convex/orders.ts` | Optional: internal mutation for “apply label data + status + timeline”; guards in `requestReturn` / cancel. |
|
||||||
|
| Actions | New or existing (e.g. `convex/fulfillmentActions.ts`) | Label flow: resolve rate → POST transaction → internal mutation; optional: refresh tracking action. |
|
||||||
|
| Refunds | `convex/returnActions.ts` | Idempotent refund: list refunds, then create or skip; call `applyRefund` only when order not already refunded. |
|
||||||
|
| Refunds | `convex/orders.ts` (`applyRefund`) | Idempotent: skip if already `paymentStatus === "refunded"`. |
|
||||||
|
| HTTP | `convex/http.ts` | New route `POST /shippo/webhook` → internal action. |
|
||||||
|
| Webhook handler | New or existing (e.g. `convex/shippoWebhook.ts`) | Parse Track Updated, find order, internal mutation to update order + insert `tracking_update` event; on DELIVERED set `delivered` and `actualDelivery`. |
|
||||||
|
| Tests | `convex/orders.test.ts`, `convex/fulfillmentActions.test.ts`, etc. | Label creation rules, webhook handler, return/cancel guards, refund idempotency. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Environment and configuration
|
||||||
|
|
||||||
|
- **Convex:** No new env vars required for core flow.
|
||||||
|
- **Shippo:** `SHIPPO_API_KEY` already used; ensure production webhook URL and (if applicable) webhook secret are configured for Track Updated.
|
||||||
|
- **Stripe:** Existing keys; no new config for refunds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Story-to-phase mapping
|
||||||
|
|
||||||
|
| Story | Phase | Backend work |
|
||||||
|
|-------|--------|----------------|
|
||||||
|
| US-ADM-1 Create shipping label | 1 | Resolve rate, POST transaction, persist label, status, timeline |
|
||||||
|
| US-ADM-2 Order status when label printed | 1 | Same mutation sets status to `processing` (or `shipped`) |
|
||||||
|
| US-ADM-3 View tracking (live via webhook) | 2 | Shippo webhook handler; optional refresh action |
|
||||||
|
| US-ADM-4 Return only when confirmed | 3 | `canCustomerRequestReturn` + guards in cancel/requestReturn |
|
||||||
|
| US-ADM-5 Refund after successful return | 4 | Idempotent Stripe refund + idempotent `applyRefund` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document version: 1.0. No UI work included; UI can consume the new/updated queries and actions as needed.*
|
||||||
488
apps/admin/docs/07-orders-ui-design-plan.md
Normal file
488
apps/admin/docs/07-orders-ui-design-plan.md
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
# Orders UI — Design Plan (Admin Dashboard)
|
||||||
|
|
||||||
|
**Audience:** Senior software engineers
|
||||||
|
**Scope:** Orders UI only — list page, detail page, all admin actions. No new backend work.
|
||||||
|
**Backend reference:** `06-order-processing-backend-implementation-plan.md`
|
||||||
|
**Design constraint:** Match the existing admin design language exactly (ShadCN only, HugeIcons, same layout shell, same patterns as Products page).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design philosophy
|
||||||
|
|
||||||
|
Three principles guide every decision here:
|
||||||
|
|
||||||
|
1. **Single responsibility per component.** Each component owns one concern: a card, a table, a button, a timeline. No component manages both data display and action side-effects unless it is a leaf action component.
|
||||||
|
2. **Status-driven UI.** The order's `status` field is the single source of truth. What you see, what buttons appear, what badges render — all computed from status. No hidden state, no guessing.
|
||||||
|
3. **Consistency over novelty.** Use the exact same patterns already in the codebase: `rounded-lg border` for table wrappers, `text-xl font-semibold` headings, `usePaginatedQuery` + "Load more", `AlertDialog` for destructive actions, `toast` (Sonner) for feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Route structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/orders → OrdersPage (list)
|
||||||
|
/orders/[id] → OrderDetailPage (detail)
|
||||||
|
```
|
||||||
|
|
||||||
|
The detail page is a full Next.js route (`app/(dashboard)/orders/[id]/page.tsx`), not a dialog. Orders are complex enough to deserve their own page; a dialog would feel cramped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Status vocabulary
|
||||||
|
|
||||||
|
Both pages share the same two badge components. Define color intent once, reuse everywhere.
|
||||||
|
|
||||||
|
### 3.1 Order status badges
|
||||||
|
|
||||||
|
| Status | Badge variant | Tailwind override | Meaning |
|
||||||
|
|--------|--------------|-------------------|---------|
|
||||||
|
| `pending` | `secondary` | — | Payment not yet confirmed |
|
||||||
|
| `confirmed` | `outline` | `border-blue-500 text-blue-600` | Paid, awaiting fulfilment |
|
||||||
|
| `processing` | `outline` | `border-violet-500 text-violet-600` | Label created, packing |
|
||||||
|
| `shipped` | `outline` | `border-indigo-500 text-indigo-600` | In transit |
|
||||||
|
| `delivered` | `default` | `bg-green-600` | Delivered |
|
||||||
|
| `cancelled` | `destructive` | — | Cancelled |
|
||||||
|
| `refunded` | `secondary` | `line-through` | Refunded |
|
||||||
|
|
||||||
|
### 3.2 Payment status badges
|
||||||
|
|
||||||
|
| Status | Badge variant | Tailwind override |
|
||||||
|
|--------|--------------|-------------------|
|
||||||
|
| `pending` | `secondary` | — |
|
||||||
|
| `paid` | `default` | `bg-green-600` |
|
||||||
|
| `failed` | `destructive` | — |
|
||||||
|
| `refunded` | `secondary` | — |
|
||||||
|
|
||||||
|
Both mappings live in `apps/admin/src/components/orders/shared/statusConfig.ts` — a plain TypeScript constants file, no JSX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Orders list page
|
||||||
|
|
||||||
|
**File:** `apps/admin/src/app/(dashboard)/orders/page.tsx`
|
||||||
|
|
||||||
|
### 4.1 Layout (top to bottom)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Orders │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ [🔍 Search by order # or email…] [Status ▾] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ Order # │ Customer │ Date │ Status │ Total │ │
|
||||||
|
│ │──────────┼──────────┼──────┼────────┼───────│ │
|
||||||
|
│ │ ORD-AB12 │ Jane Doe │ … │ [Conf] │ £42 │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
│ 12 loaded [Load more] │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Data fetching
|
||||||
|
|
||||||
|
- **Default (no filter, no search):** `usePaginatedQuery(api.orders.listAll, {}, { initialNumItems: 25 })`
|
||||||
|
- **Status filter active:** `usePaginatedQuery(api.orders.listAll, { status }, { initialNumItems: 25 })`
|
||||||
|
- Pagination: "Load more" button + count footer — identical to ProductsPage.
|
||||||
|
- No search query for now (orders list does not have a full-text search index). Search by order number or email is client-side filtered from the loaded page; add a note in the component that server-side search can be added when an index exists.
|
||||||
|
|
||||||
|
### 4.3 Table columns
|
||||||
|
|
||||||
|
| Column | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| Order # | `font-mono text-xs`; clicking the row navigates to detail |
|
||||||
|
| Customer | `order.email`; `text-muted-foreground` |
|
||||||
|
| Date | `createdAt` formatted short; `text-xs text-muted-foreground` |
|
||||||
|
| Status | `<OrderStatusBadge status={order.status} />` |
|
||||||
|
| Payment | `<OrderPaymentBadge status={order.paymentStatus} />` |
|
||||||
|
| Total | `formatPrice(order.total)` right-aligned |
|
||||||
|
|
||||||
|
No actions menu column — clicking anywhere on the row navigates to the detail page. Keeps the list clean. A chevron icon on the right of each row communicates navigability.
|
||||||
|
|
||||||
|
### 4.4 Extracted components
|
||||||
|
|
||||||
|
| Component | File | Responsibility |
|
||||||
|
|-----------|------|---------------|
|
||||||
|
| `OrderStatusBadge` | `components/orders/shared/OrderStatusBadge.tsx` | Renders one `<Badge>` for order status |
|
||||||
|
| `OrderPaymentBadge` | `components/orders/shared/OrderPaymentBadge.tsx` | Renders one `<Badge>` for payment status |
|
||||||
|
| `OrdersTableSkeleton` | inline in list page | 10 skeleton rows, same columns |
|
||||||
|
|
||||||
|
### 4.5 Empty and loading states
|
||||||
|
|
||||||
|
- **Loading:** 10 skeleton rows with matching column widths.
|
||||||
|
- **Empty (no orders):** Centered row: *"No orders yet."*
|
||||||
|
- **Empty (filter applied):** *"No orders with status 'confirmed'."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Order detail page
|
||||||
|
|
||||||
|
**File:** `apps/admin/src/app/(dashboard)/orders/[id]/page.tsx`
|
||||||
|
|
||||||
|
This page uses `useQuery(api.orders.getById, { id })` and `useQuery(api.orders.getTimeline, { orderId: id })` to load both the order and its timeline in parallel.
|
||||||
|
|
||||||
|
### 5.1 Layout
|
||||||
|
|
||||||
|
Two-column layout on `md+`, stacked on mobile. The left column is wider (about 60%) and holds the primary data. The right column holds contextual cards.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┬───────────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ ← Orders │ Customer │
|
||||||
|
│ ORD-AB12 [Conf] │ Jane Doe │
|
||||||
|
│ 14 Jan 2026 │ jane@example.com │
|
||||||
|
│ ├───────────────────┤
|
||||||
|
│ [Admin actions bar] │ Shipping address │
|
||||||
|
│ │ 123 Main St… │
|
||||||
|
├──────────────────────┤ │
|
||||||
|
│ Order items ├───────────────────┤
|
||||||
|
│ ────────────────── │ Fulfilment │
|
||||||
|
│ Product Qty Total │ Carrier: DPD UK │
|
||||||
|
│ … │ Service: Next Day│
|
||||||
|
├──────────────────────┤ [Create Label] │
|
||||||
|
│ Financials ├───────────────────┤
|
||||||
|
│ Subtotal £38.00 │ │
|
||||||
|
│ Shipping £4.99 │ (empty if no │
|
||||||
|
│ Total £42.99 │ tracking yet) │
|
||||||
|
├──────────────────────┤ │
|
||||||
|
│ Timeline │ │
|
||||||
|
│ ○ Order confirmed │ │
|
||||||
|
│ ○ Label created │ │
|
||||||
|
│ ○ Delivered │ │
|
||||||
|
└──────────────────────┴───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
On mobile, right column cards stack below the left column, in this order: Customer → Shipping → Fulfilment.
|
||||||
|
|
||||||
|
### 5.2 Loading state
|
||||||
|
|
||||||
|
All cards render as `Skeleton` blocks while data is `undefined`. Each card has its own skeleton shape so the layout does not shift when data loads. No full-page spinner.
|
||||||
|
|
||||||
|
### 5.3 Component breakdown
|
||||||
|
|
||||||
|
Every component below has **one job**. None of them fetch data themselves — the page fetches and passes props down.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.1 `OrderPageHeader`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/OrderPageHeader.tsx`
|
||||||
|
**Job:** Back link, order number, date, status badges.
|
||||||
|
|
||||||
|
```
|
||||||
|
← Orders ORD-AB12 [Confirmed] [Paid] 14 Jan 2026, 10:22
|
||||||
|
```
|
||||||
|
|
||||||
|
Props: `orderNumber`, `createdAt`, `status`, `paymentStatus`.
|
||||||
|
No actions here. Badges are read-only. Headings use `text-xl font-semibold`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.2 `OrderActionsBar`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/OrderActionsBar.tsx`
|
||||||
|
**Job:** Render the correct set of action buttons based on the order's current status. This is the only component that "knows" which actions are valid — it reads `status`, `paymentStatus`, `trackingNumber`, `returnRequestedAt`, `returnReceivedAt` and decides what to render.
|
||||||
|
|
||||||
|
Action matrix:
|
||||||
|
|
||||||
|
| Condition | Buttons shown |
|
||||||
|
|-----------|--------------|
|
||||||
|
| `status === "confirmed"` and no `trackingNumber` | **Create Label** (primary), **Update Status** (outline) |
|
||||||
|
| `status === "confirmed"` and `trackingNumber` exists | **Update Status** (outline) |
|
||||||
|
| `status === "confirmed"` and `returnRequestedAt` and no `returnReceivedAt` | **Mark Return Received** (outline), **Update Status** |
|
||||||
|
| `status === "confirmed"` and `returnReceivedAt` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
|
||||||
|
| `status === "processing"` or `"shipped"` | **Update Status** only |
|
||||||
|
| `status === "delivered"` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
|
||||||
|
| `status === "cancelled"` or `"refunded"` | Read-only — no buttons |
|
||||||
|
|
||||||
|
**`OrderActionsBar` does not implement the action logic itself.** It renders the individual action components below. It is purely a conditional layout shell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.3 `CreateLabelButton`
|
||||||
|
|
||||||
|
**File:** `components/orders/actions/CreateLabelButton.tsx`
|
||||||
|
**Job:** Call `api.fulfillmentActions.createShippingLabel`, show loading spinner, surface structured errors.
|
||||||
|
|
||||||
|
- Uses `useAction(api.fulfillmentActions.createShippingLabel)`.
|
||||||
|
- On click: sets loading, calls action, on success shows `toast.success("Label created. Tracking: {trackingNumber}")`, on structured error shows `toast.error(result.message)`, on thrown error shows generic toast.
|
||||||
|
- Button label: "Create Label" → "Creating…" while loading.
|
||||||
|
- Disabled while loading.
|
||||||
|
- No AlertDialog — creating a label is not destructive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.4 `UpdateStatusDialog`
|
||||||
|
|
||||||
|
**File:** `components/orders/actions/UpdateStatusDialog.tsx`
|
||||||
|
**Job:** A `Dialog` containing a `Select` of valid next statuses. On confirm, calls `api.orders.updateStatus`.
|
||||||
|
|
||||||
|
- Triggered by "Update Status" button in `OrderActionsBar`.
|
||||||
|
- Select shows all statuses except the current one.
|
||||||
|
- Submit button shows spinner while mutating.
|
||||||
|
- On success: `toast.success("Status updated to {status}")`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.5 `MarkReturnReceivedButton`
|
||||||
|
|
||||||
|
**File:** `components/orders/actions/MarkReturnReceivedButton.tsx`
|
||||||
|
**Job:** Call `api.orders.markReturnReceived` with an `AlertDialog` confirmation.
|
||||||
|
|
||||||
|
- AlertDialog title: *"Mark return as received?"*
|
||||||
|
- Description: *"Confirm that the returned items have arrived. You can then issue a refund."*
|
||||||
|
- Uses `useMutation(api.orders.markReturnReceived)`.
|
||||||
|
- Shows spinner on the confirm button while loading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.6 `IssueRefundButton`
|
||||||
|
|
||||||
|
**File:** `components/orders/actions/IssueRefundButton.tsx`
|
||||||
|
**Job:** Call `api.returnActions.issueRefund` with an `AlertDialog` confirmation.
|
||||||
|
|
||||||
|
- AlertDialog title: *"Issue full refund?"*
|
||||||
|
- Description: *"A full refund of £{order.total} will be sent to the customer via Stripe. This cannot be undone."*
|
||||||
|
- Uses `useAction(api.returnActions.issueRefund)`.
|
||||||
|
- Confirm button: destructive variant, "Issue Refund" → "Refunding…".
|
||||||
|
- On success: `toast.success("Refund issued")`.
|
||||||
|
- On error: `toast.error(err.message)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.7 `OrderItemsCard`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/OrderItemsCard.tsx`
|
||||||
|
**Job:** Display the line items in a `Card` with a bordered table inside.
|
||||||
|
|
||||||
|
Columns: Product name (+ variant name below in `text-xs text-muted-foreground`), SKU (`font-mono text-xs`), Qty, Unit price, Total.
|
||||||
|
No actions. Read-only.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Order items ────────────────────────────────────┐
|
||||||
|
│ Product SKU Qty Unit Total │
|
||||||
|
│ Royal Canin… │
|
||||||
|
│ Adult 2kg SKU-001 2 £19.00 £38.00 │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.8 `OrderFinancialsCard`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/OrderFinancialsCard.tsx`
|
||||||
|
**Job:** Render the price breakdown.
|
||||||
|
|
||||||
|
Uses `InfoRow`-style layout (label left, value right) inside a `Card`. Subtotal, shipping, discount (if > 0), tax (if > 0), and a `Separator` before the bold total.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.9 `CustomerCard`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/CustomerCard.tsx`
|
||||||
|
**Job:** Display the customer name and email.
|
||||||
|
|
||||||
|
`Card` with `CardHeader` (title "Customer") and `CardContent`. Email is a `mailto:` link styled as `text-sm text-muted-foreground hover:underline`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.10 `ShippingAddressCard`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/ShippingAddressCard.tsx`
|
||||||
|
**Job:** Render the `shippingAddressSnapshot` in a `Card`.
|
||||||
|
|
||||||
|
Displays each address line in `text-sm`, country in `text-xs text-muted-foreground`. No editing — it's a snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.11 `FulfilmentCard`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/FulfilmentCard.tsx`
|
||||||
|
**Job:** Shipping service info + tracking state. No action buttons — those live in `OrderActionsBar`.
|
||||||
|
|
||||||
|
Three visual states:
|
||||||
|
|
||||||
|
**State A — No label yet (status: `confirmed`, no `trackingNumber`):**
|
||||||
|
```
|
||||||
|
┌─ Fulfilment ──────────────────────────────────┐
|
||||||
|
│ Carrier DPD UK │
|
||||||
|
│ Service Next Day │
|
||||||
|
│ Method Express │
|
||||||
|
│ │
|
||||||
|
│ No label created yet. │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**State B — Label created, in transit:**
|
||||||
|
```
|
||||||
|
┌─ Fulfilment ──────────────────────────────────┐
|
||||||
|
│ Carrier DPD UK │
|
||||||
|
│ Service Next Day │
|
||||||
|
│ Tracking 1Z999AA1… [↗ Track] │
|
||||||
|
│ Status TRANSIT │
|
||||||
|
│ Est. delivery 18 Jan 2026 │
|
||||||
|
│ Label [↗ Download] │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**State C — Delivered:**
|
||||||
|
```
|
||||||
|
┌─ Fulfilment ──────────────────────────────────┐
|
||||||
|
│ Carrier DPD UK │
|
||||||
|
│ Tracking 1Z999AA1… [↗ Track] │
|
||||||
|
│ Status DELIVERED │
|
||||||
|
│ Delivered 17 Jan 2026, 14:35 │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The `trackingStatus` field drives the displayed status string. Tracking and label URLs open in a new tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3.12 `OrderTimelineCard`
|
||||||
|
|
||||||
|
**File:** `components/orders/detail/OrderTimelineCard.tsx`
|
||||||
|
**Job:** Render `orderTimelineEvents` as a vertical stepper/timeline.
|
||||||
|
|
||||||
|
Receives the `events` array as a prop (fetched by the page).
|
||||||
|
|
||||||
|
Each event row:
|
||||||
|
```
|
||||||
|
○ Status changed: confirmed → processing 14 Jan · 10:35
|
||||||
|
by admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Event type → human label mapping:
|
||||||
|
|
||||||
|
| `eventType` | Display label |
|
||||||
|
|-------------|--------------|
|
||||||
|
| `status_change` | "Status changed: {fromStatus} → {toStatus}" |
|
||||||
|
| `label_created` | "Shipping label created" + tracking number from payload |
|
||||||
|
| `tracking_update` | "Tracking update: {status}" |
|
||||||
|
| `customer_cancel` | "Customer requested cancellation" |
|
||||||
|
| `return_requested` | "Customer requested return" |
|
||||||
|
| `return_received` | "Return marked as received" |
|
||||||
|
| `refund` | "Refund issued" |
|
||||||
|
|
||||||
|
The dot (`○`) color matches the status badge color for `status_change` events; neutral for all others.
|
||||||
|
|
||||||
|
Loading state: 3–4 skeleton rows.
|
||||||
|
Empty state: *"No activity yet."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Shared utilities
|
||||||
|
|
||||||
|
**File:** `components/orders/shared/statusConfig.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Order status → { label, badgeVariant, className }
|
||||||
|
// Payment status → { label, badgeVariant, className }
|
||||||
|
// Event type → human label
|
||||||
|
// Timeline dot color by toStatus
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep all display constants here. Both pages and all badge components import from this single file. No duplication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File tree
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/admin/src/
|
||||||
|
├── app/(dashboard)/
|
||||||
|
│ └── orders/
|
||||||
|
│ ├── page.tsx ← Orders list page
|
||||||
|
│ └── [id]/
|
||||||
|
│ └── page.tsx ← Order detail page
|
||||||
|
└── components/
|
||||||
|
└── orders/
|
||||||
|
├── shared/
|
||||||
|
│ ├── statusConfig.ts ← Constants only, no JSX
|
||||||
|
│ ├── OrderStatusBadge.tsx
|
||||||
|
│ └── OrderPaymentBadge.tsx
|
||||||
|
├── detail/
|
||||||
|
│ ├── OrderPageHeader.tsx
|
||||||
|
│ ├── OrderActionsBar.tsx ← Layout shell, no action logic
|
||||||
|
│ ├── OrderItemsCard.tsx
|
||||||
|
│ ├── OrderFinancialsCard.tsx
|
||||||
|
│ ├── CustomerCard.tsx
|
||||||
|
│ ├── ShippingAddressCard.tsx
|
||||||
|
│ ├── FulfilmentCard.tsx
|
||||||
|
│ └── OrderTimelineCard.tsx
|
||||||
|
└── actions/
|
||||||
|
├── CreateLabelButton.tsx
|
||||||
|
├── UpdateStatusDialog.tsx
|
||||||
|
├── MarkReturnReceivedButton.tsx
|
||||||
|
└── IssueRefundButton.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
OrderDetailPage (page.tsx)
|
||||||
|
│
|
||||||
|
│ useQuery(api.orders.getById, { id }) → order
|
||||||
|
│ useQuery(api.orders.getTimeline, { orderId }) → events
|
||||||
|
│
|
||||||
|
├── OrderPageHeader (order)
|
||||||
|
├── OrderActionsBar (order)
|
||||||
|
│ ├── CreateLabelButton (orderId, order.status, order.trackingNumber)
|
||||||
|
│ ├── UpdateStatusDialog (orderId, order.status)
|
||||||
|
│ ├── MarkReturnReceivedButton (orderId)
|
||||||
|
│ └── IssueRefundButton (orderId, order.total, order.currency)
|
||||||
|
│
|
||||||
|
├── [left column]
|
||||||
|
│ ├── OrderItemsCard (order.items)
|
||||||
|
│ ├── OrderFinancialsCard (order)
|
||||||
|
│ └── OrderTimelineCard (events)
|
||||||
|
│
|
||||||
|
└── [right column]
|
||||||
|
├── CustomerCard (order.email, order.userId)
|
||||||
|
├── ShippingAddressCard (order.shippingAddressSnapshot)
|
||||||
|
└── FulfilmentCard (order)
|
||||||
|
```
|
||||||
|
|
||||||
|
All queries are read-only in the page. Action components call mutations/actions themselves and handle their own loading/error state. The page never passes mutation functions as props.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation order
|
||||||
|
|
||||||
|
| Step | What to build | Why first |
|
||||||
|
|------|---------------|-----------|
|
||||||
|
| 1 | `statusConfig.ts`, `OrderStatusBadge`, `OrderPaymentBadge` | Shared atoms used by both pages |
|
||||||
|
| 2 | Orders list page | Validates data fetching works, gives navigation to detail |
|
||||||
|
| 3 | `OrderPageHeader`, `OrderItemsCard`, `OrderFinancialsCard` | Core detail page scaffold — read-only |
|
||||||
|
| 4 | `CustomerCard`, `ShippingAddressCard`, `FulfilmentCard` | Right column — all read-only |
|
||||||
|
| 5 | `OrderTimelineCard` | Needs timeline query wired |
|
||||||
|
| 6 | `UpdateStatusDialog` | Lowest-risk action — any status |
|
||||||
|
| 7 | `CreateLabelButton` | Most impactful Phase 1 action |
|
||||||
|
| 8 | `MarkReturnReceivedButton`, `IssueRefundButton` | Phase 3/4 actions |
|
||||||
|
| 9 | `OrderActionsBar` | Wire all actions together with condition logic |
|
||||||
|
|
||||||
|
Build read-only views first, add write actions last. This way the detail page is useful even before all actions are wired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Design decisions and rationale
|
||||||
|
|
||||||
|
**Why a full page for detail, not a dialog?**
|
||||||
|
Orders have 5+ cards, a timeline, and multiple action flows. A dialog forces vertical scrolling with no space for the two-column layout. A page also makes deep-linking (e.g. linking from a customer email) trivial.
|
||||||
|
|
||||||
|
**Why does `OrderActionsBar` not implement actions?**
|
||||||
|
If it did, adding or changing one action would require modifying a large, complex component. With leaf action components, each action is independently testable and replaceable.
|
||||||
|
|
||||||
|
**Why no search on the list page?**
|
||||||
|
The `orders` table has no full-text search index. Client-side filtering over 25 loaded rows is fine for a small operation, but it does not scale past the first page. A future iteration can add a `by_order_number` or `by_email` query when the need is clear.
|
||||||
|
|
||||||
|
**Why `useAction` for `createShippingLabel` and `issueRefund`?**
|
||||||
|
Both are Convex actions (they call external APIs). `useAction` from `convex/react` is the correct hook — not `useMutation`.
|
||||||
|
|
||||||
|
**Why show `trackingStatus` in `FulfilmentCard` rather than a separate card?**
|
||||||
|
Tracking info and fulfilment info are tightly related; an admin needs them together. Separating them would force eye-scanning across two cards to understand the delivery state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document version: 1.0. UI only — no new backend work required.*
|
||||||
189
apps/admin/docs/other/Image_Processing_API.md
Normal file
189
apps/admin/docs/other/Image_Processing_API.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# API Usage Guide
|
||||||
|
|
||||||
|
This document describes how to use the background removal API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
- Local: `http://localhost:8000`
|
||||||
|
- Production: `http://localhost:8000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### 1. API Info
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns service metadata and links.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"service": "withoutbg-api",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"docs": "/docs",
|
||||||
|
"health": "/api/health"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Health Check
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the API is running and models are loaded.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"service": "withoutbg-api",
|
||||||
|
"models_loaded": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Remove Background
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/remove-background
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the background from an image and return the result.
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Type | Required | Default | Description |
|
||||||
|
|---------|--------|----------|---------|------------------------------------------------------------|
|
||||||
|
| `file` | File | Yes | — | Image file (PNG, JPEG, WebP) |
|
||||||
|
| `format`| String | No | `webp` | Output format: `webp`, `png`, or `jpg` |
|
||||||
|
| `quality` | Integer | No | `95` | Compression quality for WebP/JPEG (1–100) |
|
||||||
|
|
||||||
|
#### Request Example (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WebP output (default)
|
||||||
|
curl -X POST https://your-api.com/api/remove-background \
|
||||||
|
-F "file=@/path/to/photo.jpg" \
|
||||||
|
-o result.webp
|
||||||
|
|
||||||
|
# PNG output
|
||||||
|
curl -X POST https://your-api.com/api/remove-background \
|
||||||
|
-F "file=@photo.png" \
|
||||||
|
-F "format=png" \
|
||||||
|
-o result.png
|
||||||
|
|
||||||
|
# JPEG output with quality
|
||||||
|
curl -X POST https://your-api.com/api/remove-background \
|
||||||
|
-F "file=@photo.jpg" \
|
||||||
|
-F "format=jpg" \
|
||||||
|
-F "quality=90" \
|
||||||
|
-o result.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request Example (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "https://your-api.com/api/remove-background"
|
||||||
|
|
||||||
|
with open("photo.jpg", "rb") as f:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
files={"file": ("photo.jpg", f, "image/jpeg")},
|
||||||
|
data={"format": "webp", "quality": 95},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.ok:
|
||||||
|
with open("result.webp", "wb") as out:
|
||||||
|
out.write(response.content)
|
||||||
|
else:
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Request Example (JavaScript / Fetch)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", imageFile);
|
||||||
|
formData.append("format", "webp");
|
||||||
|
formData.append("quality", "95");
|
||||||
|
|
||||||
|
const response = await fetch("https://your-api.com/api/remove-background", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
// Use blob (e.g. create download link, display in img)
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error(error.detail);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
- **Success (200):** Binary image (WebP, PNG, or JPEG)
|
||||||
|
- **Content-Type:** `image/webp`, `image/png`, or `image/jpeg`
|
||||||
|
- **Content-Disposition:** `inline; filename=withoutbg.{format}`
|
||||||
|
|
||||||
|
#### Response Headers
|
||||||
|
|
||||||
|
| Header | Description |
|
||||||
|
|-----------------------|-----------------------------------------------------------------------------|
|
||||||
|
| `X-Format-Fallback` | Present if WebP was requested but PNG was returned (e.g. `X-Format-Fallback: png`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Processing Behavior
|
||||||
|
|
||||||
|
- Input image is centered on a transparent square canvas (height × height)
|
||||||
|
- Output is resized so the longest side is at most 1000px
|
||||||
|
- Output size is limited to ~50KB; if larger, the API may compress further
|
||||||
|
- Supported input formats: PNG, JPEG, WebP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
Errors return JSON with a `detail` field.
|
||||||
|
|
||||||
|
| Status | Meaning | Example `detail` |
|
||||||
|
|--------|----------------------------|-----------------------------------------|
|
||||||
|
| 400 | Bad request | `File too small (64 bytes)...` |
|
||||||
|
| 400 | Invalid image | `Invalid or unsupported image format` |
|
||||||
|
| 500 | Processing error | `Processing failed: ...` |
|
||||||
|
| 503 | Service not ready | `Models not loaded. Server may still be starting up.` |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "File too small (64 bytes). Verify the file exists and use full path: -F 'file=@/path/to/image.webp'"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interactive Documentation
|
||||||
|
|
||||||
|
When the API is running, Swagger UI is available at:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it to try endpoints from the browser.
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
||||||
|
turbopack: {
|
||||||
|
root: path.join(__dirname, "..", ".."),
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user