# Layer 7 Plan 1: Foundation + Dashboard + Inbox

> **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:** Stand up the backend + frontend foundation needed by every seller page, then ship the seller Dashboard (triage hub) and Inbox (categorized notification history).

**Architecture:** (1) Backend — add a `category` column to the `notifications` table so category filtering is indexable, add a `SellerDashboardController::metrics` endpoint, add a `filter=needs-attention` query param on the items endpoint. (2) Frontend foundation — refactor the seller layout to include a top bar with `NotificationBell`, a no-store guard, and a new set of shared `components/seller/*` building blocks. (3) Dashboard at `/seller` and Inbox at `/seller/inbox`.

**Tech Stack:** Laravel 11 (Eloquent, DatabaseNotification), Pest PHP tests, Postgres, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query, Tailwind + design tokens, Vitest + React Testing Library.

**Spec:** `Alqove/docs/superpowers/specs/2026-04-22-layer-7-seller-dashboard-design.md`
**Prerequisites:** Layer 6 merged. Seller route group `web/src/app/(seller)/` exists with placeholder pages. `NotificationBell` already mounted on buyer layout. API middleware `store.owner` guards store-scoped endpoints.
**Successor plans:** `2026-XX-XX-layer-7-orders-listings.md`, `2026-XX-XX-layer-7-settings.md` (written after this plan's changes merge).

---

## Phase A — Backend: notification category column + filter

### Task 1: Add `category` column to notifications table

**Files:**
- Create: `api/database/migrations/2026_04_22_000001_add_category_to_notifications_table.php`
- Test: `api/tests/Feature/Notifications/NotificationsCategoryColumnTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;

class NotificationsCategoryColumnTest extends TestCase
{
    use RefreshDatabase;

    public function test_notifications_table_has_category_column(): void
    {
        $this->assertTrue(Schema::hasColumn('notifications', 'category'));
    }

    public function test_category_column_is_nullable_string_and_indexed(): void
    {
        $user = User::factory()->create();

        DB::table('notifications')->insert([
            'id' => Str::uuid()->toString(),
            'type' => 'App\\Modules\\Notifications\\Notifications\\BuyerOrderDeliveredNotification',
            'notifiable_type' => User::class,
            'notifiable_id' => $user->id,
            'data' => json_encode([]),
            'category' => 'shipping',
            'read_at' => null,
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $this->assertDatabaseHas('notifications', [
            'notifiable_id' => $user->id,
            'category' => 'shipping',
        ]);
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=NotificationsCategoryColumnTest`
Expected: FAIL — "column category does not exist" (or `hasColumn` returns false).

- [ ] **Step 3: Create the migration**

Create `api/database/migrations/2026_04_22_000001_add_category_to_notifications_table.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('notifications', function (Blueprint $table) {
            $table->string('category', 32)->nullable()->after('data');
            $table->index(['notifiable_id', 'category'], 'notifications_notifiable_category_idx');
        });
    }

    public function down(): void
    {
        Schema::table('notifications', function (Blueprint $table) {
            $table->dropIndex('notifications_notifiable_category_idx');
            $table->dropColumn('category');
        });
    }
};
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `docker compose exec laravel.test php artisan migrate && docker compose exec laravel.test php artisan test --filter=NotificationsCategoryColumnTest`
Expected: PASS (both test methods).

- [ ] **Step 5: Commit**

```bash
git add api/database/migrations/2026_04_22_000001_add_category_to_notifications_table.php api/tests/Feature/Notifications/NotificationsCategoryColumnTest.php
git commit -m "feat(notifications): add category column to notifications table"
```

---

### Task 2: NotificationCategoryMap helper

**Purpose:** Map notification class FQCN → `NotificationCategory` enum. Used both when writing new rows and when backfilling existing ones.

**Files:**
- Create: `api/app/Modules/Notifications/Services/NotificationCategoryMap.php`
- Test: `api/tests/Unit/Notifications/NotificationCategoryMapTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Notifications;

use App\Modules\Notifications\Notifications\BuyerOrderDeliveredNotification;
use App\Modules\Notifications\Notifications\BuyerOrderPaidNotification;
use App\Modules\Notifications\Notifications\SellerOrderDelayedNotification;
use App\Modules\Notifications\Notifications\SellerShipByReminderNotification;
use App\Modules\Notifications\Services\NotificationCategoryMap;
use App\Support\Enums\NotificationCategory;
use PHPUnit\Framework\TestCase;

class NotificationCategoryMapTest extends TestCase
{
    public function test_maps_order_notifications_to_orders_category(): void
    {
        $this->assertSame(
            NotificationCategory::Orders,
            NotificationCategoryMap::forType(BuyerOrderPaidNotification::class),
        );
    }

    public function test_maps_shipping_notifications_to_shipping_category(): void
    {
        $this->assertSame(
            NotificationCategory::Shipping,
            NotificationCategoryMap::forType(BuyerOrderDeliveredNotification::class),
        );

        $this->assertSame(
            NotificationCategory::Shipping,
            NotificationCategoryMap::forType(SellerOrderDelayedNotification::class),
        );

        $this->assertSame(
            NotificationCategory::Shipping,
            NotificationCategoryMap::forType(SellerShipByReminderNotification::class),
        );
    }

    public function test_returns_null_for_unknown_type(): void
    {
        $this->assertNull(NotificationCategoryMap::forType('App\\Made\\Up\\Class'));
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=NotificationCategoryMapTest`
Expected: FAIL — class does not exist.

- [ ] **Step 3: Create the helper**

First scan the existing notification classes to make sure the class-name list matches reality:

```bash
docker compose exec laravel.test find app/Modules/Notifications/Notifications -name '*.php' -exec basename {} \;
```

Then create `api/app/Modules/Notifications/Services/NotificationCategoryMap.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Services;

use App\Support\Enums\NotificationCategory;

final class NotificationCategoryMap
{
    /** @var array<string, NotificationCategory> */
    private const MAP = [
        // Orders — purchase lifecycle signals
        'BuyerOrderPaidNotification'              => NotificationCategory::Orders,
        'BuyerOrderAutoCancelledNotification'     => NotificationCategory::Orders,
        'BuyerOrderCancelledNotification'         => NotificationCategory::Orders,
        'SellerOrderPaidNotification'             => NotificationCategory::Orders,
        'SellerOrderAutoCancelledNotification'    => NotificationCategory::Orders,
        'SellerOrderCancelledNotification'        => NotificationCategory::Orders,
        'AdminPurchaseDisputedNotification'       => NotificationCategory::Orders,

        // Shipping — physical movement signals
        'BuyerOrderShippedNotification'           => NotificationCategory::Shipping,
        'BuyerOrderDeliveredNotification'         => NotificationCategory::Shipping,
        'BuyerOrderDelayedNotification'           => NotificationCategory::Shipping,
        'BuyerOrderDeliveryFailedNotification'    => NotificationCategory::Shipping,
        'SellerOrderDelayedNotification'          => NotificationCategory::Shipping,
        'SellerOrderDeliveryFailedNotification'   => NotificationCategory::Shipping,
        'SellerShipByReminderNotification'        => NotificationCategory::Shipping,
    ];

    public static function forType(string $fqcn): ?NotificationCategory
    {
        $basename = class_basename($fqcn);

        return self::MAP[$basename] ?? null;
    }
}
```

Note: if `find` reveals class names not listed above, add them here. Unknown classes return `null` and are treated as the `System` bucket at query time.

- [ ] **Step 4: Run the test and confirm it passes**

Run: `docker compose exec laravel.test php artisan test --filter=NotificationCategoryMapTest`
Expected: PASS (all three methods).

- [ ] **Step 5: Commit**

```bash
git add api/app/Modules/Notifications/Services/NotificationCategoryMap.php api/tests/Unit/Notifications/NotificationCategoryMapTest.php
git commit -m "feat(notifications): add NotificationCategoryMap class-to-category resolver"
```

---

### Task 3: Observer that stamps category on save

**Purpose:** Any new notification row written to the database gets its `category` column populated automatically from its class name. This works with every Notification class without editing each one.

**Files:**
- Create: `api/app/Modules/Notifications/Observers/DatabaseNotificationObserver.php`
- Modify: `api/app/Providers/AppServiceProvider.php`
- Test: `api/tests/Feature/Notifications/DatabaseNotificationCategoryStampTest.php`

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

Create `api/tests/Feature/Notifications/DatabaseNotificationCategoryStampTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\User;
use App\Modules\Notifications\Notifications\BuyerOrderDeliveredNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class DatabaseNotificationCategoryStampTest extends TestCase
{
    use RefreshDatabase;

    public function test_sending_a_notification_stamps_the_category_column(): void
    {
        $user = User::factory()->create();
        $order = Order::factory()->create();

        $user->notify(new BuyerOrderDeliveredNotification($order));

        $row = DB::table('notifications')
            ->where('notifiable_id', $user->id)
            ->first();

        $this->assertNotNull($row, 'notification row was not created');
        $this->assertSame('shipping', $row->category, 'category column was not stamped');
    }
}
```

Note: `Notification::fake()` swaps out the dispatcher and skips the DB write entirely, so the observer never runs — that's why this test uses the real pipeline. The `ShouldQueue` marker is honoured by Laravel's sync queue driver in tests, which still runs the DB channel, which still fires the observer.

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=DatabaseNotificationCategoryStampTest`
Expected: FAIL — row's `category` is `null` (column exists but observer is not yet wired).

- [ ] **Step 3: Create the observer**

Create `api/app/Modules/Notifications/Observers/DatabaseNotificationObserver.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Observers;

use App\Modules\Notifications\Services\NotificationCategoryMap;
use Illuminate\Notifications\DatabaseNotification;

class DatabaseNotificationObserver
{
    public function creating(DatabaseNotification $notification): void
    {
        if ($notification->category !== null) {
            return;
        }

        $category = NotificationCategoryMap::forType($notification->type);
        $notification->category = $category?->value;
    }
}
```

- [ ] **Step 4: Register the observer**

In `api/app/Providers/AppServiceProvider.php`, inside the `boot()` method (add if not present):

```php
use App\Modules\Notifications\Observers\DatabaseNotificationObserver;
use Illuminate\Notifications\DatabaseNotification;

public function boot(): void
{
    // ...existing code...

    DatabaseNotification::observe(DatabaseNotificationObserver::class);
}
```

If `AppServiceProvider` already imports other observer classes, add to the same block and keep imports alphabetized per the codebase convention.

- [ ] **Step 5: Run the test and confirm it passes**

Run: `docker compose exec laravel.test php artisan test --filter=DatabaseNotificationCategoryStampTest`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add api/app/Modules/Notifications/Observers/DatabaseNotificationObserver.php api/app/Providers/AppServiceProvider.php api/tests/Feature/Notifications/DatabaseNotificationCategoryStampTest.php
git commit -m "feat(notifications): stamp category column via DatabaseNotificationObserver"
```

---

### Task 4: Backfill category on existing rows

**Files:**
- Create: `api/database/migrations/2026_04_22_000002_backfill_notification_categories.php`
- Test: `api/tests/Feature/Notifications/NotificationCategoryBackfillTest.php`

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

Create `api/tests/Feature/Notifications/NotificationCategoryBackfillTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\User;
use App\Modules\Notifications\Services\NotificationCategoryBackfiller;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;

class NotificationCategoryBackfillTest extends TestCase
{
    use RefreshDatabase;

    public function test_backfill_populates_category_on_existing_rows(): void
    {
        $user = User::factory()->create();

        // Seed legacy rows with null categories (simulating pre-Layer-7 data):
        DB::table('notifications')->insert([
            $this->row($user->id, 'App\\Modules\\Notifications\\Notifications\\BuyerOrderDeliveredNotification'),
            $this->row($user->id, 'App\\Modules\\Notifications\\Notifications\\BuyerOrderPaidNotification'),
            $this->row($user->id, 'App\\Totally\\Unknown\\Class'),
        ]);

        NotificationCategoryBackfiller::run();

        $categories = DB::table('notifications')->pluck('category', 'type');
        $this->assertSame('shipping', $categories['App\\Modules\\Notifications\\Notifications\\BuyerOrderDeliveredNotification']);
        $this->assertSame('orders',   $categories['App\\Modules\\Notifications\\Notifications\\BuyerOrderPaidNotification']);
        $this->assertNull($categories['App\\Totally\\Unknown\\Class']);
    }

    /** @return array<string, mixed> */
    private function row(string $userId, string $type): array
    {
        return [
            'id' => Str::uuid()->toString(),
            'type' => $type,
            'notifiable_type' => User::class,
            'notifiable_id' => $userId,
            'data' => json_encode([]),
            'category' => null,
            'read_at' => null,
            'created_at' => now(),
            'updated_at' => now(),
        ];
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=NotificationCategoryBackfillTest`
Expected: FAIL — class `NotificationCategoryBackfiller` does not exist.

- [ ] **Step 3: Create the backfill service**

Create `api/app/Modules/Notifications/Services/NotificationCategoryBackfiller.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Services;

use Illuminate\Support\Facades\DB;

final class NotificationCategoryBackfiller
{
    public static function run(): int
    {
        $updated = 0;

        $types = DB::table('notifications')
            ->whereNull('category')
            ->select('type')
            ->distinct()
            ->pluck('type');

        foreach ($types as $type) {
            $category = NotificationCategoryMap::forType((string) $type);
            if ($category === null) {
                continue;
            }

            $updated += DB::table('notifications')
                ->where('type', $type)
                ->whereNull('category')
                ->update(['category' => $category->value]);
        }

        return $updated;
    }
}
```

- [ ] **Step 4: Create the backfill migration**

Create `api/database/migrations/2026_04_22_000002_backfill_notification_categories.php`:

```php
<?php

declare(strict_types=1);

use App\Modules\Notifications\Services\NotificationCategoryBackfiller;
use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
    public function up(): void
    {
        NotificationCategoryBackfiller::run();
    }

    public function down(): void
    {
        // No-op; a reversal would need to distinguish backfilled rows from later
        // observer-stamped rows, and the column is non-destructive either way.
    }
};
```

- [ ] **Step 5: Run the test and migration, confirm pass**

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

Expected: test PASSES; migration logs 0 rows updated on a fresh DB (no legacy rows).

- [ ] **Step 6: Commit**

```bash
git add api/app/Modules/Notifications/Services/NotificationCategoryBackfiller.php api/database/migrations/2026_04_22_000002_backfill_notification_categories.php api/tests/Feature/Notifications/NotificationCategoryBackfillTest.php
git commit -m "feat(notifications): backfill category on existing notification rows"
```

---

### Task 5: Add `category` filter to inbox endpoint

**Files:**
- Modify: `api/app/Modules/Notifications/Controllers/InboxController.php` (extend `index`)
- Test: `api/tests/Feature/Notifications/InboxCategoryFilterTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class InboxCategoryFilterTest extends TestCase
{
    use RefreshDatabase;

    public function test_category_filter_limits_results_to_that_category(): void
    {
        $user = User::factory()->create();
        Sanctum::actingAs($user);

        $this->seedRow($user->id, 'orders',   'title-o');
        $this->seedRow($user->id, 'shipping', 'title-s');
        $this->seedRow($user->id, null,       'title-u'); // unknown/system

        $response = $this->getJson('/v1/me/notifications?category=shipping');

        $response->assertOk();
        $titles = collect($response->json('data'))->pluck('title')->all();

        $this->assertEqualsCanonicalizing(['title-s'], $titles);
    }

    public function test_no_category_filter_returns_all_categories(): void
    {
        $user = User::factory()->create();
        Sanctum::actingAs($user);

        $this->seedRow($user->id, 'orders',   'a');
        $this->seedRow($user->id, 'shipping', 'b');
        $this->seedRow($user->id, null,       'c');

        $response = $this->getJson('/v1/me/notifications');

        $response->assertOk();
        $this->assertCount(3, $response->json('data'));
    }

    public function test_system_category_returns_rows_with_null_category(): void
    {
        $user = User::factory()->create();
        Sanctum::actingAs($user);

        $this->seedRow($user->id, 'orders',   'o');
        $this->seedRow($user->id, null,       's1');
        $this->seedRow($user->id, null,       's2');

        $response = $this->getJson('/v1/me/notifications?category=system');

        $response->assertOk();
        $this->assertCount(2, $response->json('data'));
    }

    private function seedRow(string $userId, ?string $category, string $title): void
    {
        DB::table('notifications')->insert([
            'id' => Str::uuid()->toString(),
            'type' => 'App\\Modules\\Notifications\\Notifications\\BuyerOrderPaidNotification',
            'notifiable_type' => User::class,
            'notifiable_id' => $userId,
            'data' => json_encode(['title' => $title, 'body' => '']),
            'category' => $category,
            'read_at' => null,
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=InboxCategoryFilterTest`
Expected: FAIL — all requests return all rows regardless of category.

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

In `api/app/Modules/Notifications/Controllers/InboxController.php`, update the `index` method query block:

```php
public function index(Request $request): JsonResponse
{
    /** @var User $user */
    $user = $request->user();

    $query = DB::table('notifications')
        ->where('notifiable_type', User::class)
        ->where('notifiable_id', $user->id);

    if ($request->query('filter') === 'unread') {
        $query->whereNull('read_at');
    }

    $category = $request->query('category');
    if (is_string($category) && $category !== '') {
        if ($category === 'system') {
            $query->whereNull('category');
        } else {
            $query->where('category', $category);
        }
    }

    $perPage = min(50, max(1, (int) $request->query('per_page', 20)));
    // ... rest unchanged
```

Leave the pagination + `InboxItemResource` mapping below this block unchanged.

- [ ] **Step 4: Run the test and confirm it passes**

Run: `docker compose exec laravel.test php artisan test --filter=InboxCategoryFilterTest`
Expected: PASS (all three methods).

- [ ] **Step 5: Commit**

```bash
git add api/app/Modules/Notifications/Controllers/InboxController.php api/tests/Feature/Notifications/InboxCategoryFilterTest.php
git commit -m "feat(notifications): add category filter to GET /me/notifications"
```

---

### Task 6: Update OpenAPI and regenerate types (notifications)

**Files:**
- Modify: `api/contracts/openapi.yaml` (find the `/me/notifications` GET definition)

- [ ] **Step 1: Locate the endpoint in the spec**

```bash
grep -n 'me/notifications' api/contracts/openapi.yaml | head -10
```

- [ ] **Step 2: Add `category` to parameters**

Under the parameters list of `GET /v1/me/notifications`, add (alphabetically with the other params if possible):

```yaml
- in: query
  name: category
  schema:
    type: string
    enum: [orders, shipping, payouts, system, promotions, price_drops, account]
  description: >-
    Filter by notification category. `system` returns rows whose category is
    NULL (legacy rows and unknown class types). Omit to return all categories.
```

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

```bash
cd Alqove && npm run build:types
```

Expected: exits 0, writes `packages/types/src/generated.ts`.

- [ ] **Step 4: Commit**

```bash
git add Alqove/api/contracts/openapi.yaml Alqove/packages/types/src/generated.ts
git commit -m "feat(notifications): document category filter in OpenAPI"
```

---

### Task 7: Extend api-client `notifications.list` signature

**Files:**
- Modify: `Alqove/packages/api-client/src/endpoints/notifications.ts`

- [ ] **Step 1: Update the list signature**

Replace the existing `list(params?: ...)` method body with:

```ts
list(params?: {
  filter?: 'all' | 'unread';
  category?: 'orders' | 'shipping' | 'payouts' | 'system' | 'promotions' | 'price_drops' | 'account';
  per_page?: number;
  page?: number;
}) {
  return client.get<NotificationInboxList>(
    '/v1/me/notifications',
    params as Record<string, string> | undefined,
  );
}
```

- [ ] **Step 2: Typecheck**

```bash
cd Alqove && npm run typecheck --workspace @alqove/api-client
```

Expected: exits 0.

- [ ] **Step 3: Commit**

```bash
git add Alqove/packages/api-client/src/endpoints/notifications.ts
git commit -m "feat(api-client): add category param to notifications.list"
```

---

## Phase B — Backend: dashboard metrics endpoint

### Task 8: Dashboard metrics service (KPI aggregation)

**Files:**
- Create: `api/app/Modules/Stores/Services/SellerDashboardMetrics.php`
- Test: `api/tests/Unit/Stores/SellerDashboardMetricsTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Stores;

use App\Models\Item;
use App\Models\Order;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\User;
use App\Modules\Stores\Services\SellerDashboardMetrics;
use App\Support\Enums\ItemStatus;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SellerDashboardMetricsTest extends TestCase
{
    use RefreshDatabase;

    public function test_revenue_this_month_sums_this_months_paid_purchase_amounts(): void
    {
        CarbonImmutable::setTestNow('2026-04-15 12:00:00');

        $store = Store::factory()->create();
        $now = CarbonImmutable::parse('2026-04-10');
        $lastMonth = CarbonImmutable::parse('2026-03-20');

        // This-month paid purchase:
        $this->paidPurchaseFor($store, 5_000, $now);
        $this->paidPurchaseFor($store, 2_500, $now);

        // Last-month paid purchase:
        $this->paidPurchaseFor($store, 3_000, $lastMonth);

        $metrics = app(SellerDashboardMetrics::class)->forStore($store);

        $this->assertSame(7_500, $metrics['revenue_this_month']['current_cents']);
        $this->assertSame(3_000, $metrics['revenue_this_month']['prior_cents']);
    }

    public function test_active_listings_counts_published_items_and_weekly_delta(): void
    {
        CarbonImmutable::setTestNow('2026-04-15 12:00:00');

        $store = Store::factory()->create();

        // Published > 7 days ago:
        Item::factory()->for($store)->create([
            'status' => ItemStatus::Published,
            'published_at' => CarbonImmutable::parse('2026-04-01'),
        ]);

        // Published within last 7 days:
        Item::factory()->for($store)->create([
            'status' => ItemStatus::Published,
            'published_at' => CarbonImmutable::parse('2026-04-12'),
        ]);
        Item::factory()->for($store)->create([
            'status' => ItemStatus::Published,
            'published_at' => CarbonImmutable::parse('2026-04-14'),
        ]);

        // Not published:
        Item::factory()->for($store)->create(['status' => ItemStatus::Active]);

        $metrics = app(SellerDashboardMetrics::class)->forStore($store);

        $this->assertSame(3, $metrics['active_listings']['current']);
        $this->assertSame(2, $metrics['active_listings']['delta_last_7_days']);
    }

    public function test_orders_this_month_counts_and_deltas(): void
    {
        CarbonImmutable::setTestNow('2026-04-15 12:00:00');

        $store = Store::factory()->create();

        Order::factory()->for($store)->create(['placed_at' => '2026-04-05']);
        Order::factory()->for($store)->create(['placed_at' => '2026-04-10']);
        Order::factory()->for($store)->create(['placed_at' => '2026-03-10']);

        $metrics = app(SellerDashboardMetrics::class)->forStore($store);

        $this->assertSame(2, $metrics['orders_this_month']['current']);
        $this->assertSame(1, $metrics['orders_this_month']['prior']);
    }

    public function test_action_counts_aggregate_paid_not_shipped_and_attention_items(): void
    {
        CarbonImmutable::setTestNow('2026-04-15 12:00:00');

        $store = Store::factory()->create();

        // Paid, ship-by overdue:
        Order::factory()->for($store)->create(['status' => 'paid', 'ship_by' => '2026-04-13']);
        // Paid, ship-by today:
        Order::factory()->for($store)->create(['status' => 'paid', 'ship_by' => '2026-04-15']);
        // Paid, ship-by in future week:
        Order::factory()->for($store)->create(['status' => 'paid', 'ship_by' => '2026-04-20']);
        // Shipped (should be ignored):
        Order::factory()->for($store)->create(['status' => 'shipped']);

        // Drafts (listings attention):
        Item::factory()->for($store)->count(2)->create(['status' => ItemStatus::Draft]);

        $metrics = app(SellerDashboardMetrics::class)->forStore($store);

        $this->assertSame(1, $metrics['action_counts']['orders_overdue']);
        $this->assertSame(1, $metrics['action_counts']['orders_today']);
        $this->assertSame(1, $metrics['action_counts']['orders_this_week']);
        $this->assertSame(2, $metrics['action_counts']['listings_needs_attention']);
    }

    /** Helper: create a Purchase + Order for $store with the given amount paid at a given date. */
    private function paidPurchaseFor(Store $store, int $cents, CarbonImmutable $at): void
    {
        $buyer = User::factory()->create();
        $purchase = Purchase::factory()->create([
            'user_id' => $buyer->id,
            'status' => 'paid',
            'paid_at' => $at,
        ]);
        Order::factory()->for($store)->create([
            'purchase_id' => $purchase->id,
            'status' => 'paid',
            'subtotal_cents' => $cents,
            'total_cents' => $cents,
            'placed_at' => $at,
        ]);
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=SellerDashboardMetricsTest`
Expected: FAIL — `SellerDashboardMetrics` does not exist.

- [ ] **Step 3: Implement the service**

Before writing, confirm the column names used in the test match the real schema:

```bash
docker compose exec laravel.test php artisan tinker --execute="echo json_encode(\Illuminate\Support\Facades\Schema::getColumnListing('orders'));"
docker compose exec laravel.test php artisan tinker --execute="echo json_encode(\Illuminate\Support\Facades\Schema::getColumnListing('purchases'));"
```

Adjust the names in the test and the service if they differ (`ship_by` may be `ship_by_at` or similar; `placed_at` may be `created_at`). The test must be updated to match reality before continuing.

Create `api/app/Modules/Stores/Services/SellerDashboardMetrics.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Stores\Services;

use App\Models\Item;
use App\Models\Order;
use App\Models\Store;
use App\Support\Enums\ItemStatus;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;

final class SellerDashboardMetrics
{
    /** @return array<string, mixed> */
    public function forStore(Store $store): array
    {
        $now = CarbonImmutable::now();
        $startOfMonth = $now->startOfMonth();
        $startOfLastMonth = $startOfMonth->subMonthNoOverflow();
        $startOfThisWeek = $now->subDays(7);

        return [
            'revenue_this_month' => $this->revenue($store, $startOfMonth, $now, $startOfLastMonth, $startOfMonth),
            'active_listings'    => $this->activeListings($store, $startOfThisWeek),
            'orders_this_month'  => $this->ordersThisMonth($store, $startOfMonth, $now, $startOfLastMonth, $startOfMonth),
            'next_payout'        => $this->nextPayout($store),
            'action_counts'      => $this->actionCounts($store, $now),
        ];
    }

    /** @return array{current_cents:int, prior_cents:int} */
    private function revenue(
        Store $store,
        CarbonImmutable $currentStart,
        CarbonImmutable $currentEnd,
        CarbonImmutable $priorStart,
        CarbonImmutable $priorEnd,
    ): array {
        $sum = fn (CarbonImmutable $s, CarbonImmutable $e) => (int) Order::query()
            ->where('store_id', $store->id)
            ->whereIn('status', ['paid', 'shipped', 'delivered', 'completed'])
            ->whereBetween('placed_at', [$s, $e])
            ->sum('total_cents');

        return [
            'current_cents' => $sum($currentStart, $currentEnd),
            'prior_cents'   => $sum($priorStart, $priorEnd),
        ];
    }

    /** @return array{current:int, delta_last_7_days:int} */
    private function activeListings(Store $store, CarbonImmutable $sevenDaysAgo): array
    {
        $current = Item::query()
            ->where('store_id', $store->id)
            ->where('status', ItemStatus::Published)
            ->count();

        $delta = Item::query()
            ->where('store_id', $store->id)
            ->where('status', ItemStatus::Published)
            ->where('published_at', '>=', $sevenDaysAgo)
            ->count();

        return ['current' => $current, 'delta_last_7_days' => $delta];
    }

    /** @return array{current:int, prior:int} */
    private function ordersThisMonth(
        Store $store,
        CarbonImmutable $currentStart,
        CarbonImmutable $currentEnd,
        CarbonImmutable $priorStart,
        CarbonImmutable $priorEnd,
    ): array {
        $count = fn (CarbonImmutable $s, CarbonImmutable $e) => Order::query()
            ->where('store_id', $store->id)
            ->whereBetween('placed_at', [$s, $e])
            ->count();

        return [
            'current' => $count($currentStart, $currentEnd),
            'prior'   => $count($priorStart, $priorEnd),
        ];
    }

    /** @return array{amount_cents:int|null, arrival_date:string|null} */
    private function nextPayout(Store $store): array
    {
        // Placeholder — Stripe Connect payout lookup lives in a later plan.
        // The endpoint returns nulls; the frontend card shows a "—" placeholder.
        return ['amount_cents' => null, 'arrival_date' => null];
    }

    /** @return array<string, int> */
    private function actionCounts(Store $store, CarbonImmutable $now): array
    {
        $today = $now->startOfDay();
        $endOfWeek = $today->addDays(7);

        $paidBase = Order::query()
            ->where('store_id', $store->id)
            ->where('status', 'paid');

        return [
            'orders_overdue'   => (clone $paidBase)->where('ship_by', '<', $today)->count(),
            'orders_today'     => (clone $paidBase)->whereBetween('ship_by', [$today, $today->endOfDay()])->count(),
            'orders_this_week' => (clone $paidBase)->whereBetween('ship_by', [$today->addDay(), $endOfWeek])->count(),
            'listings_needs_attention' => Item::query()
                ->where('store_id', $store->id)
                ->where('status', ItemStatus::Draft)
                ->count(),
        ];
    }
}
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `docker compose exec laravel.test php artisan test --filter=SellerDashboardMetricsTest`
Expected: PASS (all four methods). If column names differ, adjust and re-run.

- [ ] **Step 5: Commit**

```bash
git add api/app/Modules/Stores/Services/SellerDashboardMetrics.php api/tests/Unit/Stores/SellerDashboardMetricsTest.php
git commit -m "feat(stores): add SellerDashboardMetrics service for KPI + action counts"
```

---

### Task 9: SellerDashboardController + route

**Files:**
- Create: `api/app/Modules/Stores/Controllers/SellerDashboardController.php`
- Modify: `api/app/Modules/Stores/routes.php`
- Test: `api/tests/Feature/Seller/DashboardMetricsEndpointTest.php`

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

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Seller;

use App\Models\Store;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class DashboardMetricsEndpointTest extends TestCase
{
    use RefreshDatabase;

    public function test_seller_gets_their_stores_metrics(): void
    {
        $store = Store::factory()->create();
        $owner = User::factory()->create(['store_id' => $store->id]);
        $store->update(['owner_id' => $owner->id]);

        Sanctum::actingAs($owner);

        $response = $this->getJson("/v1/stores/{$store->id}/dashboard/metrics");

        $response->assertOk()
            ->assertJsonStructure([
                'data' => [
                    'revenue_this_month' => ['current_cents', 'prior_cents'],
                    'active_listings'    => ['current', 'delta_last_7_days'],
                    'orders_this_month'  => ['current', 'prior'],
                    'next_payout'        => ['amount_cents', 'arrival_date'],
                    'action_counts'      => [
                        'orders_overdue',
                        'orders_today',
                        'orders_this_week',
                        'listings_needs_attention',
                    ],
                ],
            ]);
    }

    public function test_non_owner_cannot_access_metrics(): void
    {
        $store = Store::factory()->create();
        $stranger = User::factory()->create();

        Sanctum::actingAs($stranger);

        $this->getJson("/v1/stores/{$store->id}/dashboard/metrics")
            ->assertForbidden();
    }

    public function test_unauthenticated_cannot_access(): void
    {
        $store = Store::factory()->create();

        $this->getJson("/v1/stores/{$store->id}/dashboard/metrics")
            ->assertUnauthorized();
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=DashboardMetricsEndpointTest`
Expected: FAIL — route not found (404).

- [ ] **Step 3: Create the controller**

Create `api/app/Modules/Stores/Controllers/SellerDashboardController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Stores\Controllers;

use App\Models\Store;
use App\Modules\Stores\Services\SellerDashboardMetrics;
use Illuminate\Http\JsonResponse;

class SellerDashboardController
{
    public function __construct(private readonly SellerDashboardMetrics $metrics) {}

    public function metrics(Store $store): JsonResponse
    {
        return response()->json([
            'data' => $this->metrics->forStore($store),
        ]);
    }
}
```

- [ ] **Step 4: Wire the route**

In `api/app/Modules/Stores/routes.php`, inside the `middleware('store.owner')->group(...)` block, add:

```php
use App\Modules\Stores\Controllers\SellerDashboardController;

Route::get('/stores/{store}/dashboard/metrics', [SellerDashboardController::class, 'metrics']);
```

- [ ] **Step 5: Run the test and confirm it passes**

Run: `docker compose exec laravel.test php artisan test --filter=DashboardMetricsEndpointTest`
Expected: PASS (all three methods).

- [ ] **Step 6: Commit**

```bash
git add api/app/Modules/Stores/Controllers/SellerDashboardController.php api/app/Modules/Stores/routes.php api/tests/Feature/Seller/DashboardMetricsEndpointTest.php
git commit -m "feat(stores): GET /stores/{store}/dashboard/metrics endpoint"
```

---

### Task 10: Document metrics endpoint in OpenAPI + regenerate types

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

- [ ] **Step 1: Add the new path**

Append under `paths:` (alphabetical-ish, near other `/stores/{storeId}/*` paths):

```yaml
  /v1/stores/{storeId}/dashboard/metrics:
    get:
      tags: [Stores]
      summary: Seller dashboard metrics
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/StoreIdPath'
      responses:
        '200':
          description: Aggregated KPI + action-count payload for the seller dashboard.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/SellerDashboardMetrics'
```

In `components.schemas:`, add:

```yaml
    SellerDashboardMetrics:
      type: object
      required: [revenue_this_month, active_listings, orders_this_month, next_payout, action_counts]
      properties:
        revenue_this_month:
          type: object
          required: [current_cents, prior_cents]
          properties:
            current_cents: { type: integer, minimum: 0 }
            prior_cents:   { type: integer, minimum: 0 }
        active_listings:
          type: object
          required: [current, delta_last_7_days]
          properties:
            current:            { type: integer, minimum: 0 }
            delta_last_7_days:  { type: integer, minimum: 0 }
        orders_this_month:
          type: object
          required: [current, prior]
          properties:
            current: { type: integer, minimum: 0 }
            prior:   { type: integer, minimum: 0 }
        next_payout:
          type: object
          required: [amount_cents, arrival_date]
          properties:
            amount_cents:  { type: [integer, 'null'], minimum: 0 }
            arrival_date:  { type: [string, 'null'], format: date }
        action_counts:
          type: object
          required: [orders_overdue, orders_today, orders_this_week, listings_needs_attention]
          properties:
            orders_overdue:            { type: integer, minimum: 0 }
            orders_today:              { type: integer, minimum: 0 }
            orders_this_week:          { type: integer, minimum: 0 }
            listings_needs_attention:  { type: integer, minimum: 0 }
```

If `StoreIdPath` parameter doesn't already exist under `components.parameters`, add it now — reusable UUID store id path param.

- [ ] **Step 2: Regenerate types and typecheck**

```bash
cd Alqove && npm run build:types && npm run typecheck
```

Expected: exits 0 across all workspaces.

- [ ] **Step 3: Commit**

```bash
git add Alqove/api/contracts/openapi.yaml Alqove/packages/types/src/generated.ts
git commit -m "feat(openapi): document seller dashboard metrics endpoint"
```

---

## Phase C — Backend: listings needs-attention filter

### Task 11: Items endpoint accepts `filter=needs-attention`

**Files:**
- Modify: `api/app/Modules/Items/Controllers/ItemController.php` (extend `index` action)
- Test: `api/tests/Feature/Items/ItemsNeedsAttentionFilterTest.php`

- [ ] **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 Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class ItemsNeedsAttentionFilterTest extends TestCase
{
    use RefreshDatabase;

    public function test_filter_needs_attention_returns_drafts(): void
    {
        $store = Store::factory()->create();
        $owner = User::factory()->create(['store_id' => $store->id]);
        $store->update(['owner_id' => $owner->id]);
        Sanctum::actingAs($owner);

        Item::factory()->for($store)->create(['status' => ItemStatus::Draft]);
        Item::factory()->for($store)->create(['status' => ItemStatus::Draft]);
        Item::factory()->for($store)->create(['status' => ItemStatus::Published]);
        Item::factory()->for($store)->create(['status' => ItemStatus::Sold]);

        $response = $this->getJson("/v1/stores/{$store->id}/items?filter=needs-attention");

        $response->assertOk();
        $this->assertCount(2, $response->json('data'));
        foreach ($response->json('data') as $row) {
            $this->assertSame('draft', $row['status']);
        }
    }

    public function test_filter_needs_attention_ignores_other_statuses(): void
    {
        $store = Store::factory()->create();
        $owner = User::factory()->create(['store_id' => $store->id]);
        $store->update(['owner_id' => $owner->id]);
        Sanctum::actingAs($owner);

        Item::factory()->for($store)->count(3)->create(['status' => ItemStatus::Published]);

        $response = $this->getJson("/v1/stores/{$store->id}/items?filter=needs-attention");

        $response->assertOk();
        $this->assertCount(0, $response->json('data'));
    }
}
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `docker compose exec laravel.test php artisan test --filter=ItemsNeedsAttentionFilterTest`
Expected: FAIL — filter returns 3 published items (filter ignored).

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

In `api/app/Modules/Items/Controllers/ItemController.php`, find the `index` query-building block and add, just before the ordering/paginate call:

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

Import `ItemStatus` at the top of the file if not already imported.

The published-with-zero-views-30-days criterion is deferred — the Item model doesn't track views yet. When view tracking lands, this block expands to an OR clause. For now: drafts only.

- [ ] **Step 4: Run the test and confirm it passes**

Run: `docker compose exec laravel.test php artisan test --filter=ItemsNeedsAttentionFilterTest`
Expected: PASS.

- [ ] **Step 5: Update OpenAPI**

Under `GET /v1/stores/{storeId}/items` parameters, add:

```yaml
- in: query
  name: filter
  schema:
    type: string
    enum: [needs-attention]
  description: >-
    Special composite filter. `needs-attention` returns items that need the
    seller's action (currently: drafts never published).
```

- [ ] **Step 6: Regenerate types and commit**

```bash
cd Alqove && npm run build:types
git add Alqove/api/app/Modules/Items/Controllers/ItemController.php Alqove/api/contracts/openapi.yaml Alqove/packages/types/src/generated.ts Alqove/api/tests/Feature/Items/ItemsNeedsAttentionFilterTest.php
git commit -m "feat(items): filter=needs-attention query param returns drafts"
```

---

## Phase D — API client seller namespace

### Task 12: Add seller namespace to @alqove/api-client

**Background:** The `AlqoveClient` class (in `packages/api-client/src/client.ts`) is a thin fetch wrapper. Per-domain endpoint factories live in `packages/api-client/src/endpoints/*.ts` and are re-exported from `packages/api-client/src/index.ts`. The web app's `web/src/lib/api.ts` composes them into a single `api` object. We add a new `seller.ts` factory following the same pattern.

**Files:**
- Create: `Alqove/packages/api-client/src/endpoints/seller.ts`
- Modify: `Alqove/packages/api-client/src/index.ts` (re-export factory and type)
- Modify: `Alqove/web/src/lib/api.ts` (compose into `api.seller`)

- [ ] **Step 1: Create the seller endpoints module**

Create `Alqove/packages/api-client/src/endpoints/seller.ts`:

```ts
import type { AlqoveClient } from '../client';

export interface SellerDashboardMetrics {
  revenue_this_month: { current_cents: number; prior_cents: number };
  active_listings:    { current: number; delta_last_7_days: number };
  orders_this_month:  { current: number; prior: number };
  next_payout:        { amount_cents: number | null; arrival_date: string | null };
  action_counts: {
    orders_overdue: number;
    orders_today: number;
    orders_this_week: number;
    listings_needs_attention: number;
  };
}

export function createSellerEndpoints(client: AlqoveClient) {
  return {
    dashboardMetrics(storeId: string) {
      return client.get<{ data: SellerDashboardMetrics }>(
        `/v1/stores/${storeId}/dashboard/metrics`,
      );
    },
  };
}
```

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

Add these lines to `Alqove/packages/api-client/src/index.ts` alongside the existing factory exports and type exports:

```ts
export { createSellerEndpoints } from './endpoints/seller';
export type { SellerDashboardMetrics } from './endpoints/seller';
```

- [ ] **Step 3: Compose into the web app's `api` object**

In `Alqove/web/src/lib/api.ts`, add `createSellerEndpoints` to the import list and `seller: createSellerEndpoints(client)` to the exported `api` object:

```ts
import {
  AlqoveClient,
  createAuthEndpoints,
  // ...existing imports...
  createStripeEndpoints,
  createSellerEndpoints,
} from '@alqove/api-client';

// ...client creation unchanged...

export const api = {
  client,
  // ...existing namespaces...
  stripe: createStripeEndpoints(client),
  seller: createSellerEndpoints(client),
};
```

- [ ] **Step 4: Typecheck**

```bash
cd Alqove && npm run typecheck
```

Expected: exits 0 across all workspaces.

- [ ] **Step 5: Commit**

```bash
git add Alqove/packages/api-client/src/endpoints/seller.ts Alqove/packages/api-client/src/index.ts Alqove/web/src/lib/api.ts
git commit -m "feat(api-client): add seller namespace with dashboardMetrics"
```

---

## Phase E — Frontend shared seller components

### Task 13: Vitest + RTL test scaffold (if not already present)

**Purpose:** Subsequent frontend tasks assume a working Vitest setup that can render React components and assert on them. If `web/` already has this, skip to Task 14.

- [ ] **Step 1: Confirm presence**

```bash
cd Alqove/web && cat package.json | grep -A 1 '"test"'
ls __tests__ 2>/dev/null || ls src/__tests__ 2>/dev/null || echo "no top-level tests folder"
```

If `"test": "vitest"` (or similar) exists and there are any `*.test.tsx` files under `web/`, skip to Task 14. Otherwise, follow Step 2.

- [ ] **Step 2: Add Vitest + RTL if missing**

```bash
cd Alqove/web
npm i -D vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
```

Create `vitest.config.ts`:

```ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'node:path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    globals: true,
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
});
```

Create `vitest.setup.ts`:

```ts
import '@testing-library/jest-dom/vitest';
```

Add to `package.json` scripts: `"test": "vitest run"`, `"test:watch": "vitest"`.

- [ ] **Step 3: Commit (only if changes made)**

```bash
git add Alqove/web/package.json Alqove/web/package-lock.json Alqove/web/vitest.config.ts Alqove/web/vitest.setup.ts
git commit -m "chore(web): add Vitest + React Testing Library for seller component tests"
```

---

### Task 14: `KpiCard` component

**Files:**
- Create: `Alqove/web/src/components/seller/kpi-card.tsx`
- Test: `Alqove/web/src/components/seller/__tests__/kpi-card.test.tsx`

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

```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { KpiCard } from '../kpi-card';

describe('KpiCard', () => {
  it('renders label and value', () => {
    render(<KpiCard label="Revenue" value="$2,840" />);

    expect(screen.getByText('Revenue')).toBeInTheDocument();
    expect(screen.getByText('$2,840')).toBeInTheDocument();
  });

  it('renders positive delta in forest color with up arrow', () => {
    render(<KpiCard label="Orders" value="43" delta={{ direction: 'up', text: '+8 vs last month' }} />);

    const delta = screen.getByText(/\+8 vs last month/);
    expect(delta).toBeInTheDocument();
    expect(delta).toHaveClass('text-forest');
  });

  it('renders negative delta in terracotta color with down arrow', () => {
    render(<KpiCard label="Orders" value="35" delta={{ direction: 'down', text: '-3 vs last month' }} />);

    const delta = screen.getByText(/-3 vs last month/);
    expect(delta).toHaveClass('text-terracotta');
  });

  it('renders no delta block when delta is omitted', () => {
    const { container } = render(<KpiCard label="Next payout" value="$1,205" />);

    expect(container.querySelector('[data-testid="kpi-delta"]')).toBeNull();
  });
});
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/kpi-card.test.tsx`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement the component**

Create `Alqove/web/src/components/seller/kpi-card.tsx`:

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

export interface KpiDelta {
  direction: 'up' | 'down' | 'flat';
  text: string;
}

export interface KpiCardProps {
  label: string;
  value: string;
  delta?: KpiDelta;
}

const DIRECTION_CLASS: Record<KpiDelta['direction'], string> = {
  up:   'text-forest',
  down: 'text-terracotta',
  flat: 'text-ink/60',
};

const DIRECTION_ARROW: Record<KpiDelta['direction'], string> = {
  up:   '▲',
  down: '▼',
  flat: '•',
};

export function KpiCard({ label, value, delta }: KpiCardProps) {
  return (
    <div className="rounded-md border border-forest/20 bg-bone p-4">
      <div className="text-xs uppercase tracking-wide text-forest/70">{label}</div>
      <div className="mt-1 text-2xl font-semibold text-ink">{value}</div>
      {delta ? (
        <div
          data-testid="kpi-delta"
          className={cn('mt-1 text-xs font-medium', DIRECTION_CLASS[delta.direction])}
        >
          {DIRECTION_ARROW[delta.direction]} {delta.text}
        </div>
      ) : null}
    </div>
  );
}
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/kpi-card.test.tsx`
Expected: PASS (all four tests).

- [ ] **Step 5: Commit**

```bash
git add Alqove/web/src/components/seller/kpi-card.tsx Alqove/web/src/components/seller/__tests__/kpi-card.test.tsx
git commit -m "feat(web/seller): add KpiCard component with delta variants"
```

---

### Task 15: `StatusPill` component

**Files:**
- Create: `Alqove/web/src/components/seller/status-pill.tsx`
- Test: `Alqove/web/src/components/seller/__tests__/status-pill.test.tsx`

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

```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { StatusPill } from '../status-pill';

describe('StatusPill', () => {
  it('renders the provided label', () => {
    render(<StatusPill tone="neutral">Published</StatusPill>);
    expect(screen.getByText('Published')).toBeInTheDocument();
  });

  it('applies the forest tone class when tone=success', () => {
    render(<StatusPill tone="success">Delivered</StatusPill>);
    expect(screen.getByText('Delivered')).toHaveClass('bg-forest', 'text-bone');
  });

  it('applies terracotta when tone=warning', () => {
    render(<StatusPill tone="warning">Overdue</StatusPill>);
    expect(screen.getByText('Overdue')).toHaveClass('bg-terracotta', 'text-white');
  });

  it('applies muted when tone=muted', () => {
    render(<StatusPill tone="muted">Cancelled</StatusPill>);
    expect(screen.getByText('Cancelled')).toHaveClass('bg-ink/10', 'text-ink/70');
  });
});
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/status-pill.test.tsx`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement the component**

Create `Alqove/web/src/components/seller/status-pill.tsx`:

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

export type StatusPillTone = 'neutral' | 'success' | 'warning' | 'muted';

const TONE_CLASS: Record<StatusPillTone, string> = {
  neutral: 'bg-forest/10 text-forest',
  success: 'bg-forest text-bone',
  warning: 'bg-terracotta text-white',
  muted:   'bg-ink/10 text-ink/70',
};

export function StatusPill({ tone, children }: { tone: StatusPillTone; children: ReactNode }) {
  return (
    <span className={cn('inline-block rounded-full px-2 py-0.5 text-xs font-semibold', TONE_CLASS[tone])}>
      {children}
    </span>
  );
}
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/status-pill.test.tsx`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add Alqove/web/src/components/seller/status-pill.tsx Alqove/web/src/components/seller/__tests__/status-pill.test.tsx
git commit -m "feat(web/seller): add StatusPill component"
```

---

### Task 16: `EmptyState` component

**Files:**
- Create: `Alqove/web/src/components/seller/empty-state.tsx`
- Test: `Alqove/web/src/components/seller/__tests__/empty-state.test.tsx`

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

```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { EmptyState } from '../empty-state';

describe('EmptyState', () => {
  it('renders title and description', () => {
    render(<EmptyState title="No orders yet" description="They'll show up here." />);

    expect(screen.getByText('No orders yet')).toBeInTheDocument();
    expect(screen.getByText("They'll show up here.")).toBeInTheDocument();
  });

  it('renders action when provided', () => {
    render(
      <EmptyState
        title="No listings"
        description="Create your first listing."
        action={<button>New item</button>}
      />,
    );

    expect(screen.getByRole('button', { name: 'New item' })).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/empty-state.test.tsx`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement the component**

Create `Alqove/web/src/components/seller/empty-state.tsx`:

```tsx
import type { ReactNode } from 'react';

export interface EmptyStateProps {
  title: string;
  description: string;
  action?: ReactNode;
}

export function EmptyState({ title, description, action }: EmptyStateProps) {
  return (
    <div className="rounded-md border border-dashed border-forest/20 bg-bone/50 p-8 text-center">
      <h3 className="text-sm font-semibold text-forest">{title}</h3>
      <p className="mt-1 text-sm text-forest/60">{description}</p>
      {action ? <div className="mt-3">{action}</div> : null}
    </div>
  );
}
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/empty-state.test.tsx`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add Alqove/web/src/components/seller/empty-state.tsx Alqove/web/src/components/seller/__tests__/empty-state.test.tsx
git commit -m "feat(web/seller): add EmptyState component"
```

---

## Phase F — Frontend layout & guards

### Task 17: `SellerTopBar` with NotificationBell

**Files:**
- Create: `Alqove/web/src/components/seller/seller-top-bar.tsx`
- Test: `Alqove/web/src/components/seller/__tests__/seller-top-bar.test.tsx`

- [ ] **Step 1: Inspect the existing NotificationBell**

```bash
cat Alqove/web/src/components/notifications/notification-bell.tsx | head -30
```

Confirm the component is exported as `NotificationBell` and takes no required props (buyer layout simply mounts it). If it requires a prop for the "see all" target URL, pass `/seller/inbox` from the seller top bar.

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

```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { SellerTopBar } from '../seller-top-bar';

// Mock NotificationBell so the top bar test doesn't require the query client.
vi.mock('@/components/notifications/notification-bell', () => ({
  NotificationBell: ({ seeAllHref }: { seeAllHref?: string }) => (
    <div data-testid="bell" data-seeall={seeAllHref ?? ''} />
  ),
}));

describe('SellerTopBar', () => {
  it('renders store name and bell', () => {
    render(<SellerTopBar storeName="Meadow Thrift" />);

    expect(screen.getByText('Meadow Thrift')).toBeInTheDocument();
    expect(screen.getByTestId('bell')).toBeInTheDocument();
  });

  it('passes /seller/inbox as the bell see-all target', () => {
    render(<SellerTopBar storeName="Meadow Thrift" />);
    expect(screen.getByTestId('bell')).toHaveAttribute('data-seeall', '/seller/inbox');
  });
});
```

- [ ] **Step 3: Run the test and confirm failure**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/seller-top-bar.test.tsx`
Expected: FAIL — module not found.

- [ ] **Step 4: Implement the top bar**

Create `Alqove/web/src/components/seller/seller-top-bar.tsx`:

```tsx
'use client';

import { NotificationBell } from '@/components/notifications/notification-bell';

export interface SellerTopBarProps {
  storeName: string;
}

export function SellerTopBar({ storeName }: SellerTopBarProps) {
  return (
    <header className="flex h-14 items-center justify-between border-b border-forest/10 bg-white px-6">
      <div className="text-sm font-semibold text-forest">{storeName}</div>
      <div className="flex items-center gap-4">
        <NotificationBell seeAllHref="/seller/inbox" />
      </div>
    </header>
  );
}
```

If the existing `NotificationBell` doesn't currently accept a `seeAllHref` prop, add that prop (optional, defaulting to `/notifications`) to the bell component and its dropdown's "See all" link in a small parallel edit. Commit that separately:

```bash
# If the bell needed the prop added:
git add Alqove/web/src/components/notifications/notification-bell.tsx Alqove/web/src/components/notifications/notification-dropdown.tsx
git commit -m "refactor(notifications): NotificationBell accepts seeAllHref prop"
```

- [ ] **Step 5: Run the top-bar test and confirm it passes**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/seller-top-bar.test.tsx`
Expected: PASS.

- [ ] **Step 6: Commit the top bar**

```bash
git add Alqove/web/src/components/seller/seller-top-bar.tsx Alqove/web/src/components/seller/__tests__/seller-top-bar.test.tsx
git commit -m "feat(web/seller): add SellerTopBar with NotificationBell"
```

---

### Task 18: No-store guard page

**Files:**
- Create: `Alqove/web/src/app/(seller)/seller/no-store/page.tsx`

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

```tsx
import Link from 'next/link';

export default function NoStorePage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-bone">
      <div className="max-w-md rounded-md border border-forest/20 bg-white p-8 text-center">
        <h1 className="text-lg font-semibold text-forest">You don't have a store yet</h1>
        <p className="mt-2 text-sm text-forest/70">
          The seller dashboard is for users with a provisioned Alqove store.
          If you believe this is a mistake, contact support.
        </p>
        <Link
          href="/"
          className="mt-4 inline-block rounded border border-forest/20 px-4 py-2 text-sm font-medium text-forest hover:bg-forest/5"
        >
          Back to marketplace
        </Link>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Manual smoke**

```bash
cd Alqove/web && npm run dev
```

Visit http://localhost:3000/seller/no-store and confirm it renders. Ctrl-C the dev server after verifying.

- [ ] **Step 3: Commit**

```bash
git add Alqove/web/src/app/(seller)/seller/no-store/page.tsx
git commit -m "feat(web/seller): add no-store guard landing page"
```

---

### Task 19: Update seller layout (nav, top bar, guard)

**Files:**
- Modify: `Alqove/web/src/app/(seller)/layout.tsx`

**Note on auth state:** The web app's auth state lives in a Zustand store at `Alqove/web/src/stores/auth.ts`, exposed as `useAuthStore()`. The `AuthUser` type (from `@alqove/types`) contains `id`, `email`, `name`, `avatar`, `roles`, `store_id` — **there is no nested `store` object**. The store's `isLoading` flag tells us when the token-hydration attempt has completed. The layout should not try to display the store name in this plan; it can show the user's name or a static "Seller dashboard" string. A future plan can add a `useStore(storeId)` hook if a store name is desired.

- [ ] **Step 1: Update nav items and layout shell**

Replace the contents of `Alqove/web/src/app/(seller)/layout.tsx`:

```tsx
'use client';

import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { cn } from '@/lib/utils';
import { SellerQueryProvider } from './providers';
import { SellerTopBar } from '@/components/seller/seller-top-bar';
import { useAuthStore } from '@/stores/auth';

const navItems = [
  { href: '/seller',          label: 'Dashboard' },
  { href: '/seller/inbox',    label: 'Inbox' },
  { href: '/seller/listings', label: 'Listings' },
  { href: '/seller/orders',   label: 'Orders' },
  { href: '/seller/settings', label: 'Settings' },
];

export default function SellerLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  const router = useRouter();
  const { user, isLoading } = useAuthStore();

  useEffect(() => {
    if (!isLoading && user && !user.store_id && pathname !== '/seller/no-store') {
      router.replace('/seller/no-store');
    }
  }, [isLoading, user, pathname, router]);

  // Bypass the shell entirely on the no-store landing page.
  if (pathname === '/seller/no-store') {
    return <>{children}</>;
  }

  const topBarLabel = user?.name ? `${user.name}'s store` : 'Seller dashboard';

  return (
    <SellerQueryProvider>
      <div className="flex min-h-screen">
        <aside className="w-64 shrink-0 border-r border-forest/10 bg-white p-6">
          <Link href="/seller" className="text-xl font-bold tracking-tight text-ink">
            alqove
          </Link>
          <p className="mt-0.5 text-xs text-forest/50">Seller Dashboard</p>
          <nav className="mt-8 flex flex-col gap-1">
            {navItems.map((item) => {
              const isActive =
                pathname === item.href ||
                (item.href !== '/seller' && pathname.startsWith(item.href));
              return (
                <Link
                  key={item.href}
                  href={item.href}
                  className={cn(
                    'rounded px-3 py-2 text-sm font-medium transition-colors',
                    isActive
                      ? 'border-l-2 border-terracotta bg-terracotta/10 text-terracotta'
                      : 'text-forest/70 hover:bg-forest/5 hover:text-forest',
                  )}
                >
                  {item.label}
                </Link>
              );
            })}
          </nav>
        </aside>
        <div className="flex flex-1 flex-col">
          <SellerTopBar storeName={topBarLabel} />
          <main className="flex-1 bg-bone p-8">{children}</main>
        </div>
      </div>
    </SellerQueryProvider>
  );
}
```

- [ ] **Step 2: Manual smoke**

```bash
cd Alqove/web && npm run dev
```

Open http://localhost:3000/seller as a user with a `store_id` → layout renders with sidebar + top bar + bell.
Open it as a user without a `store_id` → redirected to `/seller/no-store`.

- [ ] **Step 3: Commit**

```bash
git add Alqove/web/src/app/(seller)/layout.tsx
git commit -m "feat(web/seller): new seller layout with top bar, bell, no-store guard, Inbox nav item"
```

---

### Task 20: Extend `useNotifications` with category param

**Files:**
- Modify: `Alqove/web/src/lib/queries/use-notifications.ts`

- [ ] **Step 1: Update `NOTIFICATION_KEYS` and hook signature**

Replace the hook section with:

```ts
export type NotificationCategory = 'orders' | 'shipping' | 'payouts' | 'system';

export const NOTIFICATION_KEYS = {
  unreadCount: ['notifications', 'unread-count'] as const,
  list: (filter: 'all' | 'unread', category: NotificationCategory | 'all') =>
    ['notifications', 'list', filter, category] as const,
};

export function useNotifications(
  filter: 'all' | 'unread' = 'all',
  category: NotificationCategory | 'all' = 'all',
) {
  return useInfiniteQuery<NotificationInboxList>({
    queryKey: NOTIFICATION_KEYS.list(filter, category),
    initialPageParam: 1,
    queryFn: async ({ pageParam }) => {
      const res = await api.notifications.list({
        filter,
        ...(category !== 'all' ? { category } : {}),
        page: pageParam as number,
        per_page: 20,
      });
      return res.data;
    },
    getNextPageParam: (lastPage) =>
      lastPage.meta.current_page < lastPage.meta.last_page
        ? lastPage.meta.current_page + 1
        : undefined,
  });
}
```

Leave `useUnreadCount`, `useMarkNotificationRead`, and `useMarkAllNotificationsRead` unchanged except for the query-key pattern: the mark-read mutations invalidate all `['notifications']` queries already, which covers the new key shape too.

- [ ] **Step 2: Verify existing buyer call sites**

The buyer notifications page calls `useNotifications(filter)` with one arg. Because the second arg defaults to `'all'`, buyer callers keep working. Confirm:

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

Expected: exits 0.

- [ ] **Step 3: Commit**

```bash
git add Alqove/web/src/lib/queries/use-notifications.ts
git commit -m "feat(web): useNotifications accepts optional category filter"
```

---

## Phase G — Dashboard

### Task 21: `useSellerDashboard` hook

**Files:**
- Create: `Alqove/web/src/lib/queries/use-seller-dashboard.ts`

- [ ] **Step 1: Implement the hook**

```ts
'use client';

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { SellerDashboardMetrics } from '@alqove/api-client';

export const SELLER_DASHBOARD_KEYS = {
  metrics: (storeId: string) => ['seller', 'dashboard', storeId] as const,
};

const REFETCH_INTERVAL_MS = 30_000;

export function useSellerDashboard(storeId: string | null | undefined) {
  return useQuery<SellerDashboardMetrics>({
    queryKey: SELLER_DASHBOARD_KEYS.metrics(storeId ?? 'none'),
    enabled: Boolean(storeId),
    queryFn: async () => {
      const res = await api.seller.dashboardMetrics(storeId!);
      return res.data.data;
    },
    refetchInterval: REFETCH_INTERVAL_MS,
    refetchOnWindowFocus: true,
    staleTime: 15_000,
  });
}
```

- [ ] **Step 2: Typecheck**

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

Expected: exits 0.

- [ ] **Step 3: Commit**

```bash
git add Alqove/web/src/lib/queries/use-seller-dashboard.ts
git commit -m "feat(web): useSellerDashboard hook fetches metrics with 30s refetch"
```

---

### Task 22: `OrdersActionList` component

**Files:**
- Create: `Alqove/web/src/components/seller/orders-action-list.tsx`
- Test: `Alqove/web/src/components/seller/__tests__/orders-action-list.test.tsx`

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

```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { OrdersActionList } from '../orders-action-list';

describe('OrdersActionList', () => {
  const counts = {
    orders_overdue: 2,
    orders_today: 3,
    orders_this_week: 5,
    listings_needs_attention: 0,
  };

  it('renders a row per group with its count', () => {
    render(<OrdersActionList counts={counts} />);

    expect(screen.getByText('Overdue')).toBeInTheDocument();
    expect(screen.getByText('2')).toBeInTheDocument();

    expect(screen.getByText('Today')).toBeInTheDocument();
    expect(screen.getByText('3')).toBeInTheDocument();

    expect(screen.getByText('This week')).toBeInTheDocument();
    expect(screen.getByText('5')).toBeInTheDocument();
  });

  it('renders an empty state when all counts are zero', () => {
    render(<OrdersActionList counts={{ orders_overdue: 0, orders_today: 0, orders_this_week: 0, listings_needs_attention: 0 }} />);

    expect(screen.getByText(/nothing urgent/i)).toBeInTheDocument();
  });

  it('links each group to the orders list filtered by paid status', () => {
    render(<OrdersActionList counts={counts} />);

    const links = screen.getAllByRole('link');
    expect(links.length).toBeGreaterThan(0);
    for (const link of links) {
      expect(link).toHaveAttribute('href', expect.stringContaining('/seller/orders?status=paid'));
    }
  });
});
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/orders-action-list.test.tsx`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement the component**

Create `Alqove/web/src/components/seller/orders-action-list.tsx`:

```tsx
import Link from 'next/link';
import type { SellerDashboardMetrics } from '@alqove/api-client';
import { EmptyState } from './empty-state';
import { cn } from '@/lib/utils';

interface Row {
  label: string;
  count: number;
  tone: 'urgent' | 'warn' | 'calm';
  bucket: 'overdue' | 'today' | 'week';
}

const TONE_CLASS: Record<Row['tone'], string> = {
  urgent: 'text-terracotta',
  warn:   'text-ink',
  calm:   'text-forest/70',
};

export function OrdersActionList({ counts }: { counts: SellerDashboardMetrics['action_counts'] }) {
  const rows: Row[] = [
    { label: 'Overdue',   count: counts.orders_overdue,   tone: 'urgent', bucket: 'overdue' },
    { label: 'Today',     count: counts.orders_today,     tone: 'warn',   bucket: 'today' },
    { label: 'This week', count: counts.orders_this_week, tone: 'calm',   bucket: 'week' },
  ];

  const totalUrgent = rows.reduce((sum, r) => sum + r.count, 0);

  if (totalUrgent === 0) {
    return (
      <div className="rounded-md border border-forest/20 bg-white p-4">
        <div className="mb-2 text-xs uppercase tracking-wide text-forest/70">Orders needing action</div>
        <EmptyState title="Nothing urgent" description="All paid orders are shipped or still have time." />
      </div>
    );
  }

  return (
    <div className="rounded-md border border-forest/20 bg-white p-4">
      <div className="mb-2 text-xs uppercase tracking-wide text-forest/70">Orders needing action</div>
      <ul className="divide-y divide-forest/10">
        {rows.map((row) => (
          <li key={row.bucket}>
            <Link
              href={`/seller/orders?status=paid&bucket=${row.bucket}`}
              className="flex items-center justify-between py-2 text-sm hover:bg-bone/50"
            >
              <span className={cn('font-medium', TONE_CLASS[row.tone])}>{row.label}</span>
              <span className={cn('rounded-full px-2 py-0.5 text-xs font-semibold',
                row.count > 0 && row.tone === 'urgent' ? 'bg-terracotta/10 text-terracotta' :
                row.count > 0 ? 'bg-forest/10 text-forest' : 'bg-ink/5 text-ink/50')}>
                {row.count}
              </span>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/orders-action-list.test.tsx`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add Alqove/web/src/components/seller/orders-action-list.tsx Alqove/web/src/components/seller/__tests__/orders-action-list.test.tsx
git commit -m "feat(web/seller): add OrdersActionList widget"
```

---

### Task 23: `ListingsAttentionWidget` component

**Files:**
- Create: `Alqove/web/src/components/seller/listings-attention-widget.tsx`
- Test: `Alqove/web/src/components/seller/__tests__/listings-attention-widget.test.tsx`

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

```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ListingsAttentionWidget } from '../listings-attention-widget';

describe('ListingsAttentionWidget', () => {
  it('renders the count and a link to the filtered listings page', () => {
    render(<ListingsAttentionWidget count={4} />);

    expect(screen.getByText(/4 listings need attention/i)).toBeInTheDocument();
    expect(screen.getByRole('link')).toHaveAttribute('href', '/seller/listings?filter=needs-attention');
  });

  it('pluralises correctly for 1', () => {
    render(<ListingsAttentionWidget count={1} />);
    expect(screen.getByText(/1 listing needs attention/i)).toBeInTheDocument();
  });

  it('renders an empty-state tone when count is 0', () => {
    render(<ListingsAttentionWidget count={0} />);
    expect(screen.getByText(/all listings look good/i)).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/listings-attention-widget.test.tsx`
Expected: FAIL.

- [ ] **Step 3: Implement the component**

Create `Alqove/web/src/components/seller/listings-attention-widget.tsx`:

```tsx
import Link from 'next/link';

export function ListingsAttentionWidget({ count }: { count: number }) {
  if (count === 0) {
    return (
      <div className="rounded-md border border-forest/10 bg-white p-4 text-sm text-forest/60">
        All listings look good — no drafts or stale items.
      </div>
    );
  }

  const noun = count === 1 ? 'listing needs attention' : 'listings need attention';

  return (
    <Link
      href="/seller/listings?filter=needs-attention"
      className="flex items-center justify-between rounded-md border border-terracotta/20 bg-white p-4 text-sm hover:bg-terracotta/5"
    >
      <span className="font-medium text-ink">
        {count} {noun}
      </span>
      <span className="text-xs font-semibold text-terracotta">Review →</span>
    </Link>
  );
}
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/listings-attention-widget.test.tsx`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add Alqove/web/src/components/seller/listings-attention-widget.tsx Alqove/web/src/components/seller/__tests__/listings-attention-widget.test.tsx
git commit -m "feat(web/seller): add ListingsAttentionWidget"
```

---

### Task 24: Dashboard page — wire everything together

**Files:**
- Rewrite: `Alqove/web/src/app/(seller)/seller/page.tsx`

- [ ] **Step 1: Inspect the current placeholder**

```bash
cat Alqove/web/src/app/\(seller\)/seller/page.tsx
```

The current page renders three static metric cards. Replace it.

- [ ] **Step 2: Implement the dashboard**

Replace the contents of `Alqove/web/src/app/(seller)/seller/page.tsx`:

```tsx
'use client';

import Link from 'next/link';
import { useSellerDashboard } from '@/lib/queries/use-seller-dashboard';
import { useNotifications } from '@/lib/queries/use-notifications';
import { useAuthStore } from '@/stores/auth';
import { KpiCard } from '@/components/seller/kpi-card';
import { OrdersActionList } from '@/components/seller/orders-action-list';
import { ListingsAttentionWidget } from '@/components/seller/listings-attention-widget';
import { NotificationRow } from '@/components/notifications/notification-row';

function formatDollars(cents: number | null): string {
  if (cents === null) return '—';
  return `$${(cents / 100).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
}

function deltaText(current: number, prior: number, suffix: string): { direction: 'up' | 'down' | 'flat'; text: string } {
  const diff = current - prior;
  if (diff === 0) return { direction: 'flat', text: `Flat ${suffix}` };
  const sign = diff > 0 ? '+' : '';
  return {
    direction: diff > 0 ? 'up' : 'down',
    text: `${sign}${diff} ${suffix}`,
  };
}

export default function SellerDashboardPage() {
  const user = useAuthStore((s) => s.user);
  const storeId = user?.store_id ?? null;

  const { data: metrics, isLoading: metricsLoading } = useSellerDashboard(storeId);
  const { data: inbox } = useNotifications('unread');
  const recentUnread = inbox?.pages.flatMap((p) => p.data).slice(0, 5) ?? [];

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-semibold text-ink">Dashboard</h1>

      {/* KPI row */}
      <section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
        <KpiCard
          label="Revenue this month"
          value={metricsLoading || !metrics ? '—' : formatDollars(metrics.revenue_this_month.current_cents)}
          delta={metrics ? deltaText(
            metrics.revenue_this_month.current_cents,
            metrics.revenue_this_month.prior_cents,
            'vs last month',
          ) : undefined}
        />
        <KpiCard
          label="Active listings"
          value={metricsLoading || !metrics ? '—' : String(metrics.active_listings.current)}
          delta={metrics ? {
            direction: metrics.active_listings.delta_last_7_days > 0 ? 'up' : 'flat',
            text: `+${metrics.active_listings.delta_last_7_days} this week`,
          } : undefined}
        />
        <KpiCard
          label="Orders this month"
          value={metricsLoading || !metrics ? '—' : String(metrics.orders_this_month.current)}
          delta={metrics ? deltaText(
            metrics.orders_this_month.current,
            metrics.orders_this_month.prior,
            'vs last month',
          ) : undefined}
        />
        <KpiCard
          label="Next payout"
          value={metricsLoading || !metrics ? '—' : formatDollars(metrics.next_payout.amount_cents)}
        />
      </section>

      {/* Triage row: orders left, inbox right */}
      <section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
        <div className="xl:col-span-2">
          {metrics ? (
            <OrdersActionList counts={metrics.action_counts} />
          ) : (
            <div className="rounded-md border border-forest/10 bg-white p-4 text-sm text-forest/50">
              Loading orders…
            </div>
          )}
        </div>

        <div className="rounded-md border border-forest/20 bg-white p-4">
          <div className="mb-2 flex items-center justify-between">
            <div className="text-xs uppercase tracking-wide text-forest/70">Unread inbox</div>
            <Link href="/seller/inbox" className="text-xs text-terracotta hover:underline">
              See all
            </Link>
          </div>
          {recentUnread.length === 0 ? (
            <div className="py-6 text-center text-sm text-forest/60">No unread notifications.</div>
          ) : (
            <div className="divide-y divide-forest/10">
              {recentUnread.map((n) => <NotificationRow key={n.id} item={n} />)}
            </div>
          )}
        </div>
      </section>

      {/* Listings attention */}
      <section>
        {metrics ? (
          <ListingsAttentionWidget count={metrics.action_counts.listings_needs_attention} />
        ) : null}
      </section>
    </div>
  );
}
```

- [ ] **Step 3: Manual smoke**

Start the dev server and sign in as a seller user. Visit http://localhost:3000/seller. Confirm:

- KPI cards render with real numbers (or dashes if the store has no data)
- Orders needing action widget shows counts
- Unread inbox list shows up to 5 rows (or empty state)
- Listings attention widget shows a count or green confirmation
- Refreshing the window causes KPIs and widgets to refetch (TanStack Query devtools if enabled)

- [ ] **Step 4: Commit**

```bash
git add Alqove/web/src/app/\(seller\)/seller/page.tsx
git commit -m "feat(web/seller): dashboard page with KPIs, triage widgets, unread inbox"
```

---

## Phase H — Inbox

### Task 25: Inbox category tabs component

**Files:**
- Create: `Alqove/web/src/components/seller/inbox-category-tabs.tsx`
- Test: `Alqove/web/src/components/seller/__tests__/inbox-category-tabs.test.tsx`

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

```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { InboxCategoryTabs } from '../inbox-category-tabs';

describe('InboxCategoryTabs', () => {
  it('renders all tabs', () => {
    render(<InboxCategoryTabs active="all" onChange={() => {}} />);

    ['All', 'Orders', 'Shipping', 'Payouts', 'System'].forEach((label) => {
      expect(screen.getByRole('tab', { name: label })).toBeInTheDocument();
    });
  });

  it('marks the active tab with aria-selected=true', () => {
    render(<InboxCategoryTabs active="shipping" onChange={() => {}} />);

    expect(screen.getByRole('tab', { name: 'Shipping' })).toHaveAttribute('aria-selected', 'true');
    expect(screen.getByRole('tab', { name: 'Orders' })).toHaveAttribute('aria-selected', 'false');
  });

  it('calls onChange with the tab key when clicked', async () => {
    const onChange = vi.fn();
    render(<InboxCategoryTabs active="all" onChange={onChange} />);

    await userEvent.click(screen.getByRole('tab', { name: 'Payouts' }));
    expect(onChange).toHaveBeenCalledWith('payouts');
  });
});
```

- [ ] **Step 2: Run the test and confirm failure**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/inbox-category-tabs.test.tsx`
Expected: FAIL.

- [ ] **Step 3: Implement the component**

Create `Alqove/web/src/components/seller/inbox-category-tabs.tsx`:

```tsx
'use client';

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

export type InboxCategory = 'all' | 'orders' | 'shipping' | 'payouts' | 'system';

const TABS: Array<{ key: InboxCategory; label: string }> = [
  { key: 'all',      label: 'All' },
  { key: 'orders',   label: 'Orders' },
  { key: 'shipping', label: 'Shipping' },
  { key: 'payouts',  label: 'Payouts' },
  { key: 'system',   label: 'System' },
];

export interface InboxCategoryTabsProps {
  active: InboxCategory;
  onChange: (cat: InboxCategory) => void;
}

export function InboxCategoryTabs({ active, onChange }: InboxCategoryTabsProps) {
  return (
    <div role="tablist" className="flex gap-1 border-b border-forest/10 px-1">
      {TABS.map((tab) => {
        const isActive = tab.key === active;
        return (
          <button
            key={tab.key}
            role="tab"
            aria-selected={isActive}
            onClick={() => onChange(tab.key)}
            className={cn(
              '-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors',
              isActive
                ? 'border-terracotta text-terracotta'
                : 'border-transparent text-forest/60 hover:text-forest',
            )}
          >
            {tab.label}
          </button>
        );
      })}
    </div>
  );
}
```

- [ ] **Step 4: Run the test and confirm it passes**

Run: `cd Alqove/web && npx vitest run src/components/seller/__tests__/inbox-category-tabs.test.tsx`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add Alqove/web/src/components/seller/inbox-category-tabs.tsx Alqove/web/src/components/seller/__tests__/inbox-category-tabs.test.tsx
git commit -m "feat(web/seller): add InboxCategoryTabs component"
```

---

### Task 26: Inbox page + client

**Files:**
- Create: `Alqove/web/src/app/(seller)/seller/inbox/page.tsx`
- Create: `Alqove/web/src/app/(seller)/seller/inbox/inbox-client.tsx`

- [ ] **Step 1: Server page (URL param passthrough)**

Create `Alqove/web/src/app/(seller)/seller/inbox/page.tsx`:

```tsx
import { Suspense } from 'react';
import { InboxClient } from './inbox-client';

export default function SellerInboxPage() {
  return (
    <div className="space-y-4">
      <h1 className="text-2xl font-semibold text-ink">Inbox</h1>
      <Suspense fallback={<div className="text-sm text-forest/60">Loading…</div>}>
        <InboxClient />
      </Suspense>
    </div>
  );
}
```

- [ ] **Step 2: Client page**

Create `Alqove/web/src/app/(seller)/seller/inbox/inbox-client.tsx`:

```tsx
'use client';

import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useNotifications, useMarkAllNotificationsRead } from '@/lib/queries/use-notifications';
import {
  InboxCategoryTabs,
  type InboxCategory,
} from '@/components/seller/inbox-category-tabs';
import { NotificationRow } from '@/components/notifications/notification-row';
import { EmptyState } from '@/components/seller/empty-state';

const CATEGORY_KEYS: InboxCategory[] = ['all', 'orders', 'shipping', 'payouts', 'system'];

function categoryFromParams(value: string | null): InboxCategory {
  if (value && (CATEGORY_KEYS as string[]).includes(value)) {
    return value as InboxCategory;
  }
  return 'all';
}

export function InboxClient() {
  const router = useRouter();
  const params = useSearchParams();

  const category = categoryFromParams(params.get('category'));
  const readFilter = params.get('filter') === 'unread' ? 'unread' : 'all';

  const updateParam = useCallback(
    (key: string, value: string | null) => {
      const next = new URLSearchParams(params.toString());
      if (value === null || value === 'all') {
        next.delete(key);
      } else {
        next.set(key, value);
      }
      const qs = next.toString();
      router.replace(qs ? `/seller/inbox?${qs}` : '/seller/inbox');
    },
    [params, router],
  );

  const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } =
    useNotifications(readFilter, category);
  const markAll = useMarkAllNotificationsRead();
  const rows = data?.pages.flatMap((p) => p.data) ?? [];

  return (
    <div className="rounded-md border border-forest/20 bg-white">
      <InboxCategoryTabs
        active={category}
        onChange={(cat) => updateParam('category', cat)}
      />

      <div className="flex items-center justify-between px-4 py-3 border-b border-forest/10">
        <div className="flex gap-2">
          <button
            type="button"
            onClick={() => updateParam('filter', null)}
            className={`text-xs px-3 py-1 rounded-full ${
              readFilter === 'all'
                ? 'bg-forest text-bone'
                : 'border border-forest/20 bg-bone text-forest'
            }`}
          >
            All
          </button>
          <button
            type="button"
            onClick={() => updateParam('filter', 'unread')}
            className={`text-xs px-3 py-1 rounded-full ${
              readFilter === 'unread'
                ? 'bg-forest text-bone'
                : 'border border-forest/20 bg-bone text-forest'
            }`}
          >
            Unread
          </button>
        </div>
        <button
          type="button"
          onClick={() => markAll.mutate()}
          className="text-xs text-terracotta hover:underline"
        >
          Mark all read
        </button>
      </div>

      <div>
        {isLoading ? (
          <div className="p-6 text-center text-sm text-forest/60">Loading…</div>
        ) : rows.length === 0 ? (
          <div className="p-6">
            <EmptyState
              title="Nothing here"
              description="When something happens, it'll show up in this category."
            />
          </div>
        ) : (
          rows.map((row) => <NotificationRow key={row.id} item={row} />)
        )}
      </div>

      {hasNextPage ? (
        <div className="border-t border-forest/10 p-4 text-center">
          <button
            type="button"
            disabled={isFetchingNextPage}
            onClick={() => fetchNextPage()}
            className="text-sm text-forest hover:text-terracotta disabled:opacity-50"
          >
            {isFetchingNextPage ? 'Loading…' : 'Load more'}
          </button>
        </div>
      ) : null}
    </div>
  );
}
```

- [ ] **Step 3: Manual smoke**

Start dev server, sign in as a seller, visit http://localhost:3000/seller/inbox:

- Default tab is "All", filter is "All"
- Clicking "Shipping" updates URL to `?category=shipping` and filters rows
- Clicking "Unread" updates URL to include `filter=unread`
- "Mark all read" button works and refreshes counts
- "Load more" appears when there's a next page

- [ ] **Step 4: Commit**

```bash
git add Alqove/web/src/app/\(seller\)/seller/inbox/page.tsx Alqove/web/src/app/\(seller\)/inbox/inbox-client.tsx
git commit -m "feat(web/seller): inbox page with category tabs and read filter"
```

---

## Phase I — Final polish

### Task 27: End-to-end manual QA + typecheck + lint + test sweep

- [ ] **Step 1: Full typecheck**

```bash
cd Alqove && npm run typecheck
```

Expected: exits 0.

- [ ] **Step 2: Full lint**

```bash
cd Alqove && npm run lint
```

Expected: exits 0. Fix any new warnings introduced by this plan's code.

- [ ] **Step 3: Frontend test suite**

```bash
cd Alqove/web && npx vitest run
```

Expected: all tests pass; specifically the eight new `src/components/seller/__tests__/*` specs.

- [ ] **Step 4: Backend test suite**

```bash
cd Alqove && docker compose exec laravel.test php artisan test --parallel
```

Expected: green. If any unrelated test regressed, investigate before merging.

- [ ] **Step 5: Pint + PHPStan**

```bash
cd Alqove && docker compose exec laravel.test ./vendor/bin/pint
cd Alqove && docker compose exec laravel.test ./vendor/bin/phpstan analyse
```

Expected: no style issues, phpstan clean.

- [ ] **Step 6: Manual QA checklist**

With the dev server running and a seller user signed in:

- `/seller` — dashboard renders, KPIs update on refresh
- `/seller/inbox` — all tabs filter correctly; "Mark all read" clears unread badges; "Load more" paginates
- Bell in top bar shows unread count and links to `/seller/inbox`
- Signing in as a user with no `store_id` redirects to `/seller/no-store`
- `/seller/no-store` renders standalone without seller shell
- Existing buyer inbox at `/notifications` still works (regression check)

- [ ] **Step 7: Final plan commit marker**

```bash
git commit --allow-empty -m "chore(layer-7): plan 1 (foundation + dashboard + inbox) complete"
```

---

## Out of scope — handed off to Plan 2 and Plan 3

The following surfaces are explicitly **not** in this plan:

- Seller orders list with filters and search (`/seller/orders`) — **Plan 2**
- Order detail page with inline fulfillment (`/seller/orders/:id`) — **Plan 2**
- Seller cancel modal — **Plan 2**
- `POST /v1/stores/{store}/orders/{order}/labels/preview` (if not already present) — **Plan 2**
- Listings list with table/grid toggle and filters — **Plan 2**
- Item create/edit form + image upload — **Plan 2**
- Seller settings sub-tabs (Store, Shipping, Notifications, Payments) — **Plan 3**
- Parcel presets CRUD UI — **Plan 3**
- Notification preferences matrix UI — **Plan 3**
