# Layer 7 — Seller Listings 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 Listings experience — a URL-backed index with table+grid toggle, filter chips, search, sort, and pagination, plus a long-form item create/edit page (images, basics, details, price, publish) with autosave-to-draft.

**Architecture:** Backend is already ~90% in place (CRUD controller + resources + api-client helpers). This plan (1) widens `ItemController::index` with `q` search and `sort`, (2) extends `ItemSummaryResource` with view/save counts + `published_at` so the table can render richer rows, (3) updates the OpenAPI spec, and (4) builds the frontend — a new `OrdersListClient`-style list page, a single long-form item page that powers create and edit, plus shared `ImageUploader`, `CategoryAutocomplete`, and `ItemStatusBadge` primitives.

**Tech Stack:** Laravel 11 (Eloquent, Spatie MediaLibrary — already present), Pest PHP tests, Postgres, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query, shadcn primitives (Dialog, Select, Input), Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-04-22-layer-7-seller-dashboard-design.md` (Listings section, lines 211–252).

**Prerequisites:** Layer 7 Orders plan merged (`cf40837`). `api.items` client already exposes `list/get/create/update/delete/uploadImages/removeImage/publish/remove/relist`. `filter=needs-attention` filter on index already lands drafts.

**Successor plan:** `2026-XX-XX-layer-7-settings.md`.

**Scope decisions (intentional simplifications, flagged here so reviewers know they were chosen, not missed):**

- **No bulk actions this layer.** Per-item publish/remove/relist only. Spec mentions bulk-publish/-remove/-relist/-export-CSV; those are deferred to a polish pass.
- **No image drag-reorder.** Primary image = the first image in `order_column`; delete + re-upload to change it. A dedicated reorder endpoint is YAGNI for v1.
- **No "Export CSV"** action.
- **No image cropper.** Upload arbitrary JPEG/PNG/WebP/HEIC; backend's Spatie conversions handle resize.
- **Autosave = blur + 1s debounce** posting `PUT /items/{id}` for existing items. New items autosave only after the first explicit "Save draft" (can't PATCH something that doesn't exist). No throttling on field typing.
- **Category autocomplete is client-side** over the existing full-tree `/v1/categories` response. No new backend endpoint.

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

| File | Responsibility |
|------|----------------|
| `api/app/Modules/Items/Controllers/ItemController.php` | Add `q` + `sort` params to `index` |
| `api/app/Modules/Items/Resources/ItemSummaryResource.php` | Expose `view_count`, `save_count`, `published_at` |
| `api/contracts/openapi.yaml` | Document new params + widened summary schema |
| `packages/api-client/src/endpoints/items.ts` | Widen `ItemSummaryData` type to match (if hand-written) |
| `web/src/app/(seller)/seller/listings/page.tsx` | Server shell → client |
| `web/src/app/(seller)/seller/listings/listings-client.tsx` | NEW — URL-backed list with filter chips, search, sort, pagination, table+grid |
| `web/src/app/(seller)/seller/listings/new/page.tsx` | NEW — create shell |
| `web/src/app/(seller)/seller/listings/[id]/page.tsx` | NEW — edit shell |
| `web/src/app/(seller)/seller/listings/item-form.tsx` | NEW — long-form component (powers both new + edit) |
| `web/src/components/seller/item-status-badge.tsx` | NEW — Draft/Active/Sold/Removed badge |
| `web/src/components/seller/image-uploader.tsx` | NEW — drag-drop uploader + thumbnail strip + delete |
| `web/src/components/seller/category-autocomplete.tsx` | NEW — client-side filter over tree |
| Feature tests under `api/tests/Feature/Items/*Test.php` | Per task |
| Vitest specs under `web/src/.../__tests__/` | Per task |

---

## Phase A — Backend: list endpoint enrichment

### Task 1: Add `q` + `sort` to `ItemController::index`

**Semantics:**
- `q` → case-insensitive partial match on `items.title` OR `items.brand`. Driver-aware like the Orders fix (Postgres `ilike`, SQLite `lower(...) like`).
- `sort` → `listed_desc` (default, by `created_at`), `listed_asc`, `price_desc`, `price_asc`, `views_desc`.
- Existing `status` + `filter=needs-attention` stay as-is.

**Files:**
- Modify: `Alqove/api/app/Modules/Items/Controllers/ItemController.php`
- Test: `Alqove/api/tests/Feature/Items/SellerItemsIndexFiltersTest.php` (new)

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Items;

use App\Models\Item;
use App\Models\Store;
use App\Models\User;
use App\Support\Enums\ItemStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SellerItemsIndexFiltersTest 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];
    }

    public function test_q_matches_title_case_insensitive(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $match = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'title' => 'Wool Cardigan']);
        Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'title' => 'Cotton Shirt']);

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

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

    public function test_q_matches_brand(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $match = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'title' => 'A', 'brand' => 'Patagonia']);
        Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'title' => 'B', 'brand' => 'Nike']);

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

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

    public function test_sort_price_desc(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $cheap = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'price' => 500]);
        $mid = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'price' => 2000]);
        $expensive = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'price' => 9999]);

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

        $response->assertOk();
        $ids = collect($response->json('data'))->pluck('id')->all();
        $this->assertSame([$expensive->id, $mid->id, $cheap->id], $ids);
    }

    public function test_sort_views_desc(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        $quiet = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'view_count' => 2]);
        $popular = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'view_count' => 77]);

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

        $ids = collect($response->json('data'))->pluck('id')->all();
        $this->assertSame([$popular->id, $quiet->id], $ids);
    }

    public function test_status_still_filters(): void
    {
        [$seller, $store] = $this->makeSellerWithStore();
        Item::factory()->draft()->create(['store_id' => $store->id, 'seller_id' => $seller->id]);
        $active = Item::factory()->create(['store_id' => $store->id, 'seller_id' => $seller->id, 'status' => ItemStatus::Active]);

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

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

- [ ] **Step 2: Run — expect FAIL**

```bash
docker compose exec laravel.test php artisan test --filter=SellerItemsIndexFiltersTest
```

- [ ] **Step 3: Implement `q` + `sort` in `ItemController::index`**

Replace the method body with:

```php
public function index(Request $request, Store $store): JsonResponse
{
    $query = $store->items()
        ->with(['store:id,name,city,state', 'category:id,name,slug', 'media']);

    if ($status = $request->query('status')) {
        $query->where('status', $status);
    }

    if ($request->query('filter') === 'needs-attention') {
        $query->where('status', ItemStatus::Draft);
    }

    if ($q = trim((string) $request->query('q', ''))) {
        $driver = DB::connection()->getDriverName();
        if ($driver === 'pgsql') {
            $query->where(function ($w) use ($q) {
                $w->where('title', 'ilike', "%{$q}%")
                    ->orWhere('brand', 'ilike', "%{$q}%");
            });
        } else {
            $qLower = strtolower($q);
            $query->where(function ($w) use ($qLower) {
                $w->whereRaw('lower(title) like ?', ["%{$qLower}%"])
                    ->orWhereRaw('lower(coalesce(brand, \'\')) like ?', ["%{$qLower}%"]);
            });
        }
    }

    $sort = $request->query('sort', 'listed_desc');
    [$col, $dir] = match ($sort) {
        'listed_asc' => ['created_at', 'asc'],
        'listed_desc' => ['created_at', 'desc'],
        'price_asc' => ['price', 'asc'],
        'price_desc' => ['price', 'desc'],
        'views_desc' => ['view_count', 'desc'],
        default => ['created_at', 'desc'],
    };
    $query->orderBy($col, $dir);

    $items = $query->paginate($request->query('per_page', 24));

    return ItemSummaryResource::collection($items)->response();
}
```

Add import at top: `use Illuminate\Support\Facades\DB;`

- [ ] **Step 4: Run tests — expect PASS**

```bash
docker compose exec laravel.test php artisan test tests/Feature/Items
```

- [ ] **Step 5: Commit**

```bash
git add Alqove/api/app/Modules/Items/Controllers/ItemController.php Alqove/api/tests/Feature/Items/SellerItemsIndexFiltersTest.php
git commit -m "feat(items): add q search and sort to seller listings index"
```

---

### Task 2: Widen `ItemSummaryResource`

**Files:**
- Modify: `Alqove/api/app/Modules/Items/Resources/ItemSummaryResource.php`
- Test: `Alqove/api/tests/Feature/Items/ItemSummaryResourceShapeTest.php` (new)

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Items;

use App\Models\Item;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ItemSummaryResourceShapeTest extends TestCase
{
    use RefreshDatabase;

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

        Item::factory()->create([
            'store_id' => $store->id,
            'seller_id' => $seller->id,
            'view_count' => 42,
            'save_count' => 3,
            'published_at' => now()->subDays(5),
        ]);

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

        $response->assertOk()
            ->assertJsonPath('data.0.view_count', 42)
            ->assertJsonPath('data.0.save_count', 3);
        $this->assertNotNull($response->json('data.0.published_at'));
    }
}
```

- [ ] **Step 2: Run — expect FAIL**

- [ ] **Step 3: Update resource**

Replace `ItemSummaryResource::toArray`:

```php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'price' => $this->price,
        'condition' => $this->condition->value,
        'brand' => $this->brand,
        'size' => $this->size,
        'status' => $this->status->value,
        'image_url' => $this->getCoverImageUrl('medium'),
        'view_count' => (int) $this->view_count,
        'save_count' => (int) $this->save_count,
        'published_at' => $this->published_at?->toIso8601String(),
        'store' => [
            'id' => $this->store->id,
            'name' => $this->store->name,
            'city' => $this->store->city,
            'state' => $this->store->state,
        ],
        'created_at' => $this->created_at->toIso8601String(),
    ];
}
```

- [ ] **Step 4: Run full Items suite — expect PASS**

```bash
docker compose exec laravel.test php artisan test tests/Feature/Items
```

- [ ] **Step 5: Commit**

```bash
git add Alqove/api/app/Modules/Items/Resources/ItemSummaryResource.php Alqove/api/tests/Feature/Items/ItemSummaryResourceShapeTest.php
git commit -m "feat(items): expose view_count, save_count, published_at on summary resource"
```

---

## Phase B — Contract

### Task 3: Update OpenAPI + regenerate types

**File:** `Alqove/api/contracts/openapi.yaml`

- [ ] **Step 1: Extend `listStoreItems` parameters**

Find operation `listStoreItems` (search yaml). Add these params (keep the existing `status`, `filter`, `per_page`):

```yaml
        - name: q
          in: query
          schema:
            type: string
          description: Partial match on title or brand.
        - name: sort
          in: query
          schema:
            type: string
            enum: [listed_desc, listed_asc, price_asc, price_desc, views_desc]
            default: listed_desc
```

- [ ] **Step 2: Extend `ItemSummaryData` schema**

Find the schema used by `listStoreItems` responses (typically `ItemSummaryData` or similar — match existing name). Add:

```yaml
        view_count:
          type: integer
        save_count:
          type: integer
        published_at:
          type: string
          format: date-time
          nullable: true
```

If the schema name differs, match it. Don't duplicate existing fields.

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

```bash
npm run build:types
```

- [ ] **Step 4: Hand-check api-client interfaces**

Open `Alqove/packages/api-client/src/endpoints/items.ts`. If `ItemSummaryData` is hand-written (not pulled from generated), add the three new optional fields:

```ts
view_count?: number;
save_count?: number;
published_at?: string | null;
```

- [ ] **Step 5: Typecheck**

```bash
npm run typecheck
```

- [ ] **Step 6: Commit**

```bash
git add Alqove/api/contracts/openapi.yaml Alqove/packages/types/src/generated.ts Alqove/packages/api-client/src/endpoints/items.ts
git commit -m "chore(contract): expand listStoreItems params + ItemSummaryData fields"
```

---

## Phase C — Frontend primitives

### Task 4: `ItemStatusBadge`

**File:** `Alqove/web/src/components/seller/item-status-badge.tsx`

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

```tsx
import { cn } from '@/lib/utils';

const CLASS: Record<string, string> = {
  draft: 'bg-bone text-forest/70',
  active: 'bg-forest/10 text-forest',
  sold: 'bg-emerald-100 text-emerald-800',
  removed: 'bg-red-50 text-red-700',
};

const LABEL: Record<string, string> = {
  draft: 'Draft',
  active: 'Published',
  sold: 'Sold',
  removed: 'Removed',
};

export function ItemStatusBadge({ status }: { status: string }) {
  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[status] ?? status}
    </span>
  );
}
```

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

```bash
cd Alqove/web && npx tsc --noEmit
git add Alqove/web/src/components/seller/item-status-badge.tsx
git commit -m "feat(seller/listings): ItemStatusBadge"
```

---

### Task 5: `CategoryAutocomplete`

A combobox-style input that fetches the category tree once, flattens to breadcrumb-path strings (`"Women › Clothing › Dresses"`), filters on keystroke, and commits a category `id` to the form.

**File:** `Alqove/web/src/components/seller/category-autocomplete.tsx`

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

```tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { api } from '@/lib/api';

interface FlatCategory {
  id: number;
  path: string; // "Women › Dresses"
}

interface CategoryNode {
  id: number;
  name: string;
  children?: CategoryNode[];
}

function flatten(nodes: CategoryNode[], parentPath = ''): FlatCategory[] {
  const out: FlatCategory[] = [];
  for (const n of nodes) {
    const path = parentPath ? `${parentPath} › ${n.name}` : n.name;
    if (!n.children || n.children.length === 0) {
      out.push({ id: n.id, path });
    } else {
      out.push(...flatten(n.children, path));
    }
  }
  return out;
}

export function CategoryAutocomplete({
  value,
  onChange,
}: {
  value: number | null;
  onChange: (id: number | null) => void;
}) {
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);

  const categoriesQ = useQuery({
    queryKey: ['categories-tree'],
    queryFn: () => api.categories.list(),
    staleTime: 5 * 60_000,
  });

  const flat = useMemo(() => {
    const tree = (categoriesQ.data as unknown as { data: CategoryNode[] } | undefined)?.data ?? [];
    return flatten(tree);
  }, [categoriesQ.data]);

  const selectedPath = useMemo(() => flat.find((c) => c.id === value)?.path ?? '', [flat, value]);

  const filtered = useMemo(() => {
    if (!query) return flat.slice(0, 20);
    const q = query.toLowerCase();
    return flat.filter((c) => c.path.toLowerCase().includes(q)).slice(0, 20);
  }, [flat, query]);

  return (
    <div className="relative">
      <input
        value={open ? query : selectedPath}
        placeholder="Search categories…"
        onFocus={() => {
          setOpen(true);
          setQuery('');
        }}
        onBlur={() => setTimeout(() => setOpen(false), 120)}
        onChange={(e) => setQuery(e.target.value)}
        className="w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm focus:border-forest focus:outline-none"
      />
      {open && filtered.length > 0 && (
        <ul className="absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border border-forest/20 bg-white shadow">
          {filtered.map((c) => (
            <li key={c.id}>
              <button
                type="button"
                onMouseDown={(e) => {
                  e.preventDefault();
                  onChange(c.id);
                  setOpen(false);
                }}
                className="flex w-full px-2 py-1.5 text-left text-sm hover:bg-bone"
              >
                {c.path}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Typecheck**

```bash
cd Alqove/web && npx tsc --noEmit
```

- [ ] **Step 3: Commit**

```bash
git add Alqove/web/src/components/seller/category-autocomplete.tsx
git commit -m "feat(seller/listings): CategoryAutocomplete combobox over /v1/categories"
```

---

### Task 6: `ImageUploader`

A drag-drop dropzone + thumbnail strip. Shows the existing images (with their server URLs), supports uploading new files (via `api.items.uploadImages`), and deleting (via `api.items.removeImage`). First thumbnail is labeled "Primary".

**File:** `Alqove/web/src/components/seller/image-uploader.tsx`

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

```tsx
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { api } from '@/lib/api';

interface ItemImage {
  id: number;
  url: string;
  thumb_url: string;
  order_column: number;
}

interface ItemDetailShape {
  id: string;
  images?: ItemImage[];
}

export function ImageUploader({
  storeId,
  itemId,
  images,
  onChange,
}: {
  storeId: string;
  itemId: string;
  images: ItemImage[];
  onChange: (images: ItemImage[]) => void;
}) {
  const qc = useQueryClient();
  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const [dragOver, setDragOver] = useState(false);

  const uploadMut = useMutation({
    mutationFn: (files: File[]) => api.items.uploadImages(storeId, itemId, files),
    onSuccess: (res) => {
      const data = res as unknown as { data: ItemDetailShape };
      onChange(data.data.images ?? []);
      qc.invalidateQueries({ queryKey: ['seller-item', storeId, itemId] });
    },
  });

  const removeMut = useMutation({
    mutationFn: (mediaId: number) => api.items.removeImage(storeId, itemId, mediaId),
    onSuccess: (_res, mediaId) => {
      onChange(images.filter((i) => i.id !== mediaId));
      qc.invalidateQueries({ queryKey: ['seller-item', storeId, itemId] });
    },
  });

  const handleFiles = (files: FileList | File[]) => {
    const arr = Array.from(files).filter((f) => f.type.startsWith('image/'));
    if (arr.length) uploadMut.mutate(arr);
  };

  return (
    <div className="space-y-3">
      <div
        onDragOver={(e) => {
          e.preventDefault();
          setDragOver(true);
        }}
        onDragLeave={() => setDragOver(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragOver(false);
          handleFiles(e.dataTransfer.files);
        }}
        onClick={() => fileInputRef.current?.click()}
        className={`cursor-pointer rounded border-2 border-dashed p-6 text-center text-sm ${
          dragOver ? 'border-forest bg-forest/5' : 'border-forest/20 text-ink/60'
        }`}
      >
        Drop images here or click to upload
        <input
          ref={fileInputRef}
          type="file"
          accept="image/*"
          multiple
          className="hidden"
          onChange={(e) => {
            if (e.target.files) handleFiles(e.target.files);
            e.target.value = '';
          }}
        />
      </div>

      {uploadMut.isPending && (
        <p className="text-xs text-ink/60">Uploading…</p>
      )}
      {uploadMut.isError && (
        <p className="text-xs text-terracotta">Upload failed. Try again.</p>
      )}

      {images.length > 0 && (
        <ul className="grid grid-cols-3 gap-3 sm:grid-cols-5">
          {images.map((img, i) => (
            <li key={img.id} className="group relative aspect-square overflow-hidden rounded border border-forest/20">
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img src={img.thumb_url} alt="" className="h-full w-full object-cover" />
              {i === 0 && (
                <span className="absolute left-1 top-1 rounded bg-forest px-1 text-xs text-white">Primary</span>
              )}
              <button
                type="button"
                onClick={() => removeMut.mutate(img.id)}
                disabled={removeMut.isPending}
                className="absolute right-1 top-1 rounded bg-black/70 px-1 text-xs text-white opacity-0 group-hover:opacity-100 disabled:opacity-30"
              >
                Remove
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

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

```bash
cd Alqove/web && npx tsc --noEmit
git add Alqove/web/src/components/seller/image-uploader.tsx
git commit -m "feat(seller/listings): ImageUploader with drop + delete"
```

---

## Phase D — Frontend list page

### Task 7: Rebuild `/seller/listings` as URL-backed client

**Files:**
- Create: `Alqove/web/src/app/(seller)/seller/listings/listings-client.tsx`
- Modify: `Alqove/web/src/app/(seller)/seller/listings/page.tsx` (shrink to shell)

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

```tsx
// Alqove/web/src/app/(seller)/seller/listings/listings-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 { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { ItemStatusBadge } from '@/components/seller/item-status-badge';
import { EmptyState } from '@/components/seller/empty-state';

interface ItemSummary {
  id: string;
  title: string;
  price: number;
  status: string;
  image_url: string | null;
  view_count?: number;
  created_at: string;
  published_at?: string | null;
}

const STATUS_CHIPS = [
  { value: 'all', label: 'All' },
  { value: 'active', label: 'Published' },
  { value: 'draft', label: 'Draft' },
  { value: 'sold', label: 'Sold' },
  { value: 'removed', label: 'Removed' },
] as const;

function formatDollars(c: number) { return `$${(c / 100).toFixed(2)}`; }
function formatDate(iso: string | null | undefined) { return iso ? new Date(iso).toLocaleDateString() : '—'; }

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

  const status = params.get('status') ?? 'all';
  const filter = params.get('filter') ?? '';
  const sort = params.get('sort') ?? 'listed_desc';
  const page = Number(params.get('page') ?? '1');
  const urlView = params.get('view');

  const [view, setView] = useState<'table' | 'grid'>(() => {
    if (urlView === 'grid' || urlView === 'table') return urlView;
    if (typeof window !== 'undefined') {
      const stored = window.localStorage.getItem('seller.listings.view');
      if (stored === 'grid' || stored === 'table') return stored;
    }
    return 'table';
  });

  useEffect(() => {
    if (!urlView && typeof window !== 'undefined') {
      window.localStorage.setItem('seller.listings.view', view);
    }
  }, [view, urlView]);

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

  const queryKey = useMemo(
    () => ['seller-items', storeId, status, filter, qDebounced, sort, page],
    [storeId, status, filter, qDebounced, sort, 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 (filter) p.filter = filter;
      if (qDebounced) p.q = qDebounced;
      return api.items.list(storeId!, p);
    },
  });

  const items: ItemSummary[] = (data as unknown as { data: ItemSummary[] } | undefined)?.data ?? [];
  const meta = (data as unknown as { meta?: { current_page?: number; last_page?: number } } | undefined)?.meta;

  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/listings?${u.toString()}`);
  };

  useEffect(() => {
    pushParams({ q: qDebounced || null });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [qDebounced]);

  const setViewSticky = (next: 'table' | 'grid') => {
    setView(next);
    if (typeof window !== 'undefined') {
      window.localStorage.setItem('seller.listings.view', next);
    }
    const u = new URLSearchParams(params.toString());
    u.delete('view');
    router.push(`/seller/listings?${u.toString()}`);
  };

  return (
    <div>
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-ink">Listings</h1>
          <p className="mt-1 text-sm text-ink/60">Manage your items.</p>
        </div>
        <Link href="/seller/listings/new" className="rounded bg-forest px-4 py-2 text-sm font-semibold text-white hover:bg-forest/90">
          + New item
        </Link>
      </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, filter: null })}
            className={`rounded-full px-3 py-1 text-sm ${
              status === c.value && !filter
                ? 'bg-forest text-white'
                : 'bg-bone text-ink hover:bg-forest/10'
            }`}
          >
            {c.label}
          </button>
        ))}
        <button
          onClick={() => pushParams({ filter: filter === 'needs-attention' ? null : 'needs-attention', status: null })}
          className={`rounded-full px-3 py-1 text-sm ${
            filter === 'needs-attention' ? 'bg-terracotta text-white' : 'bg-bone text-ink hover:bg-forest/10'
          }`}
        >
          Needs attention
        </button>

        <div className="ml-auto flex items-center gap-2">
          <input
            value={qInput}
            onChange={(e) => setQInput(e.target.value)}
            placeholder="Search title or brand…"
            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="listed_desc">Newest</option>
            <option value="listed_asc">Oldest</option>
            <option value="price_desc">Price high→low</option>
            <option value="price_asc">Price low→high</option>
            <option value="views_desc">Most views</option>
          </select>
          <div className="flex overflow-hidden rounded border border-forest/20">
            <button
              onClick={() => setViewSticky('table')}
              className={`px-2 py-1.5 text-sm ${view === 'table' ? 'bg-forest text-white' : 'bg-white text-ink'}`}
            >
              Table
            </button>
            <button
              onClick={() => setViewSticky('grid')}
              className={`px-2 py-1.5 text-sm ${view === 'grid' ? 'bg-forest text-white' : 'bg-white text-ink'}`}
            >
              Grid
            </button>
          </div>
        </div>
      </div>

      <div className="mt-4">
        {isLoading && <div className="p-8 text-center text-sm text-ink/60">Loading items…</div>}
        {isError && <div className="p-8 text-center text-sm text-terracotta">Failed to load items.</div>}
        {!isLoading && !isError && items.length === 0 && (
          <EmptyState title="No items" description="Create your first item to get started." />
        )}

        {!isLoading && !isError && items.length > 0 && view === 'table' && (
          <div className="overflow-hidden rounded-md border border-forest/20 bg-white">
            <table className="w-full text-sm">
              <thead className="bg-bone/60">
                <tr>
                  <Th>Image</Th><Th>Title</Th><Th>Status</Th><Th>Price</Th><Th>Views</Th><Th>Listed</Th>
                </tr>
              </thead>
              <tbody>
                {items.map((it) => (
                  <tr
                    key={it.id}
                    onClick={() => router.push(`/seller/listings/${it.id}`)}
                    className="cursor-pointer border-t border-forest/10 hover:bg-bone/40"
                  >
                    <td className="px-4 py-3">
                      {it.image_url ? (
                        // eslint-disable-next-line @next/next/no-img-element
                        <img src={it.image_url} alt="" className="h-10 w-10 rounded object-cover" />
                      ) : <div className="h-10 w-10 rounded bg-bone" />}
                    </td>
                    <td className="px-4 py-3 text-ink">{it.title}</td>
                    <td className="px-4 py-3"><ItemStatusBadge status={it.status} /></td>
                    <td className="px-4 py-3 text-ink">{formatDollars(it.price)}</td>
                    <td className="px-4 py-3 text-ink/70">{it.view_count ?? 0}</td>
                    <td className="px-4 py-3 text-xs text-ink/60">{formatDate(it.published_at ?? it.created_at)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}

        {!isLoading && !isError && items.length > 0 && view === 'grid' && (
          <ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
            {items.map((it) => (
              <li key={it.id}>
                <Link href={`/seller/listings/${it.id}`} className="block overflow-hidden rounded-md border border-forest/20 bg-white hover:border-forest">
                  <div className="aspect-square bg-bone">
                    {it.image_url && (
                      // eslint-disable-next-line @next/next/no-img-element
                      <img src={it.image_url} alt="" className="h-full w-full object-cover" />
                    )}
                  </div>
                  <div className="p-3">
                    <div className="truncate text-sm font-medium text-ink">{it.title}</div>
                    <div className="mt-1 flex items-center justify-between text-xs">
                      <span className="text-ink/70">{formatDollars(it.price)}</span>
                      <ItemStatusBadge status={it.status} />
                    </div>
                  </div>
                </Link>
              </li>
            ))}
          </ul>
        )}
      </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={() => goToPage(router, 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={() => goToPage(router, 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 goToPage(router: ReturnType<typeof useRouter>, params: URLSearchParams | ReturnType<typeof useSearchParams>, page: number) {
  const u = new URLSearchParams(params.toString());
  u.set('page', String(page));
  router.push(`/seller/listings?${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;
}
```

- [ ] **Step 2: Shrink the page shell**

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

```tsx
import { ListingsClient } from './listings-client';

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

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

```bash
cd Alqove/web && npx tsc --noEmit && npm run lint
```

- [ ] **Step 4: Commit**

```bash
git add "Alqove/web/src/app/(seller)/seller/listings/page.tsx" "Alqove/web/src/app/(seller)/seller/listings/listings-client.tsx"
git commit -m "feat(seller/listings): URL-backed list with chips, search, sort, grid/table toggle"
```

---

### Task 8: Vitest for ListingsClient

**File:** `Alqove/web/src/app/(seller)/seller/listings/__tests__/listings-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 { ListingsClient } from '../listings-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: { items: { 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('ListingsClient', () => {
  const push = vi.fn();
  beforeEach(() => {
    push.mockClear();
    listMock.mockReset();
    vi.mocked(useRouter).mockReturnValue({
      push, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), replace: vi.fn(), prefetch: vi.fn(),
    } as unknown as ReturnType<typeof useRouter>);
    vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams('') as unknown as ReturnType<typeof useSearchParams>);
    listMock.mockResolvedValue({
      data: [{
        id: 'item-1', title: 'Wool cardigan', price: 4500, status: 'active',
        image_url: null, view_count: 12, created_at: '2026-04-22T00:00:00Z',
        published_at: '2026-04-22T00:00:00Z',
      }],
      meta: { current_page: 1, last_page: 1 },
    });
  });

  it('renders a row and routes to detail on click', async () => {
    render(wrap(<ListingsClient />));
    await waitFor(() => expect(screen.getByText('Wool cardigan')).toBeInTheDocument());
    fireEvent.click(screen.getByText('Wool cardigan').closest('tr')!);
    expect(push).toHaveBeenCalledWith('/seller/listings/item-1');
  });

  it('clicking Needs attention pushes filter=needs-attention', async () => {
    render(wrap(<ListingsClient />));
    fireEvent.click(screen.getByRole('button', { name: /Needs attention/i }));
    expect(push).toHaveBeenCalledWith(expect.stringContaining('filter=needs-attention'));
  });

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

- [ ] **Step 2: Run — expect PASS**

```bash
cd Alqove/web && npm test -- listings-client
```

- [ ] **Step 3: Commit**

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

---

## Phase E — Item form (create + edit)

### Task 9: Build `ItemForm` + new/edit page shells

**Files:**
- Create: `Alqove/web/src/app/(seller)/seller/listings/item-form.tsx`
- Create: `Alqove/web/src/app/(seller)/seller/listings/new/page.tsx`
- Create: `Alqove/web/src/app/(seller)/seller/listings/[id]/page.tsx`

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

```tsx
// Alqove/web/src/app/(seller)/seller/listings/item-form.tsx
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { CategoryAutocomplete } from '@/components/seller/category-autocomplete';
import { ImageUploader } from '@/components/seller/image-uploader';
import { ItemStatusBadge } from '@/components/seller/item-status-badge';

type Condition = 'NWT' | 'NWOT' | 'EUC' | 'GUC' | 'Fair' | 'Poor';
const CONDITIONS: Condition[] = ['NWT', 'NWOT', 'EUC', 'GUC', 'Fair', 'Poor'];

interface ItemImage { id: number; url: string; thumb_url: string; order_column: number }
interface ItemDetail {
  id: string;
  title: string;
  description: string | null;
  category: { id: number; name: string } | null;
  brand: string | null;
  size: string | null;
  condition: Condition;
  colors: string[] | null;
  price: number;
  status: string;
  images: ItemImage[];
}

interface FormState {
  title: string;
  description: string;
  category_id: number | null;
  brand: string;
  size: string;
  condition: Condition;
  colors: string[];
  price_dollars: string; // user-entered
}

function emptyForm(): FormState {
  return { title: '', description: '', category_id: null, brand: '', size: '', condition: 'GUC', colors: [], price_dollars: '' };
}

function formFromItem(it: ItemDetail): FormState {
  return {
    title: it.title ?? '',
    description: it.description ?? '',
    category_id: it.category?.id ?? null,
    brand: it.brand ?? '',
    size: it.size ?? '',
    condition: it.condition,
    colors: it.colors ?? [],
    price_dollars: it.price ? (it.price / 100).toFixed(2) : '',
  };
}

function toPayload(f: FormState) {
  const price = Math.round(parseFloat(f.price_dollars || '0') * 100);
  return {
    title: f.title,
    description: f.description || null,
    category_id: f.category_id,
    brand: f.brand || null,
    size: f.size || null,
    condition: f.condition,
    colors: f.colors,
    price,
  };
}

function canPublish(f: FormState, images: ItemImage[]): string | null {
  if (!f.title.trim()) return 'Title is required';
  if (!f.category_id) return 'Category is required';
  if (!f.condition) return 'Condition is required';
  if (images.length === 0) return 'At least one image is required';
  const price = Math.round(parseFloat(f.price_dollars || '0') * 100);
  if (price <= 0) return 'Price must be greater than $0';
  return null;
}

export function ItemForm({ storeId, itemId }: { storeId: string; itemId?: string }) {
  const router = useRouter();
  const qc = useQueryClient();
  const [form, setForm] = useState<FormState>(emptyForm());
  const [images, setImages] = useState<ItemImage[]>([]);
  const [status, setStatus] = useState<string>('draft');
  const [savedAt, setSavedAt] = useState<Date | null>(null);
  const [error, setError] = useState<string | null>(null);

  const itemQ = useQuery({
    queryKey: ['seller-item', storeId, itemId],
    enabled: !!itemId,
    queryFn: () => api.items.get(storeId, itemId!),
  });

  useEffect(() => {
    const data = itemQ.data as unknown as { data: ItemDetail } | undefined;
    if (data?.data) {
      setForm(formFromItem(data.data));
      setImages(data.data.images ?? []);
      setStatus(data.data.status);
    }
  }, [itemQ.data]);

  const createMut = useMutation({
    mutationFn: () => api.items.create(storeId, toPayload(form)),
    onSuccess: (res) => {
      const data = res as unknown as { data: ItemDetail };
      router.replace(`/seller/listings/${data.data.id}`);
    },
    onError: () => setError('Failed to create item.'),
  });

  const updateMut = useMutation({
    mutationFn: () => api.items.update(storeId, itemId!, toPayload(form)),
    onSuccess: () => {
      setSavedAt(new Date());
      qc.invalidateQueries({ queryKey: ['seller-item', storeId, itemId] });
    },
    onError: () => setError('Failed to save changes.'),
  });

  const publishMut = useMutation({
    mutationFn: () => api.items.publish(storeId, itemId!),
    onSuccess: () => {
      setStatus('active');
      qc.invalidateQueries({ queryKey: ['seller-item', storeId, itemId] });
      qc.invalidateQueries({ queryKey: ['seller-items', storeId] });
    },
    onError: () => setError('Failed to publish.'),
  });

  const removeMut = useMutation({
    mutationFn: () => api.items.remove(storeId, itemId!),
    onSuccess: () => {
      setStatus('removed');
      qc.invalidateQueries({ queryKey: ['seller-items', storeId] });
    },
  });

  const relistMut = useMutation({
    mutationFn: () => api.items.relist(storeId, itemId!),
    onSuccess: () => {
      setStatus('active');
      qc.invalidateQueries({ queryKey: ['seller-items', storeId] });
    },
  });

  // Autosave on blur for existing items (debounce 1s)
  const autosaveRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const scheduleAutosave = () => {
    if (!itemId) return;
    if (autosaveRef.current) clearTimeout(autosaveRef.current);
    autosaveRef.current = setTimeout(() => updateMut.mutate(), 1000);
  };

  const savedText = savedAt ? `Saved ${Math.max(0, Math.round((Date.now() - savedAt.getTime()) / 1000))}s ago` : '';
  const publishBlocker = canPublish(form, images);

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <button onClick={() => router.push('/seller/listings')} className="text-sm text-ink/60 hover:text-ink">
            ← Listings
          </button>
          <h1 className="mt-1 text-2xl font-bold text-ink">
            {itemId ? 'Edit item' : 'New item'}
          </h1>
          <div className="mt-2 flex items-center gap-2 text-sm text-ink/70">
            <ItemStatusBadge status={status} />
            {savedText && <span className="text-xs text-ink/50">{savedText}</span>}
          </div>
        </div>
        <div className="flex gap-2">
          {!itemId && (
            <button onClick={() => createMut.mutate()} disabled={createMut.isPending || !form.title.trim()} className="rounded border border-forest/20 px-3 py-1.5 text-sm hover:bg-bone disabled:opacity-50">
              {createMut.isPending ? 'Saving…' : 'Save draft'}
            </button>
          )}
          {itemId && status === 'draft' && (
            <button
              onClick={() => publishMut.mutate()}
              disabled={publishBlocker !== null || publishMut.isPending}
              title={publishBlocker ?? undefined}
              className="rounded bg-forest px-3 py-1.5 text-sm font-semibold text-white hover:bg-forest/90 disabled:opacity-50"
            >
              {publishMut.isPending ? 'Publishing…' : 'Publish'}
            </button>
          )}
          {itemId && status === 'active' && (
            <button onClick={() => removeMut.mutate()} className="rounded border border-terracotta/30 px-3 py-1.5 text-sm text-terracotta hover:bg-terracotta/5">
              Remove
            </button>
          )}
          {itemId && status === 'removed' && (
            <button onClick={() => relistMut.mutate()} className="rounded bg-forest px-3 py-1.5 text-sm font-semibold text-white hover:bg-forest/90">
              Relist
            </button>
          )}
        </div>
      </div>

      {error && <div className="rounded bg-red-50 p-3 text-sm text-red-700">{error}</div>}
      {publishBlocker && itemId && status === 'draft' && (
        <div className="rounded bg-amber-50 p-3 text-sm text-amber-800">Before publishing: {publishBlocker}.</div>
      )}

      <Section title="Images">
        {itemId ? (
          <ImageUploader storeId={storeId} itemId={itemId} images={images} onChange={setImages} />
        ) : (
          <p className="text-sm text-ink/60">Save a draft first to upload images.</p>
        )}
      </Section>

      <Section title="Basics">
        <Field label="Title">
          <input
            value={form.title}
            onChange={(e) => setForm({ ...form, title: e.target.value })}
            onBlur={scheduleAutosave}
            className="w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          />
        </Field>
        <Field label="Category">
          <CategoryAutocomplete
            value={form.category_id}
            onChange={(id) => {
              setForm({ ...form, category_id: id });
              scheduleAutosave();
            }}
          />
        </Field>
        <Field label="Condition">
          <select
            value={form.condition}
            onChange={(e) => {
              setForm({ ...form, condition: e.target.value as Condition });
              scheduleAutosave();
            }}
            className="rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          >
            {CONDITIONS.map((c) => <option key={c} value={c}>{c}</option>)}
          </select>
        </Field>
        <Field label="Description">
          <textarea
            rows={4}
            value={form.description}
            onChange={(e) => setForm({ ...form, description: e.target.value })}
            onBlur={scheduleAutosave}
            className="w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          />
        </Field>
      </Section>

      <Section title="Details">
        <Field label="Brand">
          <input
            value={form.brand}
            onChange={(e) => setForm({ ...form, brand: e.target.value })}
            onBlur={scheduleAutosave}
            className="w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          />
        </Field>
        <Field label="Size">
          <input
            value={form.size}
            onChange={(e) => setForm({ ...form, size: e.target.value })}
            onBlur={scheduleAutosave}
            className="w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          />
        </Field>
        <Field label="Colors (comma-separated, max 5)">
          <input
            value={form.colors.join(', ')}
            onChange={(e) =>
              setForm({
                ...form,
                colors: e.target.value
                  .split(',')
                  .map((c) => c.trim())
                  .filter(Boolean)
                  .slice(0, 5),
              })
            }
            onBlur={scheduleAutosave}
            className="w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          />
        </Field>
      </Section>

      <Section title="Price">
        <Field label="Listed price (USD)">
          <input
            inputMode="decimal"
            value={form.price_dollars}
            onChange={(e) => setForm({ ...form, price_dollars: e.target.value.replace(/[^0-9.]/g, '') })}
            onBlur={scheduleAutosave}
            className="w-40 rounded border border-forest/20 bg-white px-2 py-1.5 text-sm"
          />
        </Field>
      </Section>
    </div>
  );
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <section className="rounded-md border border-forest/20 bg-white p-5">
      <h2 className="text-sm font-semibold uppercase tracking-wide text-ink/60">{title}</h2>
      <div className="mt-4 space-y-4">{children}</div>
    </section>
  );
}

function Field({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <label className="flex flex-col gap-1 text-sm">
      <span className="text-xs uppercase tracking-wide text-ink/60">{label}</span>
      {children}
    </label>
  );
}
```

- [ ] **Step 2: Create `new/page.tsx`**

```tsx
// Alqove/web/src/app/(seller)/seller/listings/new/page.tsx
'use client';

import { useAuthStore } from '@/stores/auth';
import { ItemForm } from '../item-form';

export default function NewItemPage() {
  const storeId = useAuthStore((s) => s.user?.store_id ?? null);
  if (!storeId) return null;
  return <ItemForm storeId={storeId} />;
}
```

- [ ] **Step 3: Create `[id]/page.tsx`**

```tsx
// Alqove/web/src/app/(seller)/seller/listings/[id]/page.tsx
'use client';

import { use } from 'react';
import { useAuthStore } from '@/stores/auth';
import { ItemForm } from '../item-form';

export default function EditItemPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = use(params);
  const storeId = useAuthStore((s) => s.user?.store_id ?? null);
  if (!storeId) return null;
  return <ItemForm storeId={storeId} itemId={id} />;
}
```

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

```bash
cd Alqove/web && npx tsc --noEmit && npm run lint
```

- [ ] **Step 5: Commit**

```bash
git add "Alqove/web/src/app/(seller)/seller/listings/item-form.tsx" "Alqove/web/src/app/(seller)/seller/listings/new" "Alqove/web/src/app/(seller)/seller/listings/[id]"
git commit -m "feat(seller/listings): item create/edit form with autosave + publish"
```

---

### Task 10: Vitest for `ItemForm` publish gating

**File:** `Alqove/web/src/app/(seller)/seller/listings/__tests__/item-form.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 { ItemForm } from '../item-form';

vi.mock('next/navigation', () => ({
  useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));

const getMock = vi.fn();
const publishMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: {
    items: {
      get: (...a: unknown[]) => getMock(...a),
      publish: (...a: unknown[]) => publishMock(...a),
      update: vi.fn(),
      remove: vi.fn(),
      relist: vi.fn(),
    },
    categories: { list: vi.fn().mockResolvedValue({ data: [] }) },
  },
}));

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

describe('ItemForm', () => {
  beforeEach(() => {
    getMock.mockReset();
    publishMock.mockReset();
  });

  it('disables Publish when required fields are missing', async () => {
    getMock.mockResolvedValue({
      data: {
        id: 'i1', title: 'Too short on fields', description: null,
        category: null, brand: null, size: null, condition: 'GUC',
        colors: [], price: 0, status: 'draft', images: [],
      },
    });
    render(wrap(<ItemForm storeId="s1" itemId="i1" />));
    await waitFor(() => expect(screen.getByText('Edit item')).toBeInTheDocument());
    const publishBtn = screen.getByRole('button', { name: /Publish/ });
    expect(publishBtn).toBeDisabled();
  });

  it('enables Publish when all requirements met', async () => {
    getMock.mockResolvedValue({
      data: {
        id: 'i1', title: 'A nice jacket', description: 'x',
        category: { id: 3, name: 'Jackets' }, brand: 'b', size: 'M', condition: 'GUC',
        colors: ['blue'], price: 2500, status: 'draft',
        images: [{ id: 1, url: 'u', thumb_url: 't', order_column: 1 }],
      },
    });
    publishMock.mockResolvedValue({ data: {} });
    render(wrap(<ItemForm storeId="s1" itemId="i1" />));
    await waitFor(() => expect(screen.getByDisplayValue('A nice jacket')).toBeInTheDocument());
    const publishBtn = screen.getByRole('button', { name: /Publish/ });
    expect(publishBtn).toBeEnabled();
    fireEvent.click(publishBtn);
    await waitFor(() => expect(publishMock).toHaveBeenCalledWith('s1', 'i1'));
  });
});
```

- [ ] **Step 2: Run — expect PASS**

```bash
cd Alqove/web && npm test -- item-form
```

- [ ] **Step 3: Commit**

```bash
git add "Alqove/web/src/app/(seller)/seller/listings/__tests__/item-form.test.tsx"
git commit -m "test(seller/listings): item-form publish gating"
```

---

## Phase F — Smoke test + regression

### Task 11: Manual smoke test

- [ ] **Step 1: Ensure stack is running**

```bash
docker compose up -d
cd Alqove && docker compose exec -T laravel.test php artisan migrate:fresh --seed
# (Next dev server is already up; hot-reload will pick up new routes)
```

- [ ] **Step 2: Sign in as `vintage-vibes-co@example.com` / `password`** and walk through:

| Scenario | Expected |
|----------|----------|
| Visit `/seller/listings` | Table loads with seeded items; "All" chip active |
| Click **Draft** chip | Only draft items shown |
| Click **Needs attention** chip | Same drafts filtered, chip highlighted |
| Search for a title keyword | 300ms debounce, list narrows |
| Change sort to "Price high→low" | Order reverses |
| Toggle **Grid** view | Card grid renders; persists to localStorage |
| Click a row/card | Navigates to `/seller/listings/<uuid>` (edit) |
| Edit form: change title, blur | "Saved Ns ago" shows within ~1s |
| **New item** button | Opens `/seller/listings/new`, Save draft creates + redirects |
| Upload images on a draft | Thumbnails appear, first marked Primary |
| Publish with required fields missing | Button disabled + amber "Before publishing: …" banner |
| Publish with everything set | Status flips to Published, chip recomputed |
| Remove a published item | Status flips to Removed, Relist button appears |
| Relist | Status returns to Published |

- [ ] **Step 3: Final regression sweep**

```bash
cd Alqove && docker compose exec -T laravel.test php artisan test tests/Feature/Items tests/Feature/Orders tests/Unit/Shipping
cd Alqove/web && npm test && npm run lint && npx tsc --noEmit
```

Expected: all green.

---

## Spec-coverage checklist (verify against spec lines 211–252)

- [x] Filter chips: All · Published · Draft · Sold · Removed — Task 7
- [x] "Needs attention" special chip — Task 7 (`?filter=needs-attention`)
- [x] URL-backed `?status=&q=&view=` — Task 7
- [x] View toggle persisted in localStorage when no `?view` — Task 7
- [x] Table columns: Thumb · Title · Status · Price · Views · Listed — Task 7
- [x] Grid view: 4-col image-first cards — Task 7
- [x] Row click → `/seller/listings/:id` — Task 7
- [x] "New item" button → `/seller/listings/new` — Task 7
- [x] Single long form for create + edit — Task 9
- [x] Sections: Images · Basics · Details · Price · Status/publish — Task 9
- [x] Autosave on blur with "Saved Xs ago" indicator — Task 9
- [x] Publish validates required fields client-side — Task 9 (`canPublish`)
- [x] Images via existing upload/delete endpoints — Task 6
- [x] Remove / Relist via existing endpoints — Task 9
- [ ] Drag-reorder images — **intentionally deferred** (see Scope decisions)
- [ ] Bulk actions (Publish/Remove/Relist/Export) — **intentionally deferred**
