# Layer 7 — Seller Orders Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship the seller-facing Orders experience — list page with filters/search/sort and a detail page with inline fulfillment (rate preview + buy label + cancel) — replacing the current placeholder list.

**Architecture:** (1) Backend — extend `OrderController::index` with filter/search/sort/bucket query params, widen `OrderDetailResource` to expose buyer + shipping_address + label fields, add a `POST .../labels/preview` endpoint backed by a new `rates()` method on the `LabelProvider` contract. (2) Contract — update `openapi.yaml`, regenerate `@alqove/types`, extend `@alqove/api-client` with a seller-scoped cancel + preview, plus a parcel-presets endpoint. (3) Frontend — rebuild `/seller/orders` as a URL-backed list, add `/seller/orders/[id]` with a ShippingPanel (ready → label-purchased → shipped/delivered) and a CancelOrderDialog. Reuse existing `components/seller/*` primitives.

**Tech Stack:** Laravel 11 (Eloquent, Pest PHP), Postgres, Illuminate\Validation\Rule, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query, Tailwind + design tokens, shadcn Dialog/Select/Input, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-04-22-layer-7-seller-dashboard-design.md` (sections *Orders*, lines 167–209).
**Prerequisites:** Layer 7 plan 1 (foundation + dashboard + inbox) merged. `store.owner` middleware guards `/stores/{store}/orders/*`. `OrderFulfillmentService::purchaseLabel` + `OrderController::sellerCancel` already exist and are tested.
**Successor plans:** `2026-XX-XX-layer-7-listings.md`, `2026-XX-XX-layer-7-settings.md`.

**File structure** — this plan touches or creates:

| File | Responsibility |
|------|----------------|
| `api/app/Modules/Orders/Controllers/OrderController.php` | Add filter/search/sort/bucket to `index` |
| `api/app/Modules/Orders/Resources/OrderDetailResource.php` | Expose buyer + shipping_address + label_url + label_purchased_at |
| `api/app/Modules/Shipping/Contracts/LabelProvider.php` | Add `rates()` method |
| `api/app/Modules/Shipping/DTOs/ShipmentRate.php` | New DTO for a single rate quote |
| `api/app/Modules/Shipping/Services/FakeLabelProvider.php` | Implement `rates()` |
| `api/app/Modules/Shipping/Services/EasyPostProvider.php` | Implement `rates()` |
| `api/app/Modules/Orders/Controllers/OrderFulfillmentController.php` | Add `previewLabel` action |
| `api/app/Modules/Orders/Requests/PreviewLabelRequest.php` | Validate `parcel_preset_id` |
| `api/app/Modules/Orders/routes.php` | Register preview route |
| `api/contracts/openapi.yaml` | New params + preview path + schema additions |
| `packages/api-client/src/endpoints/orders.ts` | Add `storeCancel`, `previewLabel` + broaden `list` params |
| `packages/api-client/src/endpoints/stores.ts` | Add `parcelPresets.list` (new) |
| `packages/api-client/src/endpoints/purchases.ts` | Extend `OrderDetail` with buyer, shipping_address, shipping_label_url, label_purchased_at |
| `web/src/app/(seller)/seller/orders/page.tsx` | Server shell that hydrates the client list |
| `web/src/app/(seller)/seller/orders/orders-list-client.tsx` | NEW — URL-backed client list |
| `web/src/app/(seller)/seller/orders/[id]/page.tsx` | NEW — detail shell |
| `web/src/app/(seller)/seller/orders/[id]/order-detail-client.tsx` | NEW — detail page |
| `web/src/components/seller/order-status-badge.tsx` | NEW — shared badge |
| `web/src/components/seller/ship-by-cell.tsx` | NEW — shared red/amber/neutral |
| `web/src/components/seller/shipping-panel.tsx` | NEW — three-state panel |
| `web/src/components/seller/cancel-order-dialog.tsx` | NEW — reason + note + submit |
| `web/src/components/seller/order-timeline.tsx` | NEW — compact timeline |
| Feature tests under `api/tests/Feature/Orders/*Test.php` | Per task |
| Vitest specs under `web/src/.../__tests__/` | Per task |

---

## Phase A — Backend: list endpoint filters

### Task 1: Add `status`, `q`, `sort`, `bucket` to `OrderController::index`

**Files:**
- Modify: `api/app/Modules/Orders/Controllers/OrderController.php`
- Test: `api/tests/Feature/Orders/SellerOrdersIndexFiltersTest.php` (new)

**Semantics (nail these exactly):**
- `status=paid` → `WHERE status IN ('pending','processing')` (seller mental model: "paid but not yet shipped"; matches the dashboard `OrdersActionList` deep-link at `orders-action-list.tsx:44`)
- `status=shipped|delivered|cancelled` → exact enum match
- `status=all` or absent → no status filter
- `q` → case-insensitive match on `orders.id` prefix (first 8 chars) OR `purchases.shipping_address->>'first_name'` / `last_name`. Join `purchases` when `q` is present.
- `sort=placed|ship_by|total` with optional `_asc|_desc` suffix (default `placed_desc`). Map: `placed`→`created_at`, `ship_by`→`ship_by` (nulls last), `total`→`(subtotal + shipping_cost)`.
- `bucket=overdue|today|week` — only meaningful with `status=paid`. Filters on `ship_by` date range and status IN pending/processing. Overdue = `ship_by < today`, today = `ship_by::date = today`, week = `ship_by::date BETWEEN today AND today+6`. Use app timezone `config('app.timezone')`.
- All params optional; stack multiplicatively.

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Item;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SellerOrdersIndexFiltersTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    private function makeSellerWithStore(): array
    {
        $seller = User::factory()->create();
        $seller->assignRole('seller');
        $store = Store::factory()->verified()->create();
        $seller->update(['store_id' => $store->id]);

        return [$seller, $store];
    }

    private function makeOrder(Store $store, array $attrs = []): Order
    {
        $purchase = Purchase::factory()->create([
            'shipping_address' => [
                'first_name' => $attrs['buyer_first'] ?? 'Jane',
                'last_name' => $attrs['buyer_last'] ?? 'Doe',
                'street' => '1 Main', 'city' => 'Portland', 'state' => 'OR', 'zip' => '97201',
            ],
        ]);
        $order = Order::factory()->create(array_merge([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'status' => OrderStatus::Pending,
            'ship_by' => now()->addDays(3),
            'subtotal' => 2000,
            'shipping_cost' => 500,
        ], array_diff_key($attrs, array_flip(['buyer_first', 'buyer_last']))));
        $item = Item::factory()->create(['store_id' => $store->id]);
        OrderItem::factory()->create(['order_id' => $order->id, 'item_id' => $item->id]);

        return $order;
    }

    public function test_status_paid_returns_pending_and_processing_only(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $pending = $this->makeOrder($store, ['status' => OrderStatus::Pending]);
        $processing = $this->makeOrder($store, ['status' => OrderStatus::Processing]);
        $shipped = $this->makeOrder($store, ['status' => OrderStatus::Shipped]);

        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders?status=paid");

        $response->assertOk();
        $ids = collect($response->json('data'))->pluck('id')->all();
        $this->assertEqualsCanonicalizing([$pending->id, $processing->id], $ids);
    }

    public function test_status_shipped_returns_only_shipped(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $shipped = $this->makeOrder($store, ['status' => OrderStatus::Shipped]);
        $this->makeOrder($store, ['status' => OrderStatus::Pending]);

        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders?status=shipped");

        $response->assertOk()->assertJsonCount(1, 'data');
        $this->assertSame($shipped->id, $response->json('data.0.id'));
    }

    public function test_q_searches_buyer_first_and_last_name_case_insensitive(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $match = $this->makeOrder($store, ['buyer_first' => 'Marigold', 'buyer_last' => 'Kim']);
        $this->makeOrder($store, ['buyer_first' => 'Bob', 'buyer_last' => 'Smith']);

        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders?q=mari");

        $response->assertOk()->assertJsonCount(1, 'data');
        $this->assertSame($match->id, $response->json('data.0.id'));
    }

    public function test_q_searches_order_id_prefix(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $match = $this->makeOrder($store);
        $this->makeOrder($store);

        $prefix = substr($match->id, 0, 6);
        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders?q={$prefix}");

        $response->assertOk();
        $ids = collect($response->json('data'))->pluck('id')->all();
        $this->assertContains($match->id, $ids);
    }

    public function test_sort_ship_by_asc_orders_earliest_first_nulls_last(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $far = $this->makeOrder($store, ['ship_by' => now()->addDays(10)]);
        $soon = $this->makeOrder($store, ['ship_by' => now()->addDays(1)]);
        $null = $this->makeOrder($store, ['ship_by' => null]);

        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders?sort=ship_by_asc");

        $response->assertOk();
        $ids = collect($response->json('data'))->pluck('id')->all();
        $this->assertSame([$soon->id, $far->id, $null->id], $ids);
    }

    public function test_bucket_overdue_only_includes_past_ship_by(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $overdue = $this->makeOrder($store, ['status' => OrderStatus::Pending, 'ship_by' => now()->subDay()]);
        $today = $this->makeOrder($store, ['status' => OrderStatus::Pending, 'ship_by' => now()]);
        $future = $this->makeOrder($store, ['status' => OrderStatus::Pending, 'ship_by' => now()->addDays(3)]);

        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders?status=paid&bucket=overdue");

        $response->assertOk()->assertJsonCount(1, 'data');
        $this->assertSame($overdue->id, $response->json('data.0.id'));
    }

    public function test_bucket_week_includes_today_through_six_days_out(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $in6 = $this->makeOrder($store, ['status' => OrderStatus::Pending, 'ship_by' => now()->addDays(6)]);
        $in7 = $this->makeOrder($store, ['status' => OrderStatus::Pending, 'ship_by' => now()->addDays(7)]);

        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders?status=paid&bucket=week");

        $ids = collect($response->json('data'))->pluck('id')->all();
        $this->assertContains($in6->id, $ids);
        $this->assertNotContains($in7->id, $ids);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
docker compose exec laravel.test php artisan test --filter=SellerOrdersIndexFiltersTest
```
Expected: multiple FAIL (filters not implemented).

- [ ] **Step 3: Implement filters in `OrderController::index`**

Replace the method body with:

```php
public function index(Request $request, Store $store): JsonResponse
{
    $query = $store->orders()->with(['store:id,name', 'orderItems']);

    $status = $request->query('status');
    if ($status === 'paid') {
        $query->whereIn('status', [OrderStatus::Pending, OrderStatus::Processing]);
    } elseif ($status !== null && $status !== 'all') {
        $query->where('status', $status);
    }

    if ($q = trim((string) $request->query('q', ''))) {
        $query->join('purchases', 'orders.purchase_id', '=', 'purchases.id')
            ->where(function ($w) use ($q) {
                $w->where('orders.id', 'ilike', $q.'%')
                    ->orWhereRaw("purchases.shipping_address->>'first_name' ilike ?", ["%{$q}%"])
                    ->orWhereRaw("purchases.shipping_address->>'last_name' ilike ?", ["%{$q}%"]);
            })
            ->select('orders.*');
    }

    if ($bucket = $request->query('bucket')) {
        $tz = config('app.timezone');
        $today = now($tz)->startOfDay();
        match ($bucket) {
            'overdue' => $query->where('ship_by', '<', $today),
            'today' => $query->whereBetween('ship_by', [$today, $today->copy()->endOfDay()]),
            'week' => $query->whereBetween('ship_by', [$today, $today->copy()->addDays(6)->endOfDay()]),
            default => null,
        };
    }

    $sort = $request->query('sort', 'placed_desc');
    [$col, $dir] = match ($sort) {
        'placed_asc' => ['created_at', 'asc'],
        'placed_desc' => ['created_at', 'desc'],
        'ship_by_asc' => ['ship_by', 'asc'],
        'ship_by_desc' => ['ship_by', 'desc'],
        'total_asc' => [DB::raw('(subtotal + shipping_cost)'), 'asc'],
        'total_desc' => [DB::raw('(subtotal + shipping_cost)'), 'desc'],
        default => ['created_at', 'desc'],
    };
    if ($col === 'ship_by') {
        $query->orderByRaw('ship_by IS NULL, ship_by '.$dir);
    } else {
        $query->orderBy($col, $dir);
    }

    $orders = $query->paginate($request->query('per_page', 15));

    return OrderDetailResource::collection($orders)->response();
}
```

Add imports at the top of the file:

```php
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;
```

- [ ] **Step 4: Run test to verify it passes**

```bash
docker compose exec laravel.test php artisan test --filter=SellerOrdersIndexFiltersTest
```
Expected: 6 tests PASS.

- [ ] **Step 5: Run full Orders feature tests — regression check**

```bash
docker compose exec laravel.test php artisan test tests/Feature/Orders
```
Expected: all existing tests still pass.

- [ ] **Step 6: Commit**

```bash
git add api/app/Modules/Orders/Controllers/OrderController.php api/tests/Feature/Orders/SellerOrdersIndexFiltersTest.php
git commit -m "feat(orders): add status/q/sort/bucket filters to seller orders list"
```

---

## Phase B — Backend: extend OrderDetailResource

### Task 2: Add buyer, shipping_address, shipping_label_url, label_purchased_at

**Files:**
- Modify: `api/app/Modules/Orders/Resources/OrderDetailResource.php`
- Modify: `api/app/Modules/Orders/Controllers/OrderController.php` (eager-load `purchase` on show/index)
- Test: `api/tests/Feature/Orders/OrderDetailResourceShapeTest.php` (new)

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Item;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderDetailResourceShapeTest extends TestCase
{
    use RefreshDatabase;

    public function test_show_returns_buyer_and_shipping_address_and_label_fields(): void
    {
        $this->seed(RoleAndPermissionSeeder::class);
        $seller = User::factory()->create();
        $seller->assignRole('seller');
        $store = Store::factory()->verified()->create();
        $seller->update(['store_id' => $store->id]);

        $purchase = Purchase::factory()->create([
            'shipping_address' => [
                'first_name' => 'Marigold', 'last_name' => 'Kim',
                'street' => '42 Pine St', 'city' => 'Portland',
                'state' => 'OR', 'zip' => '97201',
            ],
        ]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $store->id,
            'status' => OrderStatus::Shipped,
            'shipping_label_url' => 'https://example.com/label/abc.pdf',
            'label_purchased_at' => now()->subHour(),
        ]);
        $item = Item::factory()->create(['store_id' => $store->id]);
        OrderItem::factory()->create(['order_id' => $order->id, 'item_id' => $item->id]);

        $response = $this->actingAs($seller)
            ->getJson("/v1/stores/{$store->id}/orders/{$order->id}");

        $response->assertOk()->assertJsonPath('data.buyer.first_name', 'Marigold')
            ->assertJsonPath('data.buyer.last_name', 'Kim')
            ->assertJsonPath('data.shipping_address.city', 'Portland')
            ->assertJsonPath('data.shipping_address.zip', '97201')
            ->assertJsonPath('data.shipping_label_url', 'https://example.com/label/abc.pdf');
        $this->assertNotNull($response->json('data.label_purchased_at'));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
docker compose exec laravel.test php artisan test --filter=OrderDetailResourceShapeTest
```
Expected: FAIL (missing keys).

- [ ] **Step 3: Extend the resource**

Replace `OrderDetailResource::toArray` with:

```php
public function toArray(Request $request): array
{
    $address = $this->purchase?->shipping_address ?? [];

    return [
        'id' => $this->id,
        'store' => [
            'id' => $this->store->id,
            'name' => $this->store->name,
        ],
        'buyer' => [
            'first_name' => $address['first_name'] ?? null,
            'last_name' => $address['last_name'] ?? null,
        ],
        'shipping_address' => [
            'street' => $address['street'] ?? null,
            'city' => $address['city'] ?? null,
            'state' => $address['state'] ?? null,
            'zip' => $address['zip'] ?? null,
        ],
        'subtotal' => $this->subtotal,
        'shipping_cost' => $this->shipping_cost,
        'status' => $this->status->value,
        'tracking_number' => $this->tracking_number,
        'tracking_url' => $this->tracking_url,
        'carrier' => $this->carrier,
        'service' => $this->service,
        'shipping_label_url' => $this->shipping_label_url,
        'label_purchased_at' => $this->label_purchased_at?->toIso8601String(),
        'ship_by' => $this->ship_by?->toIso8601String(),
        'shipped_at' => $this->shipped_at?->toIso8601String(),
        'delivered_at' => $this->delivered_at?->toIso8601String(),
        'cancelled_by' => $this->cancelled_by?->value,
        'cancellation_reason' => $this->cancellation_reason?->value,
        'cancelled_at' => $this->cancelled_at?->toIso8601String(),
        'is_delayed' => (bool) $this->is_delayed,
        'items' => OrderItemResource::collection($this->orderItems),
        'created_at' => $this->created_at->toIso8601String(),
    ];
}
```

- [ ] **Step 4: Eager-load `purchase` in the controller**

In `OrderController::index`, change the `with(...)` call to:

```php
->with(['store:id,name', 'orderItems', 'purchase:id,shipping_address'])
```

In `OrderController::show`, change the load to:

```php
$order->load(['store:id,name', 'orderItems', 'purchase:id,shipping_address']);
```

- [ ] **Step 5: Run the new test + existing Orders tests**

```bash
docker compose exec laravel.test php artisan test tests/Feature/Orders
```
Expected: all PASS.

- [ ] **Step 6: Commit**

```bash
git add api/app/Modules/Orders/Resources/OrderDetailResource.php api/app/Modules/Orders/Controllers/OrderController.php api/tests/Feature/Orders/OrderDetailResourceShapeTest.php
git commit -m "feat(orders): expose buyer + shipping address + label fields on order detail"
```

---

## Phase C — Backend: label rate preview

### Task 3: Add `ShipmentRate` DTO and `rates()` to `LabelProvider` contract (+ FakeLabelProvider impl)

**Files:**
- Create: `api/app/Modules/Shipping/DTOs/ShipmentRate.php`
- Modify: `api/app/Modules/Shipping/Contracts/LabelProvider.php`
- Modify: `api/app/Modules/Shipping/Services/FakeLabelProvider.php`
- Test: `api/tests/Unit/Shipping/FakeLabelProviderRatesTest.php` (new)

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Shipping;

use App\Modules\Shipping\DTOs\ShipmentRate;
use App\Modules\Shipping\DTOs\ShipmentRequest;
use App\Modules\Shipping\Services\FakeLabelProvider;
use PHPUnit\Framework\TestCase;

class FakeLabelProviderRatesTest extends TestCase
{
    public function test_rates_returns_at_least_one_rate(): void
    {
        $provider = new FakeLabelProvider();
        $request = new ShipmentRequest(
            fromAddress: ['street1' => '1 A', 'city' => 'X', 'state' => 'CA', 'zip' => '90000', 'country' => 'US'],
            toAddress: ['street1' => '2 B', 'city' => 'Y', 'state' => 'CA', 'zip' => '90001', 'country' => 'US'],
            parcel: ['weight_oz' => 16, 'length_in' => 10, 'width_in' => 8, 'height_in' => 4],
            reference: 'ord_test',
        );

        $rates = $provider->rates($request);

        $this->assertNotEmpty($rates);
        $this->assertContainsOnlyInstancesOf(ShipmentRate::class, $rates);
        $this->assertNotEmpty($rates[0]->carrier);
        $this->assertGreaterThan(0, $rates[0]->amountCents);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
docker compose exec laravel.test php artisan test --filter=FakeLabelProviderRatesTest
```
Expected: FAIL (`ShipmentRate` class undefined or `rates` method missing).

- [ ] **Step 3: Create `ShipmentRate` DTO**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Shipping\DTOs;

class ShipmentRate
{
    public function __construct(
        public readonly string $id,
        public readonly string $carrier,
        public readonly string $service,
        public readonly int $amountCents,
        public readonly ?int $estimatedDays = null,
    ) {}
}
```

- [ ] **Step 4: Add `rates()` to the contract**

Modify `api/app/Modules/Shipping/Contracts/LabelProvider.php` — add this method to the interface:

```php
/**
 * Fetch available rates for a shipment without purchasing.
 *
 * @return array<int, ShipmentRate>
 *
 * @throws LabelProviderException
 */
public function rates(ShipmentRequest $request): array;
```

Add `use App\Modules\Shipping\DTOs\ShipmentRate;` at the top.

- [ ] **Step 5: Implement in `FakeLabelProvider`**

Add to `FakeLabelProvider.php`:

```php
public function rates(ShipmentRequest $request): array
{
    if ($this->shouldFail) {
        throw new LabelProviderException('Fake provider forced failure');
    }

    return [
        new ShipmentRate(id: 'rate_usps_priority', carrier: 'USPS', service: 'Priority', amountCents: 899, estimatedDays: 3),
        new ShipmentRate(id: 'rate_usps_ground',   carrier: 'USPS', service: 'Ground',   amountCents: 649, estimatedDays: 5),
    ];
}
```

Add `use App\Modules\Shipping\DTOs\ShipmentRate;` at the top.

- [ ] **Step 6: Run the test**

```bash
docker compose exec laravel.test php artisan test --filter=FakeLabelProviderRatesTest
```
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add api/app/Modules/Shipping/Contracts/LabelProvider.php api/app/Modules/Shipping/DTOs/ShipmentRate.php api/app/Modules/Shipping/Services/FakeLabelProvider.php api/tests/Unit/Shipping/FakeLabelProviderRatesTest.php
git commit -m "feat(shipping): add rates() to LabelProvider contract with fake impl"
```

### Task 4: Implement `rates()` in `EasyPostProvider`

**Files:**
- Modify: `api/app/Modules/Shipping/Services/EasyPostProvider.php`
- Test: `api/tests/Unit/Shipping/EasyPostProviderRatesTest.php` (new)

- [ ] **Step 1: Read existing `EasyPostProvider.php` to understand how shipments are created**

```bash
cat api/app/Modules/Shipping/Services/EasyPostProvider.php
```
Note the exact library call used in `buyCheapestLabel` (typically `\EasyPost\Shipment::create`) and mimic it.

- [ ] **Step 2: Write the failing test** — mock the EasyPost client / shipment at the object level.

Structure it like the existing `EasyPostProviderTest.php` if present (check `api/tests/Unit/Shipping/`). If no mocking helper exists, inject a fake `EasyPost\EasyPostClient` in the constructor (if `EasyPostProvider` accepts one) and stub `shipment->create()` to return a fixed object with a `rates` array.

Minimal test:

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Shipping;

use App\Modules\Shipping\DTOs\ShipmentRate;
use App\Modules\Shipping\DTOs\ShipmentRequest;
use App\Modules\Shipping\Services\EasyPostProvider;
use EasyPost\EasyPostClient;
use Mockery;
use Tests\TestCase;

class EasyPostProviderRatesTest extends TestCase
{
    public function test_rates_maps_easypost_rates_to_shipment_rate_dtos(): void
    {
        $shipmentService = Mockery::mock();
        $shipmentService->shouldReceive('create')->andReturn((object) [
            'rates' => [
                (object) ['id' => 'rate_1', 'carrier' => 'USPS', 'service' => 'Priority', 'rate' => '8.99', 'delivery_days' => 3],
                (object) ['id' => 'rate_2', 'carrier' => 'USPS', 'service' => 'Ground',   'rate' => '6.49', 'delivery_days' => 5],
            ],
        ]);
        $client = Mockery::mock(EasyPostClient::class);
        $client->shipment = $shipmentService;

        $provider = new EasyPostProvider($client, 'whsec_test');
        $req = new ShipmentRequest(
            fromAddress: ['street1' => '1 A', 'city' => 'X', 'state' => 'CA', 'zip' => '90000', 'country' => 'US'],
            toAddress: ['street1' => '2 B', 'city' => 'Y', 'state' => 'CA', 'zip' => '90001', 'country' => 'US'],
            parcel: ['weight_oz' => 16, 'length_in' => 10, 'width_in' => 8, 'height_in' => 4],
            reference: 'ord_test',
        );

        $rates = $provider->rates($req);

        $this->assertCount(2, $rates);
        $this->assertContainsOnlyInstancesOf(ShipmentRate::class, $rates);
        $this->assertSame('USPS', $rates[0]->carrier);
        $this->assertSame(899, $rates[0]->amountCents);
    }
}
```

Note: if `EasyPostProvider`'s constructor signature differs (e.g., takes an API key string instead of a client), adapt the test to match it. Read the class first.

- [ ] **Step 3: Run test to verify it fails**

```bash
docker compose exec laravel.test php artisan test --filter=EasyPostProviderRatesTest
```
Expected: FAIL.

- [ ] **Step 4: Implement `rates()` in `EasyPostProvider`**

Pattern-match against the existing `buyCheapestLabel` method: call `$this->client->shipment->create([...])` with the same `from_address` / `to_address` / `parcel` payload, then map `$shipment->rates` → `ShipmentRate[]`:

```php
public function rates(\App\Modules\Shipping\DTOs\ShipmentRequest $request): array
{
    try {
        $shipment = $this->client->shipment->create([
            'from_address' => $request->fromAddress,
            'to_address' => $request->toAddress,
            'parcel' => [
                'weight' => $request->parcel['weight_oz'],
                'length' => $request->parcel['length_in'],
                'width' => $request->parcel['width_in'],
                'height' => $request->parcel['height_in'],
            ],
            'reference' => $request->reference,
        ]);
    } catch (\EasyPost\Exception\General\EasyPostException $e) {
        throw new \App\Modules\Shipping\Exceptions\LabelProviderException($e->getMessage(), previous: $e);
    }

    return array_map(
        fn ($r) => new \App\Modules\Shipping\DTOs\ShipmentRate(
            id: $r->id,
            carrier: $r->carrier,
            service: $r->service,
            amountCents: (int) round(((float) $r->rate) * 100),
            estimatedDays: isset($r->delivery_days) ? (int) $r->delivery_days : null,
        ),
        $shipment->rates ?? [],
    );
}
```

(If the existing `buyCheapestLabel` already creates a shipment and then buys the cheapest rate, you can extract a private `createShipment()` helper to DRY them — optional.)

- [ ] **Step 5: Run tests**

```bash
docker compose exec laravel.test php artisan test tests/Unit/Shipping
```
Expected: all PASS.

- [ ] **Step 6: Commit**

```bash
git add api/app/Modules/Shipping/Services/EasyPostProvider.php api/tests/Unit/Shipping/EasyPostProviderRatesTest.php
git commit -m "feat(shipping): implement rates() in EasyPostProvider"
```

### Task 5: Add `OrderFulfillmentController::previewLabel` + route + request + feature test

**Files:**
- Create: `api/app/Modules/Orders/Requests/PreviewLabelRequest.php`
- Modify: `api/app/Modules/Orders/Controllers/OrderFulfillmentController.php`
- Modify: `api/app/Modules/Orders/routes.php`
- Test: `api/tests/Feature/Orders/PreviewLabelTest.php` (new)

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Item;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\StoreParcelPreset;
use App\Models\User;
use App\Modules\Shipping\Contracts\LabelProvider;
use App\Modules\Shipping\Services\FakeLabelProvider;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PreviewLabelTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
        $this->app->bind(LabelProvider::class, FakeLabelProvider::class);
    }

    public function test_preview_returns_rates_without_purchasing_label(): void
    {
        $seller = User::factory()->create();
        $seller->assignRole('seller');
        $store = Store::factory()->verified()->withCompleteShipFromAddress()->create();
        $seller->update(['store_id' => $store->id]);

        $preset = StoreParcelPreset::factory()->create(['store_id' => $store->id]);
        $purchase = Purchase::factory()->create([
            'shipping_address' => ['first_name' => 'J', 'last_name' => 'D', 'street' => '1 A', 'city' => 'X', 'state' => 'CA', 'zip' => '90000'],
        ]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'store_id' => $store->id, 'status' => OrderStatus::Pending]);
        OrderItem::factory()->create(['order_id' => $order->id, 'item_id' => Item::factory()->create(['store_id' => $store->id])->id]);

        $response = $this->actingAs($seller)
            ->postJson("/v1/stores/{$store->id}/orders/{$order->id}/labels/preview", [
                'parcel_preset_id' => $preset->id,
            ]);

        $response->assertOk()
            ->assertJsonStructure(['data' => [['id', 'carrier', 'service', 'amount_cents']]]);

        $this->assertNull($order->fresh()->shipping_label_url);
    }

    public function test_preview_requires_ownership(): void
    {
        $otherSeller = User::factory()->create();
        $otherSeller->assignRole('seller');
        $store = Store::factory()->verified()->create();

        $preset = StoreParcelPreset::factory()->create(['store_id' => $store->id]);
        $order = Order::factory()->create(['store_id' => $store->id, 'status' => OrderStatus::Pending]);

        $response = $this->actingAs($otherSeller)
            ->postJson("/v1/stores/{$store->id}/orders/{$order->id}/labels/preview", [
                'parcel_preset_id' => $preset->id,
            ]);

        $response->assertForbidden();
    }
}
```

If `Store::factory()->withCompleteShipFromAddress()` doesn't exist, use inline attribute overrides for street1/city/state/zip; check the factory first.

- [ ] **Step 2: Run test to verify it fails**

```bash
docker compose exec laravel.test php artisan test --filter=PreviewLabelTest
```
Expected: 404 or other failure.

- [ ] **Step 3: Create `PreviewLabelRequest`**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PreviewLabelRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, array<int, string>>
     */
    public function rules(): array
    {
        return [
            'parcel_preset_id' => ['required', 'uuid', 'exists:store_parcel_presets,id'],
        ];
    }
}
```

- [ ] **Step 4: Add `previewLabel` to `OrderFulfillmentController`**

```php
public function previewLabel(
    \App\Modules\Orders\Requests\PreviewLabelRequest $request,
    \App\Models\Store $store,
    \App\Models\Order $order,
    \App\Modules\Shipping\Contracts\LabelProvider $provider,
): \Illuminate\Http\JsonResponse {
    abort_unless((string) $order->store_id === (string) $store->id, 404);

    $preset = \App\Models\StoreParcelPreset::findOrFail($request->validated('parcel_preset_id'));
    if ((string) $preset->store_id !== (string) $store->id) {
        abort(422, 'Parcel preset does not belong to this store.');
    }
    if (! $store->hasCompleteShipFromAddress()) {
        abort(422, 'Store ship-from address is incomplete.');
    }

    $shippingAddress = $order->purchase->shipping_address ?? [];
    $req = new \App\Modules\Shipping\DTOs\ShipmentRequest(
        fromAddress: [
            'name' => $store->name,
            'street1' => $store->street1, 'street2' => $store->street2,
            'city' => $store->city, 'state' => $store->state, 'zip' => $store->zip,
            'country' => $store->country ?? 'US',
        ],
        toAddress: [
            'name' => trim(($shippingAddress['first_name'] ?? '').' '.($shippingAddress['last_name'] ?? '')),
            'street1' => $shippingAddress['street'] ?? '', 'street2' => null,
            'city' => $shippingAddress['city'] ?? '', 'state' => $shippingAddress['state'] ?? '',
            'zip' => $shippingAddress['zip'] ?? '', 'country' => 'US',
        ],
        parcel: [
            'weight_oz' => $preset->weight_oz,
            'length_in' => $preset->length_in,
            'width_in' => $preset->width_in,
            'height_in' => $preset->height_in,
        ],
        reference: $order->id,
    );

    $rates = $provider->rates($req);

    return response()->json(['data' => array_map(fn ($r) => [
        'id' => $r->id,
        'carrier' => $r->carrier,
        'service' => $r->service,
        'amount_cents' => $r->amountCents,
        'estimated_days' => $r->estimatedDays,
    ], $rates)]);
}
```

- [ ] **Step 5: Register the route**

In `api/app/Modules/Orders/routes.php`, inside the `store.owner` group:

```php
Route::post('/stores/{store}/orders/{order}/labels/preview', [OrderFulfillmentController::class, 'previewLabel']);
```

- [ ] **Step 6: Run tests**

```bash
docker compose exec laravel.test php artisan test --filter=PreviewLabelTest
docker compose exec laravel.test php artisan test tests/Feature/Orders
```
Expected: all PASS.

- [ ] **Step 7: Commit**

```bash
git add api/app/Modules/Orders/Requests/PreviewLabelRequest.php api/app/Modules/Orders/Controllers/OrderFulfillmentController.php api/app/Modules/Orders/routes.php api/tests/Feature/Orders/PreviewLabelTest.php
git commit -m "feat(orders): POST /labels/preview returns rates without purchasing"
```

---

## Phase D — Contract + api-client

### Task 6: Update OpenAPI — list params, detail schema, preview path, parcel preset list, seller-scoped cancel body

**Files:**
- Modify: `api/contracts/openapi.yaml`

- [ ] **Step 1: Under `paths./v1/stores/{store}/orders.get`, extend `parameters`**

Add these params (keep the existing `StoreId`, `page`, `per_page`):

```yaml
        - name: status
          in: query
          schema:
            type: string
            enum: [all, paid, pending, processing, shipped, delivered, cancelled, refunded, disputed]
          description: |
            `paid` = pending+processing (not yet shipped). Other values filter by exact enum.
        - name: q
          in: query
          schema:
            type: string
          description: Search across order ID prefix and buyer first/last name.
        - name: sort
          in: query
          schema:
            type: string
            enum: [placed_desc, placed_asc, ship_by_asc, ship_by_desc, total_asc, total_desc]
            default: placed_desc
        - name: bucket
          in: query
          schema:
            type: string
            enum: [overdue, today, week]
          description: Ship-by bucket. Only meaningful when status=paid.
```

- [ ] **Step 2: Extend the `OrderDetail` schema** (search for `OrderDetail:` under `components.schemas`)

Add these properties (preserve existing ones):

```yaml
        buyer:
          type: object
          nullable: true
          properties:
            first_name: { type: string, nullable: true }
            last_name:  { type: string, nullable: true }
        shipping_address:
          type: object
          nullable: true
          properties:
            street: { type: string, nullable: true }
            city:   { type: string, nullable: true }
            state:  { type: string, nullable: true }
            zip:    { type: string, nullable: true }
        shipping_label_url:
          type: string
          format: uri
          nullable: true
        label_purchased_at:
          type: string
          format: date-time
          nullable: true
```

- [ ] **Step 3: Add the preview path**

Under `paths`, next to the existing `/v1/stores/{store}/orders/{order}/labels`:

```yaml
  /v1/stores/{store}/orders/{order}/labels/preview:
    post:
      operationId: previewOrderLabel
      summary: Preview shipping rates without purchasing a label
      tags:
        - Orders
      parameters:
        - $ref: '#/components/parameters/StoreId'
        - in: path
          name: order
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [parcel_preset_id]
              properties:
                parcel_preset_id:
                  type: string
                  format: uuid
      responses:
        '200':
          description: List of rate options
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:             { type: string }
                        carrier:        { type: string }
                        service:        { type: string }
                        amount_cents:   { type: integer }
                        estimated_days: { type: integer, nullable: true }
```

- [ ] **Step 4: Confirm `sellerCancelOrder` request body schema includes `reason` + optional `note`**

Under the existing `sellerCancelOrder` operation's `requestBody`, ensure:

```yaml
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  enum: [sold_locally, item_damaged, other]
                note:
                  type: string
                  maxLength: 500
                  nullable: true
```

If it already matches, skip. Otherwise, update to match.

- [ ] **Step 5: Regenerate types**

```bash
cd packages/types && npm run generate
```

Verify the output includes the new props (spot check `packages/types/src/generated.ts`).

- [ ] **Step 6: Run contract test** (if any) — otherwise just typecheck packages:

```bash
npm run typecheck
```
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add api/contracts/openapi.yaml packages/types/src/generated.ts
git commit -m "chore(contract): expand seller orders params + add preview + buyer fields"
```

### Task 7: Extend api-client — `storeCancel`, `previewLabel`, parcel presets, `OrderDetail` shape

**Files:**
- Modify: `packages/api-client/src/endpoints/orders.ts`
- Modify: `packages/api-client/src/endpoints/stores.ts`
- Modify: `packages/api-client/src/endpoints/purchases.ts`
- Modify: `packages/api-client/src/index.ts` (if new exports)

- [ ] **Step 1: Extend `OrderDetail` interface** in `purchases.ts`:

```ts
export interface OrderDetail {
  id: string;
  store: { id: string; name: string };
  buyer?: { first_name: string | null; last_name: string | null } | null;
  shipping_address?: {
    street: string | null; city: string | null;
    state: string | null; zip: string | null;
  } | null;
  subtotal: number;
  shipping_cost: number;
  status: string;
  tracking_number?: string | null;
  tracking_url?: string | null;
  carrier?: string | null;
  service?: string | null;
  shipping_label_url?: string | null;
  label_purchased_at?: string | null;
  ship_by?: string | null;
  shipped_at?: string | null;
  delivered_at?: string | null;
  cancelled_by?: string | null;
  cancellation_reason?: string | null;
  cancelled_at?: string | null;
  is_delayed?: boolean;
  items: OrderItemData[];
  created_at: string;
}

export interface LabelRateOption {
  id: string;
  carrier: string;
  service: string;
  amount_cents: number;
  estimated_days?: number | null;
}
```

- [ ] **Step 2: Update `orders.ts`**

```ts
import type { AlqoveClient } from '../client';
import type { OrderDetail, LabelRateOption } from './purchases';

export interface SellerCancelBody {
  reason: 'sold_locally' | 'item_damaged' | 'other';
  note?: string | null;
}

export function createOrderEndpoints(client: AlqoveClient) {
  return {
    list(storeId: string, params?: Record<string, string>) {
      return client.get<OrderDetail[]>(`/v1/stores/${storeId}/orders`, params);
    },

    get(storeId: string, orderId: string) {
      return client.get<OrderDetail>(`/v1/stores/${storeId}/orders/${orderId}`);
    },

    // Buyer-scoped cancel (existing)
    cancel(orderId: string) {
      return client.post<OrderDetail>(`/v1/orders/${orderId}/cancel`, {});
    },

    // Seller-scoped cancel (new)
    storeCancel(storeId: string, orderId: string, body: SellerCancelBody) {
      return client.post<OrderDetail>(`/v1/stores/${storeId}/orders/${orderId}/cancel`, body);
    },

    buyLabel(storeId: string, orderId: string, body: { parcel_preset_id: string }) {
      return client.post<OrderDetail>(`/v1/stores/${storeId}/orders/${orderId}/labels`, body);
    },

    previewLabel(storeId: string, orderId: string, body: { parcel_preset_id: string }) {
      return client.post<LabelRateOption[]>(
        `/v1/stores/${storeId}/orders/${orderId}/labels/preview`,
        body,
      );
    },
  };
}
```

- [ ] **Step 3: Add parcel-presets list to `stores.ts`**

Open `packages/api-client/src/endpoints/stores.ts`. If there's no `parcelPresets.list`, add:

```ts
export interface ParcelPreset {
  id: string;
  name: string;
  weight_oz: number;
  length_in: number;
  width_in: number;
  height_in: number;
  is_default: boolean;
}
```

And inside `createStoreEndpoints`, add:

```ts
    parcelPresets: {
      list(storeId: string) {
        return client.get<ParcelPreset[]>(`/v1/stores/${storeId}/parcel-presets`);
      },
    },
```

(If the existing file already has a differently-shaped preset type, match that instead of adding a duplicate. Read the file first.)

- [ ] **Step 4: Re-export new types from `packages/api-client/src/index.ts`**

Add to the existing `export type` lines:

```ts
export type { LabelRateOption, SellerCancelBody } from './endpoints/orders';
// If added in stores.ts:
export type { ParcelPreset } from './endpoints/stores';
```

- [ ] **Step 5: Typecheck**

```bash
npm run typecheck
```
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add packages/api-client/src
git commit -m "feat(api-client): add seller order cancel, label preview, parcel presets list"
```

---

## Phase E — Frontend: list page

### Task 8: Rebuild `/seller/orders` with URL-backed filters/search/sort

**Files:**
- Create: `web/src/app/(seller)/seller/orders/orders-list-client.tsx`
- Modify: `web/src/app/(seller)/seller/orders/page.tsx` (shrink to server shell that renders the client)
- Create: `web/src/components/seller/order-status-badge.tsx`
- Create: `web/src/components/seller/ship-by-cell.tsx`

- [ ] **Step 1: Create `OrderStatusBadge`**

```tsx
// web/src/components/seller/order-status-badge.tsx
import { cn } from '@/lib/utils';

const CLASS: Record<string, string> = {
  pending:    'bg-bone text-forest/70',
  processing: 'bg-amber-100 text-amber-800',
  shipped:    'bg-forest/10 text-forest',
  delivered:  'bg-emerald-100 text-emerald-800',
  cancelled:  'bg-red-50 text-red-700',
  refunded:   'bg-red-50 text-red-700',
  disputed:   'bg-terracotta/10 text-terracotta',
};

export function OrderStatusBadge({ status }: { status: string }) {
  const label = status.charAt(0).toUpperCase() + status.slice(1);
  return (
    <span className={cn('inline-block rounded px-2 py-0.5 text-xs font-medium', CLASS[status] ?? 'bg-slate-100 text-slate-600')}>
      {label}
    </span>
  );
}
```

- [ ] **Step 2: Create `ShipByCell`**

```tsx
// web/src/components/seller/ship-by-cell.tsx
import { cn } from '@/lib/utils';

export function ShipByCell({ shipBy, status }: { shipBy: string | null | undefined; status: string }) {
  if (!shipBy) return <span className="text-ink/40">—</span>;
  if (status === 'shipped' || status === 'delivered' || status === 'cancelled' || status === 'refunded') {
    return <span className="text-ink/60">{new Date(shipBy).toLocaleDateString()}</span>;
  }
  const now = Date.now();
  const due = new Date(shipBy).getTime();
  const msPerDay = 86_400_000;
  const days = Math.floor((due - now) / msPerDay);
  const tone = days < 0 ? 'text-terracotta' : days <= 1 ? 'text-amber-700' : 'text-forest/70';
  return <span className={cn('font-medium', tone)}>{new Date(shipBy).toLocaleDateString()}</span>;
}
```

- [ ] **Step 3: Write the client list component**

```tsx
// web/src/app/(seller)/seller/orders/orders-list-client.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { OrderDetail } from '@alqove/api-client';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { OrderStatusBadge } from '@/components/seller/order-status-badge';
import { ShipByCell } from '@/components/seller/ship-by-cell';
import { EmptyState } from '@/components/seller/empty-state';

const STATUS_CHIPS = [
  { value: 'all', label: 'All' },
  { value: 'paid', label: 'Paid' },
  { value: 'shipped', label: 'Shipped' },
  { value: 'delivered', label: 'Delivered' },
  { value: 'cancelled', label: 'Cancelled' },
] as const;

function formatDollars(cents: number) { return `$${(cents / 100).toFixed(2)}`; }
function formatDate(iso: string) { return new Date(iso).toLocaleDateString(); }

export function OrdersListClient() {
  const storeId = useAuthStore((s) => s.user?.store_id ?? null);
  const router = useRouter();
  const params = useSearchParams();

  const status = params.get('status') ?? 'all';
  const bucket = params.get('bucket') ?? '';
  const sort = params.get('sort') ?? 'placed_desc';
  const page = Number(params.get('page') ?? '1');

  const [qInput, setQInput] = useState(params.get('q') ?? '');
  const qDebounced = useDebouncedValue(qInput, 300);

  const queryKey = useMemo(
    () => ['seller-orders', storeId, status, qDebounced, sort, bucket, page],
    [storeId, status, qDebounced, sort, bucket, page],
  );

  const { data, isLoading, isError } = useQuery({
    queryKey,
    enabled: !!storeId,
    queryFn: () => {
      const p: Record<string, string> = { sort, page: String(page) };
      if (status !== 'all') p.status = status;
      if (qDebounced) p.q = qDebounced;
      if (bucket) p.bucket = bucket;
      return api.orders.list(storeId!, p);
    },
  });

  const orders: OrderDetail[] = data?.data ?? [];
  const meta = data?.meta as { current_page?: number; last_page?: number } | undefined;

  const pushParams = (next: Record<string, string | null>) => {
    const u = new URLSearchParams(params.toString());
    Object.entries(next).forEach(([k, v]) => {
      if (v === null || v === '') u.delete(k);
      else u.set(k, v);
    });
    u.delete('page');
    router.push(`/seller/orders?${u.toString()}`);
  };

  // keep `q` URL in sync after debounce settles
  useEffect(() => {
    pushParams({ q: qDebounced || null });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [qDebounced]);

  return (
    <div>
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-ink">Orders</h1>
          <p className="mt-1 text-sm text-ink/60">Manage and fulfill incoming orders.</p>
        </div>
      </div>

      <div className="mt-6 flex flex-wrap items-center gap-2">
        {STATUS_CHIPS.map((c) => (
          <button
            key={c.value}
            onClick={() => pushParams({ status: c.value === 'all' ? null : c.value, bucket: null })}
            className={`rounded-full px-3 py-1 text-sm ${
              (status === c.value) || (c.value === 'all' && status === 'all')
                ? 'bg-forest text-white'
                : 'bg-bone text-ink hover:bg-forest/10'
            }`}
          >
            {c.label}
          </button>
        ))}
        <div className="ml-auto flex items-center gap-2">
          <input
            value={qInput}
            onChange={(e) => setQInput(e.target.value)}
            placeholder="Search order ID or buyer…"
            className="rounded border border-forest/20 px-3 py-1.5 text-sm outline-none focus:border-forest"
          />
          <select
            value={sort}
            onChange={(e) => pushParams({ sort: e.target.value })}
            className="rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          >
            <option value="placed_desc">Newest first</option>
            <option value="placed_asc">Oldest first</option>
            <option value="ship_by_asc">Ship-by soonest</option>
            <option value="ship_by_desc">Ship-by latest</option>
            <option value="total_desc">Total high→low</option>
            <option value="total_asc">Total low→high</option>
          </select>
        </div>
      </div>

      <div className="mt-4 overflow-hidden rounded-md border border-forest/20 bg-white">
        {isLoading && <div className="p-8 text-center text-sm text-ink/60">Loading orders…</div>}
        {isError && <div className="p-8 text-center text-sm text-terracotta">Failed to load orders.</div>}
        {!isLoading && !isError && orders.length === 0 && (
          <EmptyState title="No orders" description="No orders match the current filters." />
        )}
        {!isLoading && !isError && orders.length > 0 && (
          <table className="w-full text-sm">
            <thead className="bg-bone/60">
              <tr>
                <Th>Order</Th><Th>Buyer</Th><Th>Items</Th><Th>Total</Th>
                <Th>Status</Th><Th>Ship-by</Th><Th>Placed</Th>
              </tr>
            </thead>
            <tbody>
              {orders.map((o) => {
                const total = o.subtotal + o.shipping_cost;
                const buyer = [o.buyer?.first_name, o.buyer?.last_name].filter(Boolean).join(' ') || '—';
                return (
                  <tr
                    key={o.id}
                    onClick={() => router.push(`/seller/orders/${o.id}`)}
                    className="cursor-pointer border-t border-forest/10 hover:bg-bone/40"
                  >
                    <td className="px-4 py-3 font-mono text-xs text-ink">{o.id.slice(0, 8)}…</td>
                    <td className="px-4 py-3 text-ink">{buyer}</td>
                    <td className="px-4 py-3 text-ink/70">{o.items.length}</td>
                    <td className="px-4 py-3 text-ink">{formatDollars(total)}</td>
                    <td className="px-4 py-3"><OrderStatusBadge status={o.status} /></td>
                    <td className="px-4 py-3"><ShipByCell shipBy={o.ship_by} status={o.status} /></td>
                    <td className="px-4 py-3 text-xs text-ink/60">{formatDate(o.created_at)}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        )}
      </div>

      {meta && meta.last_page && meta.last_page > 1 && (
        <div className="mt-4 flex items-center justify-between text-sm">
          <span className="text-ink/60">Page {meta.current_page} of {meta.last_page}</span>
          <div className="flex gap-2">
            <button disabled={page <= 1} onClick={() => router.push(pageHref(params, page - 1))} className="rounded border border-forest/20 px-3 py-1 disabled:opacity-40">Prev</button>
            <button disabled={page >= (meta.last_page ?? 1)} onClick={() => router.push(pageHref(params, page + 1))} className="rounded border border-forest/20 px-3 py-1 disabled:opacity-40">Next</button>
          </div>
        </div>
      )}
    </div>
  );
}

function Th({ children }: { children: React.ReactNode }) {
  return <th className="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-ink/60">{children}</th>;
}

function pageHref(params: URLSearchParams, page: number) {
  const u = new URLSearchParams(params.toString());
  u.set('page', String(page));
  return `/seller/orders?${u.toString()}`;
}

function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [v, setV] = useState(value);
  const ref = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => {
    if (ref.current) clearTimeout(ref.current);
    ref.current = setTimeout(() => setV(value), delayMs);
    return () => { if (ref.current) clearTimeout(ref.current); };
  }, [value, delayMs]);
  return v;
}
```

(`useSearchParams` returns a `ReadonlyURLSearchParams` — that's fine since we construct a fresh `URLSearchParams` from its `toString()`.)

- [ ] **Step 4: Shrink the server page to a shell**

Replace `web/src/app/(seller)/seller/orders/page.tsx` with:

```tsx
import { OrdersListClient } from './orders-list-client';

export default function Page() {
  return <OrdersListClient />;
}
```

- [ ] **Step 5: Typecheck + lint**

```bash
cd web && npm run typecheck
cd web && npm run lint
```
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add web/src/app/(seller)/seller/orders/page.tsx web/src/app/(seller)/seller/orders/orders-list-client.tsx web/src/components/seller/order-status-badge.tsx web/src/components/seller/ship-by-cell.tsx
git commit -m "feat(seller/orders): URL-backed filters, search, sort, pagination"
```

### Task 9: Vitest for list filter + search + row navigation

**Files:**
- Create: `web/src/app/(seller)/seller/orders/__tests__/orders-list-client.test.tsx`

- [ ] **Step 1: Write the test**

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useRouter, useSearchParams } from 'next/navigation';
import { OrdersListClient } from '../orders-list-client';

vi.mock('next/navigation', () => ({
  useRouter: vi.fn(),
  useSearchParams: vi.fn(),
}));
vi.mock('@/stores/auth', () => ({
  useAuthStore: (sel: (s: { user: { store_id: string } }) => unknown) => sel({ user: { store_id: 'store-1' } }),
}));

const listMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: { orders: { list: (...a: unknown[]) => listMock(...a) } },
}));

function wrap(node: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

describe('OrdersListClient', () => {
  const push = vi.fn();
  beforeEach(() => {
    push.mockClear();
    listMock.mockReset();
    (useRouter as unknown as vi.Mock).mockReturnValue({ push });
    (useSearchParams as unknown as vi.Mock).mockReturnValue(new URLSearchParams(''));
    listMock.mockResolvedValue({
      data: [{
        id: '11111111-2222-3333-4444-555555555555',
        store: { id: 'store-1', name: 'S' },
        buyer: { first_name: 'Mari', last_name: 'Kim' },
        subtotal: 2000, shipping_cost: 500,
        status: 'pending', items: [{ id: 'i1', item_id: 'x', title_snapshot: 't', price_snapshot: 2000, image_url_snapshot: null }],
        ship_by: null, created_at: '2026-04-22T00:00:00Z',
      }],
      meta: { current_page: 1, last_page: 1 },
    });
  });

  it('renders a row and routes to the detail on click', async () => {
    render(wrap(<OrdersListClient />));
    await waitFor(() => expect(screen.getByText('Mari Kim')).toBeInTheDocument());
    fireEvent.click(screen.getByText('Mari Kim').closest('tr')!);
    expect(push).toHaveBeenCalledWith('/seller/orders/11111111-2222-3333-4444-555555555555');
  });

  it('sending Paid chip pushes status=paid', async () => {
    render(wrap(<OrdersListClient />));
    fireEvent.click(screen.getByRole('button', { name: /^Paid$/ }));
    expect(push).toHaveBeenCalledWith(expect.stringContaining('status=paid'));
  });

  it('changing sort updates the URL', async () => {
    render(wrap(<OrdersListClient />));
    fireEvent.change(screen.getByDisplayValue('Newest first'), { target: { value: 'ship_by_asc' } });
    expect(push).toHaveBeenCalledWith(expect.stringContaining('sort=ship_by_asc'));
  });
});
```

- [ ] **Step 2: Run the test**

```bash
cd web && npm test -- orders-list-client
```
Expected: 3 tests PASS.

- [ ] **Step 3: Commit**

```bash
git add web/src/app/(seller)/seller/orders/__tests__/orders-list-client.test.tsx
git commit -m "test(seller/orders): list filter, search, row navigation"
```

---

## Phase F — Frontend: detail page + fulfillment + cancel

### Task 10: Detail page shell + summary + buyer/shipping panel + timeline

**Files:**
- Create: `web/src/app/(seller)/seller/orders/[id]/page.tsx`
- Create: `web/src/app/(seller)/seller/orders/[id]/order-detail-client.tsx`
- Create: `web/src/components/seller/order-timeline.tsx`

- [ ] **Step 1: Create the server shell**

```tsx
// web/src/app/(seller)/seller/orders/[id]/page.tsx
import { OrderDetailClient } from './order-detail-client';

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return <OrderDetailClient orderId={id} />;
}
```

- [ ] **Step 2: Create `OrderTimeline`**

```tsx
// web/src/components/seller/order-timeline.tsx
import type { OrderDetail } from '@alqove/api-client';

const STEPS = ['Placed', 'Paid', 'Label purchased', 'Shipped', 'Delivered'] as const;

export function OrderTimeline({ order }: { order: OrderDetail }) {
  const reached = new Set<(typeof STEPS)[number]>(['Placed', 'Paid']);
  if (order.label_purchased_at) reached.add('Label purchased');
  if (order.shipped_at) reached.add('Shipped');
  if (order.delivered_at) reached.add('Delivered');

  return (
    <ol className="flex flex-wrap items-center gap-3 text-sm">
      {STEPS.map((s, i) => (
        <li key={s} className="flex items-center gap-3">
          <span className={`inline-block h-2 w-2 rounded-full ${reached.has(s) ? 'bg-forest' : 'bg-ink/20'}`} />
          <span className={reached.has(s) ? 'text-ink' : 'text-ink/40'}>{s}</span>
          {i < STEPS.length - 1 && <span className="text-ink/20">→</span>}
        </li>
      ))}
    </ol>
  );
}
```

- [ ] **Step 3: Create the client detail page**

```tsx
// web/src/app/(seller)/seller/orders/[id]/order-detail-client.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import type { OrderDetail } from '@alqove/api-client';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { OrderStatusBadge } from '@/components/seller/order-status-badge';
import { ShipByCell } from '@/components/seller/ship-by-cell';
import { OrderTimeline } from '@/components/seller/order-timeline';
import { ShippingPanel } from '@/components/seller/shipping-panel';
import { CancelOrderDialog } from '@/components/seller/cancel-order-dialog';

function formatDollars(c: number) { return `$${(c / 100).toFixed(2)}`; }

export function OrderDetailClient({ orderId }: { orderId: string }) {
  const storeId = useAuthStore((s) => s.user?.store_id ?? null);
  const { data, isLoading, isError } = useQuery({
    queryKey: ['seller-order', storeId, orderId],
    enabled: !!storeId,
    queryFn: () => api.orders.get(storeId!, orderId),
  });

  if (isLoading) return <div className="p-8 text-sm text-ink/60">Loading…</div>;
  if (isError || !data) return <div className="p-8 text-sm text-terracotta">Failed to load order.</div>;
  const order: OrderDetail = data.data;

  const canCancel = ['pending', 'processing'].includes(order.status);
  const buyer = [order.buyer?.first_name, order.buyer?.last_name].filter(Boolean).join(' ') || '—';
  const addr = order.shipping_address;
  const total = order.subtotal + order.shipping_cost;

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <Link href="/seller/orders" className="text-sm text-ink/60 hover:text-ink">← Orders</Link>
          <h1 className="mt-1 font-mono text-2xl font-bold text-ink">Order {order.id.slice(0, 8)}…</h1>
          <div className="mt-2 flex items-center gap-2 text-sm text-ink/70">
            <OrderStatusBadge status={order.status} />
            <span>·</span>
            <span>Placed {new Date(order.created_at).toLocaleDateString()}</span>
          </div>
        </div>
        {canCancel && <CancelOrderDialog storeId={storeId!} orderId={order.id} />}
      </div>

      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
        <section className="rounded-md border border-forest/20 bg-white p-5">
          <h2 className="text-sm font-semibold uppercase tracking-wide text-ink/60">Order summary</h2>
          <ul className="mt-3 divide-y divide-forest/10">
            {order.items.map((it) => (
              <li key={it.id} className="flex items-center gap-3 py-3">
                {it.image_url_snapshot ? (
                  <img src={it.image_url_snapshot} alt="" className="h-12 w-12 rounded object-cover" />
                ) : <div className="h-12 w-12 rounded bg-bone" />}
                <div className="flex-1 text-sm">
                  <div className="text-ink">{it.title_snapshot}</div>
                  <div className="text-ink/60">{formatDollars(it.price_snapshot)}</div>
                </div>
              </li>
            ))}
          </ul>
          <dl className="mt-4 space-y-1 text-sm">
            <Row label="Subtotal" value={formatDollars(order.subtotal)} />
            <Row label="Shipping" value={formatDollars(order.shipping_cost)} />
            <Row label="Total" value={formatDollars(total)} strong />
          </dl>
        </section>

        <section className="rounded-md border border-forest/20 bg-white p-5">
          <h2 className="text-sm font-semibold uppercase tracking-wide text-ink/60">Buyer &amp; shipping</h2>
          <p className="mt-3 text-sm text-ink">{buyer}</p>
          {addr && (
            <p className="mt-1 whitespace-pre-line text-sm text-ink/70">
              {[addr.street, `${addr.city ?? ''}, ${addr.state ?? ''} ${addr.zip ?? ''}`].filter(Boolean).join('\n')}
            </p>
          )}
          <div className="mt-4 text-sm">
            <span className="text-ink/60">Ship by: </span>
            <ShipByCell shipBy={order.ship_by} status={order.status} />
          </div>
        </section>
      </div>

      <section className="rounded-md border border-forest/20 bg-white p-5">
        <h2 className="text-sm font-semibold uppercase tracking-wide text-ink/60">Shipping label</h2>
        <div className="mt-3">
          <ShippingPanel storeId={storeId!} order={order} />
        </div>
      </section>

      <section className="rounded-md border border-forest/20 bg-white p-5">
        <h2 className="text-sm font-semibold uppercase tracking-wide text-ink/60">Timeline</h2>
        <div className="mt-3">
          <OrderTimeline order={order} />
        </div>
      </section>
    </div>
  );
}

function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
  return (
    <div className="flex justify-between">
      <dt className="text-ink/60">{label}</dt>
      <dd className={strong ? 'font-semibold text-ink' : 'text-ink'}>{value}</dd>
    </div>
  );
}
```

- [ ] **Step 4: Typecheck** — will fail on `ShippingPanel`/`CancelOrderDialog` imports; that's expected. We'll create those next.

```bash
cd web && npm run typecheck
```
Expected: FAIL on the two missing imports. Don't commit yet.

### Task 11: `ShippingPanel` — three-state preset picker + preview + buy label

**Files:**
- Create: `web/src/components/seller/shipping-panel.tsx`

- [ ] **Step 1: Create the component**

```tsx
// web/src/components/seller/shipping-panel.tsx
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import type { OrderDetail, LabelRateOption } from '@alqove/api-client';
import { api } from '@/lib/api';

function formatDollars(c: number) { return `$${(c / 100).toFixed(2)}`; }

export function ShippingPanel({ storeId, order }: { storeId: string; order: OrderDetail }) {
  const qc = useQueryClient();

  if (order.status === 'shipped' || order.status === 'delivered') {
    return (
      <div className="flex flex-wrap items-center gap-4 text-sm">
        <span className="text-ink/70">Shipped via {order.carrier} {order.service}</span>
        {order.tracking_url && (
          <a href={order.tracking_url} target="_blank" rel="noreferrer" className="text-forest underline">
            Track: {order.tracking_number}
          </a>
        )}
        {order.shipping_label_url && (
          <a href={order.shipping_label_url} target="_blank" rel="noreferrer" className="text-forest underline">
            Re-print label
          </a>
        )}
      </div>
    );
  }

  if (order.status === 'cancelled' || order.status === 'refunded') {
    return <div className="text-sm text-ink/60">Order is cancelled — no label required.</div>;
  }

  // Pending / processing → ready to ship
  return <ReadyToShip storeId={storeId} order={order} onPurchased={() => qc.invalidateQueries({ queryKey: ['seller-order', storeId, order.id] })} />;
}

function ReadyToShip({ storeId, order, onPurchased }: { storeId: string; order: OrderDetail; onPurchased: () => void }) {
  const presetsQ = useQuery({
    queryKey: ['parcel-presets', storeId],
    queryFn: () => api.stores.parcelPresets.list(storeId),
  });

  const presets = presetsQ.data?.data ?? [];
  const [presetId, setPresetId] = useState<string>('');
  useEffect(() => {
    if (!presetId && presets.length) {
      const def = presets.find((p) => p.is_default) ?? presets[0];
      setPresetId(def.id);
    }
  }, [presets, presetId]);

  const previewQ = useQuery({
    queryKey: ['label-preview', storeId, order.id, presetId],
    enabled: !!presetId,
    queryFn: () => api.orders.previewLabel(storeId, order.id, { parcel_preset_id: presetId }),
  });

  const rates: LabelRateOption[] = previewQ.data?.data ?? [];
  const [rateId, setRateId] = useState<string>('');
  useEffect(() => {
    if (!rateId && rates.length) setRateId(rates[0].id);
  }, [rates, rateId]);

  const buyMut = useMutation({
    mutationFn: () => api.orders.buyLabel(storeId, order.id, { parcel_preset_id: presetId }),
    onSuccess: onPurchased,
  });

  if (presetsQ.isLoading) return <div className="text-sm text-ink/60">Loading presets…</div>;
  if (presetsQ.isError) return <div className="text-sm text-terracotta">Failed to load parcel presets.</div>;
  if (presets.length === 0) {
    return <div className="text-sm text-ink/70">No parcel presets yet. Create one in Settings → Shipping.</div>;
  }

  const selectedRate = rates.find((r) => r.id === rateId);

  return (
    <div className="space-y-3 text-sm">
      <div className="flex flex-wrap items-end gap-3">
        <label className="flex flex-col">
          <span className="mb-1 text-xs uppercase tracking-wide text-ink/60">Parcel preset</span>
          <select value={presetId} onChange={(e) => setPresetId(e.target.value)} className="rounded border border-forest/20 bg-white px-2 py-1.5">
            {presets.map((p) => <option key={p.id} value={p.id}>{p.name}{p.is_default ? ' (default)' : ''}</option>)}
          </select>
        </label>
        <label className="flex flex-col">
          <span className="mb-1 text-xs uppercase tracking-wide text-ink/60">Service</span>
          <select
            value={rateId}
            onChange={(e) => setRateId(e.target.value)}
            disabled={previewQ.isLoading || rates.length === 0}
            className="rounded border border-forest/20 bg-white px-2 py-1.5"
          >
            {rates.map((r) => (
              <option key={r.id} value={r.id}>{r.carrier} {r.service} — {formatDollars(r.amount_cents)}</option>
            ))}
          </select>
        </label>
        {selectedRate && (
          <div className="text-ink/70">
            Est. cost: <span className="font-semibold text-ink">{formatDollars(selectedRate.amount_cents)}</span>
            {selectedRate.estimated_days != null && <> · {selectedRate.estimated_days} days</>}
          </div>
        )}
      </div>

      <div className="flex items-center gap-3">
        <button
          onClick={() => buyMut.mutate()}
          disabled={buyMut.isPending || !presetId}
          className="rounded bg-forest px-4 py-2 text-sm font-semibold text-white hover:bg-forest/90 disabled:opacity-50"
        >
          {buyMut.isPending ? 'Purchasing…' : 'Buy label'}
        </button>
        {buyMut.isError && <span className="text-sm text-terracotta">Purchase failed. Try again.</span>}
      </div>

      <p className="text-xs text-ink/50">
        Note: buying the label always uses the carrier's cheapest available rate. The preview above helps you estimate.
      </p>
    </div>
  );
}
```

(The spec says "Buy label" uses the "cheapest" rate as backend-implemented; the UI shows a dropdown for transparency but does not pass the rate ID to the purchase endpoint — current controller signature only accepts `parcel_preset_id`. Keep it simple this layer.)

- [ ] **Step 2: Typecheck**

```bash
cd web && npm run typecheck
```
Expected: FAIL only on `CancelOrderDialog` import.

### Task 12: `CancelOrderDialog`

**Files:**
- Create: `web/src/components/seller/cancel-order-dialog.tsx`

- [ ] **Step 1: Create the dialog**

```tsx
// web/src/components/seller/cancel-order-dialog.tsx
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { api } from '@/lib/api';

type Reason = 'sold_locally' | 'item_damaged' | 'other';
const REASONS: { value: Reason; label: string }[] = [
  { value: 'sold_locally', label: 'Sold locally (in store)' },
  { value: 'item_damaged', label: 'Item damaged / unsellable' },
  { value: 'other',         label: 'Other' },
];

export function CancelOrderDialog({ storeId, orderId }: { storeId: string; orderId: string }) {
  const [open, setOpen] = useState(false);
  const [reason, setReason] = useState<Reason>('sold_locally');
  const [note, setNote] = useState('');
  const qc = useQueryClient();

  const mut = useMutation({
    mutationFn: () => api.orders.storeCancel(storeId, orderId, { reason, note: note || null }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['seller-order', storeId, orderId] });
      qc.invalidateQueries({ queryKey: ['seller-orders', storeId] });
      setOpen(false);
    },
  });

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <button className="rounded border border-terracotta/30 px-3 py-1.5 text-sm text-terracotta hover:bg-terracotta/5">
          Cancel order
        </button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Cancel this order?</DialogTitle>
          <DialogDescription>The buyer will be refunded and items will be relisted.</DialogDescription>
        </DialogHeader>
        <div className="space-y-3">
          <label className="flex flex-col text-sm">
            <span className="mb-1 text-xs uppercase tracking-wide text-ink/60">Reason</span>
            <select value={reason} onChange={(e) => setReason(e.target.value as Reason)} className="rounded border border-forest/20 bg-white px-2 py-1.5">
              {REASONS.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
            </select>
          </label>
          <label className="flex flex-col text-sm">
            <span className="mb-1 text-xs uppercase tracking-wide text-ink/60">Note (optional)</span>
            <textarea
              value={note}
              onChange={(e) => setNote(e.target.value.slice(0, 500))}
              rows={3}
              className="rounded border border-forest/20 bg-white px-2 py-1.5"
            />
            <span className="mt-1 text-xs text-ink/40">{note.length}/500</span>
          </label>
          {mut.isError && <p className="text-sm text-terracotta">Cancellation failed. Try again.</p>}
        </div>
        <DialogFooter>
          <button onClick={() => setOpen(false)} className="rounded px-3 py-1.5 text-sm text-ink/70 hover:bg-bone">
            Keep order
          </button>
          <button
            onClick={() => mut.mutate()}
            disabled={mut.isPending}
            className="rounded bg-terracotta px-3 py-1.5 text-sm font-semibold text-white hover:bg-terracotta/90 disabled:opacity-50"
          >
            {mut.isPending ? 'Cancelling…' : 'Cancel order'}
          </button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 2: Typecheck + lint**

```bash
cd web && npm run typecheck && npm run lint
```
Expected: PASS.

- [ ] **Step 3: Commit the whole detail-page slice** (covers Tasks 10, 11, 12)

```bash
git add web/src/app/(seller)/seller/orders/[id] web/src/components/seller/order-timeline.tsx web/src/components/seller/shipping-panel.tsx web/src/components/seller/cancel-order-dialog.tsx
git commit -m "feat(seller/orders): detail page with inline fulfillment and cancel flow"
```

### Task 13: Vitest for `ShippingPanel` state transitions and `CancelOrderDialog` submit

**Files:**
- Create: `web/src/components/seller/__tests__/shipping-panel.test.tsx`
- Create: `web/src/components/seller/__tests__/cancel-order-dialog.test.tsx`

- [ ] **Step 1: `shipping-panel.test.tsx`**

```tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect } from 'vitest';
import type { OrderDetail } from '@alqove/api-client';
import { ShippingPanel } from '../shipping-panel';

vi.mock('@/lib/api', () => ({
  api: {
    stores: { parcelPresets: { list: vi.fn().mockResolvedValue({ data: [{ id: 'p1', name: 'Small box', is_default: true, weight_oz: 8, length_in: 6, width_in: 4, height_in: 2 }] }) } },
    orders: { previewLabel: vi.fn().mockResolvedValue({ data: [{ id: 'r1', carrier: 'USPS', service: 'Priority', amount_cents: 899, estimated_days: 3 }] }) },
  },
}));

function wrap(node: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

const base: OrderDetail = {
  id: 'o1', store: { id: 's1', name: 'S' }, subtotal: 2000, shipping_cost: 500, status: 'pending',
  items: [{ id: 'i1', item_id: 'x', title_snapshot: 't', price_snapshot: 2000, image_url_snapshot: null }],
  created_at: '2026-04-22T00:00:00Z',
};

describe('ShippingPanel', () => {
  it('shows tracking + re-print when shipped', () => {
    const order: OrderDetail = { ...base, status: 'shipped', carrier: 'USPS', service: 'Priority', tracking_number: '9400...', tracking_url: 'http://x', shipping_label_url: 'http://pdf' };
    render(wrap(<ShippingPanel storeId="s1" order={order} />));
    expect(screen.getByText(/Shipped via USPS Priority/)).toBeInTheDocument();
    expect(screen.getByText(/Re-print label/)).toBeInTheDocument();
  });

  it('renders preset + rate + Buy label for pending orders', async () => {
    render(wrap(<ShippingPanel storeId="s1" order={base} />));
    await waitFor(() => expect(screen.getByText(/USPS Priority — \$8\.99/)).toBeInTheDocument());
    expect(screen.getByRole('button', { name: /Buy label/ })).toBeInTheDocument();
  });

  it('shows a cancelled state message', () => {
    const order: OrderDetail = { ...base, status: 'cancelled' };
    render(wrap(<ShippingPanel storeId="s1" order={order} />));
    expect(screen.getByText(/cancelled — no label required/)).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: `cancel-order-dialog.test.tsx`**

```tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect } from 'vitest';
import { CancelOrderDialog } from '../cancel-order-dialog';

const storeCancel = vi.fn().mockResolvedValue({ data: {} });
vi.mock('@/lib/api', () => ({
  api: { orders: { storeCancel: (...a: unknown[]) => storeCancel(...a) } },
}));

function wrap(node: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

describe('CancelOrderDialog', () => {
  it('submits reason + note to storeCancel', async () => {
    render(wrap(<CancelOrderDialog storeId="s1" orderId="o1" />));
    fireEvent.click(screen.getByText('Cancel order'));
    fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: 'item_damaged' } });
    fireEvent.change(screen.getByLabelText(/Note/i), { target: { value: 'water damage' } });
    fireEvent.click(screen.getAllByText('Cancel order')[1]); // submit button inside dialog
    await waitFor(() => expect(storeCancel).toHaveBeenCalledWith('s1', 'o1', { reason: 'item_damaged', note: 'water damage' }));
  });
});
```

- [ ] **Step 3: Run the tests**

```bash
cd web && npm test -- shipping-panel cancel-order-dialog
```
Expected: all PASS.

- [ ] **Step 4: Commit**

```bash
git add web/src/components/seller/__tests__
git commit -m "test(seller/orders): ShippingPanel states + CancelOrderDialog submit"
```

---

## Phase G — Wire-up + smoke test

### Task 14: Manual smoke test end-to-end

- [ ] **Step 1: Start the stack**

```bash
docker compose up -d
cd web && npm run dev
```

- [ ] **Step 2: Seed fresh data**

```bash
docker compose exec laravel.test php artisan migrate:fresh --seed
```

- [ ] **Step 3: Sign in as a seller** (see how login is seeded, e.g. `seller@example.com / password`) and manually verify:

| Scenario | Expected |
|----------|----------|
| Visit `/seller/orders` | Table loads, chips highlight All |
| Click Paid chip | URL gains `?status=paid`, table shows pending/processing only |
| Use dashboard widget "Overdue" link | Lands on `/seller/orders?status=paid&bucket=overdue`, filtered correctly |
| Type buyer name in search | After 300ms, results narrow |
| Change sort to "Ship-by soonest" | URL gains `?sort=ship_by_asc`, order reverses |
| Click a row | Navigates to `/seller/orders/<uuid>` |
| Detail page pending order | ShippingPanel shows preset + rates, Buy label enabled |
| Click Buy label | After refetch, panel shows tracking + re-print link, status → Shipped |
| Cancel an unshipped order | Dialog opens, reason required, submit succeeds, detail reloads with Cancelled badge |

- [ ] **Step 4: Record findings + fix any issues**

If any scenario fails, fix inline (typically in the client pages). Commit fixes as separate `fix:` commits.

- [ ] **Step 5: Final regression pass**

```bash
docker compose exec laravel.test php artisan test tests/Feature/Orders tests/Unit/Shipping
cd web && npm test
cd web && npm run lint && npm run typecheck
```
Expected: all green.

- [ ] **Step 6: Final commit (if any pending smoke-test fixes)**

```bash
git status
# if anything is pending:
git commit -m "fix(seller/orders): smoke-test follow-ups"
```

---

## Spec-coverage checklist (verify against the spec before shipping)

- [x] List columns: Order ID · Buyer · Items · Total · Status · Ship-by · Placed — Task 8
- [x] Filter chips: All · Paid · Shipped · Delivered · Cancelled — Task 8
- [x] Server-side search (debounced 300 ms, URL-backed `?q=`) — Tasks 1 + 8
- [x] Sort: Placed (default desc), Ship-by, Total — Tasks 1 + 8
- [x] Ship-by colored red/amber/neutral — Task 8 (`ShipByCell`)
- [x] Row click → detail — Task 8
- [x] Detail: header + summary + buyer/shipping + shipping panel + timeline — Task 10
- [x] Shipping panel states (ready / purchased / shipped-delivered) — Task 11
- [x] Cancel flow (reason dropdown, optional note, modal) — Task 12
- [x] New `POST /labels/preview` endpoint — Tasks 3, 4, 5
- [x] No bulk actions — implied by omitting them
