output:
Chunk ID: f119d7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1949
Output:
<?php

namespace App\Livewire\Admin\Orders;

use App\Enums\RefundStatus;
use App\Exceptions\InvalidFulfillmentOperationException;
use App\Exceptions\InvalidOrderOperationException;
use App\Exceptions\InvalidRefundOperationException;
use App\Models\Fulfillment;
use App\Models\Order;
use App\Models\OrderLine;
use App\Models\Refund;
use App\Models\Store;
use App\Services\FulfillmentService;
use App\Services\OrderService;
use App\Services\RefundService;
use App\Support\Money;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Locked;
use Livewire\Component;

class Show extends Component
{
    #[Locked]
    public int $storeId;

    #[Locked]
    public int $orderId;

    public string $refundAmount = '';

    public string $refundReason = '';

    /**
     * @var array<int, int>
     */
    public array $fulfillmentLineQuantities = [];

    public string $trackingCompany = '';

    public string $trackingNumber = '';

    public string $trackingUrl = '';

    public function mount(Order $order): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $order = Order::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->whereKey($order->getKey())
            ->first();

        abort_unless($order instanceof Order, 404);

        $this->storeId = $store->getKey();
        $this->orderId = $order->getKey();
        $this->resetFulfillmentLineQuantities($this->order());
    }

    public function confirmBankTransferPayment(OrderService $orders): void
    {
        try {
            $orders->confirmBankTransferPayment($this->order());
            $this->resetFulfillmentLineQuantities($this->order());
            $this->dispatch('toast', type: 'success', message: __('Payment confirmed'));
        } catch (InvalidOrderOperationException $exception) {
            throw ValidationException::withMessages([
                'orderAction' => $exception->getMessage(),
            ]);
        }
    }

    public function processRefund(RefundService $refunds): void
    {
        $this->validate([
            'refundAmount' => ['nullable', 'numeric', 'min:0.01'],
            'refundReason' => ['nullable', 'string', 'max:500'],
        ]);

        $request = [
            'reason' => $this->refundReason !== '' ? $this->refundReason : null,
        ];

        if ($this->refundAmount !== '') {
            $request['amount'] = Money::fromDecimalString($this->refundAmount);
        }

        try {
            $refunds->process($this->order(), $request);
            $this->refundAmount = '';
            $this->refundReason = '';
            $this->modal('refund-order')->close();
            $this->dispatch('toast', type: 'success', message: __('Refund processed'));
        } catch (InvalidRefundOperationException $exception) {
            throw ValidationException::withMessages([
                'refundAmount' => $exception->getMessage(),
            ]);
        }
    }

    public function createFulfillment(FulfillmentService $fulfillments): void
    {
        $this->validate([
            'fulfillmentLineQuantities' => ['array'],
            'trackingCompany' => ['nullable', 'string', 'max:255'],
            'trackingNumber' => ['nullable', 'string', 'max:255'],
            'trackingUrl' => ['nullable', 'url', 'max:2048'],
        ]);

        $lines = collect($this->fulfillmentLineQuantities)
            ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity])
            ->filter(fn (int $quantity): bool => $quantity > 0)
            ->all();

        if ($lines === []) {
            throw ValidationException::withMessages([
                'fulfillment' => __('At least one fulfillment line is required.'),
            ]);
        }

        try {
            $fulfillments->create($this->order(), $lines, [
                'tracking_company' => $this->trackingCompany !== '' ? $this->trackingCompany : null,
                'tracking_number' => $this->trackingNumber !== '' ? $this->trackingNumber : null,
                'tracking_url' => $this->trackingUrl !== '' ? $this->trackingUrl : null,
            ]);

            $this->trackingCompany = '';
            $this->trackingNumber = '';
            $this->trackingUrl = '';
            $this->resetFulfillmentLineQuantities($this->order());
            $this->modal('fulfillment-order')->close();
            $this->dispatch('toast', type: 'success', message: __('Fulfillment created'));
        } catch (InvalidFulfillmentOperationException $exception) {
            throw ValidationException::withMessages([
                'fulfillment' => $exception->getMessage(),
            ]);
        }
    }

    public function markFulfillmentShipped(int $fulfillmentId, FulfillmentService $fulfillments): void
    {
        try {
            $fulfillments->markShipped($this->fulfillment($fulfillmentId));
            $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as shipped'));
        } catch (InvalidFulfillmentOperationException $exception) {
            throw ValidationException::withMessages([
                'fulfillment' => $exception->getMessage(),
            ]);
        }
    }

    public function markFulfillmentDelivered(int $fulfillmentId, FulfillmentService $fulfillments): void
    {
        try {
            $fulfillments->markDelivered($this->fulfillment($fulfillmentId));
            $this->dispatch('toast', type: 'success', message: __('Fulfillment marked as delivered'));
        } catch (InvalidFulfillmentOperationException $exception) {
            throw ValidationException::withMessages([
                'fulfillment' => $exception->getMessage(),
            ]);
        }
    }

    public function render(): mixed
    {
        $order = $this->order();

        return view('livewire.admin.orders.show', [
            'order' => $order,
            'remainingFulfillmentQuantities' => $this->remainingFulfillmentQuantities($order),
            'refundableAmount' => $this->refundableAmount($order),
        ])->layout('layouts.app', [
            'title' => $order->order_number,
        ]);
    }

    private function order(): Order
    {
        return Order::withoutGlobalScopes()
            ->with([
                'customer',
                'lines.fulfillmentLines',
                'payments',
                'refunds',
                'fulfillments.lines.orderLine',
            ])
            ->where('store_id', $this->storeId)
            ->whereKey($this->orderId)
            ->firstOrFail();
    }

    private function fulfillment(int $fulfillmentId): Fulfillment
    {
        $fulfillment = $this->order()
            ->fulfillments
            ->firstWhere('id', $fulfillmentId);

        abort_unless($fulfillment instanceof Fulfillment, 404);

        return $fulfillment;
    }

    /**
     * @return array<int, int>
     */
    private function remainingFulfillmentQuantities(Order $order): array
    {
        return $order->lines
            ->mapWithKeys(function (OrderLine $line): array {
                $fulfilled = $line->fulfillmentLines->sum('quantity');

                return [$line->getKey() => max(0, $line->quantity - $fulfilled)];
            })
            ->all();
    }

    private function resetFulfillmentLineQuantities(Order $order): void
    {
        $this->fulfillmentLineQuantities = $this->remainingFulfillmentQuantities($order);
    }

    private function refundableAmount(Order $order): int
    {
        $refunded = $order->refunds
            ->reject(fn (Refund $refund): bool => $refund->status === RefundStatus::Failed)
            ->sum('amount');

        return max(0, $order->total_amount - $refunded);
    }
}


cmd: sed -n '1,220p' app/Models/CustomerAddress.php


command: sed -n '1,220p' app/Models/CustomerAddress.php

CustomerAddress.php

type: read

aggregated_output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class CustomerAddress extends Model
{
    /** @use HasFactory<\Database\Factories\CustomerAddressFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'customer_id',
        'label',
        'address_json',
        'is_default',
    ];

    /**
     * @return BelongsTo<Customer, $this>
     */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'address_json' => 'array',
            'is_default' => 'bool',
        ];
    }
}

status: completed


output:
Chunk ID: dcc549
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 220
Output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class CustomerAddress extends Model
{
    /** @use HasFactory<\Database\Factories\CustomerAddressFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'customer_id',
        'label',
        'address_json',
        'is_default',
    ];

    /**
     * @return BelongsTo<Customer, $this>
     */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'address_json' => 'array',
            'is_default' => 'bool',
        ];
    }
}


cmd: sed -n '1,180p' database/factories/CustomerAddressFactory.php


command: sed -n '1,180p' database/factories/CustomerAddressFactory.php

aggregated_output:
<?php

namespace Database\Factories;

use App\Models\Customer;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CustomerAddress>
 */
class CustomerAddressFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'customer_id' => Customer::factory(),
            'label' => fake()->randomElement(['Shipping', 'Billing', 'Home', 'Office']),
            'address_json' => [
                'first_name' => fake()->firstName(),
                'last_name' => fake()->lastName(),
                'address1' => fake()->streetAddress(),
                'address2' => null,
                'city' => fake()->city(),
                'province_code' => null,
                'country' => 'DE',
                'postal_code' => fake()->postcode(),
            ],
            'is_default' => false,
        ];
    }

    public function default(): static
    {
        return $this->state(fn (array $attributes): array => [
            'is_default' => true,
        ]);
    }
}


output:
Chunk ID: 5a9655
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 298
Output:
<?php

namespace Database\Factories;

use App\Models\Customer;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CustomerAddress>
 */
class CustomerAddressFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'customer_id' => Customer::factory(),
            'label' => fake()->randomElement(['Shipping', 'Billing', 'Home', 'Office']),
            'address_json' => [
                'first_name' => fake()->firstName(),
                'last_name' => fake()->lastName(),
                'address1' => fake()->streetAddress(),
                'address2' => null,
                'city' => fake()->city(),
                'province_code' => null,
                'country' => 'DE',
                'postal_code' => fake()->postcode(),
            ],
            'is_default' => false,
        ];
    }

    public function default(): static
    {
        return $this->state(fn (array $attributes): array => [
            'is_default' => true,
        ]);
    }
}


cmd: ls app/Policies && sed -n '1,200p' app/Policies/CustomerPolicy.php


command: ls app/Policies && sed -n '1,200p' app/Policies/CustomerPolicy.php

cmd: ls app/Policies

path: Policies

CustomerPolicy.php

aggregated_output:
CollectionPolicy.php
CustomerPolicy.php
DiscountPolicy.php
FulfillmentPolicy.php
OrderPolicy.php
PagePolicy.php
ProductPolicy.php
RefundPolicy.php
StorePolicy.php
ThemePolicy.php
<?php

namespace App\Policies;

use App\Models\User;
use App\Traits\ChecksStoreRole;

class CustomerPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->isAnyRole($user);
    }

    public function view(User $user, object $customer): bool
    {
        return $this->isAnyRole($user, $this->storeIdForModel($customer));
    }

    public function update(User $user, object $customer): bool
    {
        return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($customer));
    }
}


output:
Chunk ID: 11bb91
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 181
Output:
CollectionPolicy.php
CustomerPolicy.php
DiscountPolicy.php
FulfillmentPolicy.php
OrderPolicy.php
PagePolicy.php
ProductPolicy.php
RefundPolicy.php
StorePolicy.php
ThemePolicy.php
<?php

namespace App\Policies;

use App\Models\User;
use App\Traits\ChecksStoreRole;

class CustomerPolicy
{
    use ChecksStoreRole;

    public function viewAny(User $user): bool
    {
        return $this->isAnyRole($user);
    }

    public function view(User $user, object $customer): bool
    {
        return $this->isAnyRole($user, $this->storeIdForModel($customer));
    }

    public function update(User $user, object $customer): bool
    {
        return $this->isOwnerAdminOrStaff($user, $this->storeIdForModel($customer));
    }
}


cmd: rg -n "flux:modal|modal\(" resources/views/livewire/admin app/Livewire/Admin -n


command: rg -n "flux:modal|modal\(" resources/views/livewire/admin app/Livewire/Admin -n

type: search

cmd: rg -n "flux:modal|modal\\(" resources/views/livewire/admin app/Livewire/Admin -n

query: flux:modal|modal\(

path: admin

aggregated_output:
app/Livewire/Admin/Orders/Show.php:95:            $this->modal('refund-order')->close();
app/Livewire/Admin/Orders/Show.php:135:            $this->modal('fulfillment-order')->close();
resources/views/livewire/admin/orders/show.blade.php:22:                <flux:modal.trigger name="refund-order">
resources/views/livewire/admin/orders/show.blade.php:26:                </flux:modal.trigger>
resources/views/livewire/admin/orders/show.blade.php:30:                <flux:modal.trigger name="fulfillment-order">
resources/views/livewire/admin/orders/show.blade.php:34:                </flux:modal.trigger>
resources/views/livewire/admin/orders/show.blade.php:214:    <flux:modal name="refund-order" class="md:w-[32rem]">
resources/views/livewire/admin/orders/show.blade.php:226:                <flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:228:                </flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:232:    </flux:modal>
resources/views/livewire/admin/orders/show.blade.php:234:    <flux:modal name="fulfillment-order" class="md:w-[38rem]">
resources/views/livewire/admin/orders/show.blade.php:266:                <flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:268:                </flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:272:    </flux:modal>


output:
Chunk ID: 64f345
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 340
Output:
app/Livewire/Admin/Orders/Show.php:95:            $this->modal('refund-order')->close();
app/Livewire/Admin/Orders/Show.php:135:            $this->modal('fulfillment-order')->close();
resources/views/livewire/admin/orders/show.blade.php:22:                <flux:modal.trigger name="refund-order">
resources/views/livewire/admin/orders/show.blade.php:26:                </flux:modal.trigger>
resources/views/livewire/admin/orders/show.blade.php:30:                <flux:modal.trigger name="fulfillment-order">
resources/views/livewire/admin/orders/show.blade.php:34:                </flux:modal.trigger>
resources/views/livewire/admin/orders/show.blade.php:214:    <flux:modal name="refund-order" class="md:w-[32rem]">
resources/views/livewire/admin/orders/show.blade.php:226:                <flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:228:                </flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:232:    </flux:modal>
resources/views/livewire/admin/orders/show.blade.php:234:    <flux:modal name="fulfillment-order" class="md:w-[38rem]">
resources/views/livewire/admin/orders/show.blade.php:266:                <flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:268:                </flux:modal.close>
resources/views/livewire/admin/orders/show.blade.php:272:    </flux:modal>


cmd: sed -n '210,276p' resources/views/livewire/admin/orders/show.blade.php


command: sed -n '210,276p' resources/views/livewire/admin/orders/show.blade.php

aggregated_output:
    <flux:modal name="refund-order" class="md:w-[32rem]">
        <form wire:submit="processRefund" class="space-y-6">

                <flux:heading size="lg">Process refund</flux:heading>
                <flux:text class="mt-2">Refundable amount: {{ \App\Support\Money::format($refundableAmount, $order->currency) }}</flux:text>

            <flux:input wire:model="refundAmount" label="Amount" placeholder="{{ number_format($refundableAmount / 100, 2, '.', '') }}" />
            <flux:error name="refundAmount" />
            <flux:textarea wire:model="refundReason" label="Reason" rows="3" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="danger">Process refund</flux:button>

        </form>
    </flux:modal>

    <flux:modal name="fulfillment-order" class="md:w-[38rem]">
        <form wire:submit="createFulfillment" class="space-y-6">

                <flux:heading size="lg">Create fulfillment</flux:heading>
                <flux:text class="mt-2">Select the remaining quantities included in this shipment.</flux:text>

                @foreach ($order->lines as $line)
                    @php($remaining = $remainingFulfillmentQuantities[$line->getKey()] ?? 0)
                    <flux:input
                        type="number"
                        min="0"
                        max="{{ $remaining }}"
                        wire:model="fulfillmentLineQuantities.{{ $line->getKey() }}"
                        label="{{ $line->title_snapshot }} ({{ $remaining }} remaining)"
                        wire:key="fulfillment-input-{{ $line->getKey() }}"
                    />
                @endforeach

            <flux:error name="fulfillment" />

                <flux:input wire:model="trackingCompany" label="Carrier" placeholder="DHL" />
                <flux:input wire:model="trackingNumber" label="Tracking number" />

                    <flux:input wire:model="trackingUrl" label="Tracking URL" />

            <flux:error name="trackingUrl" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="primary">Create fulfillment</flux:button>

        </form>
    </flux:modal>


output:
Chunk ID: dce7af
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 714
Output:

    <flux:modal name="refund-order" class="md:w-[32rem]">
        <form wire:submit="processRefund" class="space-y-6">

                <flux:heading size="lg">Process refund</flux:heading>
                <flux:text class="mt-2">Refundable amount: {{ \App\Support\Money::format($refundableAmount, $order->currency) }}</flux:text>

            <flux:input wire:model="refundAmount" label="Amount" placeholder="{{ number_format($refundableAmount / 100, 2, '.', '') }}" />
            <flux:error name="refundAmount" />
            <flux:textarea wire:model="refundReason" label="Reason" rows="3" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="danger">Process refund</flux:button>

        </form>
    </flux:modal>

    <flux:modal name="fulfillment-order" class="md:w-[38rem]">
        <form wire:submit="createFulfillment" class="space-y-6">

                <flux:heading size="lg">Create fulfillment</flux:heading>
                <flux:text class="mt-2">Select the remaining quantities included in this shipment.</flux:text>

                @foreach ($order->lines as $line)
                    @php($remaining = $remainingFulfillmentQuantities[$line->getKey()] ?? 0)
                    <flux:input
                        type="number"
                        min="0"
                        max="{{ $remaining }}"
                        wire:model="fulfillmentLineQuantities.{{ $line->getKey() }}"
                        label="{{ $line->title_snapshot }} ({{ $remaining }} remaining)"
                        wire:key="fulfillment-input-{{ $line->getKey() }}"
                    />
                @endforeach

            <flux:error name="fulfillment" />

                <flux:input wire:model="trackingCompany" label="Carrier" placeholder="DHL" />
                <flux:input wire:model="trackingNumber" label="Tracking number" />

                    <flux:input wire:model="trackingUrl" label="Tracking URL" />

            <flux:error name="trackingUrl" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="primary">Create fulfillment</flux:button>

        </form>
    </flux:modal>


message: I’m going to build customer list/detail as the next focused admin slice: routes/sidebar link, searchable customer table, customer detail with order history and address modal actions.

phase: commentary


type: message

role: assistant

text: I’m going to build customer list/detail as the next focused admin slice: routes/sidebar link, searchable customer table, customer detail with order history and address modal actions.


cmd: php artisan make:livewire Admin/Customers/Index --class --no-interaction


cmd: php artisan make:livewire Admin/Customers/Show --class --no-interaction


cmd: php artisan make:test --pest Admin/CustomerManagementTest --no-interaction


output:
Chunk ID: 64c4d9
Wall time: 0.2123 seconds
Process exited with code 0
Original token count: 24
Output:

   INFO  Livewire component [app/Livewire/Admin/Customers/Index.php] created successfully.


output:
Chunk ID: 2537b4
Wall time: 0.2110 seconds
Process exited with code 0
Original token count: 24
Output:

   INFO  Livewire component [app/Livewire/Admin/Customers/Show.php] created successfully.


output:
Chunk ID: 0d77fa
Wall time: 0.2050 seconds
Process exited with code 0
Original token count: 23
Output:

   INFO  Test [tests/Feature/Admin/CustomerManagementTest.php] created successfully.


command: php artisan make:livewire Admin/Customers/Show --class --no-interaction

type: unknown

aggregated_output: INFO  Livewire component [app/Livewire/Admin/Customers/Show.php] created successfully.


command: php artisan make:test --pest Admin/CustomerManagementTest --no-interaction

aggregated_output: INFO  Test [tests/Feature/Admin/CustomerManagementTest.php] created successfully.


command: php artisan make:livewire Admin/Customers/Index --class --no-interaction

aggregated_output: INFO  Livewire component [app/Livewire/Admin/Customers/Index.php] created successfully.


step: Inspect customer admin spec and model relationships

step: Build customer admin routes, sidebar, list/detail components

step: Add customer management Pest coverage

status: pending

step: Run formatter, affected/full tests, build, browser, progress, commit

explanation: The dashboard slice is committed, so the active work is now the customer management admin surface from Spec 03.


output: Plan updated


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Index.php
@@
 <?php

 namespace App\Livewire\Admin\Customers;

+use App\Models\Customer;
+use App\Models\Store;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Livewire\Attributes\Locked;
 use Livewire\Component;
+use Livewire\WithPagination;

 class Index extends Component
 {
-    public function render()
+    use WithPagination;
+
+    #[Locked]
+    public int $storeId;
+
+    public string $search = '';
+
+    public function mount(): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $this->storeId = $store->getKey();
+    }
+
+    public function updatedSearch(): void
+    {
+        $this->resetPage();
+    }
+
+    public function customers(): LengthAwarePaginator
+    {
+        return Customer::withoutGlobalScopes()
+            ->withCount('orders')
+            ->withSum('orders as total_spent', 'total_amount')
+            ->where('store_id', $this->storeId)
+            ->when($this->search !== '', function (Builder $query): void {
+                $search = '%'.$this->search.'%';
+
+                $query->where(function (Builder $query) use ($search): void {
+                    $query
+                        ->where('name', 'like', $search)
+                        ->orWhere('email', 'like', $search);
+                });
+            })
+            ->latest('created_at')
+            ->paginate(20);
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.admin.customers.index');
+        return view('livewire.admin.customers.index', [
+            'customers' => $this->customers(),
+        ])->layout('layouts.app', [
+            'title' => __('Customers'),
+        ]);
     }
 }
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Index.php

type: update

unified_diff:
@@ -4,3 +4,9 @@

+use App\Models\Customer;
+use App\Models\Store;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Livewire\Attributes\Locked;
 use Livewire\Component;
+use Livewire\WithPagination;

@@ -8,5 +14,49 @@
 {
-    public function render()
+    use WithPagination;
+
+    #[Locked]
+    public int $storeId;
+
+    public string $search = '';
+
+    public function mount(): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $this->storeId = $store->getKey();
+    }
+
+    public function updatedSearch(): void
+    {
+        $this->resetPage();
+    }
+
+    public function customers(): LengthAwarePaginator
     {
-        return view('livewire.admin.customers.index');
+        return Customer::withoutGlobalScopes()
+            ->withCount('orders')
+            ->withSum('orders as total_spent', 'total_amount')
+            ->where('store_id', $this->storeId)
+            ->when($this->search !== '', function (Builder $query): void {
+                $search = '%'.$this->search.'%';
+
+                $query->where(function (Builder $query) use ($search): void {
+                    $query
+                        ->where('name', 'like', $search)
+                        ->orWhere('email', 'like', $search);
+                });
+            })
+            ->latest('created_at')
+            ->paginate(20);
+    }
+
+    public function render(): mixed
+    {
+        return view('livewire.admin.customers.index', [
+            'customers' => $this->customers(),
+        ])->layout('layouts.app', [
+            'title' => __('Customers'),
+        ]);
     }


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Show.php
@@
 <?php

 namespace App\Livewire\Admin\Customers;

+use App\Models\Customer;
+use App\Models\CustomerAddress;
+use App\Models\Order;
+use App\Models\Store;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\Locked;
 use Livewire\Component;
+use Livewire\WithPagination;

 class Show extends Component
 {
-    public function render()
+    use WithPagination;
+
+    #[Locked]
+    public int $storeId;
+
+    #[Locked]
+    public int $customerId;
+
+    public ?int $editingAddressId = null;
+
+    public string $addressLabel = '';
+
+    /**
+     * @var array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string}
+     */
+    public array $addressJson = [
+        'first_name' => '',
+        'last_name' => '',
+        'address1' => '',
+        'address2' => '',
+        'city' => '',
+        'province_code' => '',
+        'country' => 'DE',
+        'postal_code' => '',
+    ];
+
+    public function mount(Customer $customer): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $customer = Customer::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->whereKey($customer->getKey())
+            ->first();
+
+        abort_unless($customer instanceof Customer, 404);
+
+        $this->storeId = $store->getKey();
+        $this->customerId = $customer->getKey();
+    }
+
+    public function openAddressForm(?int $addressId = null): void
+    {
+        $this->editingAddressId = $addressId;
+
+        if ($addressId) {
+            $address = $this->address($addressId);
+            $this->addressLabel = (string) $address->label;
+            $this->addressJson = array_merge($this->emptyAddressJson(), $address->address_json ?? []);
+        } else {
+            $this->resetAddressForm();
+        }
+
+        $this->modal('address-form')->show();
+    }
+
+    public function saveAddress(): void
+    {
+        $this->validate([
+            'addressLabel' => ['nullable', 'string', 'max:255'],
+            'addressJson.first_name' => ['nullable', 'string', 'max:255'],
+            'addressJson.last_name' => ['nullable', 'string', 'max:255'],
+            'addressJson.address1' => ['required', 'string', 'max:255'],
+            'addressJson.address2' => ['nullable', 'string', 'max:255'],
+            'addressJson.city' => ['required', 'string', 'max:255'],
+            'addressJson.province_code' => ['nullable', 'string', 'max:255'],
+            'addressJson.country' => ['required', 'string', 'size:2'],
+            'addressJson.postal_code' => ['required', 'string', 'max:32'],
+        ]);
+
+        $address = $this->editingAddressId
+            ? $this->address($this->editingAddressId)
+            : new CustomerAddress(['customer_id' => $this->customerId]);
+
+        $address->fill([
+            'label' => $this->addressLabel !== '' ? $this->addressLabel : null,
+            'address_json' => $this->addressJson,
+            'is_default' => $address->exists ? $address->is_default : ! $this->customer()->addresses()->exists(),
+        ]);
+        $address->save();
+
+        $this->resetAddressForm();
+        $this->modal('address-form')->close();
+        $this->dispatch('toast', type: 'success', message: __('Address saved'));
+    }
+
+    public function deleteAddress(int $addressId): void
+    {
+        $address = $this->address($addressId);
+        $wasDefault = $address->is_default;
+        $address->delete();
+
+        if ($wasDefault) {
+            $this->setFirstAddressAsDefault();
+        }
+
+        $this->dispatch('toast', type: 'success', message: __('Address deleted'));
+    }
+
+    public function setDefaultAddress(int $addressId): void
+    {
+        $address = $this->address($addressId);
+
+        DB::transaction(function () use ($address): void {
+            CustomerAddress::query()
+                ->where('customer_id', $this->customerId)
+                ->update(['is_default' => false]);
+
+            $address->forceFill(['is_default' => true])->save();
+        });
+
+        $this->dispatch('toast', type: 'success', message: __('Default address updated'));
+    }
+
+    public function orders(): LengthAwarePaginator
+    {
+        return Order::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->where('customer_id', $this->customerId)
+            ->latest('placed_at')
+            ->latest('id')
+            ->paginate(10);
+    }
+
+    public function render(): mixed
     {
-        return view('livewire.admin.customers.show');
+        $customer = $this->customer();
+
+        return view('livewire.admin.customers.show', [
+            'customer' => $customer,
+            'orders' => $this->orders(),
+        ])->layout('layouts.app', [
+            'title' => $customer->name ?: $customer->email,
+        ]);
+    }
+
+    private function customer(): Customer
+    {
+        return Customer::withoutGlobalScopes()
+            ->with(['addresses' => fn ($query) => $query->orderByDesc('is_default')->orderBy('id')])
+            ->where('store_id', $this->storeId)
+            ->whereKey($this->customerId)
+            ->firstOrFail();
+    }
+
+    private function address(int $addressId): CustomerAddress
+    {
+        return CustomerAddress::query()
+            ->where('customer_id', $this->customerId)
+            ->whereKey($addressId)
+            ->firstOrFail();
+    }
+
+    private function setFirstAddressAsDefault(): void
+    {
+        $nextAddress = CustomerAddress::query()
+            ->where('customer_id', $this->customerId)
+            ->oldest('id')
+            ->first();
+
+        if ($nextAddress instanceof CustomerAddress) {
+            $nextAddress->forceFill(['is_default' => true])->save();
+        }
+    }
+
+    private function resetAddressForm(): void
+    {
+        $this->editingAddressId = null;
+        $this->addressLabel = '';
+        $this->addressJson = $this->emptyAddressJson();
+    }
+
+    /**
+     * @return array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string}
+     */
+    private function emptyAddressJson(): array
+    {
+        return [
+            'first_name' => '',
+            'last_name' => '',
+            'address1' => '',
+            'address2' => '',
+            'city' => '',
+            'province_code' => '',
+            'country' => 'DE',
+            'postal_code' => '',
+        ];
     }
 }
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Show.php

unified_diff:
@@ -4,3 +4,11 @@

+use App\Models\Customer;
+use App\Models\CustomerAddress;
+use App\Models\Order;
+use App\Models\Store;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\Locked;
 use Livewire\Component;
+use Livewire\WithPagination;

@@ -8,5 +16,191 @@
 {
-    public function render()
+    use WithPagination;
+
+    #[Locked]
+    public int $storeId;
+
+    #[Locked]
+    public int $customerId;
+
+    public ?int $editingAddressId = null;
+
+    public string $addressLabel = '';
+
+    /**
+     * @var array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string}
+     */
+    public array $addressJson = [
+        'first_name' => '',
+        'last_name' => '',
+        'address1' => '',
+        'address2' => '',
+        'city' => '',
+        'province_code' => '',
+        'country' => 'DE',
+        'postal_code' => '',
+    ];
+
+    public function mount(Customer $customer): void
+    {
+        $store = app('current_store');
+
+        abort_unless($store instanceof Store, 404);
+
+        $customer = Customer::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->whereKey($customer->getKey())
+            ->first();
+
+        abort_unless($customer instanceof Customer, 404);
+
+        $this->storeId = $store->getKey();
+        $this->customerId = $customer->getKey();
+    }
+
+    public function openAddressForm(?int $addressId = null): void
+    {
+        $this->editingAddressId = $addressId;
+
+        if ($addressId) {
+            $address = $this->address($addressId);
+            $this->addressLabel = (string) $address->label;
+            $this->addressJson = array_merge($this->emptyAddressJson(), $address->address_json ?? []);
+        } else {
+            $this->resetAddressForm();
+        }
+
+        $this->modal('address-form')->show();
+    }
+
+    public function saveAddress(): void
+    {
+        $this->validate([
+            'addressLabel' => ['nullable', 'string', 'max:255'],
+            'addressJson.first_name' => ['nullable', 'string', 'max:255'],
+            'addressJson.last_name' => ['nullable', 'string', 'max:255'],
+            'addressJson.address1' => ['required', 'string', 'max:255'],
+            'addressJson.address2' => ['nullable', 'string', 'max:255'],
+            'addressJson.city' => ['required', 'string', 'max:255'],
+            'addressJson.province_code' => ['nullable', 'string', 'max:255'],
+            'addressJson.country' => ['required', 'string', 'size:2'],
+            'addressJson.postal_code' => ['required', 'string', 'max:32'],
+        ]);
+
+        $address = $this->editingAddressId
+            ? $this->address($this->editingAddressId)
+            : new CustomerAddress(['customer_id' => $this->customerId]);
+
+        $address->fill([
+            'label' => $this->addressLabel !== '' ? $this->addressLabel : null,
+            'address_json' => $this->addressJson,
+            'is_default' => $address->exists ? $address->is_default : ! $this->customer()->addresses()->exists(),
+        ]);
+        $address->save();
+
+        $this->resetAddressForm();
+        $this->modal('address-form')->close();
+        $this->dispatch('toast', type: 'success', message: __('Address saved'));
+    }
+
+    public function deleteAddress(int $addressId): void
+    {
+        $address = $this->address($addressId);
+        $wasDefault = $address->is_default;
+        $address->delete();
+
+        if ($wasDefault) {
+            $this->setFirstAddressAsDefault();
+        }
+
+        $this->dispatch('toast', type: 'success', message: __('Address deleted'));
+    }
+
+    public function setDefaultAddress(int $addressId): void
+    {
+        $address = $this->address($addressId);
+
+        DB::transaction(function () use ($address): void {
+            CustomerAddress::query()
+                ->where('customer_id', $this->customerId)
+                ->update(['is_default' => false]);
+
+            $address->forceFill(['is_default' => true])->save();
+        });
+
+        $this->dispatch('toast', type: 'success', message: __('Default address updated'));
+    }
+
+    public function orders(): LengthAwarePaginator
     {
-        return view('livewire.admin.customers.show');
+        return Order::withoutGlobalScopes()
+            ->where('store_id', $this->storeId)
+            ->where('customer_id', $this->customerId)
+            ->latest('placed_at')
+            ->latest('id')
+            ->paginate(10);
+    }
+
+    public function render(): mixed
+    {
+        $customer = $this->customer();
+
+        return view('livewire.admin.customers.show', [
+            'customer' => $customer,
+            'orders' => $this->orders(),
+        ])->layout('layouts.app', [
+            'title' => $customer->name ?: $customer->email,
+        ]);
+    }
+
+    private function customer(): Customer
+    {
+        return Customer::withoutGlobalScopes()
+            ->with(['addresses' => fn ($query) => $query->orderByDesc('is_default')->orderBy('id')])
+            ->where('store_id', $this->storeId)
+            ->whereKey($this->customerId)
+            ->firstOrFail();
+    }
+
+    private function address(int $addressId): CustomerAddress
+    {
+        return CustomerAddress::query()
+            ->where('customer_id', $this->customerId)
+            ->whereKey($addressId)
+            ->firstOrFail();
+    }
+
+    private function setFirstAddressAsDefault(): void
+    {
+        $nextAddress = CustomerAddress::query()
+            ->where('customer_id', $this->customerId)
+            ->oldest('id')
+            ->first();
+
+        if ($nextAddress instanceof CustomerAddress) {
+            $nextAddress->forceFill(['is_default' => true])->save();
+        }
+    }
+
+    private function resetAddressForm(): void
+    {
+        $this->editingAddressId = null;
+        $this->addressLabel = '';
+        $this->addressJson = $this->emptyAddressJson();
+    }
+
+    /**
+     * @return array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string}
+     */
+    private function emptyAddressJson(): array
+    {
+        return [
+            'first_name' => '',
+            'last_name' => '',
+            'address1' => '',
+            'address2' => '',
+            'city' => '',
+            'province_code' => '',
+            'country' => 'DE',
+            'postal_code' => '',
+        ];
     }


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Show.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php
@@
-

-    {{-- The best athlete wants his opponent at his best. --}}
-

+

+

+        <flux:heading size="xl">Customers</flux:heading>
+        <flux:text class="mt-1">Search customer accounts and review order value.</flux:text>
+

+
+

+        <flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="Search name or email..." aria-label="Search customers" />
+

+
+

+

+

+

+

+                        Name
+                        Email
+                        Orders
+                        Total spent
+                        Created
+

+

+

+                    @forelse ($customers as $customer)
+
getKey() }}">
+
+
+                                    {{ $customer->name ?: 'Unnamed customer' }}
+
+
+                            {{ $customer->email }}
+                            {{ $customer->orders_count }}
+
+                                <x-storefront.price :amount="(int) ($customer->total_spent ?? 0)" currency="EUR" class="justify-end" />
+
+                            {{ $customer->created_at?->format('M j, Y') }}
+

+                    @empty
+

+
+

+

+                                        <flux:icon name="users" class="size-6" />
+

+                                    <flux:heading size="lg">No customers found</flux:heading>
+                                    <flux:text>Customer accounts will appear here after registration or checkout.</flux:text>
+

+
+

+                    @endforelse
+

+

+

+

+
+    {{ $customers->links() }}
+

*** End Patch


output:
apply_patch verification failed: Failed to find expected lines in /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php:
<div>
    {{-- The best athlete wants his opponent at his best. --}}
</div>


cmd: sed -n '1,80p' resources/views/livewire/admin/customers/index.blade.php


cmd: sed -n '1,80p' resources/views/livewire/admin/customers/show.blade.php


command: sed -n '1,80p' resources/views/livewire/admin/customers/show.blade.php

aggregated_output: {{-- Live as if you were to die tomorrow. Learn as if you were to live forever. - Mahatma Gandhi --}}


command: sed -n '1,80p' resources/views/livewire/admin/customers/index.blade.php

aggregated_output: {{-- Walk as if you are kissing the Earth with your feet. - Thich Nhat Hanh --}}


output:
Chunk ID: 89b7e6
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 25
Output:
<div>
    {{-- Walk as if you are kissing the Earth with your feet. - Thich Nhat Hanh --}}
</div>


output:
Chunk ID: 303359
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 30
Output:
<div>
    {{-- Live as if you were to die tomorrow. Learn as if you were to live forever. - Mahatma Gandhi --}}
</div>


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php
*** End Patch


stdout:
Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php

type: delete

content: {{-- Walk as if you are kissing the Earth with your feet. - Thich Nhat Hanh --}}


output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php
+

+

+        <flux:heading size="xl">Customers</flux:heading>
+        <flux:text class="mt-1">Search customer accounts and review order value.</flux:text>
+

+
+

+        <flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="Search name or email..." aria-label="Search customers" />
+

+
+

+

+

+

+

+                        Name
+                        Email
+                        Orders
+                        Total spent
+                        Created
+

+

+

+                    @forelse ($customers as $customer)
+
getKey() }}">
+
+
+                                    {{ $customer->name ?: 'Unnamed customer' }}
+
+
+                            {{ $customer->email }}
+                            {{ $customer->orders_count }}
+
+                                <x-storefront.price :amount="(int) ($customer->total_spent ?? 0)" currency="EUR" class="justify-end" />
+
+                            {{ $customer->created_at?->format('M j, Y') }}
+

+                    @empty
+

+
+

+

+                                        <flux:icon name="users" class="size-6" />
+

+                                    <flux:heading size="lg">No customers found</flux:heading>
+                                    <flux:text>Customer accounts will appear here after registration or checkout.</flux:text>
+

+
+

+                    @endforelse
+

+

+

+

+
+    {{ $customers->links() }}
+

*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php

type: add

content:
        <flux:heading size="xl">Customers</flux:heading>
        <flux:text class="mt-1">Search customer accounts and review order value.</flux:text>

        <flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="Search name or email..." aria-label="Search customers" />

                        Name
                        Email
                        Orders
                        Total spent
                        Created

                    @forelse ($customers as $customer)

getKey() }}">

                                    {{ $customer->name ?: 'Unnamed customer' }}

                            {{ $customer->email }}
                            {{ $customer->orders_count }}

                                <x-storefront.price :amount="(int) ($customer->total_spent ?? 0)" currency="EUR" class="justify-end" />

                            {{ $customer->created_at?->format('M j, Y') }}

                    @empty

                                        <flux:icon name="users" class="size-6" />

                                    <flux:heading size="lg">No customers found</flux:heading>
                                    <flux:text>Customer accounts will appear here after registration or checkout.</flux:text>

                    @endforelse

    {{ $customers->links() }}


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/show.blade.php
*** End Patch


stdout:
Success. Updated the following files:
D /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/show.blade.php

content: {{-- Live as if you were to die tomorrow. Learn as if you were to live forever. - Mahatma Gandhi --}}


output: {"output":"Success. Updated the following files:\nD /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/show.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/show.blade.php
+

+

+

+            <flux:heading size="xl">{{ $customer->name ?: 'Unnamed customer' }}</flux:heading>
+            <flux:text class="mt-1">{{ $customer->email }}</flux:text>
+

+
+        <flux:button :href="route('admin.customers.index')" wire:navigate variant="filled" icon="arrow-left">
+            Customers
+        </flux:button>
+

+
+

+

+

+                <flux:heading size="lg">Customer info</flux:heading>
+
+

+

+
Name

+
{{ $customer->name ?: 'Unnamed customer' }}

+

+

+
Email

+
{{ $customer->email }}

+

+

+
Created

+
{{ $customer->created_at?->format('M j, Y') }}

+

+

+
Marketing

+

+                            <flux:badge :color="$customer->marketing_opt_in ? 'green' : 'zinc'">
+                                {{ $customer->marketing_opt_in ? 'Opted in' : 'Not opted in' }}
+                            </flux:badge>
+

+

+

+

+
+

+

+                    <flux:heading size="lg">Order history</flux:heading>
+

+
+

+

+

+

+                                Order
+                                Date
+                                Status
+                                Total
+

+

+

+                            @forelse ($orders as $order)
+                                @php
+                                    $statusColor = match ($order->financial_status->value) {
+                                        'paid' => 'green',
+                                        'partially_refunded' => 'amber',
+                                        'refunded', 'voided' => 'red',
+                                        default => 'zinc',
+                                    };
+                                @endphp
+
+
getKey() }}">
+
+
+                                            {{ $order->order_number }}
+
+
+                                    {{ $order->placed_at?->format('M j, Y') }}
+
+                                        <flux:badge :color="$statusColor">{{ Str::headline($order->financial_status->value) }}</flux:badge>
+
+
+                                        <x-storefront.price :amount="$order->total_amount" :currency="$order->currency" class="justify-end" />
+
+

+                            @empty
+

+                                    No orders for this customer.
+

+                            @endforelse
+

+

+

+

+
+            {{ $orders->links() }}
+

+
+
+

+                <flux:heading size="lg">Addresses</flux:heading>
+                <flux:button wire:click="openAddressForm" variant="primary" icon="plus" size="sm">Add</flux:button>
+

+
+

+                @forelse ($customer->addresses as $address)
+                    @php($addressData = $address->address_json ?? [])
+
+
getKey() }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
+

+

+

+
{{ $address->label ?: 'Address' }}

+                                    @if ($address->is_default)
+                                        <flux:badge color="green">Default</flux:badge>
+                                    @endif
+

+

+
{{ data_get($addressData, 'address1') }}

+                                    @if (data_get($addressData, 'address2'))
+
{{ data_get($addressData, 'address2') }}

+                                    @endif
+
{{ trim(data_get($addressData, 'postal_code').' '.data_get($addressData, 'city')) }}

+
{{ data_get($addressData, 'country') }}

+

+

+

+
+

+                            <flux:button wire:click="openAddressForm({{ $address->getKey() }})" size="sm" variant="filled">Edit</flux:button>
+                            @unless ($address->is_default)
+                                <flux:button wire:click="setDefaultAddress({{ $address->getKey() }})" size="sm" variant="ghost">Set default</flux:button>
+                            @endunless
+                            <flux:button wire:click="deleteAddress({{ $address->getKey() }})" wire:confirm="Delete this address?" size="sm" variant="danger">Delete</flux:button>
+

+

+                @empty
+

+                        <flux:icon name="map-pin" class="mx-auto size-8 text-zinc-400" />
+                        <flux:text class="mt-3">No addresses saved.</flux:text>
+

+                @endforelse
+

+
+

+
+    <flux:modal name="address-form" class="md:w-[32rem]">
+        <form wire:submit="saveAddress" class="space-y-6">
+

+                <flux:heading size="lg">{{ $editingAddressId ? 'Edit address' : 'Add address' }}</flux:heading>
+

+
+            <flux:input wire:model="addressLabel" label="Label" placeholder="Home, Office..." />
+
+

+                <flux:input wire:model="addressJson.first_name" label="First name" />
+                <flux:input wire:model="addressJson.last_name" label="Last name" />
+

+                    <flux:input wire:model="addressJson.address1" label="Address line 1" />
+                    <flux:error name="addressJson.address1" />
+

+

+                    <flux:input wire:model="addressJson.address2" label="Address line 2" />
+

+                <flux:input wire:model="addressJson.city" label="City" />
+                <flux:input wire:model="addressJson.province_code" label="State / Province" />
+                <flux:input wire:model="addressJson.postal_code" label="Postal code" />
+                <flux:select wire:model="addressJson.country" label="Country">
+                    <flux:select.option value="DE">Germany</flux:select.option>
+                    <flux:select.option value="US">United States</flux:select.option>
+                    <flux:select.option value="GB">United Kingdom</flux:select.option>
+                    <flux:select.option value="FR">France</flux:select.option>
+                    <flux:select.option value="NL">Netherlands</flux:select.option>
+                </flux:select>
+

+            <flux:error name="addressJson.city" />
+            <flux:error name="addressJson.postal_code" />
+            <flux:error name="addressJson.country" />
+
+

+                <flux:modal.close>
+                    <flux:button variant="filled">Cancel</flux:button>
+                </flux:modal.close>
+                <flux:button type="submit" variant="primary">Save</flux:button>
+

+        </form>
+    </flux:modal>
+

*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/show.blade.php

content:
            <flux:heading size="xl">{{ $customer->name ?: 'Unnamed customer' }}</flux:heading>
            <flux:text class="mt-1">{{ $customer->email }}</flux:text>

        <flux:button :href="route('admin.customers.index')" wire:navigate variant="filled" icon="arrow-left">
            Customers
        </flux:button>

                <flux:heading size="lg">Customer info</flux:heading>

Name

{{ $customer->name ?: 'Unnamed customer' }}

Email

{{ $customer->email }}

Created

{{ $customer->created_at?->format('M j, Y') }}

Marketing

                            <flux:badge :color="$customer->marketing_opt_in ? 'green' : 'zinc'">
                                {{ $customer->marketing_opt_in ? 'Opted in' : 'Not opted in' }}
                            </flux:badge>

                    <flux:heading size="lg">Order history</flux:heading>

                                Order
                                Date
                                Status
                                Total

                            @forelse ($orders as $order)
                                @php
                                    $statusColor = match ($order->financial_status->value) {
                                        'paid' => 'green',
                                        'partially_refunded' => 'amber',
                                        'refunded', 'voided' => 'red',
                                        default => 'zinc',
                                    };
                                @endphp

getKey() }}">

                                            {{ $order->order_number }}

                                    {{ $order->placed_at?->format('M j, Y') }}

                                        <flux:badge :color="$statusColor">{{ Str::headline($order->financial_status->value) }}</flux:badge>

                                        <x-storefront.price :amount="$order->total_amount" :currency="$order->currency" class="justify-end" />

                            @empty

                                    No orders for this customer.

                            @endforelse

            {{ $orders->links() }}

                <flux:heading size="lg">Addresses</flux:heading>
                <flux:button wire:click="openAddressForm" variant="primary" icon="plus" size="sm">Add</flux:button>

                @forelse ($customer->addresses as $address)
                    @php($addressData = $address->address_json ?? [])

getKey() }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">

{{ $address->label ?: 'Address' }}

                                    @if ($address->is_default)
                                        <flux:badge color="green">Default</flux:badge>
                                    @endif

{{ data_get($addressData, 'address1') }}

                                    @if (data_get($addressData, 'address2'))

{{ data_get($addressData, 'address2') }}

                                    @endif

{{ trim(data_get($addressData, 'postal_code').' '.data_get($addressData, 'city')) }}

{{ data_get($addressData, 'country') }}

                            <flux:button wire:click="openAddressForm({{ $address->getKey() }})" size="sm" variant="filled">Edit</flux:button>
                            @unless ($address->is_default)
                                <flux:button wire:click="setDefaultAddress({{ $address->getKey() }})" size="sm" variant="ghost">Set default</flux:button>
                            @endunless
                            <flux:button wire:click="deleteAddress({{ $address->getKey() }})" wire:confirm="Delete this address?" size="sm" variant="danger">Delete</flux:button>

                @empty

                        <flux:icon name="map-pin" class="mx-auto size-8 text-zinc-400" />
                        <flux:text class="mt-3">No addresses saved.</flux:text>

                @endforelse

    <flux:modal name="address-form" class="md:w-[32rem]">
        <form wire:submit="saveAddress" class="space-y-6">

                <flux:heading size="lg">{{ $editingAddressId ? 'Edit address' : 'Add address' }}</flux:heading>

            <flux:input wire:model="addressLabel" label="Label" placeholder="Home, Office..." />

                <flux:input wire:model="addressJson.first_name" label="First name" />
                <flux:input wire:model="addressJson.last_name" label="Last name" />

                    <flux:input wire:model="addressJson.address1" label="Address line 1" />
                    <flux:error name="addressJson.address1" />

                    <flux:input wire:model="addressJson.address2" label="Address line 2" />

                <flux:input wire:model="addressJson.city" label="City" />
                <flux:input wire:model="addressJson.province_code" label="State / Province" />
                <flux:input wire:model="addressJson.postal_code" label="Postal code" />
                <flux:select wire:model="addressJson.country" label="Country">
                    <flux:select.option value="DE">Germany</flux:select.option>
                    <flux:select.option value="US">United States</flux:select.option>
                    <flux:select.option value="GB">United Kingdom</flux:select.option>
                    <flux:select.option value="FR">France</flux:select.option>
                    <flux:select.option value="NL">Netherlands</flux:select.option>
                </flux:select>

            <flux:error name="addressJson.city" />
            <flux:error name="addressJson.postal_code" />
            <flux:error name="addressJson.country" />

                <flux:modal.close>
                    <flux:button variant="filled">Cancel</flux:button>
                </flux:modal.close>
                <flux:button type="submit" variant="primary">Save</flux:button>

        </form>
    </flux:modal>


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/show.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Index.php
@@
     #[Locked]
     public int $storeId;

+    public string $storeCurrency = 'EUR';
+
     public string $search = '';
@@

         $this->storeId = $store->getKey();
+        $this->storeCurrency = $store->default_currency;
     }
*** End Patch


unified_diff:
@@ -19,2 +19,4 @@

+    public string $storeCurrency = 'EUR';
+
     public string $search = '';
@@ -28,2 +30,3 @@
         $this->storeId = $store->getKey();
+        $this->storeCurrency = $store->default_currency;
     }


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Livewire/Admin/Customers/Index.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php
@@
                             {{ $customer->email }}
                             {{ $customer->orders_count }}

-                                <x-storefront.price :amount="(int) ($customer->total_spent ?? 0)" currency="EUR" class="justify-end" />
+                                <x-storefront.price :amount="(int) ($customer->total_spent ?? 0)" :currency="$storeCurrency" class="justify-end" />

*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php

unified_diff:
@@ -33,3 +33,3 @@
                             <td class="px-4 py-3 text-right">
-                                <x-storefront.price :amount="(int) ($customer->total_spent ?? 0)" currency="EUR" class="justify-end" />
+                                <x-storefront.price :amount="(int) ($customer->total_spent ?? 0)" :currency="$storeCurrency" class="justify-end" />
                             </td>


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/customers/index.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/routes/web.php
@@
 use App\Livewire\Admin\Auth\Login as AdminLogin;
 use App\Livewire\Admin\Collections\Form as AdminCollectionForm;
 use App\Livewire\Admin\Collections\Index as AdminCollectionsIndex;
+use App\Livewire\Admin\Customers\Index as AdminCustomersIndex;
+use App\Livewire\Admin\Customers\Show as AdminCustomerShow;
 use App\Livewire\Admin\Dashboard as AdminDashboard;
@@
     Route::livewire('inventory', AdminInventoryIndex::class)->name('inventory.index');
     Route::livewire('orders', AdminOrdersIndex::class)->name('orders.index');
     Route::livewire('orders/{order}', AdminOrderShow::class)->name('orders.show');
+    Route::livewire('customers', AdminCustomersIndex::class)->name('customers.index');
+    Route::livewire('customers/{customer}', AdminCustomerShow::class)->name('customers.show');
     Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/routes/web.php

unified_diff:
@@ -5,2 +5,4 @@
 use App\Livewire\Admin\Collections\Index as AdminCollectionsIndex;
+use App\Livewire\Admin\Customers\Index as AdminCustomersIndex;
+use App\Livewire\Admin\Customers\Show as AdminCustomerShow;
 use App\Livewire\Admin\Dashboard as AdminDashboard;
@@ -60,2 +62,4 @@
     Route::livewire('orders/{order}', AdminOrderShow::class)->name('orders.show');
+    Route::livewire('customers', AdminCustomersIndex::class)->name('customers.index');
+    Route::livewire('customers/{customer}', AdminCustomerShow::class)->name('customers.show');
     Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index');


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/routes/web.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/resources/views/layouts/app/sidebar.blade.php
@@
                 <flux:sidebar.group :heading="__('Operations')" class="grid">
                     <flux:sidebar.item icon="shopping-bag" :href="route('admin.orders.index')" :current="request()->routeIs('admin.orders.*')" wire:navigate>
                         {{ __('Orders') }}
                     </flux:sidebar.item>
+
+                    <flux:sidebar.item icon="users" :href="route('admin.customers.index')" :current="request()->routeIs('admin.customers.*')" wire:navigate>
+                        {{ __('Customers') }}
+                    </flux:sidebar.item>
                 </flux:sidebar.group>
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/resources/views/layouts/app/sidebar.blade.php

unified_diff:
@@ -37,2 +37,6 @@
                     </flux:sidebar.item>
+
+                    <flux:sidebar.item icon="users" :href="route('admin.customers.index')" :current="request()->routeIs('admin.customers.*')" wire:navigate>
+                        {{ __('Customers') }}
+                    </flux:sidebar.item>
                 </flux:sidebar.group>


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/resources/views/layouts/app/sidebar.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/orders/show.blade.php
@@

                 <flux:heading size="lg">Customer</flux:heading>

{{ $order->customer?->name ?: 'Guest checkout' }}

{{ $order->email }}

+                @if ($order->customer)
+                    <flux:button :href="route('admin.customers.show', $order->customer)" wire:navigate variant="filled" size="sm" class="mt-4">
+                        View customer
+                    </flux:button>
+                @endif

*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/orders/show.blade.php

unified_diff:
@@ -166,2 +166,7 @@
                 </div>
+                @if ($order->customer)
+                    <flux:button :href="route('admin.customers.show', $order->customer)" wire:navigate variant="filled" size="sm" class="mt-4">
+                        View customer
+                    </flux:button>
+                @endif
             </div>


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/resources/views/livewire/admin/orders/show.blade.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The customer UI files and routes are wired. I’m adding tests for store-scoped listing/detail, search, address modal actions, and cross-store rejection.


text: The customer UI files and routes are wired. I’m adding tests for store-scoped listing/detail, search, address modal actions, and cross-store rejection.


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Admin/CustomerManagementTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Livewire\Admin\Customers\Index as AdminCustomersIndex;
+use App\Livewire\Admin\Customers\Show as AdminCustomerShow;
+use App\Models\Customer;
+use App\Models\CustomerAddress;
+use App\Models\Order;
+use App\Models\Store;
+use App\Models\User;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Livewire\Livewire;

-    $response->assertStatus(200);
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminCustomerManagementStore(): Store
+{
+    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+    app()->instance('current_store', $store);
+
+    return $store;
+}
+
+function adminCustomerManagementUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+function adminCustomerManagementCustomer(Store $store, string $email, string $name): Customer
+{
+    return Customer::factory()->create([
+        'store_id' => $store->getKey(),
+        'email' => $email,
+        'name' => $name,
+    ]);
+}
+
+function adminCustomerManagementOrder(Store $store, Customer $customer, string $orderNumber, int $totalAmount): Order
+{
+    return Order::factory()->paid()->create([
+        'store_id' => $store->getKey(),
+        'customer_id' => $customer->getKey(),
+        'order_number' => $orderNumber,
+        'subtotal_amount' => $totalAmount,
+        'shipping_amount' => 0,
+        'tax_amount' => 0,
+        'total_amount' => $totalAmount,
+        'email' => $customer->email,
+    ]);
+}
+
+test('admin customer routes require authentication and render store scoped customers', function (): void {
+    $store = adminCustomerManagementStore();
+    $customer = adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example');
+    adminCustomerManagementOrder($store, $customer, '#8101', 7000);
+    CustomerAddress::factory()->default()->create([
+        'customer_id' => $customer->getKey(),
+        'label' => 'Home',
+    ]);
+
+    $otherStore = Store::factory()->create();
+    $otherCustomer = adminCustomerManagementCustomer($otherStore, 'other@example.test', 'Other Customer');
+
+    $this->get('/admin/customers')->assertRedirect('/admin/login');
+
+    $this->actingAs(adminCustomerManagementUser())
+        ->withSession(['current_store_id' => $store->getKey()])
+        ->get('/admin/customers')
+        ->assertSuccessful()
+        ->assertSee('Jane Example')
+        ->assertSee('70.00 EUR')
+        ->assertDontSee('Other Customer');
+
+    $this->actingAs(adminCustomerManagementUser())
+        ->withSession(['current_store_id' => $store->getKey()])
+        ->get('/admin/customers/'.$customer->getKey())
+        ->assertSuccessful()
+        ->assertSee('Jane Example')
+        ->assertSee('#8101')
+        ->assertSee('Home');
+
+    $this->actingAs(adminCustomerManagementUser())
+        ->withSession(['current_store_id' => $store->getKey()])
+        ->get('/admin/customers/'.$otherCustomer->getKey())
+        ->assertNotFound();
+});
+
+test('admin customer index filters by name and email', function (): void {
+    $store = adminCustomerManagementStore();
+    $user = adminCustomerManagementUser();
+    adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example');
+    adminCustomerManagementCustomer($store, 'bravo@example.test', 'Bravo Example');
+
+    Livewire::actingAs($user)
+        ->test(AdminCustomersIndex::class)
+        ->assertSee('Jane Example')
+        ->assertSee('Bravo Example')
+        ->set('search', 'jane')
+        ->assertSee('Jane Example')
+        ->assertDontSee('Bravo Example')
+        ->set('search', 'bravo@example.test')
+        ->assertSee('Bravo Example')
+        ->assertDontSee('Jane Example');
+});
+
+test('admin customer detail manages addresses', function (): void {
+    $store = adminCustomerManagementStore();
+    $user = adminCustomerManagementUser();
+    $customer = adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example');
+    $home = CustomerAddress::factory()->default()->create([
+        'customer_id' => $customer->getKey(),
+        'label' => 'Home',
+    ]);
+
+    Livewire::actingAs($user)
+        ->test(AdminCustomerShow::class, ['customer' => $customer])
+        ->call('openAddressForm')
+        ->set('addressLabel', 'Office')
+        ->set('addressJson.address1', 'Business Street 5')
+        ->set('addressJson.city', 'Berlin')
+        ->set('addressJson.postal_code', '10115')
+        ->set('addressJson.country', 'DE')
+        ->call('saveAddress')
+        ->assertHasNoErrors();
+
+    $office = CustomerAddress::query()
+        ->where('customer_id', $customer->getKey())
+        ->where('label', 'Office')
+        ->firstOrFail();
+
+    expect($office->is_default)->toBeFalse();
+
+    Livewire::actingAs($user)
+        ->test(AdminCustomerShow::class, ['customer' => $customer])
+        ->call('setDefaultAddress', $office->getKey())
+        ->assertHasNoErrors()
+        ->call('openAddressForm', $office->getKey())
+        ->set('addressLabel', 'HQ')
+        ->call('saveAddress')
+        ->assertHasNoErrors()
+        ->call('deleteAddress', $home->getKey())
+        ->assertHasNoErrors();
+
+    expect($office->refresh()->is_default)->toBeTrue()
+        ->and($office->label)->toBe('HQ')
+        ->and(CustomerAddress::query()->whereKey($home->getKey())->exists())->toBeFalse();
+});
+
+test('admin customer detail rejects customers from another store', function (): void {
+    $store = adminCustomerManagementStore();
+    $otherStore = Store::factory()->create();
+    $otherCustomer = adminCustomerManagementCustomer($otherStore, 'other@example.test', 'Other Customer');
+
+    app()->instance('current_store', $store);
+
+    Livewire::actingAs(adminCustomerManagementUser())
+        ->test(AdminCustomerShow::class, ['customer' => $otherCustomer])
+        ->assertStatus(404);
 });
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/tests/Feature/Admin/CustomerManagementTest.php

unified_diff:
@@ -2,6 +2,163 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Livewire\Admin\Customers\Index as AdminCustomersIndex;
+use App\Livewire\Admin\Customers\Show as AdminCustomerShow;
+use App\Models\Customer;
+use App\Models\CustomerAddress;
+use App\Models\Order;
+use App\Models\Store;
+use App\Models\User;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Livewire\Livewire;
+
+uses(RefreshDatabase::class);
+
+beforeEach(function (): void {
+    $this->withoutVite();
+    $this->seed(DatabaseSeeder::class);
+});
+
+function adminCustomerManagementStore(): Store
+{
+    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+    app()->instance('current_store', $store);
+
+    return $store;
+}
+
+function adminCustomerManagementUser(): User
+{
+    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
+}
+
+function adminCustomerManagementCustomer(Store $store, string $email, string $name): Customer
+{
+    return Customer::factory()->create([
+        'store_id' => $store->getKey(),
+        'email' => $email,
+        'name' => $name,
+    ]);
+}
+
+function adminCustomerManagementOrder(Store $store, Customer $customer, string $orderNumber, int $totalAmount): Order
+{
+    return Order::factory()->paid()->create([
+        'store_id' => $store->getKey(),
+        'customer_id' => $customer->getKey(),
+        'order_number' => $orderNumber,
+        'subtotal_amount' => $totalAmount,
+        'shipping_amount' => 0,
+        'tax_amount' => 0,
+        'total_amount' => $totalAmount,
+        'email' => $customer->email,
+    ]);
+}

-    $response->assertStatus(200);
+test('admin customer routes require authentication and render store scoped customers', function (): void {
+    $store = adminCustomerManagementStore();
+    $customer = adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example');
+    adminCustomerManagementOrder($store, $customer, '#8101', 7000);
+    CustomerAddress::factory()->default()->create([
+        'customer_id' => $customer->getKey(),
+        'label' => 'Home',
+    ]);
+
+    $otherStore = Store::factory()->create();
+    $otherCustomer = adminCustomerManagementCustomer($otherStore, 'other@example.test', 'Other Customer');
+
+    $this->get('/admin/customers')->assertRedirect('/admin/login');
+
+    $this->actingAs(adminCustomerManagementUser())
+        ->withSession(['current_store_id' => $store->getKey()])
+        ->get('/admin/customers')
+        ->assertSuccessful()
+        ->assertSee('Jane Example')
+        ->assertSee('70.00 EUR')
+        ->assertDontSee('Other Customer');
+
+    $this->actingAs(adminCustomerManagementUser())
+        ->withSession(['current_store_id' => $store->getKey()])
+        ->get('/admin/customers/'.$customer->getKey())
+        ->assertSuccessful()
+        ->assertSee('Jane Example')
+        ->assertSee('#8101')
+        ->assertSee('Home');
+
+    $this->actingAs(adminCustomerManagementUser())
+        ->withSession(['current_store_id' => $store->getKey()])
+        ->get('/admin/customers/'.$otherCustomer->getKey())
+        ->assertNotFound();
+});
+
+test('admin customer index filters by name and email', function (): void {
+    $store = adminCustomerManagementStore();
+    $user = adminCustomerManagementUser();
+    adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example');
+    adminCustomerManagementCustomer($store, 'bravo@example.test', 'Bravo Example');
+
+    Livewire::actingAs($user)
+        ->test(AdminCustomersIndex::class)
+        ->assertSee('Jane Example')
+        ->assertSee('Bravo Example')
+        ->set('search', 'jane')
+        ->assertSee('Jane Example')
+        ->assertDontSee('Bravo Example')
+        ->set('search', 'bravo@example.test')
+        ->assertSee('Bravo Example')
+        ->assertDontSee('Jane Example');
+});
+
+test('admin customer detail manages addresses', function (): void {
+    $store = adminCustomerManagementStore();
+    $user = adminCustomerManagementUser();
+    $customer = adminCustomerManagementCustomer($store, 'jane@example.test', 'Jane Example');
+    $home = CustomerAddress::factory()->default()->create([
+        'customer_id' => $customer->getKey(),
+        'label' => 'Home',
+    ]);
+
+    Livewire::actingAs($user)
+        ->test(AdminCustomerShow::class, ['customer' => $customer])
+        ->call('openAddressForm')
+        ->set('addressLabel', 'Office')
+        ->set('addressJson.address1', 'Business Street 5')
+        ->set('addressJson.city', 'Berlin')
+        ->set('addressJson.postal_code', '10115')
+        ->set('addressJson.country', 'DE')
+        ->call('saveAddress')
+        ->assertHasNoErrors();
+
+    $office = CustomerAddress::query()
+        ->where('customer_id', $customer->getKey())
+        ->where('label', 'Office')
+        ->firstOrFail();
+
+    expect($office->is_default)->toBeFalse();
+
+    Livewire::actingAs($user)
+        ->test(AdminCustomerShow::class, ['customer' => $customer])
+        ->call('setDefaultAddress', $office->getKey())
+        ->assertHasNoErrors()
+        ->call('openAddressForm', $office->getKey())
+        ->set('addressLabel', 'HQ')
+        ->call('saveAddress')
+        ->assertHasNoErrors()
+        ->call('deleteAddress', $home->getKey())
+        ->assertHasNoErrors();
+
+    expect($office->refresh()->is_default)->toBeTrue()
+        ->and($office->label)->toBe('HQ')
+        ->and(CustomerAddress::query()->whereKey($home->getKey())->exists())->toBeFalse();
+});
+
+test('admin customer detail rejects customers from another store', function (): void {
+    $store = adminCustomerManagementStore();
+    $otherStore = Store::factory()->create();
+    $otherCustomer = adminCustomerManagementCustomer($otherStore, 'other@example.test', 'Other Customer');
+
+    app()->instance('current_store', $store);
+
+    Livewire::actingAs(adminCustomerManagementUser())
+        ->test(AdminCustomerShow::class, ['customer' => $otherCustomer])
+        ->assertStatus(404);
 });


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/tests/Feature/Admin/CustomerManagementTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The customer tests are in place. I’m formatting and running the focused customer management test first, then I’ll broaden to admin coverage if it’s clean.


text: The customer tests are in place. I’m formatting and running the focused customer management test first, then I’ll broaden to admin coverage if it’s clean.


output:
Chunk ID: e45233
Wall time: 0.3267 seconds
Process exited with code 0
Original token count: 5
Output:
{"result":"pass"}


command:
/bin/zsh
-lc
vendor/bin/pint --dirty --format agent

cmd: vendor/bin/pint --dirty --format agent

result: pass


cmd: php artisan test --compact tests/Feature/Admin/CustomerManagementTest.php


output:
Chunk ID: 82130a
Wall time: 1.0013 seconds
Process running with session ID 77394
Original token count: 0
Output:


command: php artisan test --compact tests/Feature/Admin/CustomerManagementTest.php

aggregated_output:
  ....

  Tests:    4 passed (26 assertions)
  Duration: 1.63s


output:
Chunk ID: d6f7d2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 17
Output:

  ....

  Tests:    4 passed (26 assertions)
  Duration: 1.63s


message: The focused customer suite passes. I’m running the affected admin suite, checking the new customer routes, and rebuilding frontend assets because the sidebar and Blade views changed.


text: The focused customer suite passes. I’m running the affected admin suite, checking the new customer routes, and rebuilding frontend assets because the sidebar and Blade views changed.


cmd: php artisan test --compact tests/Feature/Admin


cmd: php artisan route:list --path=admin/customers


command: php artisan route:list --path=admin/customers

aggregated_output:
  GET|HEAD       admin/customers admin.customers.index › Livewire\Mechanisms …
  GET|HEAD       admin/customers/{customer} admin.customers.show › Livewire\M…

                                                            Showing [2] routes


command:
/bin/zsh
-lc
npm run build

cmd: npm run build

aggregated_output:
> build
> vite build

vite v7.3.1 building client environment for production...
transforming...
✓ 2 modules transformed.
Generated an empty chunk: "app".
rendering chunks...
computing gzip size...
public/build/manifest.json              0.33 kB │ gzip:  0.17 kB
public/build/assets/app-B7dSQqFy.css  225.94 kB │ gzip: 31.01 kB
public/build/assets/app-l0sNRNKZ.js     0.00 kB │ gzip:  0.02 kB
✓ built in 396ms


output:
Chunk ID: ddd5b5
Wall time: 1.0004 seconds
Process running with session ID 90602
Original token count: 0
Output:


output:
Chunk ID: 107aeb
Wall time: 0.1116 seconds
Process exited with code 0
Original token count: 62
Output:

  GET|HEAD       admin/customers admin.customers.index › Livewire\Mechanisms …
  GET|HEAD       admin/customers/{customer} admin.customers.show › Livewire\M…

                                                            Showing [2] routes


output:
Chunk ID: e83dce
Wall time: 0.7438 seconds
Process exited with code 0
Original token count: 105
Output:

> build
> vite build

vite v7.3.1 building client environment for production...
transforming...
✓ 2 modules transformed.
Generated an empty chunk: "app".
rendering chunks...
computing gzip size...
public/build/manifest.json              0.33 kB │ gzip:  0.17 kB
public/build/assets/app-B7dSQqFy.css  225.94 kB │ gzip: 31.01 kB
public/build/assets/app-l0sNRNKZ.js     0.00 kB │ gzip:  0.02 kB
✓ built in 396ms


command: php artisan test --compact tests/Feature/Admin

aggregated_output:
  ...........

  Tests:    11 passed (69 assertions)
  Duration: 3.80s


output:
Chunk ID: 564e96
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 19
Output:

  ...........

  Tests:    11 passed (69 assertions)
  Duration: 3.80s


cmd: sed -n '1,280p' app/Livewire/Admin/Customers/Show.php


cmd: sed -n '1,220p' app/Livewire/Admin/Customers/Index.php


command: sed -n '1,280p' app/Livewire/Admin/Customers/Show.php

aggregated_output:
<?php

namespace App\Livewire\Admin\Customers;

use App\Models\Customer;
use App\Models\CustomerAddress;
use App\Models\Order;
use App\Models\Store;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;

class Show extends Component
{
    use WithPagination;

    #[Locked]
    public int $storeId;

    #[Locked]
    public int $customerId;

    public ?int $editingAddressId = null;

    public string $addressLabel = '';

    /**
     * @var array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string}
     */
    public array $addressJson = [
        'first_name' => '',
        'last_name' => '',
        'address1' => '',
        'address2' => '',
        'city' => '',
        'province_code' => '',
        'country' => 'DE',
        'postal_code' => '',
    ];

    public function mount(Customer $customer): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $customer = Customer::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->whereKey($customer->getKey())
            ->first();

        abort_unless($customer instanceof Customer, 404);

        $this->storeId = $store->getKey();
        $this->customerId = $customer->getKey();
    }

    public function openAddressForm(?int $addressId = null): void
    {
        $this->editingAddressId = $addressId;

        if ($addressId) {
            $address = $this->address($addressId);
            $this->addressLabel = (string) $address->label;
            $this->addressJson = array_merge($this->emptyAddressJson(), $address->address_json ?? []);
        } else {
            $this->resetAddressForm();
        }

        $this->modal('address-form')->show();
    }

    public function saveAddress(): void
    {
        $this->validate([
            'addressLabel' => ['nullable', 'string', 'max:255'],
            'addressJson.first_name' => ['nullable', 'string', 'max:255'],
            'addressJson.last_name' => ['nullable', 'string', 'max:255'],
            'addressJson.address1' => ['required', 'string', 'max:255'],
            'addressJson.address2' => ['nullable', 'string', 'max:255'],
            'addressJson.city' => ['required', 'string', 'max:255'],
            'addressJson.province_code' => ['nullable', 'string', 'max:255'],
            'addressJson.country' => ['required', 'string', 'size:2'],
            'addressJson.postal_code' => ['required', 'string', 'max:32'],
        ]);

        $address = $this->editingAddressId
            ? $this->address($this->editingAddressId)
            : new CustomerAddress(['customer_id' => $this->customerId]);

        $address->fill([
            'label' => $this->addressLabel !== '' ? $this->addressLabel : null,
            'address_json' => $this->addressJson,
            'is_default' => $address->exists ? $address->is_default : ! $this->customer()->addresses()->exists(),
        ]);
        $address->save();

        $this->resetAddressForm();
        $this->modal('address-form')->close();
        $this->dispatch('toast', type: 'success', message: __('Address saved'));
    }

    public function deleteAddress(int $addressId): void
    {
        $address = $this->address($addressId);
        $wasDefault = $address->is_default;
        $address->delete();

        if ($wasDefault) {
            $this->setFirstAddressAsDefault();
        }

        $this->dispatch('toast', type: 'success', message: __('Address deleted'));
    }

    public function setDefaultAddress(int $addressId): void
    {
        $address = $this->address($addressId);

        DB::transaction(function () use ($address): void {
            CustomerAddress::query()
                ->where('customer_id', $this->customerId)
                ->update(['is_default' => false]);

            $address->forceFill(['is_default' => true])->save();
        });

        $this->dispatch('toast', type: 'success', message: __('Default address updated'));
    }

    public function orders(): LengthAwarePaginator
    {
        return Order::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->where('customer_id', $this->customerId)
            ->latest('placed_at')
            ->latest('id')
            ->paginate(10);
    }

    public function render(): mixed
    {
        $customer = $this->customer();

        return view('livewire.admin.customers.show', [
            'customer' => $customer,
            'orders' => $this->orders(),
        ])->layout('layouts.app', [
            'title' => $customer->name ?: $customer->email,
        ]);
    }

    private function customer(): Customer
    {
        return Customer::withoutGlobalScopes()
            ->with(['addresses' => fn ($query) => $query->orderByDesc('is_default')->orderBy('id')])
            ->where('store_id', $this->storeId)
            ->whereKey($this->customerId)
            ->firstOrFail();
    }

    private function address(int $addressId): CustomerAddress
    {
        return CustomerAddress::query()
            ->where('customer_id', $this->customerId)
            ->whereKey($addressId)
            ->firstOrFail();
    }

    private function setFirstAddressAsDefault(): void
    {
        $nextAddress = CustomerAddress::query()
            ->where('customer_id', $this->customerId)
            ->oldest('id')
            ->first();

        if ($nextAddress instanceof CustomerAddress) {
            $nextAddress->forceFill(['is_default' => true])->save();
        }
    }

    private function resetAddressForm(): void
    {
        $this->editingAddressId = null;
        $this->addressLabel = '';
        $this->addressJson = $this->emptyAddressJson();
    }

    /**
     * @return array{first_name: string, last_name: string, address1: string, address2: string, city: string, province_code: string, country: string, postal_code: string}
     */
    private function emptyAddressJson(): array
    {
        return [
            'first_name' => '',
            'last_name' => '',
            'address1' => '',
            'address2' => '',
            'city' => '',
            'province_code' => '',
            'country' => 'DE',
            'postal_code' => '',
        ];
    }
}


command: sed -n '1,220p' app/Livewire/Admin/Customers/Index.php

aggregated_output:
<?php

namespace App\Livewire\Admin\Customers;

use App\Models\Customer;
use App\Models\Store;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    #[Locked]
    public int $storeId;

    public string $storeCurrency = 'EUR';

    public string $search = '';

    public function mount(): void
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        $this->storeId = $store->getKey();
        $this->storeCurrency = $store->default_currency;
    }

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function customers(): LengthAwarePaginator
    {
        return Customer::withoutGlobalScopes()
            ->withCount('orders')
            ->withSum('orders as total_spent', 'total_amount')
            ->where('store_id', $this->storeId)
            ->when($this->search !== '', function (Builder $query): void {
                $search = '%'.$this->search.'%';

                $query->where(function (Builder $query) use ($search): void {
                    $query
                        ->where('name', 'like', $search)
                        ->orWhere('email', 'like', $search);
                });
            })
            ->latest('created_at')
            ->paginate(20);
    }

    public function render(): mixed
    {
        return view('livewire.admin.customers.index', [
            'customers' => $this->customers(),
        ])->layout('layouts.app', [
            'title' => __('Customers'),
        ]);
    }
}


command:
/bin/zsh
-lc
git diff --stat

cmd: git diff --stat

aggregated_output:
 resources/views/layouts/app/sidebar.blade.php        | 4 ++++
 resources/views/livewire/admin/orders/show.blade.php | 5 +++++
 routes/web.php                                       | 4 ++++
 3 files changed, 13 insertions(+)