# Layer 7 — Seller Settings Implementation Plan

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

**Goal:** Ship the seller Settings surface as four URL-addressable tabs — Store, Shipping, Notifications, Payments — backed entirely by endpoints that already exist (plus two tiny backend additions: a Stripe dashboard link endpoint and a computed `stripe_connected` attribute).

**Architecture:** (1) Backend — add `stripe_connected` computed attribute on `Store` and a `GET /v1/stores/{store}/stripe/status` endpoint that returns a dashboard/onboarding URL based on Connect state. (2) Contract + api-client — expose that endpoint and add the missing parcel-preset create/update/delete methods. (3) Frontend — convert `/seller/settings` from its single-page placeholder into a nested-route layout with a tab bar and four sub-routes (`/store`, `/shipping`, `/notifications`, `/payments`). Each tab is a simple form hooked to existing TanStack Query mutations.

**Tech Stack:** Laravel 11 (Eloquent, Stripe PHP SDK — already present), Pest, Next.js 15 App Router nested routes, TanStack Query, shadcn primitives (Dialog, Input, Select, Checkbox), Vitest + React Testing Library.

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

**Prerequisites:** Layer 7 Listings plan merged (`2e658e6`). `api.stores.get/update/getSettings/updateSettings/parcelPresets.list` already exist. `PreferenceController` + routes live at `/v1/me/notification-preferences`. `StripeConnectController` already ships onboarding links.

**Successor plan:** (none — this completes Layer 7 as scoped in the spec.)

## Scope decisions (intentional simplifications)

- **No logo/banner uploads this layer.** The backend has no file-upload endpoint for Store imagery (only a `logo_image` URL string on `UpdateStoreRequest`). Ship a URL text input for now; defer a proper uploader + Spatie Media conversions to follow-on work. This keeps Settings 100% backend-already-exists.
- **No shadcn Tabs component.** The spec explicitly calls for URL-addressable tabs — use Next.js nested sub-routes instead of a client-only Tabs component. One file per tab; the `settings/layout.tsx` renders the nav.
- **Return policy lives in the Shipping tab.** `StoreSettings.return_window_days` + `return_policy_text` logically belong with shipping/fulfillment — keeping them in a single tab avoids a 5th route for one more field.
- **No Stripe account-health surfacing beyond "charges enabled".** The Connect dashboard link + a single status badge (onboarded / needs-action) is enough for v1.

## File structure

| File | Responsibility |
|------|----------------|
| `api/app/Models/Store.php` | Add `stripe_connected` computed attribute via `$appends` |
| `api/app/Modules/Stores/Controllers/StripeConnectController.php` | Add `status` action |
| `api/app/Modules/Stores/routes.php` | Register `GET .../stripe/status` route |
| `api/contracts/openapi.yaml` | Add `stripe_connected` to Store schema + new path |
| `packages/api-client/src/endpoints/stripe.ts` | Add `getStatus(storeId)` |
| `packages/api-client/src/endpoints/stores.ts` | Add `parcelPresets.create/update/delete` |
| `packages/api-client/src/endpoints/notifications.ts` | Widen `NotificationPreference.category` to include `'payouts'` |
| `web/src/app/(seller)/seller/settings/page.tsx` | Redirect → `/seller/settings/store` |
| `web/src/app/(seller)/seller/settings/layout.tsx` | NEW — tab nav shared across sub-routes |
| `web/src/app/(seller)/seller/settings/store/page.tsx` | NEW — Store Profile tab |
| `web/src/app/(seller)/seller/settings/shipping/page.tsx` | NEW — Shipping tab (ship-from + processing + returns + parcel presets) |
| `web/src/app/(seller)/seller/settings/notifications/page.tsx` | NEW — Preferences matrix |
| `web/src/app/(seller)/seller/settings/payments/page.tsx` | NEW — Connect status + dashboard link |
| `web/src/components/seller/parcel-preset-form-dialog.tsx` | NEW — create/edit preset via Dialog |
| Feature tests for Stripe `status` endpoint | New |
| Vitest specs per tab | New |

---

## Phase A — Backend: Stripe status endpoint + computed attribute

### Task 1: `stripe_connected` attribute on Store

**Files:**
- Modify: `Alqove/api/app/Models/Store.php`
- Test: `Alqove/api/tests/Feature/Stores/StoreStripeConnectedAttributeTest.php` (new)

- [ ] **Step 1: Failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Stores;

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

class StoreStripeConnectedAttributeTest extends TestCase
{
    use RefreshDatabase;

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

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

        $response->assertOk()->assertJsonPath('data.stripe_connected', true);
    }

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

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

        $response->assertOk()->assertJsonPath('data.stripe_connected', false);
    }
}
```

- [ ] **Step 2: Run — expect FAIL**
- [ ] **Step 3: Add to `Store` model**

Add `protected $appends = ['stripe_connected'];` and the accessor:

```php
protected function stripeConnected(): \Illuminate\Database\Eloquent\Casts\Attribute
{
    return \Illuminate\Database\Eloquent\Casts\Attribute::make(
        get: fn (): bool => filled($this->stripe_connect_id),
    );
}
```

If `$appends` is already defined on the model, add `'stripe_connected'` to the array.

Verify the resource used by `GET /v1/stores/{store}` (i.e., `StoreResource` or similar) includes `stripe_connected` — it likely passes the whole model through, in which case appended attrs flow automatically.

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

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

- [ ] **Step 5: Commit**

```bash
git add Alqove/api/app/Models/Store.php Alqove/api/tests/Feature/Stores/StoreStripeConnectedAttributeTest.php
git commit -m "feat(stores): expose stripe_connected computed attribute"
```

---

### Task 2: `GET /v1/stores/{store}/stripe/status` endpoint

Returns current Connect state + a dashboard login link (if onboarded) or onboarding link (if not).

**Files:**
- Modify: `Alqove/api/app/Modules/Stores/Controllers/StripeConnectController.php`
- Modify: `Alqove/api/app/Modules/Stores/routes.php`
- Modify: `Alqove/api/app/Modules/Checkout/Services/StripeService.php` (add `createLoginLink` + `getAccountStatus`)
- Test: `Alqove/api/tests/Feature/Stores/StripeStatusTest.php` (new)

- [ ] **Step 1: Failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Stores;

use App\Models\Store;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;

class StripeStatusTest extends TestCase
{
    use RefreshDatabase;

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

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

        $mock = Mockery::mock(StripeService::class);
        $mock->shouldReceive('getAccountStatus')->with('acct_test')->andReturn([
            'charges_enabled' => true,
            'details_submitted' => true,
        ]);
        $mock->shouldReceive('createLoginLink')->with('acct_test')->andReturn('https://connect.stripe.com/express/abc');
        $this->app->instance(StripeService::class, $mock);

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

        $response->assertOk()
            ->assertJsonPath('data.charges_enabled', true)
            ->assertJsonPath('data.details_submitted', true)
            ->assertJsonPath('data.dashboard_url', 'https://connect.stripe.com/express/abc')
            ->assertJsonPath('data.onboarding_url', null);
    }

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

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

        $response->assertOk()
            ->assertJsonPath('data.charges_enabled', false)
            ->assertJsonPath('data.details_submitted', false)
            ->assertJsonPath('data.dashboard_url', null);
    }
}
```

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

- [ ] **Step 3: Extend `StripeService`**

The service currently has a `isChargesEnabled(string $accountId)` helper (line ~92). Add two methods next to it:

```php
/**
 * @return array{charges_enabled: bool, details_submitted: bool}
 */
public function getAccountStatus(string $accountId): array
{
    $account = Account::retrieve($accountId);
    return [
        'charges_enabled' => (bool) $account->charges_enabled,
        'details_submitted' => (bool) $account->details_submitted,
    ];
}

public function createLoginLink(string $accountId): string
{
    $link = \Stripe\LoginLink::create($accountId);
    return $link->url;
}
```

If `\Stripe\LoginLink` isn't available in the installed SDK version, use `Account::createLoginLink($accountId)` instead (both exist in modern stripe-php).

- [ ] **Step 4: Add `status` action to `StripeConnectController`**

```php
public function status(Store $store, StripeService $stripe): JsonResponse
{
    if (! $store->stripe_connect_id) {
        return response()->json(['data' => [
            'charges_enabled' => false,
            'details_submitted' => false,
            'dashboard_url' => null,
            'onboarding_url' => null,
        ]]);
    }

    $status = $stripe->getAccountStatus($store->stripe_connect_id);
    $fullyOnboarded = $status['details_submitted'] && $status['charges_enabled'];

    return response()->json(['data' => [
        'charges_enabled' => $status['charges_enabled'],
        'details_submitted' => $status['details_submitted'],
        'dashboard_url' => $fullyOnboarded ? $stripe->createLoginLink($store->stripe_connect_id) : null,
        'onboarding_url' => null,
    ]]);
}
```

Add required imports: `use Illuminate\Http\JsonResponse;`, `use App\Modules\Checkout\Services\StripeService;`.

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

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

```php
Route::get('/stores/{store}/stripe/status', [StripeConnectController::class, 'status']);
```

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

```bash
docker compose exec laravel.test php artisan test tests/Feature/Stores
docker compose exec laravel.test ./vendor/bin/pint app/Modules/Checkout/Services/StripeService.php app/Modules/Stores/Controllers/StripeConnectController.php app/Modules/Stores/routes.php tests/Feature/Stores/StripeStatusTest.php
```

- [ ] **Step 7: Commit**

```bash
git add Alqove/api/app/Modules/Checkout/Services/StripeService.php Alqove/api/app/Modules/Stores/Controllers/StripeConnectController.php Alqove/api/app/Modules/Stores/routes.php Alqove/api/tests/Feature/Stores/StripeStatusTest.php
git commit -m "feat(stores): GET /stripe/status returns charges state + dashboard link"
```

---

## Phase B — Contract + api-client

### Task 3: OpenAPI + api-client additions

**Files:**
- Modify: `Alqove/api/contracts/openapi.yaml`
- Modify: `Alqove/packages/api-client/src/endpoints/stripe.ts`
- Modify: `Alqove/packages/api-client/src/endpoints/stores.ts`
- Modify: `Alqove/packages/api-client/src/endpoints/notifications.ts`
- Regenerate: `Alqove/packages/types/src/generated.ts`

- [ ] **Step 1: OpenAPI — add `/stripe/status` path**

Find the `createStripeConnectLink` operation. Add a sibling `GET` path above or below it:

```yaml
  /v1/stores/{store}/stripe/status:
    get:
      operationId: getStripeStatus
      summary: Get Stripe Connect account status + dashboard login link
      tags:
        - Stores
      parameters:
        - $ref: '#/components/parameters/StoreId'
      responses:
        '200':
          description: Current Connect state for the store
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      charges_enabled:    { type: boolean }
                      details_submitted:  { type: boolean }
                      dashboard_url:      { type: [string, 'null'] }
                      onboarding_url:     { type: [string, 'null'] }
```

- [ ] **Step 2: OpenAPI — add `stripe_connected` to Store schema**

Find the `Store` (or `StoreData`) schema used by `getStore` / `updateStore`. Add:

```yaml
        stripe_connected:
          type: boolean
```

Do NOT change existing fields; only append.

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

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

- [ ] **Step 4: Extend `stripe.ts` api-client**

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

export interface StripeStatus {
  charges_enabled: boolean;
  details_submitted: boolean;
  dashboard_url: string | null;
  onboarding_url: string | null;
}

export function createStripeEndpoints(client: AlqoveClient) {
  return {
    createConnectLink(storeId: string) {
      return client.post<{ onboarding_url: string }>(
        `/v1/stores/${storeId}/stripe/connect`,
        {},
      );
    },
    getStatus(storeId: string) {
      return client.get<StripeStatus>(`/v1/stores/${storeId}/stripe/status`);
    },
  };
}
```

Re-export `StripeStatus` from `packages/api-client/src/index.ts`.

- [ ] **Step 5: Extend `stores.ts` api-client — add parcel preset CRUD**

Inside `createStoreEndpoints`, update the `parcelPresets` object:

```ts
    parcelPresets: {
      list(storeId: string) {
        return client.get<ParcelPreset[]>(`/v1/stores/${storeId}/parcel-presets`);
      },
      create(storeId: string, body: Omit<ParcelPreset, 'id'>) {
        return client.post<ParcelPreset>(`/v1/stores/${storeId}/parcel-presets`, body);
      },
      update(storeId: string, presetId: string, body: Partial<Omit<ParcelPreset, 'id'>>) {
        return client.patch<ParcelPreset>(
          `/v1/stores/${storeId}/parcel-presets/${presetId}`,
          body,
        );
      },
      delete(storeId: string, presetId: string) {
        return client.delete<{ message: string }>(
          `/v1/stores/${storeId}/parcel-presets/${presetId}`,
        );
      },
    },
```

- [ ] **Step 6: Widen `NotificationPreference.category` in `notifications.ts`**

Add `'payouts'` to the category union (backend enum includes it):

```ts
export interface NotificationPreference {
  channel: 'email' | 'push';
  category: 'orders' | 'shipping' | 'payouts' | 'promotions' | 'price_drops' | 'account';
  enabled: boolean;
  is_transactional: boolean;
}
```

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

```bash
cd Alqove && npm run typecheck
git add Alqove/api/contracts/openapi.yaml Alqove/packages/types/src/generated.ts Alqove/packages/api-client/src
git commit -m "chore(contract): stripe status + parcel preset CRUD + notification payouts category"
```

---

## Phase C — Settings shell

### Task 4: Nested-route layout + redirect

**Files:**
- Modify: `Alqove/web/src/app/(seller)/seller/settings/page.tsx` (rewrite as redirect)
- Create: `Alqove/web/src/app/(seller)/seller/settings/layout.tsx`

- [ ] **Step 1: Rewrite `page.tsx` as a redirect**

```tsx
import { redirect } from 'next/navigation';

export default function Page() {
  redirect('/seller/settings/store');
}
```

- [ ] **Step 2: Create the shared layout**

```tsx
// Alqove/web/src/app/(seller)/seller/settings/layout.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';

const TABS = [
  { href: '/seller/settings/store', label: 'Store' },
  { href: '/seller/settings/shipping', label: 'Shipping' },
  { href: '/seller/settings/notifications', label: 'Notifications' },
  { href: '/seller/settings/payments', label: 'Payments' },
] as const;

export default function SettingsLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <div>
      <h1 className="text-2xl font-bold text-ink">Settings</h1>
      <nav className="mt-4 flex gap-1 border-b border-forest/15">
        {TABS.map((t) => {
          const active = pathname === t.href || pathname.startsWith(`${t.href}/`);
          return (
            <Link
              key={t.href}
              href={t.href}
              className={cn(
                '-mb-px border-b-2 px-4 py-2 text-sm font-medium',
                active
                  ? 'border-forest text-forest'
                  : 'border-transparent text-ink/60 hover:text-ink',
              )}
            >
              {t.label}
            </Link>
          );
        })}
      </nav>
      <div className="mt-6">{children}</div>
    </div>
  );
}
```

- [ ] **Step 3: Typecheck + commit (tab pages come next; placeholder 404s are expected)**

```bash
cd Alqove/web && npx tsc --noEmit
git add "Alqove/web/src/app/(seller)/seller/settings/page.tsx" "Alqove/web/src/app/(seller)/seller/settings/layout.tsx"
git commit -m "feat(seller/settings): nested-route layout with tab nav"
```

---

## Phase D — Store tab

### Task 5: `/seller/settings/store`

A form bound to `api.stores.get` + `api.stores.update` for name, description, city, state, street1/2, zip, country. No logo/banner uploads (see scope cuts).

**File:** `Alqove/web/src/app/(seller)/seller/settings/store/page.tsx`

- [ ] **Step 1: Create the page (drop-in)**

```tsx
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';

interface StoreDetail {
  id: string;
  name: string;
  description: string | null;
  street1: string | null;
  street2: string | null;
  city: string | null;
  state: string | null;
  zip: string | null;
  country: string | null;
}

interface FormState {
  name: string;
  description: string;
  street1: string;
  street2: string;
  city: string;
  state: string;
  zip: string;
  country: string;
}

function fromStore(s: StoreDetail): FormState {
  return {
    name: s.name ?? '',
    description: s.description ?? '',
    street1: s.street1 ?? '',
    street2: s.street2 ?? '',
    city: s.city ?? '',
    state: s.state ?? '',
    zip: s.zip ?? '',
    country: s.country ?? 'US',
  };
}

export default function StoreProfilePage() {
  const storeId = useAuthStore((s) => s.user?.store_id ?? null);
  const qc = useQueryClient();
  const [form, setForm] = useState<FormState | null>(null);
  const [savedAt, setSavedAt] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const storeQ = useQuery({
    queryKey: ['seller-store', storeId],
    enabled: !!storeId,
    queryFn: () => api.stores.get(storeId!),
  });

  useEffect(() => {
    const data = storeQ.data as unknown as { data: StoreDetail } | undefined;
    if (data?.data && !form) setForm(fromStore(data.data));
  }, [storeQ.data, form]);

  const saveMut = useMutation({
    mutationFn: () => api.stores.update(storeId!, form!),
    onSuccess: () => {
      setSavedAt(new Date().toLocaleTimeString());
      setError(null);
      qc.invalidateQueries({ queryKey: ['seller-store', storeId] });
    },
    onError: () => setError('Failed to save changes.'),
  });

  if (!form) return <div className="text-sm text-ink/60">Loading…</div>;

  const field = <K extends keyof FormState>(key: K) => ({
    value: form[key],
    onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
      setForm({ ...form, [key]: e.target.value }),
  });

  return (
    <section className="max-w-2xl space-y-5 rounded-md border border-forest/20 bg-white p-6">
      <header>
        <h2 className="text-lg font-semibold text-ink">Store profile</h2>
        <p className="text-sm text-ink/60">Basic details about your store.</p>
      </header>

      <Field label="Store name"><input className={cls} {...field('name')} /></Field>
      <Field label="Description">
        <textarea rows={3} className={cls} {...field('description')} />
      </Field>
      <div className="grid grid-cols-2 gap-3">
        <Field label="Street"><input className={cls} {...field('street1')} /></Field>
        <Field label="Street 2 (optional)"><input className={cls} {...field('street2')} /></Field>
      </div>
      <div className="grid grid-cols-3 gap-3">
        <Field label="City"><input className={cls} {...field('city')} /></Field>
        <Field label="State"><input className={cls} {...field('state')} /></Field>
        <Field label="Zip"><input className={cls} {...field('zip')} /></Field>
      </div>
      <Field label="Country">
        <input className={cls} {...field('country')} />
      </Field>

      <div className="flex items-center gap-3 pt-2">
        <button
          onClick={() => saveMut.mutate()}
          disabled={saveMut.isPending}
          className="rounded bg-forest px-4 py-2 text-sm font-semibold text-white hover:bg-forest/90 disabled:opacity-50"
        >
          {saveMut.isPending ? 'Saving…' : 'Save changes'}
        </button>
        {savedAt && <span className="text-xs text-ink/60">Saved at {savedAt}</span>}
        {error && <span className="text-xs text-terracotta">{error}</span>}
      </div>
    </section>
  );
}

const cls = 'w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm';

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

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

```bash
cd Alqove/web && npx tsc --noEmit
git add "Alqove/web/src/app/(seller)/seller/settings/store/page.tsx"
git commit -m "feat(seller/settings): Store tab"
```

---

## Phase E — Shipping tab + parcel preset dialog

### Task 6: `ParcelPresetFormDialog` component

A shadcn Dialog wrapper that handles both create and edit via `api.stores.parcelPresets.create/update`.

**File:** `Alqove/web/src/components/seller/parcel-preset-form-dialog.tsx`

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

```tsx
'use client';

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

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

interface Form {
  name: string;
  weight_oz: string;
  length_in: string;
  width_in: string;
  height_in: string;
  is_default: boolean;
}

function emptyForm(): Form {
  return { name: '', weight_oz: '', length_in: '', width_in: '', height_in: '', is_default: false };
}

function fromPreset(p: ParcelPreset): Form {
  return {
    name: p.name,
    weight_oz: String(p.weight_oz),
    length_in: String(p.length_in),
    width_in: String(p.width_in),
    height_in: String(p.height_in),
    is_default: p.is_default,
  };
}

function toPayload(f: Form) {
  return {
    name: f.name,
    weight_oz: Number(f.weight_oz),
    length_in: Number(f.length_in),
    width_in: Number(f.width_in),
    height_in: Number(f.height_in),
    is_default: f.is_default,
  };
}

export function ParcelPresetFormDialog({
  storeId,
  preset,
  trigger,
}: {
  storeId: string;
  preset?: ParcelPreset;
  trigger: React.ReactNode;
}) {
  const qc = useQueryClient();
  const [open, setOpen] = useState(false);
  const [form, setForm] = useState<Form>(preset ? fromPreset(preset) : emptyForm());

  useEffect(() => {
    setForm(preset ? fromPreset(preset) : emptyForm());
  }, [preset, open]);

  const mut = useMutation({
    mutationFn: () =>
      preset
        ? api.stores.parcelPresets.update(storeId, preset.id, toPayload(form))
        : api.stores.parcelPresets.create(storeId, toPayload(form)),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['parcel-presets', storeId] });
      setOpen(false);
    },
  });

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>{trigger}</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{preset ? 'Edit preset' : 'New parcel preset'}</DialogTitle>
        </DialogHeader>
        <div className="space-y-3">
          <Field label="Name">
            <input
              value={form.name}
              onChange={(e) => setForm({ ...form, name: e.target.value })}
              className={cls}
            />
          </Field>
          <div className="grid grid-cols-2 gap-3">
            <Field label="Weight (oz)"><NumberInput val={form.weight_oz} onVal={(v) => setForm({ ...form, weight_oz: v })} /></Field>
            <Field label="Length (in)"><NumberInput val={form.length_in} onVal={(v) => setForm({ ...form, length_in: v })} /></Field>
            <Field label="Width (in)"><NumberInput val={form.width_in} onVal={(v) => setForm({ ...form, width_in: v })} /></Field>
            <Field label="Height (in)"><NumberInput val={form.height_in} onVal={(v) => setForm({ ...form, height_in: v })} /></Field>
          </div>
          <label className="flex items-center gap-2 text-sm">
            <input
              type="checkbox"
              checked={form.is_default}
              onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
            />
            Set as default preset
          </label>
          {mut.isError && <p className="text-sm text-terracotta">Failed to save. Check values and try again.</p>}
        </div>
        <DialogFooter>
          <button onClick={() => setOpen(false)} className="rounded px-3 py-1.5 text-sm text-ink/70 hover:bg-bone">
            Cancel
          </button>
          <button
            onClick={() => mut.mutate()}
            disabled={mut.isPending || !form.name.trim()}
            className="rounded bg-forest px-3 py-1.5 text-sm font-semibold text-white hover:bg-forest/90 disabled:opacity-50"
          >
            {mut.isPending ? 'Saving…' : 'Save'}
          </button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

const cls = 'w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm';

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

function NumberInput({ val, onVal }: { val: string; onVal: (v: string) => void }) {
  return (
    <input
      inputMode="decimal"
      value={val}
      onChange={(e) => onVal(e.target.value.replace(/[^0-9.]/g, ''))}
      className={cls}
    />
  );
}
```

- [ ] **Step 2: Commit alongside Task 7 below** (don't commit yet — Shipping tab imports this)

### Task 7: `/seller/settings/shipping`

**File:** `Alqove/web/src/app/(seller)/seller/settings/shipping/page.tsx`

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

```tsx
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { ParcelPresetFormDialog } from '@/components/seller/parcel-preset-form-dialog';

interface StoreSettings {
  flat_shipping_rate: number;
  free_shipping_threshold: number | null;
  minimum_order_amount: number | null;
  processing_days: number;
  return_window_days: number;
  return_policy_text: string | null;
}

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

export default function ShippingPage() {
  const storeId = useAuthStore((s) => s.user?.store_id ?? null);
  const qc = useQueryClient();
  const [form, setForm] = useState<StoreSettings | null>(null);
  const [savedAt, setSavedAt] = useState<string | null>(null);

  const settingsQ = useQuery({
    queryKey: ['seller-store-settings', storeId],
    enabled: !!storeId,
    queryFn: () => api.stores.getSettings(storeId!),
  });

  useEffect(() => {
    const data = settingsQ.data as unknown as { data: StoreSettings } | undefined;
    if (data?.data && !form) setForm(data.data);
  }, [settingsQ.data, form]);

  const presetsQ = useQuery({
    queryKey: ['parcel-presets', storeId],
    enabled: !!storeId,
    queryFn: () => api.stores.parcelPresets.list(storeId!),
  });

  const presets: ParcelPreset[] = (presetsQ.data as unknown as { data: ParcelPreset[] } | undefined)?.data ?? [];

  const saveMut = useMutation({
    mutationFn: () => api.stores.updateSettings(storeId!, form!),
    onSuccess: () => {
      setSavedAt(new Date().toLocaleTimeString());
      qc.invalidateQueries({ queryKey: ['seller-store-settings', storeId] });
    },
  });

  const deletePresetMut = useMutation({
    mutationFn: (presetId: string) => api.stores.parcelPresets.delete(storeId!, presetId),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['parcel-presets', storeId] }),
  });

  if (!form) return <div className="text-sm text-ink/60">Loading…</div>;

  return (
    <div className="space-y-6">
      <section className="max-w-2xl rounded-md border border-forest/20 bg-white p-6">
        <h2 className="text-lg font-semibold text-ink">Shipping & returns</h2>
        <div className="mt-4 grid grid-cols-2 gap-3">
          <Field label="Flat shipping rate (cents)">
            <input
              inputMode="numeric"
              value={String(form.flat_shipping_rate ?? 0)}
              onChange={(e) => setForm({ ...form, flat_shipping_rate: Number(e.target.value.replace(/\D/g, '')) })}
              className={cls}
            />
          </Field>
          <Field label="Free shipping threshold (cents)">
            <input
              inputMode="numeric"
              value={String(form.free_shipping_threshold ?? '')}
              onChange={(e) => {
                const v = e.target.value.replace(/\D/g, '');
                setForm({ ...form, free_shipping_threshold: v ? Number(v) : null });
              }}
              className={cls}
            />
          </Field>
          <Field label="Processing days (1–7)">
            <input
              inputMode="numeric"
              value={String(form.processing_days)}
              onChange={(e) => {
                const v = Number(e.target.value.replace(/\D/g, ''));
                setForm({ ...form, processing_days: Math.max(1, Math.min(7, v || 1)) });
              }}
              className={cls}
            />
          </Field>
          <Field label="Return window (0–90 days)">
            <input
              inputMode="numeric"
              value={String(form.return_window_days)}
              onChange={(e) => {
                const v = Number(e.target.value.replace(/\D/g, ''));
                setForm({ ...form, return_window_days: Math.max(0, Math.min(90, v)) });
              }}
              className={cls}
            />
          </Field>
        </div>
        <div className="mt-3">
          <Field label="Return policy description">
            <textarea
              rows={3}
              value={form.return_policy_text ?? ''}
              onChange={(e) => setForm({ ...form, return_policy_text: e.target.value || null })}
              className={cls}
            />
          </Field>
        </div>
        <div className="mt-4 flex items-center gap-3">
          <button
            onClick={() => saveMut.mutate()}
            disabled={saveMut.isPending}
            className="rounded bg-forest px-4 py-2 text-sm font-semibold text-white hover:bg-forest/90 disabled:opacity-50"
          >
            {saveMut.isPending ? 'Saving…' : 'Save changes'}
          </button>
          {savedAt && <span className="text-xs text-ink/60">Saved at {savedAt}</span>}
        </div>
      </section>

      <section className="max-w-2xl rounded-md border border-forest/20 bg-white p-6">
        <div className="flex items-center justify-between">
          <div>
            <h2 className="text-lg font-semibold text-ink">Parcel presets</h2>
            <p className="text-sm text-ink/60">Package dimensions you ship with most often.</p>
          </div>
          <ParcelPresetFormDialog
            storeId={storeId!}
            trigger={
              <button className="rounded border border-forest/20 px-3 py-1.5 text-sm hover:bg-bone">+ Add preset</button>
            }
          />
        </div>

        {presetsQ.isLoading && <p className="mt-4 text-sm text-ink/60">Loading…</p>}
        {!presetsQ.isLoading && presets.length === 0 && (
          <p className="mt-4 text-sm text-ink/60">No presets yet. Add one before buying labels.</p>
        )}
        {presets.length > 0 && (
          <table className="mt-4 w-full text-sm">
            <thead className="bg-bone/60">
              <tr>
                <Th>Name</Th><Th>Weight (oz)</Th><Th>Dimensions (in)</Th><Th>Default</Th><Th></Th>
              </tr>
            </thead>
            <tbody>
              {presets.map((p) => (
                <tr key={p.id} className="border-t border-forest/10">
                  <td className="px-3 py-2">{p.name}</td>
                  <td className="px-3 py-2">{p.weight_oz}</td>
                  <td className="px-3 py-2">{p.length_in} × {p.width_in} × {p.height_in}</td>
                  <td className="px-3 py-2">{p.is_default ? '✓' : ''}</td>
                  <td className="px-3 py-2 text-right">
                    <div className="flex justify-end gap-2">
                      <ParcelPresetFormDialog
                        storeId={storeId!}
                        preset={p}
                        trigger={<button className="text-forest hover:underline">Edit</button>}
                      />
                      <button
                        onClick={() => {
                          if (confirm(`Delete preset "${p.name}"?`)) deletePresetMut.mutate(p.id);
                        }}
                        disabled={deletePresetMut.isPending}
                        className="text-terracotta hover:underline disabled:opacity-50"
                      >
                        Delete
                      </button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </section>
    </div>
  );
}

const cls = 'w-full rounded border border-forest/20 bg-white px-2 py-1.5 text-sm';

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

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

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

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

- [ ] **Step 3: Commit (both files together)**

```bash
git add "Alqove/web/src/components/seller/parcel-preset-form-dialog.tsx" "Alqove/web/src/app/(seller)/seller/settings/shipping/page.tsx"
git commit -m "feat(seller/settings): Shipping tab with parcel preset CRUD"
```

---

## Phase F — Notifications tab

### Task 8: `/seller/settings/notifications`

A matrix of categories × channels. Orders + Shipping rows are locked (transactional).

**File:** `Alqove/web/src/app/(seller)/seller/settings/notifications/page.tsx`

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

```tsx
'use client';

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

interface Preference {
  channel: 'email' | 'push';
  category: 'orders' | 'shipping' | 'payouts' | 'promotions' | 'price_drops' | 'account';
  enabled: boolean;
  is_transactional: boolean;
}

const ROWS: { key: Preference['category']; label: string }[] = [
  { key: 'orders',      label: 'Orders' },
  { key: 'shipping',    label: 'Shipping' },
  { key: 'payouts',     label: 'Payouts' },
  { key: 'account',     label: 'Account' },
  { key: 'promotions',  label: 'Marketing & tips' },
  { key: 'price_drops', label: 'Price drops' },
];

const CHANNELS: { key: Preference['channel']; label: string }[] = [
  { key: 'email', label: 'Email' },
];

export default function NotificationsPage() {
  const qc = useQueryClient();

  const prefsQ = useQuery({
    queryKey: ['notification-preferences'],
    queryFn: () => api.notifications.listPreferences(),
  });

  const prefs: Preference[] = (prefsQ.data as unknown as { data: Preference[] } | undefined)?.data ?? [];

  const mut = useMutation({
    mutationFn: (next: Array<Pick<Preference, 'channel' | 'category' | 'enabled'>>) =>
      api.notifications.updatePreferences({ preferences: next }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-preferences'] }),
  });

  const get = (channel: Preference['channel'], category: Preference['category']) =>
    prefs.find((p) => p.channel === channel && p.category === category);

  const toggle = (channel: Preference['channel'], category: Preference['category'], enabled: boolean) => {
    mut.mutate([{ channel, category, enabled }]);
  };

  if (prefsQ.isLoading) return <div className="text-sm text-ink/60">Loading…</div>;

  return (
    <section className="max-w-2xl rounded-md border border-forest/20 bg-white p-6">
      <h2 className="text-lg font-semibold text-ink">Notifications</h2>
      <p className="mt-1 text-sm text-ink/60">
        Transactional messages (orders, shipping) are always on.
      </p>
      <table className="mt-4 w-full text-sm">
        <thead className="bg-bone/60">
          <tr>
            <th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-ink/60">Category</th>
            {CHANNELS.map((c) => (
              <th key={c.key} className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-ink/60">
                {c.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {ROWS.map((r) => (
            <tr key={r.key} className="border-t border-forest/10">
              <td className="px-3 py-2 text-ink">{r.label}</td>
              {CHANNELS.map((c) => {
                const p = get(c.key, r.key);
                const locked = p?.is_transactional ?? false;
                return (
                  <td key={c.key} className="px-3 py-2">
                    <label className="inline-flex items-center gap-2">
                      <input
                        type="checkbox"
                        checked={p?.enabled ?? false}
                        disabled={locked || mut.isPending}
                        onChange={(e) => toggle(c.key, r.key, e.target.checked)}
                      />
                      {locked && <span className="text-xs text-ink/40">(locked)</span>}
                    </label>
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
      {mut.isError && <p className="mt-3 text-sm text-terracotta">Failed to update preferences.</p>}
    </section>
  );
}
```

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

```bash
cd Alqove/web && npx tsc --noEmit
git add "Alqove/web/src/app/(seller)/seller/settings/notifications/page.tsx"
git commit -m "feat(seller/settings): Notifications preferences tab"
```

---

## Phase G — Payments tab

### Task 9: `/seller/settings/payments`

Shows Connect status, dashboard link when fully onboarded, onboarding button when not. Replaces the existing `PaymentsSection` component shape.

**File:** `Alqove/web/src/app/(seller)/seller/settings/payments/page.tsx`

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

```tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';

interface StripeStatus {
  charges_enabled: boolean;
  details_submitted: boolean;
  dashboard_url: string | null;
  onboarding_url: string | null;
}

export default function PaymentsPage() {
  const storeId = useAuthStore((s) => s.user?.store_id ?? null);
  const [redirecting, setRedirecting] = useState(false);

  const statusQ = useQuery({
    queryKey: ['stripe-status', storeId],
    enabled: !!storeId,
    queryFn: () => api.stripe.getStatus(storeId!),
  });

  const status = (statusQ.data as unknown as { data: StripeStatus } | undefined)?.data;

  const startOnboarding = async () => {
    if (!storeId) return;
    setRedirecting(true);
    try {
      const res = await api.stripe.createConnectLink(storeId);
      const url = (res as unknown as { data: { onboarding_url: string } }).data.onboarding_url;
      window.location.href = url;
    } catch {
      setRedirecting(false);
    }
  };

  if (!status) return <div className="text-sm text-ink/60">Loading…</div>;

  const onboarded = status.details_submitted && status.charges_enabled;
  const pending = status.details_submitted && !status.charges_enabled;

  return (
    <section className="max-w-2xl rounded-md border border-forest/20 bg-white p-6">
      <h2 className="text-lg font-semibold text-ink">Payments</h2>

      {onboarded && (
        <div className="mt-4 rounded border border-emerald-200 bg-emerald-50 p-4">
          <p className="text-sm font-semibold text-emerald-800">Stripe connected · Charges enabled</p>
          <p className="mt-1 text-sm text-emerald-800/80">
            Payouts arrive automatically after each sale. A 15% platform fee applies.
          </p>
          {status.dashboard_url && (
            <a
              href={status.dashboard_url}
              target="_blank"
              rel="noreferrer"
              className="mt-2 inline-block text-sm text-emerald-700 underline"
            >
              Open Stripe dashboard →
            </a>
          )}
        </div>
      )}

      {pending && (
        <div className="mt-4 rounded border border-amber-200 bg-amber-50 p-4">
          <p className="text-sm font-semibold text-amber-800">Onboarding in review</p>
          <p className="mt-1 text-sm text-amber-800/80">
            Stripe is verifying your details. You can continue listing items but can&apos;t accept payouts yet.
          </p>
          <button
            onClick={startOnboarding}
            disabled={redirecting}
            className="mt-3 rounded bg-forest px-3 py-1.5 text-sm font-semibold text-white hover:bg-forest/90 disabled:opacity-50"
          >
            {redirecting ? 'Redirecting…' : 'Continue onboarding →'}
          </button>
        </div>
      )}

      {!onboarded && !pending && (
        <div className="mt-4 rounded border border-amber-200 bg-amber-50 p-4">
          <p className="text-sm font-semibold text-amber-800">Payments not set up</p>
          <p className="mt-1 text-sm text-amber-800/80">
            Connect a Stripe account before buyers can check out. Your items stay visible in the meantime.
          </p>
          <button
            onClick={startOnboarding}
            disabled={redirecting}
            className="mt-3 rounded bg-forest px-3 py-1.5 text-sm font-semibold text-white hover:bg-forest/90 disabled:opacity-50"
          >
            {redirecting ? 'Redirecting…' : 'Connect with Stripe →'}
          </button>
        </div>
      )}
    </section>
  );
}
```

- [ ] **Step 2: Remove the now-dead `payments-section.tsx`**

The old `Alqove/web/src/app/(seller)/seller/settings/payments-section.tsx` is no longer used (the redirect shell doesn't render it). Delete it.

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

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

- [ ] **Step 4: Commit**

```bash
git add "Alqove/web/src/app/(seller)/seller/settings/payments/page.tsx"
git rm "Alqove/web/src/app/(seller)/seller/settings/payments-section.tsx"
git commit -m "feat(seller/settings): Payments tab with Connect status and dashboard link"
```

---

## Phase H — Tests + smoke

### Task 10: Vitest for Notifications transactional lock

**File:** `Alqove/web/src/app/(seller)/seller/settings/__tests__/notifications-page.test.tsx`

- [ ] **Step 1: Create test**

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import NotificationsPage from '../notifications/page';

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

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

describe('NotificationsPage', () => {
  beforeEach(() => {
    listMock.mockReset();
    updateMock.mockReset();
    listMock.mockResolvedValue({
      data: [
        { channel: 'email', category: 'orders', enabled: true, is_transactional: true },
        { channel: 'email', category: 'shipping', enabled: true, is_transactional: true },
        { channel: 'email', category: 'payouts', enabled: true, is_transactional: false },
        { channel: 'email', category: 'account', enabled: true, is_transactional: false },
        { channel: 'email', category: 'promotions', enabled: false, is_transactional: false },
        { channel: 'email', category: 'price_drops', enabled: false, is_transactional: false },
      ],
    });
  });

  it('locks transactional categories and allows toggling non-transactional ones', async () => {
    updateMock.mockResolvedValue({ data: [] });
    render(wrap(<NotificationsPage />));
    await waitFor(() => expect(screen.getByText('Orders')).toBeInTheDocument());

    const boxes = screen.getAllByRole('checkbox');
    const ordersBox = boxes[0];
    expect(ordersBox).toBeDisabled();

    const promotionsBox = boxes[4];
    expect(promotionsBox).not.toBeDisabled();
    fireEvent.click(promotionsBox);
    await waitFor(() =>
      expect(updateMock).toHaveBeenCalledWith({
        preferences: [{ channel: 'email', category: 'promotions', enabled: true }],
      }),
    );
  });
});
```

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

```bash
cd Alqove/web && npm test -- notifications-page
```

- [ ] **Step 3: Commit**

```bash
git add "Alqove/web/src/app/(seller)/seller/settings/__tests__"
git commit -m "test(seller/settings): notifications transactional lock"
```

---

### Task 11: Manual smoke test + final regression

- [ ] **Step 1: Sign in as a seller and walk through:**

| Tab | Action | Expected |
|-----|--------|----------|
| Store | Change name/description, Save | "Saved at …" timestamp |
| Shipping | Edit processing days to 5, Save | Saved indicator |
| Shipping | Click + Add preset, fill in, check "Set as default", Save | Row appears marked default |
| Shipping | Edit that preset, change name | Row updates |
| Shipping | Attempt to delete the only preset | 409 surfaces inline |
| Notifications | Toggle Marketing checkbox | Row updates, no errors |
| Notifications | Try to toggle Orders row | Disabled; no request fired |
| Payments | Click Connect (if not onboarded) | Redirects to Stripe |
| Payments | If already onboarded | Shows green panel + dashboard link |

- [ ] **Step 2: Final regression**

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

Expect all green (the known pre-existing Typesense flake may still fail — not blocking).

---

## Spec-coverage checklist

- [x] Tabbed parent route with URL-addressable sub-routes — Task 4
- [x] Store tab: name, description, address — Task 5
- [ ] Store tab: logo + banner uploads — **deferred** (see Scope decisions)
- [x] Shipping tab: ship-from (merged into Store address), processing days, parcel presets CRUD — Tasks 5, 7
- [x] Notifications tab: categories × channels matrix with transactional lock — Task 8
- [x] Payments tab: Connect status + dashboard link + onboarding — Task 9
- [x] Save with optimistic feedback — each tab's `Saved at …` indicator
