server: laravel-boost

tool: search-docs

type: text

text:
# Search Results

## Http Tests Testing Views Rendering Blade and Components
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

### Rendering Blade and Components

If necessary, you may use the `blade` method to evaluate and render a raw [Blade](/docs/{{version}}/blade) string. Like the `view` method, the `blade` method returns an instance of `Illuminate\Testing\TestView`:

```php
$view = $this->blade(
    '<x-component :name="$name" />',
    ['name' => 'Taylor']
);

$view->assertSee('Taylor');
```

You may use the `component` method to evaluate and render a [Blade component](/docs/{{version}}/blade#components). The `component` method returns an instance of `Illuminate\Testing\TestComponent`:

```php
$view = $this->component(Profile::class, ['name' => 'Taylor']);

$view->assertSee('Taylor');
```

---

## Navigate Using with analytics software
Navigate | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/navigate

## Using with analytics software

When navigating pages using `wire:navigate` in your app, any `<script>` tags in the `<head>` only evaluate when the page is initially loaded.

This creates a problem for analytics software such as [Fathom Analytics](https://usefathom.com/). These tools rely on a `<script>` snippet being evaluated on every single page change, not just the first.

Tools like [Google Analytics](https://marketingplatform.google.com/about/analytics/) are smart enough to handle this automatically, however, when using Fathom Analytics, you must add `data-spa="auto"` to your script tag to ensure each page visit is tracked properly:

```blade
<head>
    <!-- ... -->

    <!-- Fathom Analytics -->
    @if (! config('app.debug'))
        <script src="https://cdn.usefathom.com/script.js" data-site="ABCDEFG" data-spa="auto" defer></script> <!-- [tl! highlight] -->
    @endif
</head>
```

---

## Wire Transition Browser support
Wire Transition | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-transition

## Browser support

View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. In browsers that don't support View Transitions, elements will appear and disappear without animation—the functionality still works, just without the visual transition.

> [!warning] Firefox has limited support
> Firefox 144+ supports basic view transitions, but does not support transition types.

[View browser support on caniuse.com →](https://caniuse.com/view-transitions)

---

## Attribute Session Example: Dashboard filters
Attribute Session | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-session

## Example: Dashboard filters

Here's a practical example of persisting dashboard filters:

```php
<?php // resources/views/pages/⚡dashboard.blade.php

use Livewire\Attributes\Session;
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Transaction;

new class extends Component {
    #[Session]
    public $dateRange = '30days';

    #[Session]
    public $category = 'all';

    #[Session]
    public $sortBy = 'date';

    #[Computed]
    public function transactions()
    {
        return Transaction::query()
            ->when($this->dateRange === '30days', fn($q) => $q->where('created_at', '>=', now()->subDays(30)))
            ->when($this->category !== 'all', fn($q) => $q->where('category', $this->category))
            ->orderBy($this->sortBy)
            ->get();
    }
};
?>

<div>
    <select wire:model.live="dateRange">
        <option value="7days">Last 7 days</option>
        <option value="30days">Last 30 days</option>
        <option value="year">This year</option>
    </select>

    <select wire:model.live="category">
        <option value="all">All categories</option>
        <option value="income">Income</option>
        <option value="expense">Expense</option>
    </select>

    <select wire:model.live="sortBy">
        <option value="date">Date</option>
        <option value="amount">Amount</option>
    </select>

    @foreach($this->transactions as $transaction)
        <div wire:key="{{ $transaction->id }}">{{ $transaction->description }}</div>
    @endforeach
</div>
```

Users can set their preferred filters and they'll persist across sessions, page refreshes, and navigation.

---

## Testing Browser testing
Testing | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing

## Browser testing

Pest v4 includes first-party browser testing support powered by Playwright. This allows you to test your Livewire components in a real browser, interacting with them just like a user would.

### Installing browser testing

First, install the Pest browser plugin:

```shell
composer require pestphp/pest-plugin-browser --dev
```

Next, install Playwright via npm:

```shell
npm install playwright@latest
npx playwright install
```

For complete browser testing documentation, see the [Pest browser testing guide](https://pestphp.com/docs/browser-testing).

### Writing browser tests

Instead of using `Livewire::test()`, you can use `Livewire::visit()` to test your component in a real browser:

```php
it('can create a new post', function () {
    Livewire::visit('post.create')
        ->type('[wire\:model="title"]', 'My first post')
        ->type('[wire\:model="content"]', 'This is the content')
        ->press('Save')
        ->assertSee('Post created successfully');
});
```

Browser tests are slower than unit tests but provide end-to-end confidence that your components work as expected in a real browser environment.

For a complete list of available browser testing assertions, see the [Pest browser testing assertions](https://pestphp.com/docs/browser-testing#content-available-assertions).

> [!info] When to use browser tests
> Use browser tests for critical user flows and complex interactions. For most component testing, the standard `Livewire::test()` approach is faster and sufficient.

---

## Browser Testing assertNoJavaScriptErrors
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

### assertNoJavaScriptErrors

The `assertNoJavaScriptErrors` method asserts there are no JavaScript errors on the page:

```php
$page->assertNoJavaScriptErrors();
```

---

## Telescope Installation Dashboard Authorization
Telescope | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/telescope.md

### Dashboard Authorization

The Telescope dashboard may be accessed via the `/telescope` route. By default, you will only be able to access this dashboard in the `local` environment. Within your `app/Providers/TelescopeServiceProvider.php` file, there is an [authorization gate](/docs/{{version}}/authorization#gates) definition. This authorization gate controls access to Telescope in **non-local** environments. You are free to modify this gate as needed to restrict access to your Telescope installation:

```php
use App\Models\User;

/**
 * Register the Telescope gate.
 *
 * This gate determines who can access Telescope in non-local environments.
 */
protected function gate(): void
{
    Gate::define('viewTelescope', function (User $user) {
        return in_array($user->email, [
            'taylor@laravel.com',
        ]);
    });
}
```

> [!WARNING]
> You should ensure you change your `APP_ENV` environment variable to `production` in your production environment. Otherwise, your Telescope installation will be publicly available.

---

## Styles Browser support
Styles | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/styles

## Browser support

Scoped styles use [CSS nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting), which is supported in all modern browsers (Chrome 120+, Firefox 117+, Safari 17.2+). For older browser support, consider using a CSS preprocessor or the `@assets` directive with pre-compiled stylesheets.

---

## Pulse Dashboard Cards Cache
Pulse | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pulse.md

#### Cache

The `<livewire:pulse.cache />` card shows the cache hit and miss statistics for your application, both globally and for individual keys.

By default, entries will be grouped by key. However, you may wish to normalize or group similar keys using regular expressions. See the [cache interactions recorder](#cache-interactions-recorder) documentation for more information.

---

## Pulse Dashboard Customization
Pulse | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pulse.md

### Customization

The Pulse dashboard cards and layout may be configured by publishing the dashboard view. The dashboard view will be published to `resources/views/vendor/pulse/dashboard.blade.php`:

```shell
php artisan vendor:publish --tag=pulse-dashboard
```

The dashboard is powered by [Livewire](https://livewire.laravel.com/), and allows you to customize the cards and layout without needing to rebuild any JavaScript assets.

Within this file, the `<x-pulse>` component is responsible for rendering the dashboard and provides a grid layout for the cards. If you would like the dashboard to span the full width of the screen, you may provide the `full-width` prop to the component:

```blade
<x-pulse full-width>
    ...
</x-pulse>
```

By default, the `<x-pulse>` component will create a 12 column grid, but you may customize this using the `cols` prop:

```blade
<x-pulse cols="16">
    ...
</x-pulse>
```

Each card accepts a `cols` and `rows` prop to control the space and positioning:

```blade
<livewire:pulse.usage cols="4" rows="2" />
```

Most cards also accept an `expand` prop to show the full card instead of scrolling:

```blade
<livewire:pulse.slow-queries expand />
```

---

## Installation Components not updating or errors in browser console
Installation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/installation

## Components not updating or errors in browser console

**Check the following:**
- Ensure `@livewireStyles` is in the `<head>` of your layout
- Ensure `@livewireScripts` is before `</body>` in your layout
- Check your browser's developer console for JavaScript errors
- Verify you're running a supported PHP version (8.1+) and Laravel version (10+)
- Clear your application cache: `php artisan cache:clear`

If issues persist, check the [troubleshooting documentation](/docs/4.x/troubleshooting) for more detailed debugging steps.

---

## Pulse Dashboard Cards Slow Outgoing Requests
Pulse | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pulse.md

#### Slow Outgoing Requests

The `<livewire:pulse.slow-outgoing-requests />` card shows outgoing requests made using Laravel's [HTTP client](/docs/{{version}}/http-client) that exceed the configured threshold, which is 1,000ms by default.

By default, entries will be grouped by the full URL. However, you may wish to normalize or group similar outgoing requests using regular expressions. See the [slow outgoing requests recorder](#slow-outgoing-requests-recorder) documentation for more information.

---

## Browser Testing
Browser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md

---
title: Browser Testing
description:
---

# Browser Testing

Browser testing is an essential part of modern web development, allowing you to ensure that your application works correctly across different browsers and devices. Pest provides a simple and elegant way to write browser tests. Here is an example of how to write a browser test using Pest:

```php
it('may welcome the user', function () {
    $page = visit('/');

    $page->assertSee('Welcome');
});
```

This is a basic example of a browser test that checks if the homepage contains the text "Welcome". However, Pest's browser testing capabilities go beyond this simple example. You can use various methods to interact with the page, such as clicking buttons, filling out forms, and navigating between pages.

Here is an example of a more complex browser test, on Laravel, that checks if a user can sign in:

```php
it('may sign in the user', function () {
    Event::fake();

    User::factory()->create([ // assumes RefreshDatabase trait is used on Pest.php...
        'email' => 'nuno@laravel.com',
        'password' => 'password',
    ]);

    $page = visit('/')->on()->mobile()->firefox();

    $page->click('Sign In')
         ->assertUrlIs('/login')
         ->assertSee('Sign In to Your Account')
         ->fill('email', 'nuno@laravel.com')
         ->fill('password', 'password')
         ->click('Submit')
         ->assertSee('Dashboard');

    $this->assertAuthenticated();

    Event::assertDispatched(UserLoggedIn::class);
});
```

Note that, you are leveraging the full power of Laravel's testing capabilities, such as refresh database, event faking, and authentication assertions, while also actually doing browser testing.

## Getting Started

To get started with browser testing in Pest, you need to install the Pest Browser plugin. You can do this by running the following command:

```bash
composer require pestphp/pest-plugin-browser --dev

npm install playwright@latest
npx playwright install
```

Finally, add `tests/Browser/Screenshots` to your `.gitignore` file to avoid committing screenshots taken during browser tests.

### Running Browser Tests

Running browser tests is similar to running regular Pest tests:

```bash
./vendor/bin/pest
```

We recommend running tests in parallel using the `--parallel` option to speed up the execution:

```bash
./vendor/bin/pest --parallel
```

For debugging purposes, you can run the tests in a headed mode and pause the execution at the end of the failed test run:

```bash
./vendor/bin/pest --debug
```

### Navigation

The `visit()` method is used to navigate to a specific URL in your browser test. It provides various methods to interact with the page:

```php
test('example', function () {
    $page = visit('/');

    $page->assertSee('Welcome');
});
```

### Using Other Browsers

By default, the `visit()` method uses Chrome as the browser. However, if you want to use a different browser, you can specify it using the `--browser` option when running the tests:

```bash
./vendor/bin/pest --browser firefox
./vendor/bin/pest --browser safari
```

If you wish to use a different browser by default without specifying it in the command line, you can set it in your `Pest.php` configuration file:

```php
pest()->browser()->inFirefox();
pest()->browser()->inSafari();
```

### Using Other Devices

The `visit()` method uses a desktop viewport. However, you can specify a mobile viewport using the `onMobile()` method. For example:

```php
$page = visit('/')->on()->mobile();
```

If you wish to use a specific device, you can use the `on()` method and chain it with the `macbook14`, `iPhone14Pro`, etc:

```php
$page = visit('/')->on()->iPhone14Pro();
```

### Using Dark Mode

Pest enforces a light color scheme by default. However, you can specify a dark color scheme using the `inDarkMode()` method:

```php
$page = visit('/')->inDarkMode();
```

### Visiting Multiple Pages

You can visit multiple pages simultaneously by passing an array of URLs to the `visit()` method. This is useful for testing scenarios where you need to interact with multiple pages at once:

```php
$pages = visit(['/', '/about']);

$pages->assertNoSmoke()
    ->assertNoAccessibilityIssues()
    ->assertNoConsoleLogs()
    ->assertNoJavaScriptErrors();

[$homePage, $aboutPage] = $pages;

$homePage->assertSee('Welcome to our website');
$aboutPage->assertSee('About Us');
```

### Navigation

After visiting a page, you can navigate to other pages using the `navigate()` method. This method allows you to navigate to a different URL while keeping the current browser context:

```php
$page = visit('/');

$page->navigate('/about')
     ->assertSee('About Us');
```

### Locating Elements

You can locate elements in the DOM using text or CSS selectors. Pest provides a simple syntax for locating elements:

```php
// Clicks the first link with the text "Login"
$page->click('Login');

// Clicks the first element with the class "btn-primary"
$page->click('.btn-primary');

// Clicks the element with the data-test attribute "login"
$page->click('@login');

// Clicks the element with the ID "submit-button"
$page->click('#submit-button');

// etc...
```

### Configuring Timeouts

Sometimes, elements may take time to appear on the page. By default, Pest waits for `5` seconds before timing out. You can configure the default timeout for browser tests in your `Pest.php` configuration file:

```php
pest()->browser()->timeout(10000);
```

### Configuring User Agent

By default, the User Agent will default to the Browser you're running for tests such as: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/133.0.6943.16 Safari/537.36`

You may wish to override the User Agent of the browser for all of your tests, you can configure this in the `Pest.php` configuration file:

```php
pest()->browser()->userAgent('CustomUserAgent');
```

### Configuring Host

By default, the server will bind to `127.0.0.1` for all browser tests.

You may wish to override the host for subdomain applications. You can configure this in the `Pest.php` configuration file:

```php
pest()->browser()->withHost('some-subdomain.localhost');
```

### Geolocation

Sometimes, you need to define where the browser believes it is physically on the earth.  This method takes a latitude and longitude and will set the `geolocation` permission in the browser and then make the coordinates available via Javascript's getCurrentPosition API:

```php
$page = visit('/')
     ->geolocation(39.399872, -8.224454);
```

### Configuring Locale

You can set the locale for your test requests using the `withLocale` method. This is particularly useful for testing multilingual applications.

```php
$page = visit('/')->withLocale('fr-FR');

$page->assertSee('Bienvenue');
```
### Configuring Timezone

You can set the timezone for your test requests using the `withTimezone` method. This is useful for testing date and time displays in different time zones.

```php
$page = visit('/')->withTimezone('America/New_York');

$page->assertSee('EST');
```

### Configuring UserAgent

You can set the User-Agent header for your test requests using the `withUserAgent` method. This is useful for testing how your application responds to different types of clients, such as mobile browsers or bots.

```php
$page = visit('/')->withUserAgent('Googlebot');

$page->assertSee('Welcome, bot!');
```

### Configuring Host

You can set the host for your test server using the `withHost` method. This is useful for testing subdomains or where different hosts serve different content.

```php
$page = visit('/dashboard')->withHost('some-subdomain.localhost');

$page->assertSee('Welcome to Some Subdomain');
```

## Table of Contents

### Available Assertions

[assertTitle](#assert-title)
[assertTitleContains](#assert-title-contains)
[assertSee](#assert-see)
[assertDontSee](#assert-dont-see)
[assertSeeIn](#assert-see-in)
[assertDontSeeIn](#assert-dont-see-in)
[assertSeeAnythingIn](#assert-see-anything-in)
[assertSeeNothingIn](#assert-see-nothing-in)
[assertCount](#assert-count)
[assertScript](#assert-script)
[assertSourceHas](#assert-source-has)
[assertSourceMissing](#assert-source-missing)
[assertSeeLink](#assert-see-link)
[assertDontSeeLink](#assert-dont-see-link)
[assertChecked](#assert-checked)
[assertNotChecked](#assert-not-checked)
[assertIndeterminate](#assert-indeterminate)
[assertRadioSelected](#assert-radio-selected)
[assertRadioNotSelected](#assert-radio-not-selected)
[assertSelected](#assert-selected)
[assertNotSelected](#assert-not-selected)
[assertValue](#assert-value)
[assertValueIsNot](#assert-value-is-not)
[assertAttribute](#assert-attribute)
[assertAttributeMissing](#assert-attribute-missing)
[assertAttributeContains](#assert-attribute-contains)
[assertAttributeDoesntContain](#assert-attribute-doesnt-contain)
[assertAriaAttribute](#assert-aria-attribute)
[assertDataAttribute](#assert-data-attribute)
[assertVisible](#assert-visible)
[assertPresent](#assert-present)
[assertNotPresent](#assert-not-present)
[assertMissing](#assert-missing)
[assertEnabled](#assert-enabled)
[assertDisabled](#assert-disabled)
[assertButtonEnabled](#assert-button-enabled)
[assertButtonDisabled](#assert-button-disabled)
[assertUrlIs](#assert-url-is)
[assertSchemeIs](#assert-scheme-is)
[assertSchemeIsNot](#assert-scheme-is-not)
[assertHostIs](#assert-host-is)
[assertHostIsNot](#assert-host-is-not)
[assertPortIs](#assert-port-is)
[assertPortIsNot](#assert-port-is-not)
[assertPathBeginsWith](#assert-path-begins-with)
[assertPathEndsWith](#assert-path-ends-with)
[assertPathContains](#assert-path-contains)
[assertPathIs](#assert-path-is)
[assertPathIsNot](#assert-path-is-not)
[assertQueryStringHas](#assert-query-string-has)
[assertQueryStringMissing](#assert-query-string-missing)
[assertFragmentIs](#assert-fragment-is)
[assertFragmentBeginsWith](#assert-fragment-begins-with)
[assertFragmentIsNot](#assert-fragment-is-not)
[assertNoSmoke](#assert-no-smoke)
[assertNoConsoleLogs](#assert-no-console-logs)
[assertNoJavaScriptErrors](#assert-no-javascript-errors)
[assertNoAccessibilityIssues](#assert-no-accessibility-issues)
[assertScreenshotMatches](#assert-screenshot-matches)

### Element Interactions

[click](#click)
[text](#text)
[attribute](#attribute)
[keys](#keys)
[withKeyDown](#withKeyDown)
[type](#type)
[typeSlowly](#type-slowly)
[select](#select)
[append](#append)
[clear](#clear)
[radio](#radio)
[check](#check)
[uncheck](#uncheck)
[attach](#attach)
[press](#press)
[pressAndWaitFor](#press-and-wait-for)
[drag](#drag)
[hover](#hover)
[submit](#submit)
[value](#value)
[withinIframe](#within-iframe)
[resize](#resize)
[script](#script)
[content](#content)
[url](#url)
[wait](#wait)
[waitForKey](#wait-for-key)

### Debugging tests

[debug](#debug)
[screenshot](#screenshot)
[screenshotElement](#screenshot-element)
[tinker](#tinker)
[headed](#headed)

## Element Assertions

_[Truncated]_


output:
Wall time: 3.4376 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Http Tests Testing Views Rendering Blade and Components\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n### Rendering Blade and Components\n\nIf necessary, you may use the `blade` method to evaluate and render a raw [Blade](/docs/{{version}}/blade) string. Like the `view` method, the `blade` method returns an instance of `Illuminate\\Testing\\TestView`:\n\n```php\n$view = $this->blade(\n    '<x-component :name=\"$name\" />',\n    ['name' => 'Taylor']\n);\n\n$view->assertSee('Taylor');\n```\n\nYou may use the `component` method to evaluate and render a [Blade component](/docs/{{version}}/blade#components). The `component` method returns an instance of `Illuminate\\Testing\\TestComponent`:\n\n```php\n$view = $this->component(Profile::class, ['name' => 'Taylor']);\n\n$view->assertSee('Taylor');\n```\n\n---\n\n## Navigate Using with analytics software\nNavigate | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/navigate\n\n## Using with analytics software\n\nWhen navigating pages using `wire:navigate` in your app, any `<script>` tags in the `<head>` only evaluate when the page is initially loaded.\n\nThis creates a problem for analytics software such as [Fathom Analytics](https://usefathom.com/). These tools rely on a `<script>` snippet being evaluated on every single page change, not just the first.\n\nTools like [Google Analytics](https://marketingplatform.google.com/about/analytics/) are smart enough to handle this automatically, however, when using Fathom Analytics, you must add `data-spa=\"auto\"` to your script tag to ensure each page visit is tracked properly:\n\n```blade\n<head>\n    <!-- ... -->\n\n    <!-- Fathom Analytics -->\n    @if (! config('app.debug'))\n        <script src=\"https://cdn.usefathom.com/script.js\" data-site=\"ABCDEFG\" data-spa=\"auto\" defer></script> <!-- [tl! highlight] -->\n    @endif\n</head>\n```\n\n---\n\n## Wire Transition Browser support\nWire Transition | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/wire-transition\n\n## Browser support\n\nView Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. In browsers that don't support View Transitions, elements will appear and disappear without animation—the functionality still works, just without the visual transition.\n\n> [!warning] Firefox has limited support\n> Firefox 144+ supports basic view transitions, but does not support transition types.\n\n[View browser support on caniuse.com →](https://caniuse.com/view-transitions)\n\n---\n\n## Attribute Session Example: Dashboard filters\nAttribute Session | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/attribute-session\n\n## Example: Dashboard filters\n\nHere's a practical example of persisting dashboard filters:\n\n```php\n<?php // resources/views/pages/⚡dashboard.blade.php\n\nuse Livewire\\Attributes\\Session;\nuse Livewire\\Attributes\\Computed;\nuse Livewire\\Component;\nuse App\\Models\\Transaction;\n\nnew class extends Component {\n    #[Session]\n    public $dateRange = '30days';\n\n    #[Session]\n    public $category = 'all';\n\n    #[Session]\n    public $sortBy = 'date';\n\n    #[Computed]\n    public function transactions()\n    {\n        return Transaction::query()\n            ->when($this->dateRange === '30days', fn($q) => $q->where('created_at', '>=', now()->subDays(30)))\n            ->when($this->category !== 'all', fn($q) => $q->where('category', $this->category))\n            ->orderBy($this->sortBy)\n            ->get();\n    }\n};\n?>\n\n<div>\n    <select wire:model.live=\"dateRange\">\n        <option value=\"7days\">Last 7 days</option>\n        <option value=\"30days\">Last 30 days</option>\n        <option value=\"year\">This year</option>\n    </select>\n\n    <select wire:model.live=\"category\">\n        <option value=\"all\">All categories</option>\n        <option value=\"income\">Income</option>\n        <option value=\"expense\">Expense</option>\n    </select>\n\n    <select wire:model.live=\"sortBy\">\n        <option value=\"date\">Date</option>\n        <option value=\"amount\">Amount</option>\n    </select>\n\n    @foreach($this->transactions as $transaction)\n        <div wire:key=\"{{ $transaction->id }}\">{{ $transaction->description }}</div>\n    @endforeach\n</div>\n```\n\nUsers can set their preferred filters and they'll persist across sessions, page refreshes, and navigation.\n\n---\n\n## Testing Browser testing\nTesting | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/testing\n\n## Browser testing\n\nPest v4 includes first-party browser testing support powered by Playwright. This allows you to test your Livewire components in a real browser, interacting with them just like a user would.\n\n### Installing browser testing\n\nFirst, install the Pest browser plugin:\n\n```shell\ncomposer require pestphp/pest-plugin-browser --dev\n```\n\nNext, install Playwright via npm:\n\n```shell\nnpm install playwright@latest\nnpx playwright install\n```\n\nFor complete browser testing documentation, see the [Pest browser testing guide](https://pestphp.com/docs/browser-testing).\n\n### Writing browser tests\n\nInstead of using `Livewire::test()`, you can use `Livewire::visit()` to test your component in a real browser:\n\n```php\nit('can create a new post', function () {\n    Livewire::visit('post.create')\n        ->type('[wire\\:model=\"title\"]', 'My first post')\n        ->type('[wire\\:model=\"content\"]', 'This is the content')\n        ->press('Save')\n        ->assertSee('Post created successfully');\n});\n```\n\nBrowser tests are slower than unit tests but provide end-to-end confidence that your components work as expected in a real browser environment.\n\nFor a complete list of available browser testing assertions, see the [Pest browser testing assertions](https://pestphp.com/docs/browser-testing#content-available-assertions).\n\n> [!info] When to use browser tests\n> Use browser tests for critical user flows and complex interactions. For most component testing, the standard `Livewire::test()` approach is faster and sufficient.\n\n---\n\n## Browser Testing assertNoJavaScriptErrors\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n\n### assertNoJavaScriptErrors\n\nThe `assertNoJavaScriptErrors` method asserts there are no JavaScript errors on the page:\n\n```php\n$page->assertNoJavaScriptErrors();\n```\n\n---\n\n## Telescope Installation Dashboard Authorization\nTelescope | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/telescope.md\n\n\n### Dashboard Authorization\n\nThe Telescope dashboard may be accessed via the `/telescope` route. By default, you will only be able to access this dashboard in the `local` environment. Within your `app/Providers/TelescopeServiceProvider.php` file, there is an [authorization gate](/docs/{{version}}/authorization#gates) definition. This authorization gate controls access to Telescope in **non-local** environments. You are free to modify this gate as needed to restrict access to your Telescope installation:\n\n```php\nuse App\\Models\\User;\n\n/**\n * Register the Telescope gate.\n *\n * This gate determines who can access Telescope in non-local environments.\n */\nprotected function gate(): void\n{\n    Gate::define('viewTelescope', function (User $user) {\n        return in_array($user->email, [\n            'taylor@laravel.com',\n        ]);\n    });\n}\n```\n\n> [!WARNING]\n> You should ensure you change your `APP_ENV` environment variable to `production` in your production environment. Otherwise, your Telescope installation will be publicly available.\n\n---\n\n## Styles Browser support\nStyles | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/styles\n\n## Browser support\n\nScoped styles use [CSS nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting), which is supported in all modern browsers (Chrome 120+, Firefox 117+, Safari 17.2+). For older browser support, consider using a CSS preprocessor or the `@assets` directive with pre-compiled stylesheets.\n\n---\n\n## Pulse Dashboard Cards Cache\nPulse | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pulse.md\n\n\n#### Cache\n\nThe `<livewire:pulse.cache />` card shows the cache hit and miss statistics for your application, both globally and for individual keys.\n\nBy default, entries will be grouped by key. However, you may wish to normalize or group similar keys using regular expressions. See the [cache interactions recorder](#cache-interactions-recorder) documentation for more information.\n\n---\n\n## Pulse Dashboard Customization\nPulse | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pulse.md\n\n\n### Customization\n\nThe Pulse dashboard cards and layout may be configured by publishing the dashboard view. The dashboard view will be published to `resources/views/vendor/pulse/dashboard.blade.php`:\n\n```shell\nphp artisan vendor:publish --tag=pulse-dashboard\n```\n\nThe dashboard is powered by [Livewire](https://livewire.laravel.com/), and allows you to customize the cards and layout without needing to rebuild any JavaScript assets.\n\nWithin this file, the `<x-pulse>` component is responsible for rendering the dashboard and provides a grid layout for the cards. If you would like the dashboard to span the full width of the screen, you may provide the `full-width` prop to the component:\n\n```blade\n<x-pulse full-width>\n    ...\n</x-pulse>\n```\n\nBy default, the `<x-pulse>` component will create a 12 column grid, but you may customize this using the `cols` prop:\n\n```blade\n<x-pulse cols=\"16\">\n    ...\n</x-pulse>\n```\n\nEach card accepts a `cols` and `rows` prop to control the space and positioning:\n\n```blade\n<livewire:pulse.usage cols=\"4\" rows=\"2\" />\n```\n\nMost cards also accept an `expand` prop to show the full card instead of scrolling:\n\n```blade\n<livewire:pulse.slow-queries expand />\n```\n\n---\n\n## Installation Components not updating or errors in browser console\nInstallation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/installation\n\n## Components not updating or errors in browser console\n\n**Check the following:**\n- Ensure `@livewireStyles` is in the `<head>` of your layout\n- Ensure `@livewireScripts` is before `</body>` in your layout\n- Check your browser's developer console for JavaScript errors\n- Verify you're running a supported PHP version (8.1+) and Laravel version (10+)\n- Clear your application cache: `php artisan cache:clear`\n\nIf issues persist, check the [troubleshooting documentation](/docs/4.x/troubleshooting) for more detailed debugging steps.\n\n---\n\n## Pulse Dashboard Cards Slow Outgoing Requests\nPulse | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/pulse.md\n\n\n#### Slow Outgoing Requests\n\nThe `<livewire:pulse.slow-outgoing-requests />` card shows outgoing requests made using Laravel's [HTTP client](/docs/{{version}}/http-client) that exceed the configured threshold, which is 1,000ms by default.\n\nBy default, entries will be grouped by the full URL. However, you may wish to normalize or group similar outgoing requests using regular expressions. See the [slow outgoing requests recorder](#slow-outgoing-requests-recorder) documentation for more information.\n\n---\n\n## Browser Testing\nBrowser Testing | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/browser-testing.md\n\n---\ntitle: Browser Testing\ndescription:\n---\n\n# Browser Testing\n\nBrowser testing is an essential part of modern web development, allowing you to ensure that your application works correctly across different browsers and devices. Pest provides a simple and elegant way to write browser tests. Here is an example of how to write a browser test using Pest:\n\n```php\nit('may welcome the user', function () {\n    $page = visit('/');\n\n    $page->assertSee('Welcome');\n});\n```\n\nThis is a basic example of a browser test that checks if the homepage contains the text \"Welcome\". However, Pest's browser testing capabilities go beyond this simple example. You can use various methods to interact with the page, such as clicking buttons, filling out forms, and navigating between pages.\n\nHere is an example of a more complex browser test, on Laravel, that checks if a user can sign in:\n\n```php\nit('may sign in the user', function () {\n    Event::fake();\n\n    User::factory()->create([ // assumes RefreshDatabase trait is used on Pest.php...\n        'email' => 'nuno@laravel.com',\n        'password' => 'password',\n    ]);\n\n    $page = visit('/')->on()->mobile()->firefox();\n\n    $page->click('Sign In')\n         ->assertUrlIs('/login')\n         ->assertSee('Sign In to Your Account')\n         ->fill('email', 'nuno@laravel.com')\n         ->fill('password', 'password')\n         ->click('Submit')\n         ->assertSee('Dashboard');\n\n    $this->assertAuthenticated();\n\n    Event::assertDispatched(UserLoggedIn::class);\n});\n```\n\nNote that, you are leveraging the full power of Laravel's testing capabilities, such as refresh database, event faking, and authentication assertions, while also actually doing browser testing.\n\n## Getting Started\n\nTo get started with browser testing in Pest, you need to install the Pest Browser plugin. You can do this by running the following command:\n\n```bash\ncomposer require pestphp/pest-plugin-browser --dev\n\nnpm install playwright@latest\nnpx playwright install\n```\n\nFinally, add `tests/Browser/Screenshots` to your `.gitignore` file to avoid committing screenshots taken during browser tests.\n\n### Running Browser Tests\n\nRunning browser tests is similar to running regular Pest tests:\n\n```bash\n./vendor/bin/pest\n```\n\nWe recommend running tests in parallel using the `--parallel` option to speed up the execution:\n\n```bash\n./vendor/bin/pest --parallel\n```\n\nFor debugging purposes, you can run the tests in a headed mode and pause the execution at the end of the failed test run:\n\n```bash\n./vendor/bin/pest --debug\n```\n\n### Navigation\n\nThe `visit()` method is used to navigate to a specific URL in your browser test. It provides various methods to interact with the page:\n\n```php\ntest('example', function () {\n    $page = visit('/');\n\n    $page->assertSee('Welcome');\n});\n```\n\n### Using Other Browsers\n\nBy default, the `visit()` method uses Chrome as the browser. However, if you want to use a different browser, you can specify it using the `--browser` option when running the tests:\n\n```bash\n./vendor/bin/pest --browser firefox\n./vendor/bin/pest --browser safari\n```\n\nIf you wish to use a different browser by default without specifying it in the command line, you can set it in your `Pest.php` configuration file:\n\n```php\npest()->browser()->inFirefox();\npest()->browser()->inSafari();\n```\n\n### Using Other Devices\n\nThe `visit()` method uses a desktop viewport. However, you can specify a mobile viewport using the `onMobile()` method. For example:\n\n```php\n$page = visit('/')->on()->mobile();\n```\n\nIf you wish to use a specific device, you can use the `on()` method and chain it with the `macbook14`, `iPhone14Pro`, etc:\n\n```php\n$page = visit('/')->on()->iPhone14Pro();\n```\n\n### Using Dark Mode\n\nPest enforces a light color scheme by default. However, you can specify a dark color scheme using the `inDarkMode()` method:\n\n```php\n$page = visit('/')->inDarkMode();\n```\n\n### Visiting Multiple Pages\n\nYou can visit multiple pages simultaneously by passing an array of URLs to the `visit()` method. This is useful for testing scenarios where you need to interact with multiple pages at once:\n\n```php\n$pages = visit(['/', '/about']);\n\n$pages->assertNoSmoke()\n    ->assertNoAccessibilityIssues()\n    ->assertNoConsoleLogs()\n    ->assertNoJavaScriptErrors();\n\n[$homePage, $aboutPage] = $pages;\n\n$homePage->assertSee('Welcome to our website');\n$aboutPage->assertSee('About Us');\n```\n\n### Navigation\n\nAfter visiting a page, you can navigate to other pages using the `navigate()` method. This method allows you to navigate to a different URL while keeping the current browser context:\n\n```php\n$page = visit('/');\n\n$page->navigate('/about')\n     ->assertSee('About Us');\n```\n\n### Locating Elements\n\nYou can locate elements in the DOM using text or CSS selectors. Pest provides a simple syntax for locating elements:\n\n```php\n// Clicks the first link with the text \"Login\"\n$page->click('Login');\n\n// Clicks the first element with the class \"btn-primary\"\n$page->click('.btn-primary');\n\n// Clicks the element with the data-test attribute \"login\"\n$page->click('@login');\n\n// Clicks the element with the ID \"submit-button\"\n$page->click('#submit-button');\n\n// etc...\n```\n\n### Configuring Timeouts\n\nSometimes, elements may take time to appear on the page. By default, Pest waits for `5` seconds before timing out. You can configure the default timeout for browser tests in your `Pest.php` configuration file:\n\n```php\npest()->browser()->timeout(10000);\n```\n\n### Configuring User Agent\n\nBy default, the User Agent will default to the Browser you're running for tests such as: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/133.0.6943.16 Safari/537.36` \n\nYou may wish to override the User Agent of the browser for all of your tests, you can configure this in the `Pest.php` configuration file:\n\n```php\npest()->browser()->userAgent('CustomUserAgent');\n```\n\n### Configuring Host\n\nBy default, the server will bind to `127.0.0.1` for all browser tests.\n\nYou may wish to override the host for subdomain applications. You can configure this in the `Pest.php` configuration file:\n\n```php\npest()->browser()->withHost('some-subdomain.localhost');\n```\n\n### Geolocation\n\nSometimes, you need to define where the browser believes it is physically on the earth.  This method takes a latitude and longitude and will set the `geolocation` permission in the browser and then make the coordinates available via Javascript's getCurrentPosition API:\n\n```php\n$page = visit('/')\n     ->geolocation(39.399872, -8.224454);\n```\n\n### Configuring Locale\n\nYou can set the locale for your test requests using the `withLocale` method. This is particularly useful for testing multilingual applications.\n\n```php\n$page = visit('/')->withLocale('fr-FR');\n\n$page->assertSee('Bienvenue');\n```\n### Configuring Timezone\n\nYou can set the timezone for your test requests using the `withTimezone` method. This is useful for testing date and time displays in different time zones.\n\n```php\n$page = visit('/')->withTimezone('America/New_York');\n\n$page->assertSee('EST');\n```\n\n### Configuring UserAgent\n\nYou can set the User-Agent header for your test requests using the `withUserAgent` method. This is useful for testing how your application responds to different types of clients, such as mobile browsers or bots.\n\n```php\n$page = visit('/')->withUserAgent('Googlebot');\n\n$page->assertSee('Welcome, bot!');\n```\n\n### Configuring Host\n\nYou can set the host for your test server using the `withHost` method. This is useful for testing subdomains or where different hosts serve different content.\n\n```php\n$page = visit('/dashboard')->withHost('some-subdomain.localhost');\n\n$page->assertSee('Welcome to Some Subdomain');\n```\n\n## Table of Contents\n\n### Available Assertions\n\n
\n\n[assertTitle](#assert-title)\n[assertTitleContains](#assert-title-contains)\n[assertSee](#assert-see)\n[assertDontSee](#assert-dont-see)\n[assertSeeIn](#assert-see-in)\n[assertDontSeeIn](#assert-dont-see-in)\n[assertSeeAnythingIn](#assert-see-anything-in)\n[assertSeeNothingIn](#assert-see-nothing-in)\n[assertCount](#assert-count)\n[assertScript](#assert-script)\n[assertSourceHas](#assert-source-has)\n[assertSourceMissing](#assert-source-missing)\n[assertSeeLink](#assert-see-link)\n[assertDontSeeLink](#assert-dont-see-link)\n[assertChecked](#assert-checked)\n[assertNotChecked](#assert-not-checked)\n[assertIndeterminate](#assert-indeterminate)\n[assertRadioSelected](#assert-radio-selected)\n[assertRadioNotSelected](#assert-radio-not-selected)\n[assertSelected](#assert-selected)\n[assertNotSelected](#assert-not-selected)\n[assertValue](#assert-value)\n[assertValueIsNot](#assert-value-is-not)\n[assertAttribute](#assert-attribute)\n[assertAttributeMissing](#assert-attribute-missing)\n[assertAttributeContains](#assert-attribute-contains)\n[assertAttributeDoesntContain](#assert-attribute-doesnt-contain)\n[assertAriaAttribute](#assert-aria-attribute)\n[assertDataAttribute](#assert-data-attribute)\n[assertVisible](#assert-visible)\n[assertPresent](#assert-present)\n[assertNotPresent](#assert-not-present)\n[assertMissing](#assert-missing)\n[assertEnabled](#assert-enabled)\n[assertDisabled](#assert-disabled)\n[assertButtonEnabled](#assert-button-enabled)\n[assertButtonDisabled](#assert-button-disabled)\n[assertUrlIs](#assert-url-is)\n[assertSchemeIs](#assert-scheme-is)\n[assertSchemeIsNot](#assert-scheme-is-not)\n[assertHostIs](#assert-host-is)\n[assertHostIsNot](#assert-host-is-not)\n[assertPortIs](#assert-port-is)\n[assertPortIsNot](#assert-port-is-not)\n[assertPathBeginsWith](#assert-path-begins-with)\n[assertPathEndsWith](#assert-path-ends-with)\n[assertPathContains](#assert-path-contains)\n[assertPathIs](#assert-path-is)\n[assertPathIsNot](#assert-path-is-not)\n[assertQueryStringHas](#assert-query-string-has)\n[assertQueryStringMissing](#assert-query-string-missing)\n[assertFragmentIs](#assert-fragment-is)\n[assertFragmentBeginsWith](#assert-fragment-begins-with)\n[assertFragmentIsNot](#assert-fragment-is-not)\n[assertNoSmoke](#assert-no-smoke)\n[assertNoConsoleLogs](#assert-no-console-logs)\n[assertNoJavaScriptErrors](#assert-no-javascript-errors)\n[assertNoAccessibilityIssues](#assert-no-accessibility-issues)\n[assertScreenshotMatches](#assert-screenshot-matches)\n\n
\n\n### Element Interactions\n\n
\n\n[click](#click)\n[text](#text)\n[attribute](#attribute)\n[keys](#keys)\n[withKeyDown](#withKeyDown)\n[type](#type)\n[typeSlowly](#type-slowly)\n[select](#select)\n[append](#append)\n[clear](#clear)\n[radio](#radio)\n[check](#check)\n[uncheck](#uncheck)\n[attach](#attach)\n[press](#press)\n[pressAndWaitFor](#press-and-wait-for)\n[drag](#drag)\n[hover](#hover)\n[submit](#submit)\n[value](#value)\n[withinIframe](#within-iframe)\n[resize](#resize)\n[script](#script)\n[content](#content)\n[url](#url)\n[wait](#wait)\n[waitForKey](#wait-for-key)\n\n
\n\n### Debugging tests\n\n
\n\n[debug](#debug)\n[screenshot](#screenshot)\n[screenshotElement](#screenshot-element)\n[tinker](#tinker)\n[headed](#headed)\n\n
\n\n## Element Assertions\n\n_[Truncated]_"}]


message: Docs are checked. I’m inspecting the analytics Livewire component and current feature coverage to decide whether Suite 18 can be a pure browser-test addition.

phase: commentary


type: message

role: assistant

text: Docs are checked. I’m inspecting the analytics Livewire component and current feature coverage to decide whether Suite 18 can be a pure browser-test addition.


cmd: sed -n '1,320p' app/Livewire/Admin/Analytics/Index.php 2>/dev/null || sed -n '1,320p' app/Livewire/Admin/Analytics.php


cmd: rg -n "class .*Analytics|admin.analytics|Analytics" app/Livewire resources/views/livewire tests/Feature/Admin routes -S


cmd: sed -n '1,340p' resources/views/livewire/admin/analytics/index.blade.php 2>/dev/null || sed -n '1,340p' resources/views/livewire/admin/analytics.blade.php


cmd: sed -n '1,260p' tests/Feature/Admin/AnalyticsDashboardTest.php


cmd: sed -n '1,220p' tests/Browser/SmokeTest.php


cmd: sed -n '1,180p' resources/views/components/layouts/app/sidebar.blade.php 2>/dev/null || rg -n "Analytics" resources/views -S


command: sed -n '1,320p' app/Livewire/Admin/Analytics/Index.php 2>/dev/null || sed -n '1,320p' app/Livewire/Admin/Analytics.php

type: unknown

aggregated_output:
<?php

namespace App\Livewire\Admin\Analytics;

use App\Enums\AnalyticsEventType;
use App\Enums\FinancialStatus;
use App\Enums\StoreUserRole;
use App\Models\AnalyticsEvent;
use App\Models\OrderLine;
use App\Models\Store;
use App\Services\AnalyticsService;
use App\Support\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Throwable;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $storeCurrency = 'EUR';

    public string $dateRange = 'last_30_days';

    public ?string $customStartDate = null;

    public ?string $customEndDate = null;

    public string $channelFilter = 'all';

    public string $deviceFilter = 'all';

    public int $totalSales = 0;

    public int $ordersCount = 0;

    public int $averageOrderValue = 0;

    public float $conversionRate = 0.0;

    /**
     * @var list<array{date: string, label: string, revenue: int, orders: int}>
     */
    public array $salesChartData = [];

    public int $maxSalesChartAmount = 1;

    /**
     * @var list<array{title: string, units_sold: int, revenue: int, percentage: float}>
     */
    public array $topProducts = [];

    /**
     * @var list<array{source: string, sessions: int, orders: int, conversion_rate: float}>
     */
    public array $topReferrers = [];

    public bool $isExporting = false;

    public ?string $exportUrl = null;

    public function mount(AnalyticsService $analytics): void
    {
        $store = $this->store();

        $this->authorize('view', $store);
        abort_unless($this->canViewAnalytics($store), 403);

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

        $this->loadAnalytics($analytics);
    }

    public function updatedDateRange(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function updatedCustomStartDate(AnalyticsService $analytics): void
    {
        if ($this->dateRange === 'custom') {
            $this->loadAnalytics($analytics);
        }
    }

    public function updatedCustomEndDate(AnalyticsService $analytics): void
    {
        if ($this->dateRange === 'custom') {
            $this->loadAnalytics($analytics);
        }
    }

    public function updatedChannelFilter(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function updatedDeviceFilter(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function loadAnalytics(AnalyticsService $analytics): void
    {
        [$start, $end] = $this->currentRange();
        $store = $this->scopedStore();

        $totals = $analytics->totals($store, $start->toDateString(), $end->toDateString());

        $this->totalSales = $totals['revenue_amount'];
        $this->ordersCount = $totals['orders_count'];
        $this->averageOrderValue = $totals['aov_amount'];
        $this->conversionRate = $totals['visits_count'] > 0
            ? round(($totals['checkout_completed_count'] / $totals['visits_count']) * 100, 2)
            : 0.0;

        $daily = $analytics->getDailyMetrics($store, $start->toDateString(), $end->toDateString())->keyBy('date');

        $this->salesChartData = collect(CarbonPeriod::create($start->copy()->startOfDay(), '1 day', $end->copy()->startOfDay()))
            ->map(function (CarbonInterface $date) use ($daily): array {
                $metric = $daily->get($date->toDateString());

                return [
                    'date' => $date->toDateString(),
                    'label' => $date->format('M j'),
                    'revenue' => (int) ($metric?->revenue_amount ?? 0),
                    'orders' => (int) ($metric?->orders_count ?? 0),
                ];
            })
            ->values()
            ->all();

        $this->maxSalesChartAmount = max(1, max(array_column($this->salesChartData, 'revenue') ?: [0]));
        $this->loadTopProducts($start, $end);
        $this->loadTopReferrers($start, $end);
    }

    public function exportCsv(AnalyticsService $analytics): void
    {
        $this->isExporting = true;
        $this->loadAnalytics($analytics);

        $lines = ['date,revenue_amount,orders_count'];

        foreach ($this->salesChartData as $point) {
            $lines[] = "{$point['date']},{$point['revenue']},{$point['orders']}";
        }

        $this->exportUrl = 'data:text/csv;charset=utf-8,'.rawurlencode(implode("\n", $lines));
        $this->isExporting = false;
    }

    #[Computed]
    public function formattedTotalSales(): string
    {
        return Money::format($this->totalSales, $this->storeCurrency);
    }

    #[Computed]
    public function formattedAov(): string
    {
        return Money…319 chars truncated…ce $end): void
    {
        $rows = OrderLine::query()
            ->selectRaw('order_lines.title_snapshot as title, sum(order_lines.quantity) as units_sold, sum(order_lines.total_amount) as revenue')
            ->join('orders', 'orders.id', '=', 'order_lines.order_id')
            ->where('orders.store_id', $this->storeId)
            ->whereBetween('orders.placed_at', [$start, $end])
            ->whereIn('orders.financial_status', [
                FinancialStatus::Paid->value,
                FinancialStatus::PartiallyRefunded->value,
            ])
            ->groupBy('order_lines.product_id', 'order_lines.title_snapshot')
            ->orderByDesc('revenue')
            ->limit(10)
            ->get();

        $totalRevenue = max(1, (int) $rows->sum('revenue'));

        $this->topProducts = $rows
            ->map(fn (OrderLine $line): array => [
                'title' => (string) $line->title,
                'units_sold' => (int) $line->units_sold,
                'revenue' => (int) $line->revenue,
                'percentage' => round(((int) $line->revenue / $totalRevenue) * 100, 1),
            ])
            ->all();
    }

    private function loadTopReferrers(CarbonInterface $start, CarbonInterface $end): void
    {
        $pageViews = $this->filteredEvents($start, $end, AnalyticsEventType::PageView);
        $orders = $this->filteredEvents($start, $end, AnalyticsEventType::CheckoutCompleted);
        $ordersBySource = $orders->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event));

        $this->topReferrers = $pageViews
            ->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event))
            ->map(function ($events, string $source) use ($ordersBySource): array {
                $sessions = $events->pluck('session_id')->filter()->unique()->count();
                $orders = $ordersBySource->get($source, collect())->count();

                return [
                    'source' => $source,
                    'sessions' => $sessions,
                    'orders' => $orders,
                    'conversion_rate' => $sessions > 0 ? round(($orders / $sessions) * 100, 2) : 0.0,
                ];
            })
            ->sortByDesc('sessions')
            ->take(5)
            ->values()
            ->all();
    }

    private function filteredEvents(CarbonInterface $start, CarbonInterface $end, AnalyticsEventType $type): Collection
    {
        return AnalyticsEvent::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->where('type', $type->value)
            ->whereBetween('occurred_at', [$start, $end])
            ->get()
            ->filter(fn (AnalyticsEvent $event): bool => $this->eventMatchesFilters($event))
            ->values();
    }

    private function eventMatchesFilters(AnalyticsEvent $event): bool
    {
        if ($this->channelFilter !== 'all' && data_get($event->properties_json, 'channel') !== $this->channelFilter) {
            return false;
        }

        return $this->deviceFilter === 'all'
            || data_get($event->properties_json, 'device') === $this->deviceFilter;
    }

    private function sourceFor(AnalyticsEvent $event): string
    {
        $referrer = (string) data_get($event->properties_json, 'referrer', 'direct');

        if ($referrer === '' || $referrer === 'direct') {
            return 'Direct';
        }

        $host = parse_url($referrer, PHP_URL_HOST);

        return $host ? str_replace('www.', '', $host) : 'Direct';
    }

    /**
     * @return array{0: CarbonInterface, 1: CarbonInterface}
     */
    private function currentRange(): array
    {
        return match ($this->dateRange) {
            'today' => [now()->startOfDay(), now()->endOfDay()],
            'last_7_days' => [now()->subDays(6)->startOfDay(), now()->endOfDay()],
            'custom' => $this->customRange() ?? [now()->subDays(29)->startOfDay(), now()->endOfDay()],
            default => [now()->subDays(29)->startOfDay(), now()->endOfDay()],
        };
    }

    /**
     * @return array{0: CarbonInterface, 1: CarbonInterface}|null
     */
    private function customRange(): ?array
    {
        if (! $this->customStartDate || ! $this->customEndDate) {
            return null;
        }

        try {
            $start = Carbon::parse($this->customStartDate)->startOfDay();
            $end = Carbon::parse($this->customEndDate)->endOfDay();
        } catch (Throwable) {
            return null;
        }

        if ($end->lessThan($start)) {
            return [$end->startOfDay(), $start->endOfDay()];
        }

        return [$start, $end];
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->findOrFail($this->storeId);
    }

    private function canViewAnalytics(Store $store): bool
    {

status: completed


command: sed -n '1,340p' resources/views/livewire/admin/analytics/index.blade.php 2>/dev/null || sed -n '1,340p' resources/views/livewire/admin/analytics.blade.php

aggregated_output:
            <flux:heading size="xl">Analytics</flux:heading>
            <flux:text class="mt-1">Sales, traffic, and conversion signals for the selected store.</flux:text>

            <flux:select wire:model.live="dateRange" aria-label="Date range">
                <flux:select.option value="today">Today</flux:select.option>
                <flux:select.option value="last_7_days">Last 7 days</flux:select.option>
                <flux:select.option value="last_30_days">Last 30 days</flux:select.option>
                <flux:select.option value="custom">Custom</flux:select.option>
            </flux:select>

            <flux:input type="date" wire:model.live="customStartDate" :disabled="$dateRange !== 'custom'" aria-label="Custom range start" />
            <flux:input type="date" wire:model.live="customEndDate" :disabled="$dateRange !== 'custom'" aria-label="Custom range end" />

            <flux:select wire:model.live="channelFilter" aria-label="Channel">
                <flux:select.option value="all">All channels</flux:select.option>
                <flux:select.option value="storefront">Storefront</flux:select.option>
                <flux:select.option value="api">API</flux:select.option>
            </flux:select>

            <flux:select wire:model.live="deviceFilter" aria-label="Device">
                <flux:select.option value="all">All devices</flux:select.option>
                <flux:select.option value="desktop">Desktop</flux:select.option>
                <flux:select.option value="mobile">Mobile</flux:select.option>
                <flux:select.option value="tablet">Tablet</flux:select.option>
            </flux:select>

        <flux:button wire:click="exportCsv" icon="arrow-down-tray" variant="ghost" wire:loading.attr="disabled">
            Export CSV
            Exporting...
        </flux:button>

        @if ($exportUrl)
            <flux:button :href="$exportUrl" download="analytics.csv" icon="document-arrow-down" variant="filled">
                Download
            </flux:button>
        @endif

        @foreach ([
            ['label' => 'Total sales', 'value' => $this->formattedTotalSales, 'icon' => 'banknotes'],
            ['label' => 'Orders', 'value' => number_format($ordersCount), 'icon' => 'shopping-bag'],
            ['label' => 'Average order', 'value' => $this->formattedAov, 'icon' => 'receipt-percent'],
            ['label' => 'Conversion', 'value' => number_format($conversionRate, 2).'%', 'icon' => 'chart-bar'],
        ] as $metric)

{{ $metric['label'] }}

{{ $metric['value'] }}

                        <flux:icon :name="$metric['icon']" class="size-5" />

        @endforeach

                <flux:heading size="lg">Sales over time</flux:heading>
                <flux:text class="mt-1">Daily revenue and order volume</flux:text>

            @foreach ($salesChartData as $point)
                @php
                    $height = $point['revenue'] === 0 ? 2 : max(8, (int) round(($point['revenue'] / $maxSalesChartAmount) * 100));
                @endphp

                    @if ($loop->first || $loop->last || $loop->iteration % 7 === 0)

{{ $point['label'] }}

                    @endif

            @endforeach

            <flux:heading size="lg">Top products</flux:heading>

                            Rank
                            Product
                            Units
                            Revenue
                            Share

                        @forelse ($topProducts as $product)

                                {{ $loop->iteration }}
                                {{ $product['title'] }}
                                {{ number_format($product['units_sold']) }}
                                {{ \App\Support\Money::format($product['revenue'], $storeCurrency) }}
                                {{ number_format($product['percentage'], 1) }}%

                        @empty

                                No product sales in this range.

                        @endforelse

            <flux:heading size="lg">Top referrers</flux:heading>

                @forelse ($topReferrers as $referrer)

{{ $referrer['source'] }}

                            <flux:badge>{{ number_format($referrer['conversion_rate'], 2) }}%</flux:badge>

{{ number_format($referrer['sessions']) }} sessions

{{ number_format($referrer['orders']) }} orders

                @empty

                        <flux:icon name="cursor-arrow-rays" class="mx-auto size-8 text-zinc-400" />
                        <flux:text class="mt-3">No referrer activity in this range.</flux:text>

                @endforelse


command: rg -n "class .*Analytics|admin.analytics|Analytics" app/Livewire resources/views/livewire tests/Feature/Admin routes -S

type: search

cmd: rg -n 'class .*Analytics|admin.analytics|Analytics' app/Livewire resources/views/livewire tests/Feature/Admin routes -S

query: class .*Analytics|admin.analytics|Analytics

path: Livewire

aggregated_output:
routes/console.php:3:use App\Jobs\AggregateAnalytics;
routes/console.php:18:Schedule::job(new AggregateAnalytics)->dailyAt('01:00')->timezone('UTC');
routes/web.php:6:use App\Livewire\Admin\Analytics\Index as AdminAnalyticsIndex;
routes/web.php:121:    Route::livewire('analytics', AdminAnalyticsIndex::class)->name('analytics.index');
resources/views/livewire/admin/analytics/index.blade.php:4:            <flux:heading size="xl">Analytics</flux:heading>
routes/api.php:3:use App\Http\Controllers\Api\Admin\V1\AnalyticsSummaryController as AdminAnalyticsSummaryController;
routes/api.php:21:use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
routes/api.php:47:            Route::post('analytics/events', [StorefrontAnalyticsEventController::class, 'store'])->name('analytics.events.store');
routes/api.php:130:            Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary');
tests/Feature/Admin/AnalyticsDashboardTest.php:4:use App\Livewire\Admin\Analytics\Index as AnalyticsIndex;
tests/Feature/Admin/AnalyticsDashboardTest.php:19:function adminAnalyticsStore(): Store
tests/Feature/Admin/AnalyticsDashboardTest.php:24:function adminAnalyticsUser(?StoreUserRole $role = null): User
tests/Feature/Admin/AnalyticsDashboardTest.php:26:    $store = adminAnalyticsStore();
tests/Feature/Admin/AnalyticsDashboardTest.php:41:test('admin analytics route renders metrics for store staff and above', function (): void {
tests/Feature/Admin/AnalyticsDashboardTest.php:42:    $this->actingAs(adminAnalyticsUser(StoreUserRole::Staff))
tests/Feature/Admin/AnalyticsDashboardTest.php:43:        ->get('/admin/analytics')
tests/Feature/Admin/AnalyticsDashboardTest.php:45:        ->assertSee('Analytics')
tests/Feature/Admin/AnalyticsDashboardTest.php:50:test('admin analytics rejects support users', function (): void {
tests/Feature/Admin/AnalyticsDashboardTest.php:51:    $this->actingAs(adminAnalyticsUser(StoreUserRole::Support))
tests/Feature/Admin/AnalyticsDashboardTest.php:52:        ->get('/admin/analytics')
tests/Feature/Admin/AnalyticsDashboardTest.php:56:test('admin analytics component filters and exports csv data', function (): void {
tests/Feature/Admin/AnalyticsDashboardTest.php:57:    $store = adminAnalyticsStore();
tests/Feature/Admin/AnalyticsDashboardTest.php:60:    Livewire::actingAs(adminAnalyticsUser(StoreUserRole::Admin))
tests/Feature/Admin/AnalyticsDashboardTest.php:61:        ->test(AnalyticsIndex::class)
app/Livewire/Admin/Analytics/Index.php:3:namespace App\Livewire\Admin\Analytics;
app/Livewire/Admin/Analytics/Index.php:5:use App\Enums\AnalyticsEventType;
app/Livewire/Admin/Analytics/Index.php:8:use App\Models\AnalyticsEvent;
app/Livewire/Admin/Analytics/Index.php:11:use App\Services\AnalyticsService;
app/Livewire/Admin/Analytics/Index.php:71:    public function mount(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:76:        abort_unless($this->canViewAnalytics($store), 403);
app/Livewire/Admin/Analytics/Index.php:81:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:84:    public function updatedDateRange(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:86:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:89:    public function updatedCustomStartDate(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:92:            $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:96:    public function updatedCustomEndDate(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:99:            $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:103:    public function updatedChannelFilter(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:105:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:108:    public function updatedDeviceFilter(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:110:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:113:    public function loadAnalytics(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:148:    public function exportCsv(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:151:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:177:        return view('livewire.admin.analytics.index')->layout('layouts.app', [
app/Livewire/Admin/Analytics/Index.php:178:            'title' => __('Analytics'),
app/Livewire/Admin/Analytics/Index.php:212:        $pageViews = $this->filteredEvents($start, $end, AnalyticsEventType::PageView);
app/Livewire/Admin/Analytics/Index.php:213:        $orders = $this->filteredEvents($start, $end, AnalyticsEventType::CheckoutCompleted);
app/Livewire/Admin/Analytics/Index.php:214:        $ordersBySource = $orders->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event));
app/Livewire/Admin/Analytics/Index.php:217:            ->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event))
app/Livewire/Admin/Analytics/Index.php:235:    private function filteredEvents(CarbonInterface $start, CarbonInterface $end, AnalyticsEventType $type): Collection
app/Livewire/Admin/Analytics/Index.php:237:        return AnalyticsEvent::withoutGlobalScopes()
app/Livewire/Admin/Analytics/Index.php:242:            ->filter(fn (AnalyticsEvent $event): bool => $this->eventMatchesFilters($event))
app/Livewire/Admin/Analytics/Index.php:246:    private function eventMatchesFilters(AnalyticsEvent $event): bool
app/Livewire/Admin/Analytics/Index.php:256:    private function sourceFor(AnalyticsEvent $event): string
app/Livewire/Admin/Analytics/Index.php:319:    private function canViewAnalytics(Store $store): bool


command: sed -n '1,260p' tests/Feature/Admin/AnalyticsDashboardTest.php

AnalyticsDashboardTest.php

type: read

aggregated_output:
<?php

use App\Enums\StoreUserRole;
use App\Livewire\Admin\Analytics\Index as AnalyticsIndex;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminAnalyticsStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminAnalyticsUser(?StoreUserRole $role = null): User
{
    $store = adminAnalyticsStore();
    $user = User::factory()->create([
        'email_verified_at' => now(),
    ]);

    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $user->getKey(),
        'role' => ($role ?? StoreUserRole::Owner)->value,
        'created_at' => now(),
    ]);

    return $user;
}

test('admin analytics route renders metrics for store staff and above', function (): void {
    $this->actingAs(adminAnalyticsUser(StoreUserRole::Staff))
        ->get('/admin/analytics')
        ->assertSuccessful()
        ->assertSee('Analytics')
        ->assertSee('Total sales')
        ->assertSee('Top referrers');
});

test('admin analytics rejects support users', function (): void {
    $this->actingAs(adminAnalyticsUser(StoreUserRole::Support))
        ->get('/admin/analytics')
        ->assertForbidden();
});

test('admin analytics component filters and exports csv data', function (): void {
    $store = adminAnalyticsStore();
    app()->instance('current_store', $store);

    Livewire::actingAs(adminAnalyticsUser(StoreUserRole::Admin))
        ->test(AnalyticsIndex::class)
        ->assertSee('Sales over time')
        ->set('dateRange', 'last_7_days')
        ->set('channelFilter', 'storefront')
        ->set('deviceFilter', 'mobile')
        ->call('exportCsv')
        ->assertSet('isExporting', false)
        ->assertSet('exportUrl', fn (?string $url): bool => str_starts_with((string) $url, 'data:text/csv'));
});


command: sed -n '1,220p' tests/Browser/SmokeTest.php

aggregated_output:
<?php

use App\Models\Collection as ProductCollection;
use App\Models\Page;
use App\Models\Product;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->seed(DatabaseSeeder::class);
});

function browserSmokeStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function browserSmokeHost(): array
{
    return ['host' => 'shop.test'];
}

test('storefront core pages render without javascript errors', function (): void {
    $store = browserSmokeStore();
    $product = Product::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('status', 'active')
        ->firstOrFail();
    $collection = ProductCollection::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('status', 'active')
        ->firstOrFail();
    $page = Page::withoutGlobalScopes()
        ->where('store_id', $store->getKey())
        ->where('status', 'published')
        ->firstOrFail();

    $pages = visit([
        '/',
        '/collections',
        "/collections/{$collection->handle}",
        "/products/{$product->handle}",
        '/search?q=shirt',
        "/pages/{$page->handle}",
        '/cart',
    ], browserSmokeHost());

    $pages->assertNoJavaScriptErrors();

    [$home, $collections, $collectionPage, $productPage, $search, $contentPage, $cart] = $pages;

    $home->assertSee($store->name);
    $collections->assertSee('Collections');
    $collectionPage->assertSee($collection->title);
    $productPage->assertSee($product->title);
    $search->assertSee('Search');
    $contentPage->assertSee($page->title);
    $cart->assertSee('Cart');
});

test('admin core pages render for an authenticated store user', function (): void {
    $store = browserSmokeStore();
    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

    $this->actingAs($user);
    $this->withSession(['current_store_id' => $store->getKey()]);

    $expected = [
        '/admin' => 'Dashboard',
        '/admin/analytics' => 'Analytics',
        '/admin/apps' => 'Apps',
        '/admin/developers' => 'Developers',
        '/admin/products' => 'Products',
        '/admin/collections' => 'Collections',
        '/admin/inventory' => 'Inventory',
        '/admin/orders' => 'Orders',
        '/admin/customers' => 'Customers',
        '/admin/discounts' => 'Discounts',
        '/admin/pages' => 'Pages',
        '/admin/navigation' => 'Navigation',
        '/admin/themes' => 'Themes',
        '/admin/settings' => 'Settings',
        '/admin/settings/shipping' => 'Shipping',
        '/admin/settings/taxes' => 'Taxes',
        '/admin/settings/checkout' => 'Checkout',
        '/admin/settings/notifications' => 'Notifications',
        '/admin/search/settings' => 'Search',
    ];

    $pages = visit(array_keys($expected), browserSmokeHost());

    $pages->assertNoJavaScriptErrors();

    foreach ($pages as $index => $page) {
        $page->assertSee(array_values($expected)[$index]);
    }
});

test('storefront account auth pages render without javascript errors', function (): void {
    $pages = visit([
        '/account/login',
        '/account/register',
        '/forgot-password',
        '/reset-password/test-token?email=customer@example.test',
        '/account/forgot-password',
        '/account/reset-password/test-token?email=customer@example.test',
    ], browserSmokeHost());

    $pages->assertNoJavaScriptErrors();

    [$login, $register, $forgotPassword, $resetPassword, $accountForgotPassword, $accountResetPassword] = $pages;

    $login->assertSee('Log in');
    $register->assertSee('Create an account');
    $forgotPassword->assertSee('Reset password');
    $resetPassword->assertSee('New password');
    $accountForgotPassword->assertSee('Reset password');
    $accountResetPassword->assertSee('New password');
});

test('storefront home renders on a mobile viewport', function (): void {
    $store = browserSmokeStore();

    visit('/', browserSmokeHost())
        ->on()
        ->mobile()
        ->assertSee($store->name)
        ->assertNoJavaScriptErrors();
});


command: sed -n '1,180p' resources/views/components/layouts/app/sidebar.blade.php 2>/dev/null || rg -n "Analytics" resources/views -S

aggregated_output:
resources/views/livewire/admin/analytics/index.blade.php:4:            <flux:heading size="xl">Analytics</flux:heading>
resources/views/layouts/app/sidebar.blade.php:22:                        {{ __('Analytics') }}


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

namespace App\Livewire\Admin\Analytics;

use App\Enums\AnalyticsEventType;
use App\Enums\FinancialStatus;
use App\Enums\StoreUserRole;
use App\Models\AnalyticsEvent;
use App\Models\OrderLine;
use App\Models\Store;
use App\Services\AnalyticsService;
use App\Support\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Throwable;

class Index extends Component
{
    use AuthorizesRequests;

    #[Locked]
    public int $storeId;

    public string $storeCurrency = 'EUR';

    public string $dateRange = 'last_30_days';

    public ?string $customStartDate = null;

    public ?string $customEndDate = null;

    public string $channelFilter = 'all';

    public string $deviceFilter = 'all';

    public int $totalSales = 0;

    public int $ordersCount = 0;

    public int $averageOrderValue = 0;

    public float $conversionRate = 0.0;

    /**
     * @var list<array{date: string, label: string, revenue: int, orders: int}>
     */
    public array $salesChartData = [];

    public int $maxSalesChartAmount = 1;

    /**
     * @var list<array{title: string, units_sold: int, revenue: int, percentage: float}>
     */
    public array $topProducts = [];

    /**
     * @var list<array{source: string, sessions: int, orders: int, conversion_rate: float}>
     */
    public array $topReferrers = [];

    public bool $isExporting = false;

    public ?string $exportUrl = null;

    public function mount(AnalyticsService $analytics): void
    {
        $store = $this->store();

        $this->authorize('view', $store);
        abort_unless($this->canViewAnalytics($store), 403);

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

        $this->loadAnalytics($analytics);
    }

    public function updatedDateRange(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function updatedCustomStartDate(AnalyticsService $analytics): void
    {
        if ($this->dateRange === 'custom') {
            $this->loadAnalytics($analytics);
        }
    }

    public function updatedCustomEndDate(AnalyticsService $analytics): void
    {
        if ($this->dateRange === 'custom') {
            $this->loadAnalytics($analytics);
        }
    }

    public function updatedChannelFilter(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function updatedDeviceFilter(AnalyticsService $analytics): void
    {
        $this->loadAnalytics($analytics);
    }

    public function loadAnalytics(AnalyticsService $analytics): void
    {
        [$start, $end] = $this->currentRange();
        $store = $this->scopedStore();

        $totals = $analytics->totals($store, $start->toDateString(), $end->toDateString());

        $this->totalSales = $totals['revenue_amount'];
        $this->ordersCount = $totals['orders_count'];
        $this->averageOrderValue = $totals['aov_amount'];
        $this->conversionRate = $totals['visits_count'] > 0
            ? round(($totals['checkout_completed_count'] / $totals['visits_count']) * 100, 2)
            : 0.0;

        $daily = $analytics->getDailyMetrics($store, $start->toDateString(), $end->toDateString())->keyBy('date');

        $this->salesChartData = collect(CarbonPeriod::create($start->copy()->startOfDay(), '1 day', $end->copy()->startOfDay()))
            ->map(function (CarbonInterface $date) use ($daily): array {
                $metric = $daily->get($date->toDateString());

                return [
                    'date' => $date->toDateString(),
                    'label' => $date->format('M j'),
                    'revenue' => (int) ($metric?->revenue_amount ?? 0),
                    'orders' => (int) ($metric?->orders_count ?? 0),
                ];
            })
            ->values()
            ->all();

        $this->maxSalesChartAmount = max(1, max(array_column($this->salesChartData, 'revenue') ?: [0]));
        $this->loadTopProducts($start, $end);
        $this->loadTopReferrers($start, $end);
    }

    public function exportCsv(AnalyticsService $analytics): void
    {
        $this->isExporting = true;
        $this->loadAnalytics($analytics);

        $lines = ['date,revenue_amount,orders_count'];

        foreach ($this->salesChartData as $point) {
            $lines[] = "{$point['date']},{$point['revenue']},{$point['orders']}";
        }

        $this->exportUrl = 'data:text/csv;charset=utf-8,'.rawurlencode(implode("\n", $lines));
        $this->isExporting = false;
    }

    #[Computed]
    public function formattedTotalSales(): string
    {
        return Money::format($this->totalSales, $this->storeCurrency);
    }

    #[Computed]
    public function formattedAov(): string
    {
        return Money::format($this->averageOrderValue, $this->storeCurrency);
    }

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

    private function loadTopProducts(CarbonInterface $start, CarbonInterface $end): void
    {
        $rows = OrderLine::query()
            ->selectRaw('order_lines.title_snapshot as title, sum(order_lines.quantity) as units_sold, sum(order_lines.total_amount) as revenue')
            ->join('orders', 'orders.id', '=', 'order_lines.order_id')
            ->where('orders.store_id', $this->storeId)
            ->whereBetween('orders.placed_at', [$start, $end])
            ->whereIn('orders.financial_status', [
                FinancialStatus::Paid->value,
                FinancialStatus::PartiallyRefunded->value,
            ])
            ->groupBy('order_lines.product_id', 'order_lines.title_snapshot')
            ->orderByDesc('revenue')
            ->limit(10)
            ->get();

        $totalRevenue = max(1, (int) $rows->sum('revenue'));

        $this->topProducts = $rows
            ->map(fn (OrderLine $line): array => [
                'title' => (string) $line->title,
                'units_sold' => (int) $line->units_sold,
                'revenue' => (int) $line->revenue,
                'percentage' => round(((int) $line->revenue / $totalRevenue) * 100, 1),
            ])
            ->all();
    }

    private function loadTopReferrers(CarbonInterface $start, CarbonInterface $end): void
    {
        $pageViews = $this->filteredEvents($start, $end, AnalyticsEventType::PageView);
        $orders = $this->filteredEvents($start, $end, AnalyticsEventType::CheckoutCompleted);
        $ordersBySource = $orders->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event));

        $this->topReferrers = $pageViews
            ->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event))
            ->map(function ($events, string $source) use ($ordersBySource): array {
                $sessions = $events->pluck('session_id')->filter()->unique()->count();
                $orders = $ordersBySource->get($source, collect())->count();

                return [
                    'source' => $source,
                    'sessions' => $sessions,
                    'orders' => $orders,
                    'conversion_rate' => $sessions > 0 ? round(($orders / $sessions) * 100, 2) : 0.0,
                ];
            })
            ->sortByDesc('sessions')
            ->take(5)
            ->values()
            ->all();
    }

    private function filteredEvents(CarbonInterface $start, CarbonInterface $end, AnalyticsEventType $type): Collection
    {
        return AnalyticsEvent::withoutGlobalScopes()
            ->where('store_id', $this->storeId)
            ->where('type', $type->value)
            ->whereBetween('occurred_at', [$start, $end])
            ->get()
            ->filter(fn (AnalyticsEvent $event): bool => $this->eventMatchesFilters($event))
            ->values();
    }

    private function eventMatchesFilters(AnalyticsEvent $event): bool
    {
        if ($this->channelFilter !== 'all' && data_get($event->properties_json, 'channel') !== $this->channelFilter) {
            return false;
        }

        return $this->deviceFilter === 'all'
            || data_get($event->properties_json, 'device') === $this->deviceFilter;
    }

    private function sourceFor(AnalyticsEvent $event): string
    {
        $referrer = (string) data_get($event->properties_json, 'referrer', 'direct');

        if ($referrer === '' || $referrer === 'direct') {
            return 'Direct';
        }

        $host = parse_url($referrer, PHP_URL_HOST);

        return $host ? str_replace('www.', '', $host) : 'Direct';
    }

    /**
     * @return array{0: CarbonInterface, 1: CarbonInterface}
     */
    private function currentRange(): array
    {
        return match ($this->dateRange) {
            'today' => [now()->startOfDay(), now()->endOfDay()],
            'last_7_days' => [now()->subDays(6)->startOfDay(), now()->endOfDay()],
            'custom' => $this->customRange() ?? [now()->subDays(29)->startOfDay(), now()->endOfDay()],
            default => [now()->subDays(29)->startOfDay(), now()->endOfDay()],
        };
    }

    /**
     * @return array{0: CarbonInterface, 1: CarbonInterface}|null
     */
    private function customRange(): ?array
    {
        if (! $this->customStartDate || ! $this->customEndDate) {
            return null;
        }

        try {
            $start = Carbon::parse($this->customStartDate)->startOfDay();
            $end = Carbon::parse($this->customEndDate)->endOfDay();
        } catch (Throwable) {
            return null;
        }

        if ($end->lessThan($start)) {
            return [$end->startOfDay(), $start->endOfDay()];
        }

        return [$start, $end];
    }

    private function store(): Store
    {
        $store = app('current_store');

        abort_unless($store instanceof Store, 404);

        return $store;
    }

    private function scopedStore(): Store
    {
        return Store::query()->findOrFail($this->storeId);
    }

    private function canViewAnalytics(Store $store): bool
    {


output:
Chunk ID: 481958
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1482
Output:
routes/console.php:3:use App\Jobs\AggregateAnalytics;
routes/console.php:18:Schedule::job(new AggregateAnalytics)->dailyAt('01:00')->timezone('UTC');
routes/web.php:6:use App\Livewire\Admin\Analytics\Index as AdminAnalyticsIndex;
routes/web.php:121:    Route::livewire('analytics', AdminAnalyticsIndex::class)->name('analytics.index');
resources/views/livewire/admin/analytics/index.blade.php:4:            <flux:heading size="xl">Analytics</flux:heading>
routes/api.php:3:use App\Http\Controllers\Api\Admin\V1\AnalyticsSummaryController as AdminAnalyticsSummaryController;
routes/api.php:21:use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
routes/api.php:47:            Route::post('analytics/events', [StorefrontAnalyticsEventController::class, 'store'])->name('analytics.events.store');
routes/api.php:130:            Route::get('analytics/summary', [AdminAnalyticsSummaryController::class, 'show'])->name('analytics.summary');
tests/Feature/Admin/AnalyticsDashboardTest.php:4:use App\Livewire\Admin\Analytics\Index as AnalyticsIndex;
tests/Feature/Admin/AnalyticsDashboardTest.php:19:function adminAnalyticsStore(): Store
tests/Feature/Admin/AnalyticsDashboardTest.php:24:function adminAnalyticsUser(?StoreUserRole $role = null): User
tests/Feature/Admin/AnalyticsDashboardTest.php:26:    $store = adminAnalyticsStore();
tests/Feature/Admin/AnalyticsDashboardTest.php:41:test('admin analytics route renders metrics for store staff and above', function (): void {
tests/Feature/Admin/AnalyticsDashboardTest.php:42:    $this->actingAs(adminAnalyticsUser(StoreUserRole::Staff))
tests/Feature/Admin/AnalyticsDashboardTest.php:43:        ->get('/admin/analytics')
tests/Feature/Admin/AnalyticsDashboardTest.php:45:        ->assertSee('Analytics')
tests/Feature/Admin/AnalyticsDashboardTest.php:50:test('admin analytics rejects support users', function (): void {
tests/Feature/Admin/AnalyticsDashboardTest.php:51:    $this->actingAs(adminAnalyticsUser(StoreUserRole::Support))
tests/Feature/Admin/AnalyticsDashboardTest.php:52:        ->get('/admin/analytics')
tests/Feature/Admin/AnalyticsDashboardTest.php:56:test('admin analytics component filters and exports csv data', function (): void {
tests/Feature/Admin/AnalyticsDashboardTest.php:57:    $store = adminAnalyticsStore();
tests/Feature/Admin/AnalyticsDashboardTest.php:60:    Livewire::actingAs(adminAnalyticsUser(StoreUserRole::Admin))
tests/Feature/Admin/AnalyticsDashboardTest.php:61:        ->test(AnalyticsIndex::class)
app/Livewire/Admin/Analytics/Index.php:3:namespace App\Livewire\Admin\Analytics;
app/Livewire/Admin/Analytics/Index.php:5:use App\Enums\AnalyticsEventType;
app/Livewire/Admin/Analytics/Index.php:8:use App\Models\AnalyticsEvent;
app/Livewire/Admin/Analytics/Index.php:11:use App\Services\AnalyticsService;
app/Livewire/Admin/Analytics/Index.php:71:    public function mount(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:76:        abort_unless($this->canViewAnalytics($store), 403);
app/Livewire/Admin/Analytics/Index.php:81:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:84:    public function updatedDateRange(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:86:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:89:    public function updatedCustomStartDate(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:92:            $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:96:    public function updatedCustomEndDate(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:99:            $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:103:    public function updatedChannelFilter(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:105:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:108:    public function updatedDeviceFilter(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:110:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:113:    public function loadAnalytics(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:148:    public function exportCsv(AnalyticsService $analytics): void
app/Livewire/Admin/Analytics/Index.php:151:        $this->loadAnalytics($analytics);
app/Livewire/Admin/Analytics/Index.php:177:        return view('livewire.admin.analytics.index')->layout('layouts.app', [
app/Livewire/Admin/Analytics/Index.php:178:            'title' => __('Analytics'),
app/Livewire/Admin/Analytics/Index.php:212:        $pageViews = $this->filteredEvents($start, $end, AnalyticsEventType::PageView);
app/Livewire/Admin/Analytics/Index.php:213:        $orders = $this->filteredEvents($start, $end, AnalyticsEventType::CheckoutCompleted);
app/Livewire/Admin/Analytics/Index.php:214:        $ordersBySource = $orders->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event));
app/Livewire/Admin/Analytics/Index.php:217:            ->groupBy(fn (AnalyticsEvent $event): string => $this->sourceFor($event))
app/Livewire/Admin/Analytics/Index.php:235:    private function filteredEvents(CarbonInterface $start, CarbonInterface $end, AnalyticsEventType $type): Collection
app/Livewire/Admin/Analytics/Index.php:237:        return AnalyticsEvent::withoutGlobalScopes()
app/Livewire/Admin/Analytics/Index.php:242:            ->filter(fn (AnalyticsEvent $event): bool => $this->eventMatchesFilters($event))
app/Livewire/Admin/Analytics/Index.php:246:    private function eventMatchesFilters(AnalyticsEvent $event): bool
app/Livewire/Admin/Analytics/Index.php:256:    private function sourceFor(AnalyticsEvent $event): string
app/Livewire/Admin/Analytics/Index.php:319:    private function canViewAnalytics(Store $store): bool


output:
Chunk ID: 1e60c9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2198
Output:

            <flux:heading size="xl">Analytics</flux:heading>
            <flux:text class="mt-1">Sales, traffic, and conversion signals for the selected store.</flux:text>

            <flux:select wire:model.live="dateRange" aria-label="Date range">
                <flux:select.option value="today">Today</flux:select.option>
                <flux:select.option value="last_7_days">Last 7 days</flux:select.option>
                <flux:select.option value="last_30_days">Last 30 days</flux:select.option>
                <flux:select.option value="custom">Custom</flux:select.option>
            </flux:select>

            <flux:input type="date" wire:model.live="customStartDate" :disabled="$dateRange !== 'custom'" aria-label="Custom range start" />
            <flux:input type="date" wire:model.live="customEndDate" :disabled="$dateRange !== 'custom'" aria-label="Custom range end" />

            <flux:select wire:model.live="channelFilter" aria-label="Channel">
                <flux:select.option value="all">All channels</flux:select.option>
                <flux:select.option value="storefront">Storefront</flux:select.option>
                <flux:select.option value="api">API</flux:select.option>
            </flux:select>

            <flux:select wire:model.live="deviceFilter" aria-label="Device">
                <flux:select.option value="all">All devices</flux:select.option>
                <flux:select.option value="desktop">Desktop</flux:select.option>
                <flux:select.option value="mobile">Mobile</flux:select.option>
                <flux:select.option value="tablet">Tablet</flux:select.option>
            </flux:select>

        <flux:button wire:click="exportCsv" icon="arrow-down-tray" variant="ghost" wire:loading.attr="disabled">
            Export CSV
            Exporting...
        </flux:button>

        @if ($exportUrl)
            <flux:button :href="$exportUrl" download="analytics.csv" icon="document-arrow-down" variant="filled">
                Download
            </flux:button>
        @endif

        @foreach ([
            ['label' => 'Total sales', 'value' => $this->formattedTotalSales, 'icon' => 'banknotes'],
            ['label' => 'Orders', 'value' => number_format($ordersCount), 'icon' => 'shopping-bag'],
            ['label' => 'Average order', 'value' => $this->formattedAov, 'icon' => 'receipt-percent'],
            ['label' => 'Conversion', 'value' => number_format($conversionRate, 2).'%', 'icon' => 'chart-bar'],
        ] as $metric)

{{ $metric['label'] }}

{{ $metric['value'] }}

                        <flux:icon :name="$metric['icon']" class="size-5" />

        @endforeach

                <flux:heading size="lg">Sales over time</flux:heading>
                <flux:text class="mt-1">Daily revenue and order volume</flux:text>

            @foreach ($salesChartData as $point)
                @php
                    $height = $point['revenue'] === 0 ? 2 : max(8, (int) round(($point['revenue'] / $maxSalesChartAmount) * 100));
                @endphp

                    @if ($loop->first || $loop->last || $loop->iteration % 7 === 0)

{{ $point['label'] }}

                    @endif

            @endforeach

            <flux:heading size="lg">Top products</flux:heading>

                            Rank
                            Product
                            Units
                            Revenue
                            Share

                        @forelse ($topProducts as $product)

                                {{ $loop->iteration }}
                                {{ $product['title'] }}
                                {{ number_format($product['units_sold']) }}
                                {{ \App\Support\Money::format($product['revenue'], $storeCurrency) }}
                                {{ number_format($product['percentage'], 1) }}%

                        @empty

                                No product sales in this range.

                        @endforelse

            <flux:heading size="lg">Top referrers</flux:heading>

                @forelse ($topReferrers as $referrer)

{{ $referrer['source'] }}

                            <flux:badge>{{ number_format($referrer['conversion_rate'], 2) }}%</flux:badge>

{{ number_format($referrer['sessions']) }} sessions

{{ number_format($referrer['orders']) }} orders

                @empty

                        <flux:icon name="cursor-arrow-rays" class="mx-auto size-8 text-zinc-400" />
                        <flux:text class="mt-3">No referrer activity in this range.</flux:text>

                @endforelse


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

use App\Enums\StoreUserRole;
use App\Livewire\Admin\Analytics\Index as AnalyticsIndex;
use App\Models\Store;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminAnalyticsStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminAnalyticsUser(?StoreUserRole $role = null): User
{
    $store = adminAnalyticsStore();
    $user = User::factory()->create([
        'email_verified_at' => now(),
    ]);

    DB::table('store_users')->insert([
        'store_id' => $store->getKey(),
        'user_id' => $user->getKey(),
        'role' => ($role ?? StoreUserRole::Owner)->value,
        'created_at' => now(),
    ]);

    return $user;
}

test('admin analytics route renders metrics for store staff and above', function (): void {
    $this->actingAs(adminAnalyticsUser(StoreUserRole::Staff))
        ->get('/admin/analytics')
        ->assertSuccessful()
        ->assertSee('Analytics')
        ->assertSee('Total sales')
        ->assertSee('Top referrers');
});

test('admin analytics rejects support users', function (): void {
    $this->actingAs(adminAnalyticsUser(StoreUserRole::Support))
        ->get('/admin/analytics')
        ->assertForbidden();
});

test('admin analytics component filters and exports csv data', function (): void {
    $store = adminAnalyticsStore();
    app()->instance('current_store', $store);

    Livewire::actingAs(adminAnalyticsUser(StoreUserRole::Admin))
        ->test(AnalyticsIndex::class)
        ->assertSee('Sales over time')
        ->set('dateRange', 'last_7_days')
        ->set('channelFilter', 'storefront')
        ->set('deviceFilter', 'mobile')
        ->call('exportCsv')
        ->assertSet('isExporting', false)
        ->assertSet('exportUrl', fn (?string $url): bool => str_starts_with((string) $url, 'data:text/csv'));
});