diff --git a/apps/admin/.env.example b/apps/admin/.env.example deleted file mode 100644 index d23eede..0000000 --- a/apps/admin/.env.example +++ /dev/null @@ -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_... diff --git a/apps/admin/docs/06-order-processing-backend-implementation-plan.md b/apps/admin/docs/06-order-processing-backend-implementation-plan.md new file mode 100644 index 0000000..07950a5 --- /dev/null +++ b/apps/admin/docs/06-order-processing-backend-implementation-plan.md @@ -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://.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.* diff --git a/apps/admin/docs/07-orders-ui-design-plan.md b/apps/admin/docs/07-orders-ui-design-plan.md new file mode 100644 index 0000000..baf1c49 --- /dev/null +++ b/apps/admin/docs/07-orders-ui-design-plan.md @@ -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 | `` | +| Payment | `` | +| 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 `` for order status | +| `OrderPaymentBadge` | `components/orders/shared/OrderPaymentBadge.tsx` | Renders one `` 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.* diff --git a/apps/admin/docs/other/Image_Processing_API.md b/apps/admin/docs/other/Image_Processing_API.md new file mode 100644 index 0000000..07703ce --- /dev/null +++ b/apps/admin/docs/other/Image_Processing_API.md @@ -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. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index f76054c..77df6d7 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,6 +1,11 @@ +const path = require("path"); + /** @type {import('next').NextConfig} */ const nextConfig = { transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"], + turbopack: { + root: path.join(__dirname, "..", ".."), + }, images: { remotePatterns: [ {