From 360218dc15a1f8fb78f29949b983b8646b980879 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 20:12:59 +0200 Subject: [PATCH 01/15] Prompt --- .../skills/developing-with-fortify/SKILL.md | 116 +++++ .claude/skills/fluxui-development/SKILL.md | 81 ++++ .../skills/laravel-best-practices/SKILL.md | 190 ++++++++ .../rules/advanced-queries.md | 106 +++++ .../rules/architecture.md | 202 ++++++++ .../rules/blade-views.md | 36 ++ .../laravel-best-practices/rules/caching.md | 70 +++ .../rules/collections.md | 44 ++ .../laravel-best-practices/rules/config.md | 73 +++ .../rules/db-performance.md | 192 ++++++++ .../laravel-best-practices/rules/eloquent.md | 148 ++++++ .../rules/error-handling.md | 72 +++ .../rules/events-notifications.md | 52 +++ .../rules/http-client.md | 160 +++++++ .../laravel-best-practices/rules/mail.md | 27 ++ .../rules/migrations.md | 121 +++++ .../rules/queue-jobs.md | 144 ++++++ .../laravel-best-practices/rules/routing.md | 99 ++++ .../rules/scheduling.md | 39 ++ .../laravel-best-practices/rules/security.md | 198 ++++++++ .../laravel-best-practices/rules/style.md | 125 +++++ .../laravel-best-practices/rules/testing.md | 43 ++ .../rules/validation.md | 75 +++ .claude/skills/livewire-development/SKILL.md | 156 +++++++ .../reference/javascript-hooks.md | 39 ++ .claude/skills/pest-testing/SKILL.md | 159 +++++++ .../skills/tailwindcss-development/SKILL.md | 119 +++++ .mcp.json | 2 +- CLAUDE.md | 441 +++++------------- README.md | 7 + boost.json | 17 + composer.json | 2 +- composer.lock | 113 +++-- 33 files changed, 3087 insertions(+), 381 deletions(-) create mode 100644 .claude/skills/developing-with-fortify/SKILL.md create mode 100644 .claude/skills/fluxui-development/SKILL.md create mode 100644 .claude/skills/laravel-best-practices/SKILL.md create mode 100644 .claude/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 .claude/skills/laravel-best-practices/rules/architecture.md create mode 100644 .claude/skills/laravel-best-practices/rules/blade-views.md create mode 100644 .claude/skills/laravel-best-practices/rules/caching.md create mode 100644 .claude/skills/laravel-best-practices/rules/collections.md create mode 100644 .claude/skills/laravel-best-practices/rules/config.md create mode 100644 .claude/skills/laravel-best-practices/rules/db-performance.md create mode 100644 .claude/skills/laravel-best-practices/rules/eloquent.md create mode 100644 .claude/skills/laravel-best-practices/rules/error-handling.md create mode 100644 .claude/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 .claude/skills/laravel-best-practices/rules/http-client.md create mode 100644 .claude/skills/laravel-best-practices/rules/mail.md create mode 100644 .claude/skills/laravel-best-practices/rules/migrations.md create mode 100644 .claude/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 .claude/skills/laravel-best-practices/rules/routing.md create mode 100644 .claude/skills/laravel-best-practices/rules/scheduling.md create mode 100644 .claude/skills/laravel-best-practices/rules/security.md create mode 100644 .claude/skills/laravel-best-practices/rules/style.md create mode 100644 .claude/skills/laravel-best-practices/rules/testing.md create mode 100644 .claude/skills/laravel-best-practices/rules/validation.md create mode 100644 .claude/skills/livewire-development/SKILL.md create mode 100644 .claude/skills/livewire-development/reference/javascript-hooks.md create mode 100644 .claude/skills/pest-testing/SKILL.md create mode 100644 .claude/skills/tailwindcss-development/SKILL.md create mode 100644 README.md create mode 100644 boost.json diff --git a/.claude/skills/developing-with-fortify/SKILL.md b/.claude/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..2ff71a4b --- /dev/null +++ b/.claude/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file diff --git a/.claude/skills/fluxui-development/SKILL.md b/.claude/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..4b5aabb1 --- /dev/null +++ b/.claude/skills/fluxui-development/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fluxui-development +description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip + +## Icons + +Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. + + +```blade +Export +``` + +For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: + +```bash +php artisan flux:icon crown grip-vertical github +``` + +## Common Patterns + +### Form Fields + + +```blade + + Email + + + +``` + +### Modals + + +```blade + + Title +

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/SKILL.md b/.claude/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..aca32c9c --- /dev/null +++ b/.claude/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/.claude/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..920714a1 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/architecture.md b/.claude/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..6112a635 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/blade-views.md b/.claude/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..c6f8aaf1 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/caching.md b/.claude/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..e65146dc --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/collections.md b/.claude/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..14f683d3 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/config.md b/.claude/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..193155d6 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls may return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/db-performance.md b/.claude/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..8fb71937 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/eloquent.md b/.claude/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..09cd66a0 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/error-handling.md b/.claude/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..bb8e7a38 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/events-notifications.md b/.claude/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..47fcf324 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/http-client.md b/.claude/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..fd37ddb9 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/mail.md b/.claude/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..2435d9cc --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/migrations.md b/.claude/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..de25aa39 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/queue-jobs.md b/.claude/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..f7aa548b --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,144 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release + +`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue. + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // Lock releases when processing begins, not when it finishes +} +``` + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/routing.md b/.claude/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..977d136e --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,99 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +// In routes/api.php — the /api prefix is applied automatically +Route::apiResource('posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/scheduling.md b/.claude/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..dfaefa26 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..909ff91a --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/style.md b/.claude/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..67af9891 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/testing.md b/.claude/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..287b083b --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/.claude/skills/laravel-best-practices/rules/validation.md b/.claude/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..a20202ff --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..c009dae6 --- /dev/null +++ b/.claude/skills/livewire-development/SKILL.md @@ -0,0 +1,156 @@ +--- +name: livewire-development +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash + +# Single-file component (default in v4) + +php artisan make:livewire create-post + +# Multi-file component + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +php artisan make:livewire create-post --class + +# With namespace + +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) | +| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) | + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +} +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) \ No newline at end of file diff --git a/.claude/skills/livewire-development/reference/javascript-hooks.md b/.claude/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..d6a44170 --- /dev/null +++ b/.claude/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors \ No newline at end of file diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..323d4723 --- /dev/null +++ b/.claude/skills/pest-testing/SKILL.md @@ -0,0 +1,159 @@ +--- +name: pest-testing +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7c8e295e --- /dev/null +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,119 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.mcp.json b/.mcp.json index 0ad95248..b2d6bef5 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,7 @@ "laravel-boost": { "command": "php", "args": [ - "./artisan", + "artisan", "boost:mcp" ] }, diff --git a/CLAUDE.md b/CLAUDE.md index 7b0f1e95..c29b66df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,432 +29,217 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R # Laravel Boost Guidelines -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. ## Foundational Context + This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.4.17 +- php - 8.4 +- laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v4 +- laravel/boost (BOOST) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pail (PAIL) - v1 - laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 - tailwindcss (TAILWINDCSS) - v4 +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. +- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling. +- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire. +- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code. +- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. ## Conventions -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. ## Verification Scripts -- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. ## Application Structure & Architecture -- Stick to existing directory structure - don't create new base folders without approval. + +- Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. ## Frontend Bundling -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. -## Replies -- Be concise in your explanations - focus on what's important rather than explaining obvious details. +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. ## Documentation Files + - You must only create documentation files if explicitly requested by the user. +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. === boost rules === -## Laravel Boost -- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. +# Laravel Boost -## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. +## Tools -## URLs -- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. -## Tinker / Debugging -- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. -- Use the `database-query` tool when you only need to read from the database. +## Searching Documentation (IMPORTANT) -## Reading Browser Logs With the `browser-logs` Tool -- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. -- Only recent browser logs will be useful - ignore old logs. +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. -## Searching Documentation (Critically Important) -- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. -- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. -- Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. -- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +### Search Syntax -### Available Search Syntax -- You can and should pass multiple queries at once. The most relevant results will be returned first. +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" -3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms +## Artisan +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. -=== php rules === +## Tinker -## PHP +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` -- Always use curly braces for control structures, even if it has one line. +=== php rules === -### Constructors -- Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters. +# PHP -### Type Declarations -- Always use explicit return type declarations for methods and functions. -- Use appropriate PHP type hints for method parameters. +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. - -protected function isAccessible(User $user, ?string $path = null): bool -{ - ... -} - +=== herd rules === -## Comments -- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. +# Laravel Herd -## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. -## Enums -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +=== tests rules === +# Test Enforcement -=== herd rules === +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. -## Laravel Herd +=== fortify/core rules === -- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. -- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd. +# Laravel Fortify +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. -- If you're creating a generic PHP class, use `artisan make:class`. +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database -- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries -- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. -- Generate code that prevents N+1 query problems by using eager loading. -- Use Laravel's query builder for very complex database operations. - ### Model Creation -- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. -### APIs & Eloquent Resources -- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. -### Controllers & Validation -- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. -- Check sibling Form Requests to see if the application uses array or string based validation rules. +## APIs & Eloquent Resources -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -### Authentication & Authorization -- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). +## URL Generation -### URL Generation - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration -- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. +## Testing -### Testing - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error -### Vite Error - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure -- No middleware files in `app/Http/Middleware/`. +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. -- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database -### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - - -=== fluxui-free/core rules === - -## Flux UI Free - -- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. -- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. -- You should use Flux UI components when available. -- Fallback to standard Blade components if Flux is unavailable. -- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. -- Flux UI components look like this: - - - - - - -### Available Components -This is correct as of Boost installation, but there may be additional components within the codebase. - - -avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip - +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. === livewire/core rules === -## Livewire Core -- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. -- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components -- State should live on the server, with the UI reflecting it. -- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. - -## Livewire Best Practices -- Livewire components require a single root element. -- Use `wire:loading` and `wire:dirty` for delightful loading states. -- Add `wire:key` in loops: - - ```blade - @foreach ($items as $item) -
- {{ $item->name }} -
- @endforeach - ``` - -- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: - - - public function mount(User $user) { $this->user = $user; } - public function updatedSearch() { $this->resetPage(); } - - - -## Testing Livewire - - - Livewire::test(Counter::class) - ->assertSet('count', 0) - ->call('increment') - ->assertSet('count', 1) - ->assertSee(1) - ->assertStatus(200); - - - - - $this->get('/posts/create') - ->assertSeeLivewire(CreatePost::class); - +# Livewire +- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. === pint/core rules === -## Laravel Pint Code Formatter - -- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. +# Laravel Pint Code Formatter +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. - -### Pest Tests -- All tests must be written using Pest. Use `php artisan make:test --pest `. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - - - -=== pest/v4 rules === - -## Pest 4 - -- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. -- Browser testing is incredibly powerful and useful for this project. -- Browser tests should live in `tests/Browser/`. -- Use the `search-docs` tool for detailed guidance on utilizing these features. - -### Browser Testing -- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. -- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. -- If requested, test on multiple browsers (Chrome, Firefox, Safari). -- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging when appropriate. - -### Example Tests - - -it('may reset the password', function () { - Notification::fake(); - - $this->actingAs(User::factory()->create()); - - $page = visit('/sign-in'); // Visit on a real browser... - - $page->assertSee('Sign In') - ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!') - - Notification::assertSent(ResetPassword::class); -}); - - - +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); - - - -=== tailwindcss/core rules === - -## Tailwind Core - -- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. -- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) -- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically -- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - -### Spacing -- When listing items, use gap utilities for spacing, don't use margins. - - -
-
Superior
-
Michigan
-
Erie
-
-
- - -### Dark Mode -- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - - -=== tailwindcss/v4 rules === - -## Tailwind 4 - -- Always use Tailwind CSS v4 - do not use the deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. -- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - - - - -### Replaced Utilities -- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. -- Opacity values are still numeric. - -| Deprecated | Replacement | -|------------+--------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - - -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. diff --git a/README.md b/README.md new file mode 100644 index 00000000..932a9ad4 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You must use team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. + +Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. + +When implementation is fully done, then make a full review meeting and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. + +Use team-mode (see https://code.claude.com/docs/en/agent-teams), not sub-agents. diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..29f40ab2 --- /dev/null +++ b/boost.json @@ -0,0 +1,17 @@ +{ + "agents": [ + "claude_code" + ], + "guidelines": true, + "mcp": true, + "nightwatch_mcp": false, + "sail": false, + "skills": [ + "developing-with-fortify", + "laravel-best-practices", + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development" + ] +} diff --git a/composer.json b/composer.json index 1f848aaf..a578e1d1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.4", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index e4255dbd..a1febb14 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4aa7ad38dac6834e5ff6bf65b1cdf23", + "content-hash": "a73f62d24e65543e17c317a1e9b580fa", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6877,35 +6877,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.4.3", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "841d52905728cfac9f93c778a1758e740ce9a367" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/841d52905728cfac9f93c778a1758e740ce9a367", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^0.5.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14|^1.23", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" }, "type": "library", "extra": { @@ -6927,7 +6928,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,35 +6939,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-04-10T15:59:10+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.6.5", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/583a6282bf0f074d754f7ff5cd1fff9d34244691", + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { @@ -6982,8 +6989,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +6996,15 @@ "license": [ "MIT" ], - "description": "The easiest way to add MCP servers to your Laravel app.", + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", "homepage": "https://github.com/laravel/mcp", "keywords": [ - "dev", "laravel", "mcp" ], @@ -7002,7 +7012,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-03-30T19:17:10+00:00" }, { "name": "laravel/pail", @@ -7153,30 +7163,31 @@ }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -7209,7 +7220,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/sail", From f6d08a65fc590f31531f9b0541b0e08667c78dfe Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 20:28:32 +0200 Subject: [PATCH 02/15] Phase 1: Foundation (tenancy, auth, core models) Implements the multi-tenant foundation for the shop platform: - Migrations: organizations, stores, store_domains, store_users, store_settings, users mods - Core models: Organization, Store, StoreDomain, StoreUser (pivot), StoreSettings - Enums: StoreStatus, StoreUserRole, StoreDomainType - BelongsToStore trait + StoreScope for tenant isolation - ResolveStore middleware (hostname and admin session variants) - Admin Login Livewire component with rate limiting - CustomerUserProvider (customer guard) registered for Phase 6 - StorePolicy + ChecksStoreRole trait for authorization - Tests: TenantResolutionTest, StoreIsolationTest, AdminAuthTest (42 pass) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Auth/CustomerUserProvider.php | 157 ++++++++++++++++++ app/Enums/StoreDomainType.php | 10 ++ app/Enums/StoreStatus.php | 9 + app/Enums/StoreUserRole.php | 11 ++ app/Http/Middleware/ResolveStore.php | 92 ++++++++++ app/Livewire/Admin/Auth/Login.php | 78 +++++++++ app/Models/Concerns/BelongsToStore.php | 31 ++++ app/Models/Organization.php | 26 +++ app/Models/Scopes/StoreScope.php | 23 +++ app/Models/Store.php | 71 ++++++++ app/Models/StoreDomain.php | 43 +++++ app/Models/StoreSettings.php | 44 +++++ app/Models/StoreUser.php | 36 ++++ app/Models/User.php | 37 +++++ app/Policies/Concerns/ChecksStoreRole.php | 29 ++++ app/Policies/StorePolicy.php | 30 ++++ app/Providers/AppServiceProvider.php | 25 +++ bootstrap/app.php | 4 +- config/auth.php | 20 ++- database/factories/OrganizationFactory.php | 25 +++ database/factories/StoreDomainFactory.php | 29 ++++ database/factories/StoreFactory.php | 39 +++++ database/factories/StoreSettingsFactory.php | 26 +++ database/factories/UserFactory.php | 2 + ...4_12_100001_create_organizations_table.php | 25 +++ .../2026_04_12_100002_create_stores_table.php | 39 +++++ ...4_12_100003_create_store_domains_table.php | 37 +++++ ..._12_100004_modify_users_table_for_shop.php | 26 +++ ..._04_12_100005_create_store_users_table.php | 35 ++++ ..._12_100006_create_store_settings_table.php | 25 +++ .../components/layouts/admin-auth.blade.php | 25 +++ .../views/livewire/admin/auth/login.blade.php | 33 ++++ routes/web.php | 15 ++ specs/progress.md | 22 +++ tests/Feature/Auth/AdminAuthTest.php | 70 ++++++++ tests/Feature/Tenancy/StoreIsolationTest.php | 61 +++++++ .../Feature/Tenancy/TenantResolutionTest.php | 63 +++++++ 37 files changed, 1368 insertions(+), 5 deletions(-) create mode 100644 app/Auth/CustomerUserProvider.php create mode 100644 app/Enums/StoreDomainType.php create mode 100644 app/Enums/StoreStatus.php create mode 100644 app/Enums/StoreUserRole.php create mode 100644 app/Http/Middleware/ResolveStore.php create mode 100644 app/Livewire/Admin/Auth/Login.php create mode 100644 app/Models/Concerns/BelongsToStore.php create mode 100644 app/Models/Organization.php create mode 100644 app/Models/Scopes/StoreScope.php create mode 100644 app/Models/Store.php create mode 100644 app/Models/StoreDomain.php create mode 100644 app/Models/StoreSettings.php create mode 100644 app/Models/StoreUser.php create mode 100644 app/Policies/Concerns/ChecksStoreRole.php create mode 100644 app/Policies/StorePolicy.php create mode 100644 database/factories/OrganizationFactory.php create mode 100644 database/factories/StoreDomainFactory.php create mode 100644 database/factories/StoreFactory.php create mode 100644 database/factories/StoreSettingsFactory.php create mode 100644 database/migrations/2026_04_12_100001_create_organizations_table.php create mode 100644 database/migrations/2026_04_12_100002_create_stores_table.php create mode 100644 database/migrations/2026_04_12_100003_create_store_domains_table.php create mode 100644 database/migrations/2026_04_12_100004_modify_users_table_for_shop.php create mode 100644 database/migrations/2026_04_12_100005_create_store_users_table.php create mode 100644 database/migrations/2026_04_12_100006_create_store_settings_table.php create mode 100644 resources/views/components/layouts/admin-auth.blade.php create mode 100644 resources/views/livewire/admin/auth/login.blade.php create mode 100644 specs/progress.md create mode 100644 tests/Feature/Auth/AdminAuthTest.php create mode 100644 tests/Feature/Tenancy/StoreIsolationTest.php create mode 100644 tests/Feature/Tenancy/TenantResolutionTest.php diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..2637fdd9 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,157 @@ + $model + */ + public function __construct( + protected Hasher $hasher, + protected string $model, + ) {} + + public function retrieveById($identifier): ?Authenticatable + { + $query = $this->newModelQuery(); + $this->constrainToCurrentStore($query); + + /** @var Authenticatable|null $model */ + $model = $query->find($identifier); + + return $model; + } + + public function retrieveByToken($identifier, $token): ?Authenticatable + { + $model = $this->createModel(); + + $query = $this->newModelQuery() + ->where($model->getAuthIdentifierName(), $identifier); + + $this->constrainToCurrentStore($query); + + /** @var Authenticatable|null $retrieved */ + $retrieved = $query->first(); + + if ($retrieved === null) { + return null; + } + + $rememberToken = $retrieved->getRememberToken(); + + if ($rememberToken !== null && hash_equals($rememberToken, $token)) { + return $retrieved; + } + + return null; + } + + public function updateRememberToken(Authenticatable $user, $token): void + { + $user->setRememberToken($token); + + $timestamps = $user->timestamps; + $user->timestamps = false; + $user->save(); + $user->timestamps = $timestamps; + } + + /** + * @param array $credentials + */ + public function retrieveByCredentials(array $credentials): ?Authenticatable + { + if (empty($credentials) + || (count($credentials) === 1 && Str::contains(array_key_first($credentials), 'password')) + ) { + return null; + } + + $query = $this->newModelQuery(); + $this->constrainToCurrentStore($query); + + foreach ($credentials as $key => $value) { + if (Str::contains($key, 'password')) { + continue; + } + + if (is_array($value) || $value instanceof \Illuminate\Contracts\Support\Arrayable) { + $query->whereIn($key, $value); + } else { + $query->where($key, $value); + } + } + + /** @var Authenticatable|null $result */ + $result = $query->first(); + + return $result; + } + + /** + * @param array $credentials + */ + public function validateCredentials(Authenticatable $user, array $credentials): bool + { + $plain = $credentials['password'] ?? null; + + if (! is_string($plain) || $plain === '') { + return false; + } + + return $this->hasher->check($plain, $user->getAuthPassword()); + } + + /** + * @param array $credentials + */ + public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false): void + { + if (! $this->hasher->needsRehash($user->getAuthPassword()) && ! $force) { + return; + } + + $plain = $credentials['password'] ?? null; + + if (! is_string($plain) || $plain === '') { + return; + } + + $user->forceFill([ + $user->getAuthPasswordName() => $this->hasher->make($plain), + ])->save(); + } + + protected function createModel(): Model + { + $class = '\\'.ltrim($this->model, '\\'); + + return new $class; + } + + protected function newModelQuery(): \Illuminate\Database\Eloquent\Builder + { + return $this->createModel()->newQuery(); + } + + protected function constrainToCurrentStore(\Illuminate\Database\Eloquent\Builder $query): void + { + if (! app()->bound('current_store')) { + return; + } + + /** @var Store $store */ + $store = app('current_store'); + + $query->where('store_id', $store->id); + } +} diff --git a/app/Enums/StoreDomainType.php b/app/Enums/StoreDomainType.php new file mode 100644 index 00000000..8b2b4869 --- /dev/null +++ b/app/Enums/StoreDomainType.php @@ -0,0 +1,10 @@ + $this->resolveFromHost($request), + 'admin' => $this->resolveFromSession($request), + default => null, + }; + + if ($store === null) { + throw new NotFoundHttpException('Store not found.'); + } + + if ($store->status === StoreStatus::Suspended) { + throw new HttpException(503, 'Store is temporarily unavailable.'); + } + + app()->instance('current_store', $store); + + return $next($request); + } + + protected function resolveFromHost(Request $request): ?Store + { + $hostname = $request->getHost(); + + $storeId = Cache::remember( + 'store_domain:'.$hostname, + now()->addMinutes(5), + function () use ($hostname): ?int { + $domain = StoreDomain::query() + ->where('hostname', $hostname) + ->first(); + + return $domain?->store_id; + } + ); + + if ($storeId === null) { + return null; + } + + return Store::query()->find($storeId); + } + + protected function resolveFromSession(Request $request): ?Store + { + $storeId = $request->session()->get('current_store_id'); + + if ($storeId === null) { + return null; + } + + $user = Auth::guard('web')->user(); + + if ($user === null) { + return null; + } + + $store = Store::query()->find($storeId); + + if ($store === null) { + return null; + } + + $hasAccess = $store->users() + ->where('users.id', $user->id) + ->exists(); + + if (! $hasAccess) { + return null; + } + + return $store; + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..22de5081 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,78 @@ +validate(); + + $throttleKey = $this->throttleKey(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + + throw ValidationException::withMessages([ + 'email' => __('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => (int) ceil($seconds / 60), + ]), + ]); + } + + if (! Auth::guard('web')->attempt( + ['email' => $this->email, 'password' => $this->password], + $this->remember, + )) { + RateLimiter::hit($throttleKey, 60); + + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + RateLimiter::clear($throttleKey); + + Session::regenerate(); + + $user = Auth::guard('web')->user(); + + $firstStore = $user?->stores()->first(); + + if ($firstStore !== null) { + Session::put('current_store_id', $firstStore->id); + } + + $user?->forceFill(['last_login_at' => now()])->save(); + + return redirect()->intended('/admin'); + } + + protected function throttleKey(): string + { + return 'login:'.strtolower($this->email).'|'.request()->ip(); + } + + #[Layout('components.layouts.admin-auth')] + public function render(): \Illuminate\Contracts\View\View + { + return view('livewire.admin.auth.login'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..d5806731 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,31 @@ +store_id === null && app()->bound('current_store')) { + /** @var Store $store */ + $store = app('current_store'); + $model->store_id = $store->id; + } + }); + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..ed94f758 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,26 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'billing_email', + ]; + + /** + * @return HasMany + */ + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..ad4711b2 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,23 @@ +bound('current_store')) { + return; + } + + /** @var Store $store */ + $store = app('current_store'); + + $builder->where($model->getTable().'.store_id', $store->id); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..ed4a7bd0 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,71 @@ + */ + use HasFactory; + + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + /** + * @return HasMany + */ + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..a62a4d70 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'store_id', + 'hostname', + 'type', + 'is_primary', + 'tls_mode', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..c7a07dba --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + protected $table = 'store_settings'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + public const CREATED_AT = null; + + protected $fillable = [ + 'store_id', + 'settings_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..8c5e63f6 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,36 @@ +attributes['created_at'])) { + $pivot->setAttribute('created_at', $pivot->freshTimestamp()); + } + }); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..34d1c156 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,8 +2,10 @@ namespace App\Models; +use App\Enums\StoreUserRole; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -23,6 +25,8 @@ class User extends Authenticatable 'name', 'email', 'password', + 'status', + 'last_login_at', ]; /** @@ -46,7 +50,9 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', 'password' => 'hashed', + 'status' => 'string', ]; } @@ -61,4 +67,35 @@ public function initials(): string ->map(fn ($word) => Str::substr($word, 0, 1)) ->implode(''); } + + /** + * @return BelongsToMany + */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + /** @var Store|null $match */ + $match = $this->stores()->where('stores.id', $store->id)->first(); + + if ($match === null) { + return null; + } + + /** @var StoreUser $pivot */ + $pivot = $match->pivot; + + $role = $pivot->role; + + if ($role instanceof StoreUserRole) { + return $role; + } + + return $role === null ? null : StoreUserRole::from($role); + } } diff --git a/app/Policies/Concerns/ChecksStoreRole.php b/app/Policies/Concerns/ChecksStoreRole.php new file mode 100644 index 00000000..ba3052bc --- /dev/null +++ b/app/Policies/Concerns/ChecksStoreRole.php @@ -0,0 +1,29 @@ +bound('current_store')) { + return null; + } + + /** @var Store $store */ + $store = app('current_store'); + + return $user->roleForStore($store); + } + + protected function hasMinRole(User $user, StoreUserRole ...$roles): bool + { + $role = $this->getUserRole($user); + + return $role !== null && in_array($role, $roles, true); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..9dcfafa9 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,30 @@ +roleForStore($store) !== null; + } + + public function update(User $user, Store $store): bool + { + $role = $user->roleForStore($store); + + return in_array($role, [StoreUserRole::Owner, StoreUserRole::Admin], true); + } + + public function delete(User $user, Store $store): bool + { + return $user->roleForStore($store) === StoreUserRole::Owner; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..664681ac 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,14 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -24,6 +29,26 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureRateLimiters(); + $this->configureCustomerAuthProvider(); + } + + protected function configureRateLimiters(): void + { + RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by((string) $request->ip())); + } + + /** + * Customer model is created in Phase 6. Guard/provider configured in advance. + */ + protected function configureCustomerAuthProvider(): void + { + Auth::provider('customer', function ($app, array $config): CustomerUserProvider { + /** @var class-string<\Illuminate\Database\Eloquent\Model> $model */ + $model = $config['model']; + + return new CustomerUserProvider($app['hash'], $model); + }); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..34a0bb98 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,7 +11,9 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'store.resolve' => \App\Http\Middleware\ResolveStore::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..c7e1a387 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -65,10 +70,10 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + 'customers' => [ + 'driver' => 'customer', + 'model' => App\Models\Customer::class, + ], ], /* @@ -97,6 +102,13 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..b77d4279 --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,25 @@ + + */ +class OrganizationFactory extends Factory +{ + protected $model = Organization::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->unique()->companyEmail(), + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..ea92a2c5 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,29 @@ + + */ +class StoreDomainFactory extends Factory +{ + protected $model = StoreDomain::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]; + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..dcd9d1b2 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,39 @@ + + */ +class StoreFactory extends Factory +{ + protected $model = Store::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => Organization::factory(), + 'name' => fake()->company().' Store', + 'handle' => Str::slug(fake()->unique()->company()).'-'.fake()->unique()->randomNumber(5), + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'suspended', + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..c565fdb5 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,26 @@ + + */ +class StoreSettingsFactory extends Factory +{ + protected $model = StoreSettings::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..eee75f1c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,8 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'status' => 'active', + 'last_login_at' => now(), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, diff --git a/database/migrations/2026_04_12_100001_create_organizations_table.php b/database/migrations/2026_04_12_100001_create_organizations_table.php new file mode 100644 index 00000000..4eb2eab2 --- /dev/null +++ b/database/migrations/2026_04_12_100001_create_organizations_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + } + + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_04_12_100002_create_stores_table.php b/database/migrations/2026_04_12_100002_create_stores_table.php new file mode 100644 index 00000000..001066b4 --- /dev/null +++ b/database/migrations/2026_04_12_100002_create_stores_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('organization_id') + ->constrained('organizations') + ->cascadeOnDelete(); + $table->string('name'); + $table->string('handle')->unique('idx_stores_handle'); + $table->string('status')->default('active'); + $table->string('default_currency')->default('USD'); + $table->string('default_locale')->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + + DB::statement("CREATE TRIGGER stores_status_check BEFORE INSERT ON stores FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','suspended') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER stores_status_check_update BEFORE UPDATE ON stores FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','suspended') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS stores_status_check'); + DB::statement('DROP TRIGGER IF EXISTS stores_status_check_update'); + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_04_12_100003_create_store_domains_table.php b/database/migrations/2026_04_12_100003_create_store_domains_table.php new file mode 100644 index 00000000..c09e5a6c --- /dev/null +++ b/database/migrations/2026_04_12_100003_create_store_domains_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('hostname')->unique('idx_store_domains_hostname'); + $table->string('type')->default('storefront'); + $table->boolean('is_primary')->default(false); + $table->string('tls_mode')->default('managed'); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_store_domains_store_id'); + $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary'); + }); + + DB::statement("CREATE TRIGGER store_domains_type_check BEFORE INSERT ON store_domains FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('storefront','admin','api') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER store_domains_tls_check BEFORE INSERT ON store_domains FOR EACH ROW BEGIN SELECT CASE WHEN NEW.tls_mode NOT IN ('managed','bring_your_own') THEN RAISE(ABORT, 'invalid tls_mode') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS store_domains_type_check'); + DB::statement('DROP TRIGGER IF EXISTS store_domains_tls_check'); + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_04_12_100004_modify_users_table_for_shop.php b/database/migrations/2026_04_12_100004_modify_users_table_for_shop.php new file mode 100644 index 00000000..92b26988 --- /dev/null +++ b/database/migrations/2026_04_12_100004_modify_users_table_for_shop.php @@ -0,0 +1,26 @@ +string('status')->default('active')->after('password'); + $table->timestamp('last_login_at')->nullable()->after('email_verified_at'); + + $table->index('status', 'idx_users_status'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropIndex('idx_users_status'); + $table->dropColumn(['status', 'last_login_at']); + }); + } +}; diff --git a/database/migrations/2026_04_12_100005_create_store_users_table.php b/database/migrations/2026_04_12_100005_create_store_users_table.php new file mode 100644 index 00000000..0529cded --- /dev/null +++ b/database/migrations/2026_04_12_100005_create_store_users_table.php @@ -0,0 +1,35 @@ +foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->string('role')->default('staff'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'user_id']); + $table->index('user_id', 'idx_store_users_user_id'); + $table->index(['store_id', 'role'], 'idx_store_users_role'); + }); + + DB::statement("CREATE TRIGGER store_users_role_check BEFORE INSERT ON store_users FOR EACH ROW BEGIN SELECT CASE WHEN NEW.role NOT IN ('owner','admin','staff','support') THEN RAISE(ABORT, 'invalid role') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS store_users_role_check'); + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_04_12_100006_create_store_settings_table.php b/database/migrations/2026_04_12_100006_create_store_settings_table.php new file mode 100644 index 00000000..3a1cf439 --- /dev/null +++ b/database/migrations/2026_04_12_100006_create_store_settings_table.php @@ -0,0 +1,25 @@ +foreignId('store_id') + ->primary() + ->constrained('stores') + ->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/resources/views/components/layouts/admin-auth.blade.php b/resources/views/components/layouts/admin-auth.blade.php new file mode 100644 index 00000000..1b1860f2 --- /dev/null +++ b/resources/views/components/layouts/admin-auth.blade.php @@ -0,0 +1,25 @@ + + + + + + + {{ $title ?? __('Admin Login') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + @fluxAppearance + + +
+
+
+
+ {{ $slot }} +
+
+
+
+ @livewireScripts + @fluxScripts + + diff --git a/resources/views/livewire/admin/auth/login.blade.php b/resources/views/livewire/admin/auth/login.blade.php new file mode 100644 index 00000000..009d4d6c --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,33 @@ +
+
+

{{ __('Admin sign in') }}

+

{{ __('Enter your email and password to access the admin panel') }}

+
+ +
+ + + + + + + + {{ __('Log in') }} + + +
diff --git a/routes/web.php b/routes/web.php index f755f111..8e2d5aa9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,8 @@ middleware(['auth', 'verified']) ->name('dashboard'); +Route::get('/admin/login', AdminLogin::class) + ->middleware('guest') + ->name('admin.login'); + +Route::post('/admin/logout', function (Request $request) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.login'); +})->middleware('auth')->name('admin.logout'); + require __DIR__.'/settings.php'; diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..f9696f6b --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,22 @@ +# Shop Build Progress + +Tracking progress for the full shop system implementation (team: shop-v2). + +## Phase Checklist + +- [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing +- [ ] Phase 2: Catalog (products, variants, inventory, collections, media) +- [ ] Phase 3: Themes, pages, navigation, storefront layout +- [ ] Phase 4: Cart, checkout, discounts, shipping, taxes +- [ ] Phase 5: Payments, orders, fulfillment +- [ ] Phase 6: Customer accounts +- [ ] Phase 7: Admin panel +- [ ] Phase 8: Search +- [ ] Phase 9: Analytics +- [ ] Phase 10: Apps and webhooks +- [ ] Phase 11: Polish +- [ ] Phase 12: Full test suite execution + browser review + +## Log + +- Starting Phase 1: Foundation diff --git a/tests/Feature/Auth/AdminAuthTest.php b/tests/Feature/Auth/AdminAuthTest.php new file mode 100644 index 00000000..3693291b --- /dev/null +++ b/tests/Feature/Auth/AdminAuthTest.php @@ -0,0 +1,70 @@ +create([ + 'email' => 'admin@example.test', + 'password' => Hash::make('secret-pass'), + ]); + + $store = Store::factory()->create(); + $store->users()->attach($user, ['role' => 'owner']); + + Livewire::test(Login::class) + ->set('email', 'admin@example.test') + ->set('password', 'secret-pass') + ->call('login') + ->assertRedirect('/admin'); + + expect(auth('web')->id())->toBe($user->id) + ->and(session('current_store_id'))->toBe($store->id); +}); + +it('rejects login with wrong password', function (): void { + User::factory()->create([ + 'email' => 'admin@example.test', + 'password' => Hash::make('secret-pass'), + ]); + + Livewire::test(Login::class) + ->set('email', 'admin@example.test') + ->set('password', 'nope') + ->call('login') + ->assertHasErrors('email'); + + expect(auth('web')->check())->toBeFalse(); +}); + +it('rate limits login after 5 failed attempts', function (): void { + User::factory()->create([ + 'email' => 'admin@example.test', + 'password' => Hash::make('secret-pass'), + ]); + + $component = Livewire::test(Login::class) + ->set('email', 'admin@example.test') + ->set('password', 'wrong'); + + for ($i = 0; $i < 5; $i++) { + $component->call('login'); + } + + $component->set('password', 'secret-pass') + ->call('login') + ->assertHasErrors('email'); + + expect(auth('web')->check())->toBeFalse(); +}); diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php new file mode 100644 index 00000000..2a15748a --- /dev/null +++ b/tests/Feature/Tenancy/StoreIsolationTest.php @@ -0,0 +1,61 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->timestamps(); + }); +}); + +afterEach(function (): void { + Schema::dropIfExists('widgets'); + app()->forgetInstance('current_store'); +}); + +it('applies store scope to models using BelongsToStore', function (): void { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + app()->instance('current_store', $storeA); + Widget::create(['name' => 'A1']); + Widget::create(['name' => 'A2']); + + app()->instance('current_store', $storeB); + Widget::create(['name' => 'B1']); + + app()->instance('current_store', $storeA); + expect(Widget::count())->toBe(2) + ->and(Widget::pluck('name')->all())->toEqual(['A1', 'A2']); + + app()->instance('current_store', $storeB); + expect(Widget::count())->toBe(1) + ->and(Widget::pluck('name')->all())->toEqual(['B1']); +}); + +it('auto-assigns store_id on creating when current store is bound', function (): void { + $store = Store::factory()->create(); + app()->instance('current_store', $store); + + $widget = Widget::create(['name' => 'auto']); + + expect($widget->store_id)->toBe($store->id); +}); + +class Widget extends Model +{ + use BelongsToStore; + + protected $table = 'widgets'; + + protected $fillable = ['name', 'store_id']; +} diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..3261d076 --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,63 @@ +json([ + 'id' => $store->id, + 'handle' => $store->handle, + ]); + })->middleware(ResolveStore::class.':storefront'); +}); + +it('resolves store from hostname via store_domains', function (): void { + $store = Store::factory()->create(); + StoreDomain::factory()->for($store)->create(['hostname' => 'shop.example.test']); + + $response = $this->get('http://shop.example.test/__resolve_probe'); + + $response->assertOk() + ->assertJson([ + 'id' => $store->id, + 'handle' => $store->handle, + ]); +}); + +it('returns 404 for unknown hostnames', function (): void { + $response = $this->get('http://unknown.example.test/__resolve_probe'); + + $response->assertNotFound(); +}); + +it('returns 503 for suspended stores', function (): void { + $store = Store::factory()->suspended()->create(); + StoreDomain::factory()->for($store)->create(['hostname' => 'suspended.example.test']); + + $response = $this->get('http://suspended.example.test/__resolve_probe'); + + $response->assertStatus(503); +}); + +it('caches hostname lookup', function (): void { + $store = Store::factory()->create(); + StoreDomain::factory()->for($store)->create(['hostname' => 'cached.example.test']); + + $this->get('http://cached.example.test/__resolve_probe')->assertOk(); + + $cached = Cache::get('store_domain:cached.example.test'); + + expect($cached)->toBe($store->id); +}); From 3c2a4172e3ed517a1f9214c47c7668a098fd3f8c Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 20:40:35 +0200 Subject: [PATCH 03/15] Phase 2: Catalog (products, variants, inventory, collections, media) - Migrations: products, product_options/values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media - Models + factories for all 7 catalog entities with BelongsToStore applied to Product, Collection, InventoryItem - Enums: ProductStatus, VariantStatus, CollectionStatus, CollectionType, MediaType, MediaStatus, InventoryPolicy - Services: ProductService (CRUD with transaction), VariantMatrixService (cartesian rebuild with orphan archival), InventoryService (reserve, release, commit, restock), HandleGenerator - Job: ProcessMediaUpload (stub, marks media Ready) - Exception: InsufficientInventoryException - Tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest (21 new tests, 63 total passing) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Enums/CollectionStatus.php | 10 + app/Enums/CollectionType.php | 9 + app/Enums/InventoryPolicy.php | 9 + app/Enums/MediaStatus.php | 10 + app/Enums/MediaType.php | 9 + app/Enums/ProductStatus.php | 10 + app/Enums/VariantStatus.php | 9 + .../InsufficientInventoryException.php | 7 + app/Jobs/ProcessMediaUpload.php | 27 +++ app/Models/Collection.php | 45 ++++ app/Models/InventoryItem.php | 48 +++++ app/Models/Product.php | 73 +++++++ app/Models/ProductMedia.php | 51 +++++ app/Models/ProductOption.php | 38 ++++ app/Models/ProductOptionValue.php | 29 +++ app/Models/ProductVariant.php | 71 +++++++ app/Services/InventoryService.php | 67 ++++++ app/Services/ProductService.php | 198 ++++++++++++++++++ app/Services/VariantMatrixService.php | 106 ++++++++++ app/Support/HandleGenerator.php | 41 ++++ database/factories/CollectionFactory.php | 35 ++++ database/factories/InventoryItemFactory.php | 31 +++ database/factories/ProductFactory.php | 52 +++++ database/factories/ProductMediaFactory.php | 36 ++++ database/factories/ProductOptionFactory.php | 27 +++ .../factories/ProductOptionValueFactory.php | 27 +++ database/factories/ProductVariantFactory.php | 43 ++++ ...026_04_12_101001_create_products_table.php | 45 ++++ ...12_101002_create_product_options_table.php | 28 +++ ...003_create_product_option_values_table.php | 28 +++ ...2_101004_create_product_variants_table.php | 46 ++++ ...005_create_variant_option_values_table.php | 28 +++ ...12_101006_create_inventory_items_table.php | 39 ++++ ..._04_12_101007_create_collections_table.php | 43 ++++ ...01008_create_collection_products_table.php | 30 +++ ...4_12_101009_create_product_media_table.php | 47 +++++ specs/progress.md | 2 +- tests/Feature/Products/CollectionTest.php | 64 ++++++ tests/Feature/Products/InventoryTest.php | 89 ++++++++ tests/Feature/Products/MediaUploadTest.php | 42 ++++ tests/Feature/Products/ProductCrudTest.php | 105 ++++++++++ tests/Feature/Products/VariantTest.php | 79 +++++++ 42 files changed, 1832 insertions(+), 1 deletion(-) create mode 100644 app/Enums/CollectionStatus.php create mode 100644 app/Enums/CollectionType.php create mode 100644 app/Enums/InventoryPolicy.php create mode 100644 app/Enums/MediaStatus.php create mode 100644 app/Enums/MediaType.php create mode 100644 app/Enums/ProductStatus.php create mode 100644 app/Enums/VariantStatus.php create mode 100644 app/Exceptions/InsufficientInventoryException.php create mode 100644 app/Jobs/ProcessMediaUpload.php create mode 100644 app/Models/Collection.php create mode 100644 app/Models/InventoryItem.php create mode 100644 app/Models/Product.php create mode 100644 app/Models/ProductMedia.php create mode 100644 app/Models/ProductOption.php create mode 100644 app/Models/ProductOptionValue.php create mode 100644 app/Models/ProductVariant.php create mode 100644 app/Services/InventoryService.php create mode 100644 app/Services/ProductService.php create mode 100644 app/Services/VariantMatrixService.php create mode 100644 app/Support/HandleGenerator.php create mode 100644 database/factories/CollectionFactory.php create mode 100644 database/factories/InventoryItemFactory.php create mode 100644 database/factories/ProductFactory.php create mode 100644 database/factories/ProductMediaFactory.php create mode 100644 database/factories/ProductOptionFactory.php create mode 100644 database/factories/ProductOptionValueFactory.php create mode 100644 database/factories/ProductVariantFactory.php create mode 100644 database/migrations/2026_04_12_101001_create_products_table.php create mode 100644 database/migrations/2026_04_12_101002_create_product_options_table.php create mode 100644 database/migrations/2026_04_12_101003_create_product_option_values_table.php create mode 100644 database/migrations/2026_04_12_101004_create_product_variants_table.php create mode 100644 database/migrations/2026_04_12_101005_create_variant_option_values_table.php create mode 100644 database/migrations/2026_04_12_101006_create_inventory_items_table.php create mode 100644 database/migrations/2026_04_12_101007_create_collections_table.php create mode 100644 database/migrations/2026_04_12_101008_create_collection_products_table.php create mode 100644 database/migrations/2026_04_12_101009_create_product_media_table.php create mode 100644 tests/Feature/Products/CollectionTest.php create mode 100644 tests/Feature/Products/InventoryTest.php create mode 100644 tests/Feature/Products/MediaUploadTest.php create mode 100644 tests/Feature/Products/ProductCrudTest.php create mode 100644 tests/Feature/Products/VariantTest.php diff --git a/app/Enums/CollectionStatus.php b/app/Enums/CollectionStatus.php new file mode 100644 index 00000000..aa9da513 --- /dev/null +++ b/app/Enums/CollectionStatus.php @@ -0,0 +1,10 @@ + $this->media->id, + 'storage_key' => $this->media->storage_key, + ]); + + $this->media->status = MediaStatus::Ready; + $this->media->save(); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..a35c5e08 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,45 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => CollectionType::class, + 'status' => CollectionStatus::class, + ]; + } + + /** + * @return BelongsToMany + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position'); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..da8560b7 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,48 @@ + */ + use BelongsToStore, HasFactory; + + const CREATED_AT = null; + + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'policy' => InventoryPolicy::class, + ]; + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function quantityAvailable(): int + { + return (int) $this->quantity_on_hand - (int) $this->quantity_reserved; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..8fb060ee --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,73 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + + /** + * @return HasMany + */ + public function options(): HasMany + { + return $this->hasMany(ProductOption::class); + } + + /** + * @return HasMany + */ + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class); + } + + /** + * @return BelongsToMany + */ + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position'); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..2b5fa32e --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + protected $table = 'product_media'; + + const UPDATED_AT = null; + + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => MediaType::class, + 'status' => MediaStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..de8f9f5b --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return HasMany + */ + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..b2f0c8b3 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + /** + * @return BelongsTo + */ + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..8255aa81 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,71 @@ + */ + use HasFactory; + + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => VariantStatus::class, + 'requires_shipping' => 'bool', + 'is_default' => 'bool', + ]; + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return HasOne + */ + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + /** + * @return BelongsToMany + */ + public function optionValues(): BelongsToMany + { + return $this->belongsToMany( + ProductOptionValue::class, + 'variant_option_values', + 'variant_id', + 'product_option_value_id' + ); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..b532b31a --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,67 @@ +quantityAvailable() >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + + $policy = $item->policy instanceof InventoryPolicy + ? $item->policy + : InventoryPolicy::from((string) $item->policy); + + if ($policy === InventoryPolicy::Deny && $item->quantityAvailable() < $quantity) { + throw new InsufficientInventoryException( + "Insufficient inventory for variant {$item->variant_id}." + ); + } + + $item->quantity_reserved = (int) $item->quantity_reserved + $quantity; + $item->save(); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + + $item->quantity_reserved = max(0, (int) $item->quantity_reserved - $quantity); + $item->save(); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + + $item->quantity_on_hand = max(0, (int) $item->quantity_on_hand - $quantity); + $item->quantity_reserved = max(0, (int) $item->quantity_reserved - $quantity); + $item->save(); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity): void { + $item->refresh(); + + $item->quantity_on_hand = (int) $item->quantity_on_hand + $quantity; + $item->save(); + }); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..6fc7cab9 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,198 @@ + $data + */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $title = (string) $data['title']; + $handle = isset($data['handle']) && $data['handle'] !== '' + ? HandleGenerator::generate((string) $data['handle'], 'products', $store->id) + : HandleGenerator::generate($title, 'products', $store->id); + + $product = new Product; + $product->store_id = $store->id; + $product->title = $title; + $product->handle = $handle; + $product->status = $data['status'] ?? ProductStatus::Draft->value; + $product->description_html = $data['description_html'] ?? null; + $product->vendor = $data['vendor'] ?? null; + $product->product_type = $data['product_type'] ?? null; + $product->tags = $data['tags'] ?? []; + $product->published_at = $data['published_at'] ?? null; + $product->save(); + + $options = $data['options'] ?? []; + $this->syncOptions($product, $options); + + if (! empty($options)) { + $this->variantMatrixService->rebuildMatrix($product->fresh(['options.values'])); + } else { + $variantsData = $data['variants'] ?? []; + + if ($variantsData === []) { + $product->variants()->create([ + 'price_amount' => 0, + 'currency' => $store->default_currency ?? 'USD', + 'is_default' => true, + 'position' => 0, + 'status' => 'active', + ]); + } else { + foreach ($variantsData as $index => $variantData) { + $product->variants()->create(array_merge([ + 'price_amount' => 0, + 'currency' => $store->default_currency ?? 'USD', + 'is_default' => $index === 0, + 'position' => $index, + 'status' => 'active', + ], $variantData)); + } + } + } + + return $product->fresh(['variants', 'options.values']); + }); + } + + /** + * @param array $data + */ + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + if (isset($data['title'])) { + $product->title = (string) $data['title']; + } + + if (isset($data['handle']) && $data['handle'] !== $product->handle) { + $product->handle = HandleGenerator::generate( + (string) $data['handle'], + 'products', + (int) $product->store_id, + $product->id + ); + } + + foreach (['description_html', 'vendor', 'product_type', 'tags', 'published_at'] as $field) { + if (array_key_exists($field, $data)) { + $product->{$field} = $data[$field]; + } + } + + $product->save(); + + if (array_key_exists('options', $data)) { + $this->syncOptions($product, $data['options']); + $this->variantMatrixService->rebuildMatrix($product->fresh(['options.values'])); + } + + return $product->fresh(['variants', 'options.values']); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $current = $product->status instanceof ProductStatus + ? $product->status + : ProductStatus::from((string) $product->status); + + $allowed = match ($current) { + ProductStatus::Draft => [ProductStatus::Active], + ProductStatus::Active => [ProductStatus::Archived], + ProductStatus::Archived => [ProductStatus::Active], + }; + + if (! in_array($newStatus, $allowed, true)) { + throw new InvalidArgumentException( + "Cannot transition product from {$current->value} to {$newStatus->value}" + ); + } + + $product->status = $newStatus; + $product->save(); + } + + public function delete(Product $product): void + { + $status = $product->status instanceof ProductStatus + ? $product->status + : ProductStatus::from((string) $product->status); + + if ($status !== ProductStatus::Draft) { + throw new InvalidArgumentException('Only draft products can be deleted.'); + } + + // Phase 5: also block deletion when order_lines reference this product. + $product->delete(); + } + + /** + * @param array> $options + */ + private function syncOptions(Product $product, array $options): void + { + $existingOptions = $product->options()->with('values')->get()->keyBy('name'); + + $product->options()->update(['position' => DB::raw('position + 1000')]); + + foreach ($existingOptions as $option) { + $option->values()->update(['position' => DB::raw('position + 1000')]); + } + + $keptOptionIds = []; + + foreach ($options as $optionIndex => $optionData) { + $name = (string) $optionData['name']; + $position = $optionData['position'] ?? $optionIndex; + + if ($existingOptions->has($name)) { + $option = $existingOptions->get($name); + $option->position = $position; + $option->save(); + } else { + $option = $product->options()->create([ + 'name' => $name, + 'position' => $position, + ]); + } + + $keptOptionIds[] = $option->id; + $existingValues = $option->values()->get()->keyBy('value'); + $keptValueIds = []; + + foreach ($optionData['values'] ?? [] as $valueIndex => $value) { + if ($existingValues->has($value)) { + $optionValue = $existingValues->get($value); + $optionValue->position = $valueIndex; + $optionValue->save(); + } else { + $optionValue = $option->values()->create([ + 'value' => $value, + 'position' => $valueIndex, + ]); + } + + $keptValueIds[] = $optionValue->id; + } + + $option->values()->whereNotIn('id', $keptValueIds)->delete(); + } + + $product->options()->whereNotIn('id', $keptOptionIds)->delete(); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..fb145f6b --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,106 @@ +load('options.values', 'variants.optionValues'); + + $options = $product->options; + + if ($options->isEmpty()) { + return; + } + + $combinations = $this->cartesian( + $options->map(fn ($option) => $option->values->all())->all() + ); + + $currency = optional($product->store)->default_currency ?? 'USD'; + $existingByKey = []; + + foreach ($product->variants as $variant) { + $key = $this->keyFor($variant->optionValues->pluck('id')->all()); + $existingByKey[$key] = $variant; + } + + $seenKeys = []; + + foreach ($combinations as $index => $combination) { + $valueIds = array_map(fn ($value) => $value->id, $combination); + $key = $this->keyFor($valueIds); + $seenKeys[] = $key; + + if (isset($existingByKey[$key])) { + $variant = $existingByKey[$key]; + + if ($variant->status !== VariantStatus::Active) { + $variant->status = VariantStatus::Active; + $variant->save(); + } + + continue; + } + + $variant = $product->variants()->create([ + 'price_amount' => 0, + 'currency' => $currency, + 'is_default' => $index === 0 && ! $product->variants()->where('is_default', true)->exists(), + 'position' => $index, + 'status' => VariantStatus::Active->value, + ]); + + $variant->optionValues()->sync($valueIds); + } + + $seenKeysMap = array_flip($seenKeys); + + foreach ($existingByKey as $key => $variant) { + if (! array_key_exists((string) $key, $seenKeysMap)) { + $variant->status = VariantStatus::Archived; + $variant->save(); + } + } + }); + } + + /** + * @param array> $groups + * @return array> + */ + private function cartesian(array $groups): array + { + $result = [[]]; + + foreach ($groups as $group) { + $next = []; + + foreach ($result as $acc) { + foreach ($group as $item) { + $next[] = array_merge($acc, [$item]); + } + } + + $result = $next; + } + + return $result; + } + + /** + * @param array $valueIds + */ + private function keyFor(array $valueIds): string + { + sort($valueIds); + + return implode('-', $valueIds); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..cbc3c8e0 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,41 @@ +where('store_id', $storeId) + ->where('handle', $handle); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..0e869780 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,35 @@ + + */ +class CollectionFactory extends Factory +{ + protected $model = Collection::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'title' => ucfirst($title), + 'handle' => Str::slug($title).'-'.fake()->unique()->randomNumber(5), + 'description_html' => '

'.fake()->sentence().'

', + 'type' => CollectionType::Manual->value, + 'status' => CollectionStatus::Active->value, + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..6a1e3088 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,31 @@ + + */ +class InventoryItemFactory extends Factory +{ + protected $model = InventoryItem::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => 100, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny->value, + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..2cfc40b8 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,52 @@ + + */ +class ProductFactory extends Factory +{ + protected $model = Product::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => ucfirst($title), + 'handle' => Str::slug($title).'-'.fake()->unique()->randomNumber(5), + 'status' => ProductStatus::Active->value, + 'description_html' => '

'.fake()->paragraph().'

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Apparel', 'Footwear', 'Accessories', 'Home']), + 'tags' => [fake()->word(), fake()->word()], + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Draft->value, + 'published_at' => null, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Archived->value, + ]); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..2eee5e26 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,36 @@ + + */ +class ProductMediaFactory extends Factory +{ + protected $model = ProductMedia::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => MediaType::Image->value, + 'storage_key' => 'products/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => 204800, + 'position' => 0, + 'status' => MediaStatus::Processing->value, + ]; + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..67acc66e --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,27 @@ + + */ +class ProductOptionFactory extends Factory +{ + protected $model = ProductOption::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'name' => fake()->randomElement(['Size', 'Color', 'Material']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductOptionValueFactory.php b/database/factories/ProductOptionValueFactory.php new file mode 100644 index 00000000..bb95a021 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,27 @@ + + */ +class ProductOptionValueFactory extends Factory +{ + protected $model = ProductOptionValue::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => fake()->word(), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..4df703dc --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,43 @@ + + */ +class ProductVariantFactory extends Factory +{ + protected $model = ProductVariant::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => strtoupper(fake()->bothify('SKU-####-???')), + 'barcode' => fake()->ean13(), + 'price_amount' => 2499, + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 250, + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active->value, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_default' => true, + ]); + } +} diff --git a/database/migrations/2026_04_12_101001_create_products_table.php b/database/migrations/2026_04_12_101001_create_products_table.php new file mode 100644 index 00000000..40789963 --- /dev/null +++ b/database/migrations/2026_04_12_101001_create_products_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->string('status')->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->json('tags')->default(DB::raw("('[]')")); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_products_store_handle'); + $table->index('store_id', 'idx_products_store_id'); + $table->index(['store_id', 'status'], 'idx_products_store_status'); + $table->index(['store_id', 'published_at'], 'idx_products_published_at'); + $table->index(['store_id', 'vendor'], 'idx_products_vendor'); + $table->index(['store_id', 'product_type'], 'idx_products_product_type'); + }); + + DB::statement("CREATE TRIGGER products_status_check BEFORE INSERT ON products FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','active','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER products_status_check_update BEFORE UPDATE ON products FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','active','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS products_status_check'); + DB::statement('DROP TRIGGER IF EXISTS products_status_check_update'); + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_04_12_101002_create_product_options_table.php b/database/migrations/2026_04_12_101002_create_product_options_table.php new file mode 100644 index 00000000..a4c489de --- /dev/null +++ b/database/migrations/2026_04_12_101002_create_product_options_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('product_id') + ->constrained('products') + ->cascadeOnDelete(); + $table->string('name'); + $table->integer('position')->default(0); + + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_04_12_101003_create_product_option_values_table.php b/database/migrations/2026_04_12_101003_create_product_option_values_table.php new file mode 100644 index 00000000..0a9ba197 --- /dev/null +++ b/database/migrations/2026_04_12_101003_create_product_option_values_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('product_option_id') + ->constrained('product_options') + ->cascadeOnDelete(); + $table->string('value'); + $table->integer('position')->default(0); + + $table->index('product_option_id', 'idx_product_option_values_option_id'); + $table->unique(['product_option_id', 'position'], 'idx_product_option_values_option_position'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_04_12_101004_create_product_variants_table.php b/database/migrations/2026_04_12_101004_create_product_variants_table.php new file mode 100644 index 00000000..8ce84916 --- /dev/null +++ b/database/migrations/2026_04_12_101004_create_product_variants_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('product_id') + ->constrained('products') + ->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->integer('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->integer('position')->default(0); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->index('product_id', 'idx_product_variants_product_id'); + $table->index('sku', 'idx_product_variants_sku'); + $table->index('barcode', 'idx_product_variants_barcode'); + $table->index(['product_id', 'position'], 'idx_product_variants_product_position'); + $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default'); + }); + + DB::statement("CREATE TRIGGER product_variants_status_check BEFORE INSERT ON product_variants FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER product_variants_status_check_update BEFORE UPDATE ON product_variants FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS product_variants_status_check'); + DB::statement('DROP TRIGGER IF EXISTS product_variants_status_check_update'); + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_04_12_101005_create_variant_option_values_table.php b/database/migrations/2026_04_12_101005_create_variant_option_values_table.php new file mode 100644 index 00000000..e050f4cd --- /dev/null +++ b/database/migrations/2026_04_12_101005_create_variant_option_values_table.php @@ -0,0 +1,28 @@ +foreignId('variant_id') + ->constrained('product_variants') + ->cascadeOnDelete(); + $table->foreignId('product_option_value_id') + ->constrained('product_option_values') + ->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_04_12_101006_create_inventory_items_table.php b/database/migrations/2026_04_12_101006_create_inventory_items_table.php new file mode 100644 index 00000000..44135633 --- /dev/null +++ b/database/migrations/2026_04_12_101006_create_inventory_items_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->foreignId('variant_id') + ->unique('idx_inventory_items_variant_id') + ->constrained('product_variants') + ->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->string('policy')->default('deny'); + $table->timestamp('updated_at')->nullable(); + + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + + DB::statement("CREATE TRIGGER inventory_items_policy_check BEFORE INSERT ON inventory_items FOR EACH ROW BEGIN SELECT CASE WHEN NEW.policy NOT IN ('deny','continue') THEN RAISE(ABORT, 'invalid policy') END; END"); + DB::statement("CREATE TRIGGER inventory_items_policy_check_update BEFORE UPDATE ON inventory_items FOR EACH ROW BEGIN SELECT CASE WHEN NEW.policy NOT IN ('deny','continue') THEN RAISE(ABORT, 'invalid policy') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS inventory_items_policy_check'); + DB::statement('DROP TRIGGER IF EXISTS inventory_items_policy_check_update'); + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_04_12_101007_create_collections_table.php b/database/migrations/2026_04_12_101007_create_collections_table.php new file mode 100644 index 00000000..9296a78e --- /dev/null +++ b/database/migrations/2026_04_12_101007_create_collections_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->string('type')->default('manual'); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_collections_store_handle'); + $table->index('store_id', 'idx_collections_store_id'); + $table->index(['store_id', 'status'], 'idx_collections_store_status'); + }); + + DB::statement("CREATE TRIGGER collections_type_check BEFORE INSERT ON collections FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('manual','automated') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER collections_type_check_update BEFORE UPDATE ON collections FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('manual','automated') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER collections_status_check BEFORE INSERT ON collections FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','active','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER collections_status_check_update BEFORE UPDATE ON collections FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','active','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS collections_type_check'); + DB::statement('DROP TRIGGER IF EXISTS collections_type_check_update'); + DB::statement('DROP TRIGGER IF EXISTS collections_status_check'); + DB::statement('DROP TRIGGER IF EXISTS collections_status_check_update'); + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_04_12_101008_create_collection_products_table.php b/database/migrations/2026_04_12_101008_create_collection_products_table.php new file mode 100644 index 00000000..ef5e27ee --- /dev/null +++ b/database/migrations/2026_04_12_101008_create_collection_products_table.php @@ -0,0 +1,30 @@ +foreignId('collection_id') + ->constrained('collections') + ->cascadeOnDelete(); + $table->foreignId('product_id') + ->constrained('products') + ->cascadeOnDelete(); + $table->integer('position')->default(0); + + $table->primary(['collection_id', 'product_id']); + $table->index('product_id', 'idx_collection_products_product_id'); + $table->index(['collection_id', 'position'], 'idx_collection_products_position'); + }); + } + + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_04_12_101009_create_product_media_table.php b/database/migrations/2026_04_12_101009_create_product_media_table.php new file mode 100644 index 00000000..906ca6b2 --- /dev/null +++ b/database/migrations/2026_04_12_101009_create_product_media_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('product_id') + ->constrained('products') + ->cascadeOnDelete(); + $table->string('type')->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->integer('position')->default(0); + $table->string('status')->default('processing'); + $table->timestamp('created_at')->nullable(); + + $table->index('product_id', 'idx_product_media_product_id'); + $table->index(['product_id', 'position'], 'idx_product_media_product_position'); + $table->index('status', 'idx_product_media_status'); + }); + + DB::statement("CREATE TRIGGER product_media_type_check BEFORE INSERT ON product_media FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('image','video') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER product_media_type_check_update BEFORE UPDATE ON product_media FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('image','video') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER product_media_status_check BEFORE INSERT ON product_media FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('processing','ready','failed') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER product_media_status_check_update BEFORE UPDATE ON product_media FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('processing','ready','failed') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS product_media_type_check'); + DB::statement('DROP TRIGGER IF EXISTS product_media_type_check_update'); + DB::statement('DROP TRIGGER IF EXISTS product_media_status_check'); + DB::statement('DROP TRIGGER IF EXISTS product_media_status_check_update'); + Schema::dropIfExists('product_media'); + } +}; diff --git a/specs/progress.md b/specs/progress.md index f9696f6b..719bea80 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -5,7 +5,7 @@ Tracking progress for the full shop system implementation (team: shop-v2). ## Phase Checklist - [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing -- [ ] Phase 2: Catalog (products, variants, inventory, collections, media) +- [x] Phase 2: Catalog (products, variants, inventory, collections, media) - 63 tests passing - [ ] Phase 3: Themes, pages, navigation, storefront layout - [ ] Phase 4: Cart, checkout, discounts, shipping, taxes - [ ] Phase 5: Payments, orders, fulfillment diff --git a/tests/Feature/Products/CollectionTest.php b/tests/Feature/Products/CollectionTest.php new file mode 100644 index 00000000..137e80b6 --- /dev/null +++ b/tests/Feature/Products/CollectionTest.php @@ -0,0 +1,64 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates a manual collection with products', function (): void { + $collection = Collection::factory()->for($this->store)->create(); + $productA = Product::factory()->for($this->store)->create(); + $productB = Product::factory()->for($this->store)->create(); + + $collection->products()->attach([ + $productA->id => ['position' => 0], + $productB->id => ['position' => 1], + ]); + + expect($collection->fresh()->products)->toHaveCount(2); +}); + +it('maintains product position in collection', function (): void { + $collection = Collection::factory()->for($this->store)->create(); + $productA = Product::factory()->for($this->store)->create(); + $productB = Product::factory()->for($this->store)->create(); + + $collection->products()->attach([ + $productA->id => ['position' => 1], + $productB->id => ['position' => 0], + ]); + + $ordered = $collection->products()->orderBy('collection_products.position')->get(); + + expect($ordered->first()->id)->toBe($productB->id) + ->and($ordered->last()->id)->toBe($productA->id); +}); + +it('scopes collections to current store', function (): void { + $storeA = $this->store; + $storeB = Store::factory()->create(); + + app()->instance('current_store', $storeA); + Collection::factory()->for($storeA)->create(['title' => 'Spring']); + + app()->instance('current_store', $storeB); + Collection::factory()->for($storeB)->create(['title' => 'Autumn']); + + expect(Collection::count())->toBe(1) + ->and(Collection::first()->title)->toBe('Autumn'); + + app()->instance('current_store', $storeA); + expect(Collection::count())->toBe(1) + ->and(Collection::first()->title)->toBe('Spring'); +}); diff --git a/tests/Feature/Products/InventoryTest.php b/tests/Feature/Products/InventoryTest.php new file mode 100644 index 00000000..a0df149e --- /dev/null +++ b/tests/Feature/Products/InventoryTest.php @@ -0,0 +1,89 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->inventory = new InventoryService; +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function makeInventoryItem(Store $store, int $onHand = 10, InventoryPolicy $policy = InventoryPolicy::Deny): InventoryItem +{ + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + return InventoryItem::factory() + ->for($store) + ->for($variant, 'variant') + ->create([ + 'quantity_on_hand' => $onHand, + 'policy' => $policy->value, + ]); +} + +it('reserves inventory when quantity is available', function (): void { + $item = makeInventoryItem($this->store, 10); + + $this->inventory->reserve($item, 3); + + expect($item->fresh()->quantity_reserved)->toBe(3) + ->and($item->fresh()->quantityAvailable())->toBe(7); +}); + +it('throws InsufficientInventoryException when policy is deny and insufficient', function (): void { + $item = makeInventoryItem($this->store, 2, InventoryPolicy::Deny); + + $this->inventory->reserve($item, 5); +})->throws(InsufficientInventoryException::class); + +it('allows over-selling when policy is continue', function (): void { + $item = makeInventoryItem($this->store, 2, InventoryPolicy::Continue); + + $this->inventory->reserve($item, 5); + + expect($item->fresh()->quantity_reserved)->toBe(5) + ->and($item->fresh()->quantityAvailable())->toBe(-3); +}); + +it('releases reserved inventory', function (): void { + $item = makeInventoryItem($this->store, 10); + $this->inventory->reserve($item, 4); + + $this->inventory->release($item->fresh(), 3); + + expect($item->fresh()->quantity_reserved)->toBe(1); +}); + +it('commits inventory on order completion', function (): void { + $item = makeInventoryItem($this->store, 10); + $this->inventory->reserve($item, 4); + + $this->inventory->commit($item->fresh(), 4); + + $fresh = $item->fresh(); + + expect($fresh->quantity_on_hand)->toBe(6) + ->and($fresh->quantity_reserved)->toBe(0); +}); + +it('restocks inventory', function (): void { + $item = makeInventoryItem($this->store, 10); + + $this->inventory->restock($item, 5); + + expect($item->fresh()->quantity_on_hand)->toBe(15); +}); diff --git a/tests/Feature/Products/MediaUploadTest.php b/tests/Feature/Products/MediaUploadTest.php new file mode 100644 index 00000000..53de3c32 --- /dev/null +++ b/tests/Feature/Products/MediaUploadTest.php @@ -0,0 +1,42 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates product media records', function (): void { + $product = Product::factory()->for($this->store)->create(); + + $media = ProductMedia::factory()->for($product)->create(); + + expect($media->fresh()) + ->product_id->toBe($product->id) + ->and($media->status)->toBe(MediaStatus::Processing); +}); + +it('marks media as ready after processing stub job', function (): void { + $product = Product::factory()->for($this->store)->create(); + $media = ProductMedia::factory()->for($product)->create([ + 'status' => MediaStatus::Processing->value, + ]); + + (new ProcessMediaUpload($media))->handle(); + + expect($media->fresh()->status)->toBe(MediaStatus::Ready); +}); diff --git a/tests/Feature/Products/ProductCrudTest.php b/tests/Feature/Products/ProductCrudTest.php new file mode 100644 index 00000000..e14f2b8b --- /dev/null +++ b/tests/Feature/Products/ProductCrudTest.php @@ -0,0 +1,105 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = new ProductService(new VariantMatrixService); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates a product with variants via ProductService', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Linen Shirt', + 'status' => ProductStatus::Draft->value, + 'options' => [ + [ + 'name' => 'Size', + 'values' => ['S', 'M', 'L'], + ], + ], + ]); + + expect($product->variants)->toHaveCount(3) + ->and($product->handle)->toBe('linen-shirt') + ->and($product->options)->toHaveCount(1); +}); + +it('generates unique handles when title collides', function (): void { + $first = $this->service->create($this->store, ['title' => 'Classic Tee']); + $second = $this->service->create($this->store, ['title' => 'Classic Tee']); + $third = $this->service->create($this->store, ['title' => 'Classic Tee']); + + expect($first->handle)->toBe('classic-tee') + ->and($second->handle)->toBe('classic-tee-2') + ->and($third->handle)->toBe('classic-tee-3'); +}); + +it('transitions status from draft to active', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Wool Coat', + 'status' => ProductStatus::Draft->value, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); + + expect($product->fresh()->status)->toBe(ProductStatus::Active); +}); + +it('rejects invalid status transitions', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Bomber Jacket', + 'status' => ProductStatus::Active->value, + ]); + + $this->service->transitionStatus($product, ProductStatus::Draft); +})->throws(\InvalidArgumentException::class); + +it('prevents deletion of active products', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Silk Scarf', + 'status' => ProductStatus::Active->value, + ]); + + $this->service->delete($product); +})->throws(\InvalidArgumentException::class); + +it('allows deletion of draft products', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Cotton Socks', + 'status' => ProductStatus::Draft->value, + ]); + + $this->service->delete($product); + + expect(Product::withoutGlobalScopes()->find($product->id))->toBeNull(); +}); + +it('scopes products to the current store', function (): void { + $storeA = $this->store; + $storeB = Store::factory()->create(); + + app()->instance('current_store', $storeA); + $this->service->create($storeA, ['title' => 'Product A']); + + app()->instance('current_store', $storeB); + $this->service->create($storeB, ['title' => 'Product B']); + + expect(Product::count())->toBe(1) + ->and(Product::first()->title)->toBe('Product B'); + + app()->instance('current_store', $storeA); + expect(Product::count())->toBe(1) + ->and(Product::first()->title)->toBe('Product A'); +}); diff --git a/tests/Feature/Products/VariantTest.php b/tests/Feature/Products/VariantTest.php new file mode 100644 index 00000000..f07bbec5 --- /dev/null +++ b/tests/Feature/Products/VariantTest.php @@ -0,0 +1,79 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = new ProductService(new VariantMatrixService); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('builds a variant matrix from product options', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Performance Tee', + 'status' => ProductStatus::Draft->value, + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Red', 'Blue']], + ], + ]); + + expect($product->variants)->toHaveCount(6); + + foreach ($product->variants as $variant) { + expect($variant->optionValues)->toHaveCount(2); + } +}); + +it('archives orphaned variants when options change', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Merino Cap', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M']], + ], + ]); + + expect($product->variants)->toHaveCount(2); + + $this->service->update($product, [ + 'options' => [ + ['name' => 'Size', 'values' => ['M', 'L']], + ], + ]); + + $product = $product->fresh(['variants']); + + $active = $product->variants->where('status', VariantStatus::Active); + $archived = $product->variants->where('status', VariantStatus::Archived); + + expect($active)->toHaveCount(2) + ->and($archived)->toHaveCount(1); +}); + +it('stores prices as integers in minor units', function (): void { + $product = $this->service->create($this->store, [ + 'title' => 'Canvas Tote', + ]); + + $variant = $product->variants->first(); + $variant->price_amount = 1999; + $variant->compare_at_amount = 2499; + $variant->save(); + + $fresh = ProductVariant::find($variant->id); + + expect($fresh->price_amount)->toBe(1999) + ->and($fresh->compare_at_amount)->toBe(2499); +}); From 851d02f0b4b475027de4b832dd5108a0f75cdb95 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 20:52:47 +0200 Subject: [PATCH 04/15] Phase 3: Themes, pages, navigation, storefront layout - Migrations: themes, theme_files, theme_settings, pages, navigation_menus, navigation_items - Models + factories: Theme (BelongsToStore), ThemeFile, ThemeSettings, Page (BelongsToStore), NavigationMenu (BelongsToStore), NavigationItem - Enums: ThemeStatus, PageStatus, NavigationItemType - Services: NavigationService (cached menu trees), ThemeSettingsService (cached store settings, registered as singleton) - Storefront base layout (layouts/storefront.blade.php) with announcement, header, footer, cart drawer slot - Storefront partials (announcement, header, footer) wired to NavigationService - Error pages (404, 503) using storefront layout - Storefront\Home Livewire component with hero, featured collections, recent products grid - Blade components: storefront.product-card, storefront.price - Routes: GET / and GET /storefront pointing to Home - Tests: ThemeTest, ThemeSettingsTest, PageTest, NavigationTest, Storefront\HomeTest (16 new tests, 79 total passing) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Enums/NavigationItemType.php | 11 +++ app/Enums/PageStatus.php | 10 ++ app/Enums/ThemeStatus.php | 9 ++ app/Livewire/Storefront/Home.php | 65 +++++++++++++ app/Models/NavigationItem.php | 55 +++++++++++ app/Models/NavigationMenu.php | 28 ++++++ app/Models/Page.php | 34 +++++++ app/Models/Theme.php | 51 ++++++++++ app/Models/ThemeFile.php | 41 +++++++++ app/Models/ThemeSettings.php | 46 ++++++++++ app/Providers/AppServiceProvider.php | 3 +- app/Services/NavigationService.php | 45 +++++++++ app/Services/ThemeSettingsService.php | 58 ++++++++++++ database/factories/NavigationItemFactory.php | 58 ++++++++++++ database/factories/NavigationMenuFactory.php | 30 ++++++ database/factories/PageFactory.php | 49 ++++++++++ database/factories/ThemeFactory.php | 38 ++++++++ database/factories/ThemeFileFactory.php | 37 ++++++++ database/factories/ThemeSettingsFactory.php | 32 +++++++ .../2026_04_12_102001_create_themes_table.php | 37 ++++++++ ..._04_12_102002_create_theme_files_table.php | 30 ++++++ ..._12_102003_create_theme_settings_table.php | 26 ++++++ .../2026_04_12_102004_create_pages_table.php | 39 ++++++++ ...2_102005_create_navigation_menus_table.php | 29 ++++++ ...2_102006_create_navigation_items_table.php | 37 ++++++++ .../components/layouts/storefront.blade.php | 42 +++++++++ .../components/storefront/price.blade.php | 20 ++++ .../storefront/product-card.blade.php | 21 +++++ resources/views/errors/404.blade.php | 10 ++ resources/views/errors/503.blade.php | 7 ++ .../views/livewire/storefront/home.blade.php | 62 +++++++++++++ .../partials/announcement.blade.php | 7 ++ .../storefront/partials/footer.blade.php | 28 ++++++ .../storefront/partials/header.blade.php | 46 ++++++++++ routes/web.php | 6 +- specs/progress.md | 2 +- tests/Feature/ExampleTest.php | 4 + tests/Feature/Navigation/NavigationTest.php | 92 +++++++++++++++++++ tests/Feature/Pages/PageTest.php | 70 ++++++++++++++ tests/Feature/Storefront/HomeTest.php | 31 +++++++ tests/Feature/Themes/ThemeSettingsTest.php | 57 ++++++++++++ tests/Feature/Themes/ThemeTest.php | 66 +++++++++++++ 42 files changed, 1464 insertions(+), 5 deletions(-) create mode 100644 app/Enums/NavigationItemType.php create mode 100644 app/Enums/PageStatus.php create mode 100644 app/Enums/ThemeStatus.php create mode 100644 app/Livewire/Storefront/Home.php create mode 100644 app/Models/NavigationItem.php create mode 100644 app/Models/NavigationMenu.php create mode 100644 app/Models/Page.php create mode 100644 app/Models/Theme.php create mode 100644 app/Models/ThemeFile.php create mode 100644 app/Models/ThemeSettings.php create mode 100644 app/Services/NavigationService.php create mode 100644 app/Services/ThemeSettingsService.php create mode 100644 database/factories/NavigationItemFactory.php create mode 100644 database/factories/NavigationMenuFactory.php create mode 100644 database/factories/PageFactory.php create mode 100644 database/factories/ThemeFactory.php create mode 100644 database/factories/ThemeFileFactory.php create mode 100644 database/factories/ThemeSettingsFactory.php create mode 100644 database/migrations/2026_04_12_102001_create_themes_table.php create mode 100644 database/migrations/2026_04_12_102002_create_theme_files_table.php create mode 100644 database/migrations/2026_04_12_102003_create_theme_settings_table.php create mode 100644 database/migrations/2026_04_12_102004_create_pages_table.php create mode 100644 database/migrations/2026_04_12_102005_create_navigation_menus_table.php create mode 100644 database/migrations/2026_04_12_102006_create_navigation_items_table.php create mode 100644 resources/views/components/layouts/storefront.blade.php create mode 100644 resources/views/components/storefront/price.blade.php create mode 100644 resources/views/components/storefront/product-card.blade.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/503.blade.php create mode 100644 resources/views/livewire/storefront/home.blade.php create mode 100644 resources/views/storefront/partials/announcement.blade.php create mode 100644 resources/views/storefront/partials/footer.blade.php create mode 100644 resources/views/storefront/partials/header.blade.php create mode 100644 tests/Feature/Navigation/NavigationTest.php create mode 100644 tests/Feature/Pages/PageTest.php create mode 100644 tests/Feature/Storefront/HomeTest.php create mode 100644 tests/Feature/Themes/ThemeSettingsTest.php create mode 100644 tests/Feature/Themes/ThemeTest.php diff --git a/app/Enums/NavigationItemType.php b/app/Enums/NavigationItemType.php new file mode 100644 index 00000000..cb39d0b0 --- /dev/null +++ b/app/Enums/NavigationItemType.php @@ -0,0 +1,11 @@ +bound('current_store')) { + $store = Store::first(); + + if ($store !== null) { + app()->instance('current_store', $store); + } + } + } + + public function render(): View + { + return view('livewire.storefront.home', [ + 'featuredCollections' => $this->featuredCollections(), + 'recentProducts' => $this->recentProducts(), + ]); + } + + /** + * @return SupportCollection + */ + protected function featuredCollections(): SupportCollection + { + if (! class_exists(Collection::class)) { + return collect(); + } + + return Collection::query() + ->latest() + ->limit(3) + ->get(); + } + + /** + * @return SupportCollection + */ + protected function recentProducts(): SupportCollection + { + if (! class_exists(Product::class)) { + return collect(); + } + + return Product::query() + ->with('variants') + ->latest() + ->limit(8) + ->get(); + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..be19c28c --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'menu_id', + 'type', + 'label', + 'url', + 'resource_id', + 'position', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + 'resource_id' => 'integer', + 'position' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } + + public function resolveUrl(): string + { + return match ($this->type) { + NavigationItemType::Link => (string) ($this->url ?? '#'), + NavigationItemType::Page => '/pages/'.$this->resource_id, + NavigationItemType::Collection => '/collections/'.$this->resource_id, + NavigationItemType::Product => '/products/'.$this->resource_id, + }; + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..105096a2 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,28 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id')->orderBy('position'); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..060539a3 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,34 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'body_html', + 'status', + 'published_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..a30e6a6d --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,51 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'name', + 'version', + 'status', + 'published_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..e5b1ce97 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'byte_size' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 00000000..d0093377 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + protected $table = 'theme_settings'; + + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + protected $keyType = 'int'; + + const CREATED_AT = null; + + protected $fillable = [ + 'theme_id', + 'settings_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 664681ac..55fa83cb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Auth\CustomerUserProvider; +use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -20,7 +21,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(ThemeSettingsService::class); } /** diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..95339a60 --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,45 @@ + + */ + public function buildTree(NavigationMenu $menu): array + { + return Cache::remember( + "nav:menu:{$menu->id}", + self::CACHE_TTL, + fn (): array => $menu->items() + ->get() + ->map(fn (NavigationItem $item): array => [ + 'id' => (int) $item->id, + 'type' => $item->type->value, + 'label' => (string) $item->label, + 'url' => $this->resolveUrl($item), + 'position' => (int) $item->position, + ]) + ->all(), + ); + } + + public function resolveUrl(NavigationItem $item): string + { + return $item->resolveUrl(); + } + + public function forgetMenu(NavigationMenu $menu): void + { + Cache::forget("nav:menu:{$menu->id}"); + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..49ab4879 --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,58 @@ + + */ + public function forStore(Store $store): array + { + return Cache::remember( + "theme:settings:store:{$store->id}", + self::CACHE_TTL, + function () use ($store): array { + $theme = Theme::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', ThemeStatus::Published->value) + ->with('settings') + ->latest('published_at') + ->first(); + + if ($theme === null || $theme->settings === null) { + return $this->defaultSettings(); + } + + return array_merge($this->defaultSettings(), (array) $theme->settings->settings_json); + }, + ); + } + + /** + * @return array + */ + public function defaultSettings(): array + { + return [ + 'colors' => ['primary' => '#111'], + 'announcement' => null, + 'footer_text' => '(c) Shop', + ]; + } + + public function forgetStore(Store $store): void + { + Cache::forget("theme:settings:store:{$store->id}"); + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..5be351c0 --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,58 @@ + + */ +class NavigationItemFactory extends Factory +{ + protected $model = NavigationItem::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => NavigationItemType::Link->value, + 'label' => fake()->randomElement(['Home', 'Shop', 'About', 'Contact', 'Blog']), + 'url' => '/'.fake()->slug(), + 'resource_id' => null, + 'position' => fake()->numberBetween(0, 10), + ]; + } + + public function page(int $pageId): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => NavigationItemType::Page->value, + 'url' => null, + 'resource_id' => $pageId, + ]); + } + + public function collection(int $collectionId): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => NavigationItemType::Collection->value, + 'url' => null, + 'resource_id' => $collectionId, + ]); + } + + public function product(int $productId): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => NavigationItemType::Product->value, + 'url' => null, + 'resource_id' => $productId, + ]); + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..2bc3599b --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,30 @@ + + */ +class NavigationMenuFactory extends Factory +{ + protected $model = NavigationMenu::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->randomElement(['Main Menu', 'Footer Menu', 'Mobile Menu']); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title).'-'.fake()->unique()->randomNumber(5), + 'title' => $title, + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..215db973 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,49 @@ + + */ +class PageFactory extends Factory +{ + protected $model = Page::class; + + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->sentence(3); + + return [ + 'store_id' => Store::factory(), + 'title' => rtrim($title, '.'), + 'handle' => Str::slug($title).'-'.fake()->unique()->randomNumber(5), + 'body_html' => '

'.fake()->paragraph().'

', + 'status' => PageStatus::Published->value, + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PageStatus::Draft->value, + 'published_at' => null, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PageStatus::Archived->value, + ]); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..0337c7ed --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,38 @@ + + */ +class ThemeFactory extends Factory +{ + protected $model = Theme::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->randomElement(['Minimal', 'Classic', 'Modern', 'Vintage']).' Theme', + 'version' => '1.0.'.fake()->numberBetween(0, 9), + 'status' => ThemeStatus::Draft->value, + 'published_at' => null, + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ThemeStatus::Published->value, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..3bbb331d --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,37 @@ + + */ +class ThemeFileFactory extends Factory +{ + protected $model = ThemeFile::class; + + /** + * @return array + */ + public function definition(): array + { + $path = fake()->randomElement([ + 'templates/index.liquid', + 'templates/product.liquid', + 'sections/header.liquid', + 'snippets/cart.liquid', + 'assets/styles.css', + ]); + + return [ + 'theme_id' => Theme::factory(), + 'path' => $path.'.'.fake()->unique()->randomNumber(5), + 'storage_key' => 'themes/'.fake()->uuid().'/'.$path, + 'sha256' => hash('sha256', fake()->text(50)), + 'byte_size' => fake()->numberBetween(100, 50_000), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..f87a81ea --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,32 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + protected $model = ThemeSettings::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [ + 'colors' => [ + 'primary' => fake()->hexColor(), + ], + 'announcement' => fake()->sentence(), + 'footer_text' => '(c) '.fake()->company(), + ], + ]; + } +} diff --git a/database/migrations/2026_04_12_102001_create_themes_table.php b/database/migrations/2026_04_12_102001_create_themes_table.php new file mode 100644 index 00000000..8bcb2fa1 --- /dev/null +++ b/database/migrations/2026_04_12_102001_create_themes_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + + DB::statement("CREATE TRIGGER themes_status_check BEFORE INSERT ON themes FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','published') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER themes_status_check_update BEFORE UPDATE ON themes FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','published') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS themes_status_check'); + DB::statement('DROP TRIGGER IF EXISTS themes_status_check_update'); + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_04_12_102002_create_theme_files_table.php b/database/migrations/2026_04_12_102002_create_theme_files_table.php new file mode 100644 index 00000000..a662cf8e --- /dev/null +++ b/database/migrations/2026_04_12_102002_create_theme_files_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('theme_id') + ->constrained('themes') + ->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256', 64); + $table->unsignedBigInteger('byte_size')->default(0); + + $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path'); + $table->index('theme_id', 'idx_theme_files_theme_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_04_12_102003_create_theme_settings_table.php b/database/migrations/2026_04_12_102003_create_theme_settings_table.php new file mode 100644 index 00000000..19f14bb3 --- /dev/null +++ b/database/migrations/2026_04_12_102003_create_theme_settings_table.php @@ -0,0 +1,26 @@ +foreignId('theme_id') + ->primary() + ->constrained('themes') + ->cascadeOnDelete(); + $table->json('settings_json')->default(DB::raw("('{}')")); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_04_12_102004_create_pages_table.php b/database/migrations/2026_04_12_102004_create_pages_table.php new file mode 100644 index 00000000..07dc27ba --- /dev/null +++ b/database/migrations/2026_04_12_102004_create_pages_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->longText('body_html')->nullable(); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_pages_store_handle'); + $table->index('store_id', 'idx_pages_store_id'); + $table->index(['store_id', 'status'], 'idx_pages_store_status'); + }); + + DB::statement("CREATE TRIGGER pages_status_check BEFORE INSERT ON pages FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','published','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER pages_status_check_update BEFORE UPDATE ON pages FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','published','archived') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS pages_status_check'); + DB::statement('DROP TRIGGER IF EXISTS pages_status_check_update'); + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_04_12_102005_create_navigation_menus_table.php b/database/migrations/2026_04_12_102005_create_navigation_menus_table.php new file mode 100644 index 00000000..4e92cfdf --- /dev/null +++ b/database/migrations/2026_04_12_102005_create_navigation_menus_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('handle'); + $table->string('title'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_navigation_menus_store_handle'); + $table->index('store_id', 'idx_navigation_menus_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_04_12_102006_create_navigation_items_table.php b/database/migrations/2026_04_12_102006_create_navigation_items_table.php new file mode 100644 index 00000000..af51d3e7 --- /dev/null +++ b/database/migrations/2026_04_12_102006_create_navigation_items_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('menu_id') + ->constrained('navigation_menus') + ->cascadeOnDelete(); + $table->string('type')->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->unsignedBigInteger('resource_id')->nullable(); + $table->unsignedInteger('position')->default(0); + + $table->index('menu_id', 'idx_navigation_items_menu_id'); + $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position'); + }); + + DB::statement("CREATE TRIGGER navigation_items_type_check BEFORE INSERT ON navigation_items FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('link','page','collection','product') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER navigation_items_type_check_update BEFORE UPDATE ON navigation_items FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('link','page','collection','product') THEN RAISE(ABORT, 'invalid type') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS navigation_items_type_check'); + DB::statement('DROP TRIGGER IF EXISTS navigation_items_type_check_update'); + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/resources/views/components/layouts/storefront.blade.php b/resources/views/components/layouts/storefront.blade.php new file mode 100644 index 00000000..d8c27ecc --- /dev/null +++ b/resources/views/components/layouts/storefront.blade.php @@ -0,0 +1,42 @@ +@php + /** @var \App\Services\ThemeSettingsService $themeSettingsService */ + $themeSettingsService = app(\App\Services\ThemeSettingsService::class); + $currentStore = app()->bound('current_store') ? app('current_store') : \App\Models\Store::first(); + $themeSettings = $currentStore ? $themeSettingsService->forStore($currentStore) : $themeSettingsService->defaultSettings(); + $announcement = $themeSettings['announcement'] ?? null; + $footerText = $themeSettings['footer_text'] ?? '(c) Shop'; + $primaryColor = $themeSettings['colors']['primary'] ?? '#111'; +@endphp + + + + + + + {{ $title ?? ($currentStore->name ?? config('app.name')) }} + + + @vite(['resources/css/app.css']) + @livewireStyles + @fluxAppearance + + + + @include('storefront.partials.announcement', ['announcement' => $announcement]) + @include('storefront.partials.header', ['store' => $currentStore]) + +
+ {{ $slot }} +
+ + @include('storefront.partials.footer', ['footerText' => $footerText]) + + {{-- Cart drawer placeholder for Phase 4 --}} +
+ + @livewireScripts + @fluxScripts + + diff --git a/resources/views/components/storefront/price.blade.php b/resources/views/components/storefront/price.blade.php new file mode 100644 index 00000000..120a5281 --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,20 @@ +@props([ + 'amount' => 0, + 'currency' => 'USD', +]) + +@php + $amountCents = is_numeric($amount) ? (int) $amount : 0; + $amountFormatted = number_format($amountCents / 100, 2); + $symbols = [ + 'USD' => '$', + 'EUR' => 'EUR ', + 'GBP' => 'GBP ', + 'JPY' => 'JPY ', + 'CHF' => 'CHF ', + ]; + $currencyUpper = strtoupper((string) $currency); + $symbol = $symbols[$currencyUpper] ?? ($currencyUpper.' '); +@endphp + +merge(['class' => 'tabular-nums']) }}>{{ $symbol }}{{ $amountFormatted }} diff --git a/resources/views/components/storefront/product-card.blade.php b/resources/views/components/storefront/product-card.blade.php new file mode 100644 index 00000000..d026c2f7 --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,21 @@ +@props(['product']) + +@php + $firstVariant = $product->variants?->first(); + $price = $firstVariant?->price_amount ?? null; + $currency = $firstVariant?->currency ?? 'USD'; +@endphp + + +
+
+

{{ $product->title }}

+ @if ($price !== null) +

+ +

+ @else +

View details

+ @endif +
+
diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..24e8c57d --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,10 @@ + +
+

404

+

Page not found

+

Sorry, we could not find the page you were looking for.

+ + Back to home + +
+
diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..0da5a944 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,7 @@ + +
+

503

+

We will be right back

+

Our store is undergoing scheduled maintenance. Please check back in a few minutes.

+
+
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..2071764c --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,62 @@ +
+
+

New season

+

+ Thoughtfully made, honestly priced. +

+

+ A curated collection of timeless goods designed to last. Explore our latest arrivals and find something you will love. +

+ +
+ + @if ($featuredCollections->isNotEmpty()) + + @endif + +
+
+
+

New arrivals

+

Fresh goods, just in.

+
+
+ + @if ($recentProducts->isNotEmpty()) +
+ @foreach ($recentProducts as $product) + + @endforeach +
+ @else +
+

No products yet. Check back soon.

+
+ @endif +
+
diff --git a/resources/views/storefront/partials/announcement.blade.php b/resources/views/storefront/partials/announcement.blade.php new file mode 100644 index 00000000..d258c3d4 --- /dev/null +++ b/resources/views/storefront/partials/announcement.blade.php @@ -0,0 +1,7 @@ +@if (! empty($announcement)) +
+
+ {{ $announcement }} +
+
+@endif diff --git a/resources/views/storefront/partials/footer.blade.php b/resources/views/storefront/partials/footer.blade.php new file mode 100644 index 00000000..1beb2e34 --- /dev/null +++ b/resources/views/storefront/partials/footer.blade.php @@ -0,0 +1,28 @@ +@php + $footerMenu = null; + if (app()->bound('current_store')) { + $store = app('current_store'); + $footerMenu = \App\Models\NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', 'footer-menu') + ->with(['items' => fn ($q) => $q->orderBy('position')]) + ->first(); + } + $footerItems = $footerMenu + ? app(\App\Services\NavigationService::class)->buildTree($footerMenu) + : []; +@endphp + diff --git a/resources/views/storefront/partials/header.blade.php b/resources/views/storefront/partials/header.blade.php new file mode 100644 index 00000000..32ecdb51 --- /dev/null +++ b/resources/views/storefront/partials/header.blade.php @@ -0,0 +1,46 @@ +@php + $mainMenu = null; + if (isset($store) && $store) { + $mainMenu = \App\Models\NavigationMenu::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', 'main-menu') + ->with(['items' => fn ($q) => $q->orderBy('position')]) + ->first(); + } + $navItems = $mainMenu + ? app(\App\Services\NavigationService::class)->buildTree($mainMenu) + : []; +@endphp + +
+
+ + {{ $store->name ?? config('app.name') }} + + + + +
+ + + + + + + +
+
+
diff --git a/routes/web.php b/routes/web.php index 8e2d5aa9..9de8cc24 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,13 @@ name('home'); +Route::get('/', StorefrontHome::class)->name('home'); +Route::get('/storefront', StorefrontHome::class)->name('storefront.home'); Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) diff --git a/specs/progress.md b/specs/progress.md index 719bea80..90caeb59 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -6,7 +6,7 @@ Tracking progress for the full shop system implementation (team: shop-v2). - [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing - [x] Phase 2: Catalog (products, variants, inventory, collections, media) - 63 tests passing -- [ ] Phase 3: Themes, pages, navigation, storefront layout +- [x] Phase 3: Themes, pages, navigation, storefront layout - 79 tests passing - [ ] Phase 4: Cart, checkout, discounts, shipping, taxes - [ ] Phase 5: Payments, orders, fulfillment - [ ] Phase 6: Customer accounts diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..93efb40b 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,5 +1,9 @@ get('/'); diff --git a/tests/Feature/Navigation/NavigationTest.php b/tests/Feature/Navigation/NavigationTest.php new file mode 100644 index 00000000..fdb101fd --- /dev/null +++ b/tests/Feature/Navigation/NavigationTest.php @@ -0,0 +1,92 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = app(NavigationService::class); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); + Cache::flush(); +}); + +it('builds an ordered tree of navigation items', function (): void { + $menu = NavigationMenu::factory()->create(); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'Third', + 'position' => 30, + ]); + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'First', + 'position' => 10, + ]); + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'Second', + 'position' => 20, + ]); + + $tree = $this->service->buildTree($menu); + + expect($tree)->toHaveCount(3) + ->and($tree[0]['label'])->toBe('First') + ->and($tree[1]['label'])->toBe('Second') + ->and($tree[2]['label'])->toBe('Third'); +}); + +it('resolves placeholder URLs for page, collection and product items', function (): void { + $menu = NavigationMenu::factory()->create(); + + $pageItem = NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Page->value, + 'url' => null, + 'resource_id' => 42, + ]); + $collectionItem = NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Collection->value, + 'url' => null, + 'resource_id' => 7, + ]); + $productItem = NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Product->value, + 'url' => null, + 'resource_id' => 99, + ]); + $linkItem = NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'type' => NavigationItemType::Link->value, + 'url' => '/external', + ]); + + expect($pageItem->resolveUrl())->toBe('/pages/42') + ->and($collectionItem->resolveUrl())->toBe('/collections/7') + ->and($productItem->resolveUrl())->toBe('/products/99') + ->and($linkItem->resolveUrl())->toBe('/external'); +}); + +it('caches the menu tree', function (): void { + $menu = NavigationMenu::factory()->create(); + NavigationItem::factory()->count(2)->create(['menu_id' => $menu->id]); + + $this->service->buildTree($menu); + + expect(Cache::has("nav:menu:{$menu->id}"))->toBeTrue(); +}); diff --git a/tests/Feature/Pages/PageTest.php b/tests/Feature/Pages/PageTest.php new file mode 100644 index 00000000..c2bad1fb --- /dev/null +++ b/tests/Feature/Pages/PageTest.php @@ -0,0 +1,70 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates a page via factory', function (): void { + $page = Page::factory()->create(['store_id' => $this->store->id]); + + expect($page->exists)->toBeTrue() + ->and($page->status)->toBe(PageStatus::Published) + ->and($page->store_id)->toBe($this->store->id); +}); + +it('scopes pages to the current store', function (): void { + $storeA = $this->store; + $storeB = Store::factory()->create(); + + Page::factory()->create(['store_id' => $storeA->id]); + + app()->instance('current_store', $storeB); + Page::factory()->create(['store_id' => $storeB->id]); + + expect(Page::count())->toBe(1); + + app()->instance('current_store', $storeA); + expect(Page::count())->toBe(1); +}); + +it('enforces unique handle per store', function (): void { + Page::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'about-us', + ]); + + expect(fn () => Page::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'about-us', + ]))->toThrow(QueryException::class); +}); + +it('allows the same handle across different stores', function (): void { + $storeB = Store::factory()->create(); + + Page::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'about-us', + ]); + + app()->instance('current_store', $storeB); + $page = Page::factory()->create([ + 'store_id' => $storeB->id, + 'handle' => 'about-us', + ]); + + expect($page->exists)->toBeTrue(); +}); diff --git a/tests/Feature/Storefront/HomeTest.php b/tests/Feature/Storefront/HomeTest.php new file mode 100644 index 00000000..7df19d5b --- /dev/null +++ b/tests/Feature/Storefront/HomeTest.php @@ -0,0 +1,31 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('renders the storefront home Livewire component', function (): void { + Livewire::test(Home::class) + ->assertStatus(200) + ->assertSee('Thoughtfully made') + ->assertSee('New arrivals'); +}); + +it('responds from the / route with a 200', function (): void { + $response = $this->get('/'); + + $response->assertOk(); + $response->assertSee('Thoughtfully made', false); +}); diff --git a/tests/Feature/Themes/ThemeSettingsTest.php b/tests/Feature/Themes/ThemeSettingsTest.php new file mode 100644 index 00000000..862812e5 --- /dev/null +++ b/tests/Feature/Themes/ThemeSettingsTest.php @@ -0,0 +1,57 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = app(ThemeSettingsService::class); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); + Cache::flush(); +}); + +it('returns default settings for a store without a published theme', function (): void { + $settings = $this->service->forStore($this->store); + + expect($settings)->toMatchArray([ + 'colors' => ['primary' => '#111'], + 'announcement' => null, + 'footer_text' => '(c) Shop', + ]); +}); + +it('returns theme settings_json for a store with a published theme', function (): void { + $theme = Theme::factory()->published()->create(['store_id' => $this->store->id]); + + ThemeSettings::create([ + 'theme_id' => $theme->id, + 'settings_json' => [ + 'colors' => ['primary' => '#ff0066'], + 'announcement' => 'Free shipping over $50', + 'footer_text' => '(c) 2026 Test Store', + ], + ]); + + $settings = $this->service->forStore($this->store); + + expect($settings['colors']['primary'])->toBe('#ff0066') + ->and($settings['announcement'])->toBe('Free shipping over $50') + ->and($settings['footer_text'])->toBe('(c) 2026 Test Store'); +}); + +it('caches theme settings per store', function (): void { + $this->service->forStore($this->store); + + expect(Cache::has("theme:settings:store:{$this->store->id}"))->toBeTrue(); +}); diff --git a/tests/Feature/Themes/ThemeTest.php b/tests/Feature/Themes/ThemeTest.php new file mode 100644 index 00000000..60e416a9 --- /dev/null +++ b/tests/Feature/Themes/ThemeTest.php @@ -0,0 +1,66 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates a theme via factory', function (): void { + $theme = Theme::factory()->create(['store_id' => $this->store->id]); + + expect($theme->exists)->toBeTrue() + ->and($theme->status)->toBe(ThemeStatus::Draft) + ->and($theme->store_id)->toBe($this->store->id); +}); + +it('publishes a theme and sets published_at', function (): void { + $theme = Theme::factory()->create(['store_id' => $this->store->id]); + + $theme->update([ + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + + $theme->refresh(); + + expect($theme->status)->toBe(ThemeStatus::Published) + ->and($theme->published_at)->not->toBeNull(); +}); + +it('scopes themes to the current store', function (): void { + $storeA = $this->store; + $storeB = Store::factory()->create(); + + Theme::factory()->create(['store_id' => $storeA->id]); + + app()->instance('current_store', $storeB); + Theme::factory()->create(['store_id' => $storeB->id]); + + expect(Theme::count())->toBe(1) + ->and(Theme::first()->store_id)->toBe($storeB->id); + + app()->instance('current_store', $storeA); + expect(Theme::count())->toBe(1) + ->and(Theme::first()->store_id)->toBe($storeA->id); +}); + +it('rejects invalid status values at the database level', function (): void { + expect(fn () => \DB::table('themes')->insert([ + 'store_id' => $this->store->id, + 'name' => 'Broken', + 'status' => 'garbage', + 'created_at' => now(), + 'updated_at' => now(), + ]))->toThrow(Exception::class); +}); From 74e458eea6c7ab380639fb063ddbbd90f723d05a Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 21:06:15 +0200 Subject: [PATCH 05/15] Phase 4: Cart, checkout, discounts, shipping, taxes - Migrations: carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts - Models + factories with BelongsToStore on Cart, Checkout, ShippingZone, Discount; TaxSettings keyed on store_id - Enums: CartStatus, CheckoutStatus, DiscountType, DiscountValueType, DiscountStatus, ShippingRateType, TaxMode - Value objects: PricingResult, TaxLine, DiscountResult - Exception: InvalidDiscountException with reason helpers - Services: * TaxCalculator (addExclusive, extractInclusive, calculate) * ShippingCalculator (zone matching, flat/weight/price/carrier rates) * DiscountService (case-insensitive validate, proportional allocate) * PricingEngine (subtotal, discount, shipping, tax, total pipeline) * CartService (line CRUD, version increments, inventory checks, guest-cart merge on login) * CheckoutService (state machine, transitions, expire, recalculate) - Jobs: ExpireAbandonedCheckouts (15min), CleanupAbandonedCarts (daily 03:00) scheduled in routes/console.php - Tests: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CheckoutFlowTest, CheckoutStateTest (59 new, 138 total passing) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Enums/CartStatus.php | 10 + app/Enums/CheckoutStatus.php | 13 ++ app/Enums/DiscountStatus.php | 11 + app/Enums/DiscountType.php | 9 + app/Enums/DiscountValueType.php | 10 + app/Enums/ShippingRateType.php | 11 + app/Enums/TaxMode.php | 9 + app/Exceptions/InvalidDiscountException.php | 45 ++++ app/Jobs/CleanupAbandonedCarts.php | 22 ++ app/Jobs/ExpireAbandonedCheckouts.php | 30 +++ app/Models/Cart.php | 55 +++++ app/Models/CartLine.php | 41 ++++ app/Models/Checkout.php | 54 +++++ app/Models/Discount.php | 68 ++++++ app/Models/ShippingRate.php | 42 ++++ app/Models/ShippingZone.php | 40 ++++ app/Models/TaxSettings.php | 52 +++++ app/Services/CartService.php | 179 +++++++++++++++ app/Services/CheckoutService.php | 173 +++++++++++++++ app/Services/DiscountService.php | 117 ++++++++++ app/Services/PricingEngine.php | 101 +++++++++ app/Services/ShippingCalculator.php | 121 ++++++++++ app/Services/TaxCalculator.php | 51 +++++ app/ValueObjects/DiscountResult.php | 15 ++ app/ValueObjects/PricingResult.php | 46 ++++ app/ValueObjects/TaxLine.php | 24 ++ database/factories/CartFactory.php | 45 ++++ database/factories/CartLineFactory.php | 36 +++ database/factories/CheckoutFactory.php | 39 ++++ database/factories/DiscountFactory.php | 85 ++++++++ database/factories/ShippingRateFactory.php | 67 ++++++ database/factories/ShippingZoneFactory.php | 28 +++ database/factories/TaxSettingsFactory.php | 40 ++++ .../2026_04_12_103001_create_carts_table.php | 40 ++++ ...6_04_12_103002_create_cart_lines_table.php | 34 +++ ...26_04_12_103003_create_checkouts_table.php | 54 +++++ ..._12_103004_create_shipping_zones_table.php | 29 +++ ..._12_103005_create_shipping_rates_table.php | 37 ++++ ...04_12_103006_create_tax_settings_table.php | 34 +++ ...26_04_12_103007_create_discounts_table.php | 53 +++++ routes/console.php | 6 + specs/progress.md | 2 +- tests/Feature/Cart/CartServiceTest.php | 105 +++++++++ tests/Feature/Checkout/CheckoutFlowTest.php | 135 ++++++++++++ tests/Feature/Checkout/CheckoutStateTest.php | 64 ++++++ tests/Pest.php | 2 + tests/Unit/CartVersionTest.php | 60 +++++ .../Unit/Discounts/DiscountCalculatorTest.php | 167 ++++++++++++++ tests/Unit/Pricing/PricingEngineTest.php | 206 ++++++++++++++++++ .../Unit/Shipping/ShippingCalculatorTest.php | 161 ++++++++++++++ tests/Unit/Taxes/TaxCalculatorTest.php | 58 +++++ 51 files changed, 2935 insertions(+), 1 deletion(-) create mode 100644 app/Enums/CartStatus.php create mode 100644 app/Enums/CheckoutStatus.php create mode 100644 app/Enums/DiscountStatus.php create mode 100644 app/Enums/DiscountType.php create mode 100644 app/Enums/DiscountValueType.php create mode 100644 app/Enums/ShippingRateType.php create mode 100644 app/Enums/TaxMode.php create mode 100644 app/Exceptions/InvalidDiscountException.php create mode 100644 app/Jobs/CleanupAbandonedCarts.php create mode 100644 app/Jobs/ExpireAbandonedCheckouts.php create mode 100644 app/Models/Cart.php create mode 100644 app/Models/CartLine.php create mode 100644 app/Models/Checkout.php create mode 100644 app/Models/Discount.php create mode 100644 app/Models/ShippingRate.php create mode 100644 app/Models/ShippingZone.php create mode 100644 app/Models/TaxSettings.php create mode 100644 app/Services/CartService.php create mode 100644 app/Services/CheckoutService.php create mode 100644 app/Services/DiscountService.php create mode 100644 app/Services/PricingEngine.php create mode 100644 app/Services/ShippingCalculator.php create mode 100644 app/Services/TaxCalculator.php create mode 100644 app/ValueObjects/DiscountResult.php create mode 100644 app/ValueObjects/PricingResult.php create mode 100644 app/ValueObjects/TaxLine.php create mode 100644 database/factories/CartFactory.php create mode 100644 database/factories/CartLineFactory.php create mode 100644 database/factories/CheckoutFactory.php create mode 100644 database/factories/DiscountFactory.php create mode 100644 database/factories/ShippingRateFactory.php create mode 100644 database/factories/ShippingZoneFactory.php create mode 100644 database/factories/TaxSettingsFactory.php create mode 100644 database/migrations/2026_04_12_103001_create_carts_table.php create mode 100644 database/migrations/2026_04_12_103002_create_cart_lines_table.php create mode 100644 database/migrations/2026_04_12_103003_create_checkouts_table.php create mode 100644 database/migrations/2026_04_12_103004_create_shipping_zones_table.php create mode 100644 database/migrations/2026_04_12_103005_create_shipping_rates_table.php create mode 100644 database/migrations/2026_04_12_103006_create_tax_settings_table.php create mode 100644 database/migrations/2026_04_12_103007_create_discounts_table.php create mode 100644 tests/Feature/Cart/CartServiceTest.php create mode 100644 tests/Feature/Checkout/CheckoutFlowTest.php create mode 100644 tests/Feature/Checkout/CheckoutStateTest.php create mode 100644 tests/Unit/CartVersionTest.php create mode 100644 tests/Unit/Discounts/DiscountCalculatorTest.php create mode 100644 tests/Unit/Pricing/PricingEngineTest.php create mode 100644 tests/Unit/Shipping/ShippingCalculatorTest.php create mode 100644 tests/Unit/Taxes/TaxCalculatorTest.php diff --git a/app/Enums/CartStatus.php b/app/Enums/CartStatus.php new file mode 100644 index 00000000..56a92071 --- /dev/null +++ b/app/Enums/CartStatus.php @@ -0,0 +1,10 @@ +withoutGlobalScopes() + ->where('status', CartStatus::Active->value) + ->where('updated_at', '<', now()->subDays(14)) + ->update(['status' => CartStatus::Abandoned->value]); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..fb8297bb --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,30 @@ +withoutGlobalScopes() + ->whereNotNull('expires_at') + ->where('expires_at', '<', now()) + ->whereNotIn('status', [ + CheckoutStatus::Completed->value, + CheckoutStatus::Expired->value, + ]) + ->get() + ->each(function (Checkout $checkout) use ($checkoutService): void { + $checkoutService->expire($checkout); + }); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..7848cf29 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,55 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'customer_id', + 'session_id', + 'currency', + 'cart_version', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => CartStatus::class, + ]; + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + /** + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function incrementVersion(): void + { + $this->cart_version = (int) $this->cart_version + 1; + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..67815b9b --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'cart_id', + 'variant_id', + 'quantity', + 'unit_price_amount', + 'line_subtotal_amount', + 'line_discount_amount', + 'line_total_amount', + ]; + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..a0e62ad0 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,54 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'cart_id', + 'customer_id', + 'status', + 'payment_method', + 'email', + 'shipping_address_json', + 'billing_address_json', + 'shipping_method_id', + 'discount_code', + 'tax_provider_snapshot_json', + 'totals_json', + 'expires_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => CheckoutStatus::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..7f54cc45 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,68 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'type', + 'code', + 'value_type', + 'value_amount', + 'starts_at', + 'ends_at', + 'usage_limit', + 'usage_count', + 'rules_json', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'status' => DiscountStatus::class, + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'rules_json' => 'array', + ]; + } + + public function isCurrentlyActive(): bool + { + if ($this->status !== DiscountStatus::Active) { + return false; + } + + $now = now(); + + if ($this->starts_at !== null && $this->starts_at->isFuture()) { + return false; + } + + if ($this->ends_at !== null && $this->ends_at->isPast()) { + return false; + } + + if ($this->usage_limit !== null && (int) $this->usage_count >= (int) $this->usage_limit) { + return false; + } + + return true; + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..91f713e6 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,42 @@ + */ + use HasFactory; + + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'config_json', + 'is_active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'bool', + ]; + } + + /** + * @return BelongsTo + */ + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..b659464a --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,40 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'name', + 'countries_json', + 'regions_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } + + /** + * @return HasMany + */ + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..7fa8441c --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + protected $table = 'tax_settings'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + protected $keyType = 'int'; + + const CREATED_AT = null; + + protected $fillable = [ + 'store_id', + 'mode', + 'provider', + 'prices_include_tax', + 'config_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'prices_include_tax' => 'bool', + 'config_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..5c66ab3c --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,179 @@ +store_id = $store->id; + $cart->customer_id = $customer?->id; + $cart->session_id = $sessionId; + $cart->currency = (string) ($store->default_currency ?? 'USD'); + $cart->cart_version = 1; + $cart->status = CartStatus::Active->value; + $cart->save(); + + return $cart; + } + + public function getOrCreateForSession(Store $store, ?object $customer = null, ?string $sessionId = null): Cart + { + $query = Cart::query() + ->where('store_id', $store->id) + ->where('status', CartStatus::Active->value); + + if ($customer !== null) { + $existing = (clone $query)->where('customer_id', $customer->id)->first(); + if ($existing !== null) { + return $existing; + } + } + + if ($sessionId !== null) { + $existing = (clone $query)->where('session_id', $sessionId)->first(); + if ($existing !== null) { + return $existing; + } + } + + return $this->create($store, $customer, $sessionId); + } + + public function addLine(Cart $cart, int $variantId, int $quantity): CartLine + { + if ($quantity < 1) { + throw new InvalidArgumentException('Quantity must be at least 1.'); + } + + $variant = ProductVariant::query() + ->with(['product', 'inventoryItem']) + ->find($variantId); + + if ($variant === null) { + throw new RuntimeException("Variant {$variantId} not found."); + } + + if ($variant->status !== VariantStatus::Active) { + throw new RuntimeException("Variant {$variantId} is not active."); + } + + $product = $variant->product; + if ($product === null || $product->status !== ProductStatus::Active) { + throw new RuntimeException("Product for variant {$variantId} is not active."); + } + + $existing = $cart->lines()->where('variant_id', $variantId)->first(); + $targetQty = ($existing?->quantity ?? 0) + $quantity; + + $inventoryItem = $variant->inventoryItem; + if ($inventoryItem !== null && ! $this->inventoryService->checkAvailability($inventoryItem, $targetQty)) { + throw new RuntimeException("Insufficient inventory for variant {$variantId}."); + } + + if ($existing !== null) { + $existing->quantity = $targetQty; + $existing->line_subtotal_amount = (int) $existing->unit_price_amount * $targetQty; + $existing->line_total_amount = $existing->line_subtotal_amount - (int) $existing->line_discount_amount; + $existing->save(); + $this->touchVersion($cart); + + return $existing; + } + + $unitPrice = (int) $variant->price_amount; + $line = new CartLine; + $line->cart_id = $cart->id; + $line->variant_id = $variantId; + $line->quantity = $quantity; + $line->unit_price_amount = $unitPrice; + $line->line_subtotal_amount = $unitPrice * $quantity; + $line->line_discount_amount = 0; + $line->line_total_amount = $unitPrice * $quantity; + $line->save(); + + $this->touchVersion($cart); + + return $line; + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine + { + if ($quantity < 1) { + throw new InvalidArgumentException('Quantity must be at least 1.'); + } + + $line = $cart->lines()->findOrFail($lineId); + + $variant = ProductVariant::query()->with('inventoryItem')->findOrFail($line->variant_id); + $inventoryItem = $variant->inventoryItem; + if ($inventoryItem !== null && ! $this->inventoryService->checkAvailability($inventoryItem, $quantity)) { + throw new RuntimeException("Insufficient inventory for variant {$line->variant_id}."); + } + + $line->quantity = $quantity; + $line->line_subtotal_amount = (int) $line->unit_price_amount * $quantity; + $line->line_total_amount = $line->line_subtotal_amount - (int) $line->line_discount_amount; + $line->save(); + + $this->touchVersion($cart); + + return $line; + } + + public function removeLine(Cart $cart, int $lineId): void + { + $line = $cart->lines()->findOrFail($lineId); + $line->delete(); + + $this->touchVersion($cart); + } + + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + foreach ($guest->lines()->get() as $guestLine) { + $existing = $customer->lines()->where('variant_id', $guestLine->variant_id)->first(); + + if ($existing !== null) { + $newQty = (int) $existing->quantity + (int) $guestLine->quantity; + $existing->quantity = $newQty; + $existing->line_subtotal_amount = (int) $existing->unit_price_amount * $newQty; + $existing->line_total_amount = $existing->line_subtotal_amount - (int) $existing->line_discount_amount; + $existing->save(); + } else { + $copy = $guestLine->replicate(); + $copy->cart_id = $customer->id; + $copy->save(); + } + } + + $guest->lines()->delete(); + $guest->status = CartStatus::Abandoned->value; + $guest->save(); + + $this->touchVersion($customer); + + return $customer; + } + + private function touchVersion(Cart $cart): void + { + $cart->incrementVersion(); + $cart->save(); + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..0b3785ae --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,173 @@ +store_id = $cart->store_id; + $checkout->cart_id = $cart->id; + $checkout->customer_id = $cart->customer_id; + $checkout->status = CheckoutStatus::Started->value; + $checkout->save(); + + return $checkout; + }); + } + + /** + * @param array $data + */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + $this->assertTransitionAllowed($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed]); + + $checkout->email = $data['email'] ?? $checkout->email; + $shipping = $data['shipping_address'] ?? []; + $billing = $data['billing_address'] ?? $shipping; + + $checkout->shipping_address_json = $shipping; + $checkout->billing_address_json = $billing; + $checkout->status = CheckoutStatus::Addressed->value; + $checkout->save(); + + return $this->recalculate($checkout); + } + + public function setShippingMethod(Checkout $checkout, int $shippingRateId): Checkout + { + $this->assertTransitionAllowed($checkout, [ + CheckoutStatus::Addressed, + CheckoutStatus::ShippingSelected, + ]); + + $rate = ShippingRate::query()->findOrFail($shippingRateId); + + $checkout->shipping_method_id = $rate->id; + $checkout->status = CheckoutStatus::ShippingSelected->value; + $checkout->save(); + + return $this->recalculate($checkout); + } + + public function selectPaymentMethod(Checkout $checkout, string $method): Checkout + { + $allowedStates = [CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected]; + + $cart = $checkout->cart()->with('lines.variant')->first(); + $requiresShipping = $cart !== null && $cart->lines->contains( + fn ($line): bool => $line->variant !== null && (bool) $line->variant->requires_shipping + ); + + if (! $requiresShipping) { + $allowedStates[] = CheckoutStatus::Addressed; + } + + $this->assertTransitionAllowed($checkout, $allowedStates); + + $allowed = ['credit_card', 'paypal', 'bank_transfer']; + if (! in_array($method, $allowed, true)) { + throw new DomainException("Invalid payment method: {$method}"); + } + + return DB::transaction(function () use ($checkout, $method): Checkout { + $checkout->payment_method = $method; + $checkout->status = CheckoutStatus::PaymentSelected->value; + $checkout->expires_at = now()->addHours(24); + $checkout->save(); + + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + if ($cart !== null) { + foreach ($cart->lines as $line) { + $inventoryItem = $line->variant?->inventoryItem; + if ($inventoryItem !== null) { + $this->inventoryService->reserve($inventoryItem, (int) $line->quantity); + } + } + } + + return $checkout; + }); + } + + public function applyDiscount(Checkout $checkout, string $code): Checkout + { + $checkout->discount_code = $code; + $checkout->save(); + + return $this->recalculate($checkout); + } + + public function recalculate(Checkout $checkout): Checkout + { + $result = $this->pricingEngine->calculate($checkout); + $checkout->totals_json = $result->toArray(); + $checkout->save(); + + return $checkout; + } + + public function expire(Checkout $checkout): void + { + DB::transaction(function () use ($checkout): void { + if ($checkout->status === CheckoutStatus::PaymentSelected) { + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + if ($cart !== null) { + foreach ($cart->lines as $line) { + $inventoryItem = $line->variant?->inventoryItem; + if ($inventoryItem !== null) { + $this->inventoryService->release($inventoryItem, (int) $line->quantity); + } + } + } + } + + $checkout->status = CheckoutStatus::Expired->value; + $checkout->save(); + }); + } + + /** + * @param array $details + */ + public function complete(Checkout $checkout, array $details): Checkout + { + $this->assertTransitionAllowed($checkout, [CheckoutStatus::PaymentSelected]); + + $checkout->status = CheckoutStatus::Completed->value; + $checkout->save(); + + return $checkout; + } + + /** + * @param array $allowed + */ + private function assertTransitionAllowed(Checkout $checkout, array $allowed): void + { + $current = $checkout->status instanceof CheckoutStatus + ? $checkout->status + : CheckoutStatus::from((string) $checkout->status); + + if (! in_array($current, $allowed, true)) { + throw new DomainException( + "Invalid checkout transition from {$current->value}." + ); + } + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..541b2c09 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,117 @@ +where('store_id', $store->id) + ->whereRaw('LOWER(code) = ?', [strtolower(trim($code))]) + ->first(); + + if ($discount === null) { + throw InvalidDiscountException::notFound(); + } + + if ($discount->status === DiscountStatus::Disabled) { + throw InvalidDiscountException::disabled(); + } + + if ($discount->status !== DiscountStatus::Active) { + throw InvalidDiscountException::expired(); + } + + $now = now(); + + if ($discount->starts_at !== null && $discount->starts_at->greaterThan($now)) { + throw InvalidDiscountException::notYetActive(); + } + + if ($discount->ends_at !== null && $discount->ends_at->lessThan($now)) { + throw InvalidDiscountException::expired(); + } + + if ($discount->usage_limit !== null && (int) $discount->usage_count >= (int) $discount->usage_limit) { + throw InvalidDiscountException::usageLimitReached(); + } + + $rules = $discount->rules_json ?? []; + $minPurchase = $rules['min_purchase_amount'] ?? null; + + if ($minPurchase !== null) { + $lines = $cart->relationLoaded('lines') ? $cart->lines : $cart->lines()->get(); + $subtotal = (int) $lines->sum('line_subtotal_amount'); + + if ($subtotal < (int) $minPurchase) { + throw InvalidDiscountException::minimumNotMet(); + } + } + + return $discount; + } + + /** + * @param Collection|\Illuminate\Database\Eloquent\Collection $lines + */ + public function calculate(Discount $discount, int $subtotal, Collection|\Illuminate\Database\Eloquent\Collection $lines): DiscountResult + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountResult(amount: 0, allocations: [], freeShipping: true); + } + + if ($subtotal <= 0 || $lines->isEmpty()) { + return new DiscountResult(amount: 0, allocations: []); + } + + $totalDiscount = match ($discount->value_type) { + DiscountValueType::Percent => (int) floor($subtotal * (int) $discount->value_amount / 100), + DiscountValueType::Fixed => min((int) $discount->value_amount, $subtotal), + default => 0, + }; + + if ($totalDiscount <= 0) { + return new DiscountResult(amount: 0, allocations: []); + } + + $allocations = []; + $remaining = $totalDiscount; + + $lineList = $lines->values(); + $lastIndex = $lineList->count() - 1; + + foreach ($lineList as $index => $line) { + if ($index === $lastIndex) { + $allocations[(int) $line->id] = $remaining; + break; + } + + $lineSubtotal = (int) $line->line_subtotal_amount; + $lineDiscount = (int) round($totalDiscount * $lineSubtotal / $subtotal); + $allocations[(int) $line->id] = $lineDiscount; + $remaining -= $lineDiscount; + } + + return new DiscountResult(amount: $totalDiscount, allocations: $allocations); + } + + public function recordUsage(Discount $discount): void + { + DB::transaction(function () use ($discount): void { + $discount->refresh(); + $discount->usage_count = (int) $discount->usage_count + 1; + $discount->save(); + }); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..3b9921b2 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,101 @@ +cart()->with('lines.variant')->first(); + + if ($cart === null) { + return new PricingResult(0, 0, 0, [], 0, 0, 'USD'); + } + + $store = Store::withoutGlobalScopes()->find($checkout->store_id); + + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + + $discount = 0; + $freeShipping = false; + + if ($checkout->discount_code !== null && $checkout->discount_code !== '' && $store !== null) { + $discountModel = $this->discountService->validate($checkout->discount_code, $store, $cart); + $result = $this->discountService->calculate($discountModel, $subtotal, $cart->lines); + $discount = $result->amount; + $freeShipping = $result->freeShipping; + } + + $discountedSubtotal = $subtotal - $discount; + + $shipping = 0; + if ($checkout->shipping_method_id !== null) { + $rate = ShippingRate::query()->find($checkout->shipping_method_id); + if ($rate !== null) { + $shipping = $this->shippingCalculator->calculate($rate, $cart); + } + } + + if ($freeShipping) { + $shipping = 0; + } + + $taxLines = []; + $taxTotal = 0; + $currency = (string) ($cart->currency ?? 'USD'); + + $taxSettings = $store !== null + ? TaxSettings::query()->where('store_id', $store->id)->first() + : null; + + if ($taxSettings !== null) { + if ((bool) $taxSettings->prices_include_tax) { + $rateBasisPoints = (int) ($taxSettings->config_json['rate_basis_points'] ?? 0); + $taxName = (string) ($taxSettings->config_json['name'] ?? 'Tax'); + $extractedBase = $discountedSubtotal + $shipping; + $extracted = $this->taxCalculator->extractInclusive($extractedBase, $rateBasisPoints); + $taxTotal = $extracted; + $taxLines = $extracted > 0 + ? [new TaxLine($taxName, $rateBasisPoints, $extracted)] + : []; + $total = $discountedSubtotal + $shipping; + } else { + $taxBase = $discountedSubtotal + $shipping; + $result = $this->taxCalculator->calculate( + $taxBase, + $taxSettings, + $checkout->shipping_address_json ?? [] + ); + $taxTotal = (int) $result['tax_total']; + $taxLines = $result['tax_lines']; + $total = $discountedSubtotal + $shipping + $taxTotal; + } + } else { + $total = $discountedSubtotal + $shipping; + } + + return new PricingResult( + subtotal: $subtotal, + discount: $discount, + shipping: $shipping, + taxLines: $taxLines, + taxTotal: $taxTotal, + total: $total, + currency: $currency, + freeShippingApplied: $freeShipping, + ); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..cc474005 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,121 @@ + $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address): Collection + { + $country = (string) ($address['country'] ?? ''); + $provinceCode = $address['province_code'] ?? null; + $regionKey = $provinceCode !== null && $provinceCode !== '' + ? $country.'-'.$provinceCode + : null; + + $zones = ShippingZone::query() + ->where('store_id', $store->id) + ->get() + ->filter(function (ShippingZone $zone) use ($country, $regionKey): bool { + $countries = $zone->countries_json ?? []; + $regions = $zone->regions_json ?? []; + + if ($country !== '' && in_array($country, $countries, true)) { + return true; + } + + return $regionKey !== null && in_array($regionKey, $regions, true); + }); + + if ($zones->isEmpty()) { + return new Collection; + } + + return ShippingRate::query() + ->whereIn('zone_id', $zones->pluck('id')->all()) + ->where('is_active', true) + ->get(); + } + + public function calculate(ShippingRate $rate, Cart $cart): int + { + $lines = $cart->relationLoaded('lines') ? $cart->lines : $cart->lines()->with('variant')->get(); + + $requiresShipping = $lines->contains(function ($line): bool { + $variant = $line->variant; + + return $variant !== null && (bool) $variant->requires_shipping; + }); + + if (! $requiresShipping) { + return 0; + } + + $config = $rate->config_json ?? []; + + return match ($rate->type) { + ShippingRateType::Flat => (int) ($config['amount'] ?? 0), + ShippingRateType::Weight => $this->weightRate($config, $lines), + ShippingRateType::Price => $this->priceRate($config, $lines), + ShippingRateType::Carrier => (int) ($config['fallback_amount'] ?? 0), + }; + } + + /** + * @param array $config + * @param iterable $lines + */ + private function weightRate(array $config, iterable $lines): int + { + $totalWeight = 0; + foreach ($lines as $line) { + $variant = $line->variant; + if ($variant === null || ! $variant->requires_shipping) { + continue; + } + $totalWeight += (int) ($variant->weight_g ?? 0) * (int) $line->quantity; + } + + foreach ($config['ranges'] ?? [] as $range) { + $min = (int) ($range['min_g'] ?? 0); + $max = (int) ($range['max_g'] ?? PHP_INT_MAX); + if ($totalWeight >= $min && $totalWeight <= $max) { + return (int) ($range['amount'] ?? 0); + } + } + + return 0; + } + + /** + * @param array $config + * @param iterable $lines + */ + private function priceRate(array $config, iterable $lines): int + { + $subtotal = 0; + foreach ($lines as $line) { + $subtotal += (int) $line->line_subtotal_amount; + } + + foreach ($config['ranges'] ?? [] as $range) { + $min = (int) ($range['min_amount'] ?? 0); + $max = (int) ($range['max_amount'] ?? PHP_INT_MAX); + if ($subtotal >= $min && $subtotal <= $max) { + return (int) ($range['amount'] ?? 0); + } + } + + return 0; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..b376426a --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,51 @@ + $address + * @return array{tax_total: int, tax_lines: array} + */ + public function calculate(int $amount, TaxSettings $settings, array $address): array + { + $config = $settings->config_json ?? []; + $rate = (int) ($config['rate_basis_points'] ?? 0); + $name = (string) ($config['name'] ?? 'Tax'); + + if ($rate === 0 || $amount === 0) { + return ['tax_total' => 0, 'tax_lines' => []]; + } + + $taxAmount = $this->addExclusive($amount, $rate); + + return [ + 'tax_total' => $taxAmount, + 'tax_lines' => [new TaxLine($name, $rate, $taxAmount)], + ]; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..4062a112 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,15 @@ + $allocations + */ + public function __construct( + public int $amount, + public array $allocations, + public bool $freeShipping = false, + ) {} +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..d76cff67 --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,46 @@ + $taxLines + */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + public bool $freeShippingApplied = false, + ) {} + + /** + * @return array{ + * subtotal: int, + * discount: int, + * shipping: int, + * tax_lines: array, + * tax_total: int, + * total: int, + * currency: string, + * free_shipping_applied: bool + * } + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'tax_total' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + 'free_shipping_applied' => $this->freeShippingApplied, + ]; + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..863420c4 --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,24 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..50976ef8 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,45 @@ + + */ +class CartFactory extends Factory +{ + protected $model = Cart::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'session_id' => fake()->uuid(), + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => CartStatus::Active->value, + ]; + } + + public function converted(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CartStatus::Converted->value, + ]); + } + + public function abandoned(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => CartStatus::Abandoned->value, + ]); + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..0be17a93 --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,36 @@ + + */ +class CartLineFactory extends Factory +{ + protected $model = CartLine::class; + + /** + * @return array + */ + public function definition(): array + { + $unitPrice = 2499; + $quantity = 1; + $subtotal = $unitPrice * $quantity; + + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..8a061337 --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,39 @@ + + */ +class CheckoutFactory extends Factory +{ + protected $model = Checkout::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => CheckoutStatus::Started->value, + 'payment_method' => null, + 'email' => null, + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => null, + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..5c4c6336 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,85 @@ + + */ +class DiscountFactory extends Factory +{ + protected $model = Discount::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => DiscountType::Code->value, + 'code' => strtoupper(fake()->bothify('SAVE##??')), + 'value_type' => DiscountValueType::Percent->value, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active->value, + ]; + } + + public function percent10(): static + { + return $this->state(fn (array $attributes): array => [ + 'value_type' => DiscountValueType::Percent->value, + 'value_amount' => 10, + ]); + } + + public function fixed500(): static + { + return $this->state(fn (array $attributes): array => [ + 'value_type' => DiscountValueType::Fixed->value, + 'value_amount' => 500, + ]); + } + + public function freeShipping(): static + { + return $this->state(fn (array $attributes): array => [ + 'value_type' => DiscountValueType::FreeShipping->value, + 'value_amount' => 0, + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes): array => [ + 'starts_at' => now()->subMonths(2), + 'ends_at' => now()->subMonth(), + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => DiscountStatus::Disabled->value, + ]); + } + + public function notYetActive(): static + { + return $this->state(fn (array $attributes): array => [ + 'starts_at' => now()->addWeek(), + 'ends_at' => now()->addMonth(), + ]); + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..a538d6bb --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,67 @@ + + */ +class ShippingRateFactory extends Factory +{ + protected $model = ShippingRate::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => 'Standard', + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]; + } + + public function flat(int $amount = 499): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => $amount], + ]); + } + + /** + * @param array $ranges + */ + public function weight(array $ranges): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => ShippingRateType::Weight->value, + 'config_json' => ['ranges' => $ranges], + ]); + } + + /** + * @param array $ranges + */ + public function price(array $ranges): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => ShippingRateType::Price->value, + 'config_json' => ['ranges' => $ranges], + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_active' => false, + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..64860eba --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,28 @@ + + */ +class ShippingZoneFactory extends Factory +{ + protected $model = ShippingZone::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->randomElement(['Europe', 'Domestic', 'International']), + 'countries_json' => ['DE', 'AT', 'CH'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..2965717e --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,40 @@ + + */ +class TaxSettingsFactory extends Factory +{ + protected $model = TaxSettings::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => TaxMode::Manual->value, + 'provider' => null, + 'prices_include_tax' => false, + 'config_json' => [ + 'name' => 'VAT', + 'rate_basis_points' => 1900, + ], + ]; + } + + public function pricesInclude(): static + { + return $this->state(fn (array $attributes): array => [ + 'prices_include_tax' => true, + ]); + } +} diff --git a/database/migrations/2026_04_12_103001_create_carts_table.php b/database/migrations/2026_04_12_103001_create_carts_table.php new file mode 100644 index 00000000..e6feb973 --- /dev/null +++ b/database/migrations/2026_04_12_103001_create_carts_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->unsignedBigInteger('customer_id')->nullable()->comment('FK to customers added in Phase 6'); + $table->string('session_id')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->integer('cart_version')->default(1); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->index('store_id', 'idx_carts_store_id'); + $table->index('customer_id', 'idx_carts_customer_id'); + $table->index(['store_id', 'status'], 'idx_carts_store_status'); + $table->index('session_id', 'idx_carts_session_id'); + }); + + DB::statement("CREATE TRIGGER carts_status_check BEFORE INSERT ON carts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','converted','abandoned') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER carts_status_check_update BEFORE UPDATE ON carts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','converted','abandoned') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS carts_status_check'); + DB::statement('DROP TRIGGER IF EXISTS carts_status_check_update'); + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_04_12_103002_create_cart_lines_table.php b/database/migrations/2026_04_12_103002_create_cart_lines_table.php new file mode 100644 index 00000000..8a82dadb --- /dev/null +++ b/database/migrations/2026_04_12_103002_create_cart_lines_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('cart_id') + ->constrained('carts') + ->cascadeOnDelete(); + $table->foreignId('variant_id') + ->constrained('product_variants') + ->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('line_subtotal_amount')->default(0); + $table->integer('line_discount_amount')->default(0); + $table->integer('line_total_amount')->default(0); + + $table->index('cart_id', 'idx_cart_lines_cart_id'); + $table->unique(['cart_id', 'variant_id'], 'idx_cart_lines_cart_variant'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_04_12_103003_create_checkouts_table.php b/database/migrations/2026_04_12_103003_create_checkouts_table.php new file mode 100644 index 00000000..07c23666 --- /dev/null +++ b/database/migrations/2026_04_12_103003_create_checkouts_table.php @@ -0,0 +1,54 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->foreignId('cart_id') + ->constrained('carts') + ->cascadeOnDelete(); + $table->unsignedBigInteger('customer_id')->nullable()->comment('FK to customers added in Phase 6'); + $table->string('status')->default('started'); + $table->string('payment_method')->nullable(); + $table->string('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->unsignedBigInteger('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_checkouts_store_id'); + $table->index('cart_id', 'idx_checkouts_cart_id'); + $table->index('customer_id', 'idx_checkouts_customer_id'); + $table->index(['store_id', 'status'], 'idx_checkouts_status'); + $table->index('expires_at', 'idx_checkouts_expires_at'); + }); + + DB::statement("CREATE TRIGGER checkouts_status_check BEFORE INSERT ON checkouts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('started','addressed','shipping_selected','payment_selected','completed','expired') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER checkouts_status_check_update BEFORE UPDATE ON checkouts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('started','addressed','shipping_selected','payment_selected','completed','expired') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER checkouts_payment_method_check BEFORE INSERT ON checkouts FOR EACH ROW WHEN NEW.payment_method IS NOT NULL BEGIN SELECT CASE WHEN NEW.payment_method NOT IN ('credit_card','paypal','bank_transfer') THEN RAISE(ABORT, 'invalid payment_method') END; END"); + DB::statement("CREATE TRIGGER checkouts_payment_method_check_update BEFORE UPDATE ON checkouts FOR EACH ROW WHEN NEW.payment_method IS NOT NULL BEGIN SELECT CASE WHEN NEW.payment_method NOT IN ('credit_card','paypal','bank_transfer') THEN RAISE(ABORT, 'invalid payment_method') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS checkouts_status_check'); + DB::statement('DROP TRIGGER IF EXISTS checkouts_status_check_update'); + DB::statement('DROP TRIGGER IF EXISTS checkouts_payment_method_check'); + DB::statement('DROP TRIGGER IF EXISTS checkouts_payment_method_check_update'); + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_04_12_103004_create_shipping_zones_table.php b/database/migrations/2026_04_12_103004_create_shipping_zones_table.php new file mode 100644 index 00000000..fce22d10 --- /dev/null +++ b/database/migrations/2026_04_12_103004_create_shipping_zones_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + $table->timestamps(); + + $table->index('store_id', 'idx_shipping_zones_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_04_12_103005_create_shipping_rates_table.php b/database/migrations/2026_04_12_103005_create_shipping_rates_table.php new file mode 100644 index 00000000..544ae663 --- /dev/null +++ b/database/migrations/2026_04_12_103005_create_shipping_rates_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('zone_id') + ->constrained('shipping_zones') + ->cascadeOnDelete(); + $table->string('name'); + $table->string('type')->default('flat'); + $table->text('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index('zone_id', 'idx_shipping_rates_zone_id'); + $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active'); + }); + + DB::statement("CREATE TRIGGER shipping_rates_type_check BEFORE INSERT ON shipping_rates FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('flat','weight','price','carrier') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER shipping_rates_type_check_update BEFORE UPDATE ON shipping_rates FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('flat','weight','price','carrier') THEN RAISE(ABORT, 'invalid type') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS shipping_rates_type_check'); + DB::statement('DROP TRIGGER IF EXISTS shipping_rates_type_check_update'); + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_04_12_103006_create_tax_settings_table.php b/database/migrations/2026_04_12_103006_create_tax_settings_table.php new file mode 100644 index 00000000..2bbbcd13 --- /dev/null +++ b/database/migrations/2026_04_12_103006_create_tax_settings_table.php @@ -0,0 +1,34 @@ +foreignId('store_id') + ->primary() + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('mode')->default('manual'); + $table->string('provider')->nullable(); + $table->boolean('prices_include_tax')->default(false); + $table->text('config_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + + DB::statement("CREATE TRIGGER tax_settings_mode_check BEFORE INSERT ON tax_settings FOR EACH ROW BEGIN SELECT CASE WHEN NEW.mode NOT IN ('manual','provider') THEN RAISE(ABORT, 'invalid mode') END; END"); + DB::statement("CREATE TRIGGER tax_settings_mode_check_update BEFORE UPDATE ON tax_settings FOR EACH ROW BEGIN SELECT CASE WHEN NEW.mode NOT IN ('manual','provider') THEN RAISE(ABORT, 'invalid mode') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS tax_settings_mode_check'); + DB::statement('DROP TRIGGER IF EXISTS tax_settings_mode_check_update'); + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_04_12_103007_create_discounts_table.php b/database/migrations/2026_04_12_103007_create_discounts_table.php new file mode 100644 index 00000000..40dcf1d1 --- /dev/null +++ b/database/migrations/2026_04_12_103007_create_discounts_table.php @@ -0,0 +1,53 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('type')->default('code'); + $table->string('code')->nullable(); + $table->string('value_type'); + $table->integer('value_amount')->default(0); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->nullable(); + $table->string('status')->default('draft'); + $table->timestamps(); + + $table->unique(['store_id', 'code'], 'idx_discounts_store_code'); + $table->index('store_id', 'idx_discounts_store_id'); + $table->index(['store_id', 'status'], 'idx_discounts_store_status'); + $table->index(['store_id', 'type'], 'idx_discounts_store_type'); + }); + + DB::statement("CREATE TRIGGER discounts_type_check BEFORE INSERT ON discounts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('code','automatic') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER discounts_type_check_update BEFORE UPDATE ON discounts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('code','automatic') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER discounts_value_type_check BEFORE INSERT ON discounts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.value_type NOT IN ('percent','fixed','free_shipping') THEN RAISE(ABORT, 'invalid value_type') END; END"); + DB::statement("CREATE TRIGGER discounts_value_type_check_update BEFORE UPDATE ON discounts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.value_type NOT IN ('percent','fixed','free_shipping') THEN RAISE(ABORT, 'invalid value_type') END; END"); + DB::statement("CREATE TRIGGER discounts_status_check BEFORE INSERT ON discounts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','active','expired','disabled') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER discounts_status_check_update BEFORE UPDATE ON discounts FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('draft','active','expired','disabled') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS discounts_type_check'); + DB::statement('DROP TRIGGER IF EXISTS discounts_type_check_update'); + DB::statement('DROP TRIGGER IF EXISTS discounts_value_type_check'); + DB::statement('DROP TRIGGER IF EXISTS discounts_value_type_check_update'); + DB::statement('DROP TRIGGER IF EXISTS discounts_status_check'); + DB::statement('DROP TRIGGER IF EXISTS discounts_status_check_update'); + Schema::dropIfExists('discounts'); + } +}; diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..0c011335 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,14 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes(); +Schedule::job(new CleanupAbandonedCarts)->dailyAt('03:00'); diff --git a/specs/progress.md b/specs/progress.md index 90caeb59..7d5490cb 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -7,7 +7,7 @@ Tracking progress for the full shop system implementation (team: shop-v2). - [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing - [x] Phase 2: Catalog (products, variants, inventory, collections, media) - 63 tests passing - [x] Phase 3: Themes, pages, navigation, storefront layout - 79 tests passing -- [ ] Phase 4: Cart, checkout, discounts, shipping, taxes +- [x] Phase 4: Cart, checkout, discounts, shipping, taxes - 138 tests passing - [ ] Phase 5: Payments, orders, fulfillment - [ ] Phase 6: Customer accounts - [ ] Phase 7: Admin panel diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..a1abfd6a --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,105 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = new CartService(new InventoryService); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function makeVariantWithStock(Store $store, int $price = 1000, int $onHand = 10): ProductVariant +{ + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create(['price_amount' => $price]); + InventoryItem::factory() + ->for($store) + ->for($variant, 'variant') + ->create(['quantity_on_hand' => $onHand]); + + return $variant; +} + +it('adds a line to a cart', function (): void { + $cart = $this->service->create($this->store); + $variant = makeVariantWithStock($this->store, price: 2000); + + $line = $this->service->addLine($cart, $variant->id, 2); + + expect($line->quantity)->toBe(2) + ->and($line->line_subtotal_amount)->toBe(4000) + ->and($line->line_total_amount)->toBe(4000); +}); + +it('increments quantity for existing variant', function (): void { + $cart = $this->service->create($this->store); + $variant = makeVariantWithStock($this->store, price: 1000); + + $this->service->addLine($cart, $variant->id, 1); + $line = $this->service->addLine($cart->fresh(), $variant->id, 2); + + expect($cart->lines()->count())->toBe(1) + ->and($line->quantity)->toBe(3) + ->and($line->line_subtotal_amount)->toBe(3000); +}); + +it('validates inventory before adding', function (): void { + $cart = $this->service->create($this->store); + $variant = makeVariantWithStock($this->store, price: 1000, onHand: 2); + + $this->service->addLine($cart, $variant->id, 5); +})->throws(RuntimeException::class, 'Insufficient inventory'); + +it('removes a line', function (): void { + $cart = $this->service->create($this->store); + $variant = makeVariantWithStock($this->store); + $line = $this->service->addLine($cart, $variant->id, 1); + + $this->service->removeLine($cart->fresh(), $line->id); + + expect($cart->lines()->count())->toBe(0); +}); + +it('updates quantity', function (): void { + $cart = $this->service->create($this->store); + $variant = makeVariantWithStock($this->store, price: 500); + $line = $this->service->addLine($cart, $variant->id, 1); + + $updated = $this->service->updateLineQuantity($cart->fresh(), $line->id, 4); + + expect($updated->quantity)->toBe(4) + ->and($updated->line_subtotal_amount)->toBe(2000); +}); + +it('merges guest cart into customer cart on login', function (): void { + $guestCart = $this->service->create($this->store); + $customerCart = $this->service->create($this->store); + $variantA = makeVariantWithStock($this->store, price: 500); + $variantB = makeVariantWithStock($this->store, price: 1500); + + $this->service->addLine($guestCart, $variantA->id, 2); + $this->service->addLine($guestCart->fresh(), $variantB->id, 1); + $this->service->addLine($customerCart, $variantA->id, 1); + + $merged = $this->service->mergeOnLogin($guestCart->fresh(), $customerCart->fresh()); + + $lines = $merged->fresh('lines')->lines; + $variantALine = $lines->firstWhere('variant_id', $variantA->id); + $variantBLine = $lines->firstWhere('variant_id', $variantB->id); + + expect($lines)->toHaveCount(2) + ->and($variantALine->quantity)->toBe(3) + ->and($variantBLine->quantity)->toBe(1); +}); diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php new file mode 100644 index 00000000..4c7a76d0 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutFlowTest.php @@ -0,0 +1,135 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->inventoryService = new InventoryService; + $this->cartService = new CartService($this->inventoryService); + $this->checkoutService = new CheckoutService( + new PricingEngine( + new DiscountService, + new ShippingCalculator, + new TaxCalculator, + ), + $this->inventoryService, + ); + + $product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($product)->create([ + 'price_amount' => 2500, + 'weight_g' => 300, + 'requires_shipping' => true, + ]); + InventoryItem::factory() + ->for($this->store) + ->for($this->variant, 'variant') + ->create(['quantity_on_hand' => 10]); + + $this->cart = $this->cartService->create($this->store); + $this->cartService->addLine($this->cart, $this->variant->id, 2); + $this->cart->refresh(); + + $this->zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $this->rate = ShippingRate::factory()->for($this->zone, 'zone')->flat(599)->create(); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('starts checkout from cart', function (): void { + $checkout = $this->checkoutService->start($this->cart); + + expect($checkout->status)->toBe(CheckoutStatus::Started) + ->and((int) $checkout->cart_id)->toBe($this->cart->id); +}); + +it('transitions started -> addressed via setAddress', function (): void { + $checkout = $this->checkoutService->start($this->cart); + + $result = $this->checkoutService->setAddress($checkout, [ + 'email' => 'buyer@example.com', + 'shipping_address' => [ + 'first_name' => 'A', + 'last_name' => 'B', + 'address1' => 'Street 1', + 'city' => 'Berlin', + 'country' => 'DE', + 'postal_code' => '10115', + ], + ]); + + expect($result->status)->toBe(CheckoutStatus::Addressed) + ->and($result->email)->toBe('buyer@example.com'); +}); + +it('transitions addressed -> shipping_selected', function (): void { + $checkout = $this->checkoutService->start($this->cart); + $this->checkoutService->setAddress($checkout, [ + 'email' => 'a@b.c', + 'shipping_address' => ['country' => 'DE'], + ]); + + $result = $this->checkoutService->setShippingMethod($checkout->fresh(), $this->rate->id); + + expect($result->status)->toBe(CheckoutStatus::ShippingSelected) + ->and((int) $result->shipping_method_id)->toBe($this->rate->id); +}); + +it('transitions shipping_selected -> payment_selected', function (): void { + $checkout = $this->checkoutService->start($this->cart); + $this->checkoutService->setAddress($checkout, [ + 'email' => 'a@b.c', + 'shipping_address' => ['country' => 'DE'], + ]); + $this->checkoutService->setShippingMethod($checkout->fresh(), $this->rate->id); + + $result = $this->checkoutService->selectPaymentMethod($checkout->fresh(), 'credit_card'); + + expect($result->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($result->payment_method)->toBe('credit_card') + ->and($result->expires_at)->not->toBeNull(); +}); + +it('rejects invalid transitions', function (): void { + $checkout = $this->checkoutService->start($this->cart); + + $this->checkoutService->selectPaymentMethod($checkout, 'credit_card'); +})->throws(DomainException::class); + +it('recalculates totals on each step', function (): void { + $checkout = $this->checkoutService->start($this->cart); + $result = $this->checkoutService->setAddress($checkout, [ + 'email' => 'a@b.c', + 'shipping_address' => ['country' => 'DE'], + ]); + + expect($result->totals_json)->not->toBeNull() + ->and($result->totals_json['subtotal'])->toBe(5000); + + $result = $this->checkoutService->setShippingMethod($result->fresh(), $this->rate->id); + + expect($result->totals_json['shipping'])->toBe(599); +}); diff --git a/tests/Feature/Checkout/CheckoutStateTest.php b/tests/Feature/Checkout/CheckoutStateTest.php new file mode 100644 index 00000000..ff91248f --- /dev/null +++ b/tests/Feature/Checkout/CheckoutStateTest.php @@ -0,0 +1,64 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->inventoryService = new InventoryService; + $this->cartService = new CartService($this->inventoryService); + $this->checkoutService = new CheckoutService( + new PricingEngine(new DiscountService, new ShippingCalculator, new TaxCalculator), + $this->inventoryService, + ); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('expires checkouts via job and releases reserved inventory', function (): void { + $product = Product::factory()->for($this->store)->create(); + $variant = ProductVariant::factory()->for($product)->create([ + 'price_amount' => 1000, + 'requires_shipping' => false, + ]); + $inventory = InventoryItem::factory() + ->for($this->store) + ->for($variant, 'variant') + ->create(['quantity_on_hand' => 10, 'quantity_reserved' => 0]); + + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $variant->id, 2); + $checkout = $this->checkoutService->start($cart); + $this->checkoutService->setAddress($checkout, [ + 'email' => 'a@b.c', + 'shipping_address' => ['country' => 'DE'], + ]); + $this->checkoutService->selectPaymentMethod($checkout->fresh(), 'credit_card'); + + expect($inventory->fresh()->quantity_reserved)->toBe(2); + + $checkout->fresh()->update(['expires_at' => now()->subHour()]); + + app(ExpireAbandonedCheckouts::class)->handle($this->checkoutService); + + expect($checkout->fresh()->status)->toBe(CheckoutStatus::Expired) + ->and($inventory->fresh()->quantity_reserved)->toBe(0); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..98fcd398 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -15,6 +15,8 @@ // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); +pest()->extend(Tests\TestCase::class)->in('Unit'); + /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/Unit/CartVersionTest.php b/tests/Unit/CartVersionTest.php new file mode 100644 index 00000000..b8fee1c3 --- /dev/null +++ b/tests/Unit/CartVersionTest.php @@ -0,0 +1,60 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = new CartService(new InventoryService); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('starts at version 1', function (): void { + $cart = $this->service->create($this->store); + expect($cart->cart_version)->toBe(1); +}); + +it('increments on add line', function (): void { + $cart = $this->service->create($this->store); + $variant = ProductVariant::factory() + ->for(Product::factory()->for($this->store)) + ->create(['price_amount' => 1000]); + + $this->service->addLine($cart, $variant->id, 1); + + expect($cart->fresh()->cart_version)->toBe(2); +}); + +it('increments on update quantity', function (): void { + $cart = $this->service->create($this->store); + $variant = ProductVariant::factory() + ->for(Product::factory()->for($this->store)) + ->create(['price_amount' => 1000]); + $line = $this->service->addLine($cart, $variant->id, 1); + + $this->service->updateLineQuantity($cart->fresh(), $line->id, 3); + + expect($cart->fresh()->cart_version)->toBe(3); +}); + +it('increments on remove line', function (): void { + $cart = $this->service->create($this->store); + $variant = ProductVariant::factory() + ->for(Product::factory()->for($this->store)) + ->create(['price_amount' => 1000]); + $line = $this->service->addLine($cart, $variant->id, 1); + + $this->service->removeLine($cart->fresh(), $line->id); + + expect($cart->fresh()->cart_version)->toBe(3); +}); diff --git a/tests/Unit/Discounts/DiscountCalculatorTest.php b/tests/Unit/Discounts/DiscountCalculatorTest.php new file mode 100644 index 00000000..6bccfcd3 --- /dev/null +++ b/tests/Unit/Discounts/DiscountCalculatorTest.php @@ -0,0 +1,167 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = new DiscountService; + $this->cart = Cart::factory()->for($this->store)->create(); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function makeCartLine(Cart $cart, int $price = 2000, int $qty = 1): CartLine +{ + $variant = ProductVariant::factory() + ->for(Product::factory()->for($cart->store)) + ->create(['price_amount' => $price]); + + return CartLine::factory()->for($cart)->for($variant, 'variant')->create([ + 'quantity' => $qty, + 'unit_price_amount' => $price, + 'line_subtotal_amount' => $price * $qty, + 'line_total_amount' => $price * $qty, + ]); +} + +it('validates an active code', function (): void { + $discount = Discount::factory()->for($this->store)->create(['code' => 'SAVE10']); + makeCartLine($this->cart); + + $result = $this->service->validate('SAVE10', $this->store, $this->cart->fresh('lines')); + + expect($result->id)->toBe($discount->id); +}); + +it('rejects expired discount', function (): void { + Discount::factory()->for($this->store)->expired()->create(['code' => 'OLD']); + + expect(fn () => $this->service->validate('OLD', $this->store, $this->cart)) + ->toThrow(InvalidDiscountException::class); +}); + +it('rejects not yet active discount', function (): void { + Discount::factory()->for($this->store)->notYetActive()->create(['code' => 'FUTURE']); + + $this->service->validate('FUTURE', $this->store, $this->cart); +})->throws(InvalidDiscountException::class, 'not yet active'); + +it('rejects usage limit reached', function (): void { + Discount::factory()->for($this->store)->create([ + 'code' => 'LIMITED', + 'usage_limit' => 5, + 'usage_count' => 5, + ]); + + $this->service->validate('LIMITED', $this->store, $this->cart); +})->throws(InvalidDiscountException::class, 'usage limit'); + +it('rejects unknown code', function (): void { + $this->service->validate('NOPE', $this->store, $this->cart); +})->throws(InvalidDiscountException::class, 'not found'); + +it('performs case insensitive lookup', function (): void { + Discount::factory()->for($this->store)->create(['code' => 'SaveMe']); + makeCartLine($this->cart); + + $result = $this->service->validate('SAVEME', $this->store, $this->cart->fresh('lines')); + + expect($result->code)->toBe('SaveMe'); +}); + +it('enforces minimum purchase rule', function (): void { + Discount::factory()->for($this->store)->create([ + 'code' => 'BIG', + 'rules_json' => ['min_purchase_amount' => 10000], + ]); + makeCartLine($this->cart, price: 1000, qty: 1); + + $this->service->validate('BIG', $this->store, $this->cart->fresh('lines')); +})->throws(InvalidDiscountException::class, 'minimum'); + +it('passes when minimum purchase is met', function (): void { + $discount = Discount::factory()->for($this->store)->create([ + 'code' => 'BIG2', + 'rules_json' => ['min_purchase_amount' => 1000], + ]); + makeCartLine($this->cart, price: 2000, qty: 1); + + $result = $this->service->validate('BIG2', $this->store, $this->cart->fresh('lines')); + + expect($result->id)->toBe($discount->id); +}); + +it('rejects disabled discount', function (): void { + Discount::factory()->for($this->store)->disabled()->create(['code' => 'OFF']); + + $this->service->validate('OFF', $this->store, $this->cart); +})->throws(InvalidDiscountException::class); + +it('calculates percent discount amount', function (): void { + $discount = Discount::factory()->for($this->store)->percent10()->create(['code' => 'P10']); + makeCartLine($this->cart, price: 10000, qty: 1); + $lines = $this->cart->fresh('lines')->lines; + + $result = $this->service->calculate($discount, 10000, $lines); + + expect($result->amount)->toBe(1000); +}); + +it('calculates fixed discount amount', function (): void { + $discount = Discount::factory()->for($this->store)->fixed500()->create(['code' => 'F500']); + makeCartLine($this->cart, price: 10000, qty: 1); + $lines = $this->cart->fresh('lines')->lines; + + $result = $this->service->calculate($discount, 10000, $lines); + + expect($result->amount)->toBe(500); +}); + +it('caps fixed discount at subtotal', function (): void { + $discount = Discount::factory()->for($this->store)->fixed500()->create(['code' => 'F500B']); + makeCartLine($this->cart, price: 300, qty: 1); + $lines = $this->cart->fresh('lines')->lines; + + $result = $this->service->calculate($discount, 300, $lines); + + expect($result->amount)->toBe(300); +}); + +it('flags free shipping discount', function (): void { + $discount = Discount::factory()->for($this->store)->freeShipping()->create(['code' => 'FREESHIP']); + makeCartLine($this->cart, price: 5000); + $lines = $this->cart->fresh('lines')->lines; + + $result = $this->service->calculate($discount, 5000, $lines); + + expect($result->freeShipping)->toBeTrue() + ->and($result->amount)->toBe(0); +}); + +it('allocates proportionally across lines with remainder on last', function (): void { + $discount = Discount::factory()->for($this->store)->percent10()->create(['code' => 'P10B']); + $line1 = makeCartLine($this->cart, price: 3333, qty: 1); + $line2 = makeCartLine($this->cart, price: 3333, qty: 1); + $line3 = makeCartLine($this->cart, price: 3334, qty: 1); + $subtotal = 10000; + $lines = $this->cart->fresh('lines')->lines; + + $result = $this->service->calculate($discount, $subtotal, $lines); + + expect($result->amount)->toBe(1000) + ->and(array_sum($result->allocations))->toBe(1000) + ->and($result->allocations)->toHaveCount(3); +}); diff --git a/tests/Unit/Pricing/PricingEngineTest.php b/tests/Unit/Pricing/PricingEngineTest.php new file mode 100644 index 00000000..e0b2f4bb --- /dev/null +++ b/tests/Unit/Pricing/PricingEngineTest.php @@ -0,0 +1,206 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->engine = new PricingEngine( + new DiscountService, + new ShippingCalculator, + new TaxCalculator, + ); + + $this->cart = Cart::factory()->for($this->store)->create(['currency' => 'EUR']); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function addLine(Cart $cart, int $price, int $qty = 1, int $weight = 250, bool $requiresShipping = true): CartLine +{ + $variant = ProductVariant::factory() + ->for(Product::factory()->for($cart->store)) + ->create([ + 'price_amount' => $price, + 'weight_g' => $weight, + 'requires_shipping' => $requiresShipping, + ]); + + return CartLine::factory()->for($cart)->for($variant, 'variant')->create([ + 'quantity' => $qty, + 'unit_price_amount' => $price, + 'line_subtotal_amount' => $price * $qty, + 'line_total_amount' => $price * $qty, + ]); +} + +it('calculates subtotal from line items', function (): void { + addLine($this->cart, 2499, 2); + addLine($this->cart, 7999, 1); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create(); + + $result = $this->engine->calculate($checkout); + + expect($result->subtotal)->toBe(12997) + ->and($result->total)->toBe(12997); +}); + +it('applies percent discount', function (): void { + addLine($this->cart, 10000, 1); + Discount::factory()->for($this->store)->percent10()->create(['code' => 'P10']); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create([ + 'discount_code' => 'P10', + ]); + + $result = $this->engine->calculate($checkout); + + expect($result->subtotal)->toBe(10000) + ->and($result->discount)->toBe(1000) + ->and($result->total)->toBe(9000); +}); + +it('applies fixed discount', function (): void { + addLine($this->cart, 10000, 1); + Discount::factory()->for($this->store)->fixed500()->create(['code' => 'F500']); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create([ + 'discount_code' => 'F500', + ]); + + $result = $this->engine->calculate($checkout); + + expect($result->discount)->toBe(500) + ->and($result->total)->toBe(9500); +}); + +it('caps fixed discount at subtotal', function (): void { + addLine($this->cart, 300, 1); + Discount::factory()->for($this->store)->fixed500()->create(['code' => 'F500B']); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create([ + 'discount_code' => 'F500B', + ]); + + $result = $this->engine->calculate($checkout); + + expect($result->discount)->toBe(300) + ->and($result->total)->toBe(0); +}); + +it('applies free shipping discount', function (): void { + addLine($this->cart, 5000, 1); + Discount::factory()->for($this->store)->freeShipping()->create(['code' => 'FREESHIP']); + + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flat(499)->create(); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create([ + 'discount_code' => 'FREESHIP', + 'shipping_method_id' => $rate->id, + ]); + + $result = $this->engine->calculate($checkout); + + expect($result->shipping)->toBe(0) + ->and($result->freeShippingApplied)->toBeTrue() + ->and($result->total)->toBe(5000); +}); + +it('calculates tax exclusive', function (): void { + addLine($this->cart, 10000, 1); + TaxSettings::factory()->for($this->store)->create(); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create(); + + $result = $this->engine->calculate($checkout); + + expect($result->subtotal)->toBe(10000) + ->and($result->taxTotal)->toBe(1900) + ->and($result->total)->toBe(11900); +}); + +it('extracts tax when prices include tax', function (): void { + addLine($this->cart, 11900, 1); + TaxSettings::factory()->for($this->store)->pricesInclude()->create(); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create(); + + $result = $this->engine->calculate($checkout); + + expect($result->subtotal)->toBe(11900) + ->and($result->taxTotal)->toBe(1900) + ->and($result->total)->toBe(11900); +}); + +it('returns zero tax when rate is zero', function (): void { + addLine($this->cart, 10000, 1); + TaxSettings::factory()->for($this->store)->create([ + 'config_json' => ['name' => 'Tax', 'rate_basis_points' => 0], + ]); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create(); + + $result = $this->engine->calculate($checkout); + + expect($result->taxTotal)->toBe(0) + ->and($result->total)->toBe(10000); +}); + +it('calculates flat shipping', function (): void { + addLine($this->cart, 2000, 1); + + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flat(499)->create(); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create([ + 'shipping_method_id' => $rate->id, + ]); + + $result = $this->engine->calculate($checkout); + + expect($result->shipping)->toBe(499) + ->and($result->total)->toBe(2499); +}); + +it('calculates full checkout end-to-end', function (): void { + addLine($this->cart, 10000, 1); + Discount::factory()->for($this->store)->percent10()->create(['code' => 'P10']); + TaxSettings::factory()->for($this->store)->create(); + + $zone = ShippingZone::factory()->for($this->store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flat(499)->create(); + + $checkout = Checkout::factory()->for($this->store)->for($this->cart)->create([ + 'discount_code' => 'P10', + 'shipping_method_id' => $rate->id, + ]); + + $result = $this->engine->calculate($checkout); + + expect($result->subtotal)->toBe(10000) + ->and($result->discount)->toBe(1000) + ->and($result->shipping)->toBe(499) + ->and($result->taxTotal)->toBe(1805) + ->and($result->total)->toBe(11304); +}); diff --git a/tests/Unit/Shipping/ShippingCalculatorTest.php b/tests/Unit/Shipping/ShippingCalculatorTest.php new file mode 100644 index 00000000..2ecb36f7 --- /dev/null +++ b/tests/Unit/Shipping/ShippingCalculatorTest.php @@ -0,0 +1,161 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->calc = new ShippingCalculator; +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function makeCartWithLine(Store $store, int $weightGrams = 500, bool $requiresShipping = true, int $price = 2000, int $qty = 1): Cart +{ + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create([ + 'weight_g' => $weightGrams, + 'requires_shipping' => $requiresShipping, + 'price_amount' => $price, + ]); + + $cart = Cart::factory()->for($store)->create(); + CartLine::factory()->for($cart)->for($variant, 'variant')->create([ + 'quantity' => $qty, + 'unit_price_amount' => $price, + 'line_subtotal_amount' => $price * $qty, + 'line_total_amount' => $price * $qty, + ]); + + return $cart->fresh('lines.variant'); +} + +it('matches zone by country', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE', 'AT'], + 'regions_json' => [], + ]); + ShippingRate::factory()->for($zone, 'zone')->flat(799)->create(); + + $rates = $this->calc->getAvailableRates($this->store, ['country' => 'DE']); + + expect($rates)->toHaveCount(1); +}); + +it('matches zone by region', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => [], + 'regions_json' => ['US-NY'], + ]); + ShippingRate::factory()->for($zone, 'zone')->flat(799)->create(); + + $rates = $this->calc->getAvailableRates($this->store, [ + 'country' => 'US', + 'province_code' => 'NY', + ]); + + expect($rates)->toHaveCount(1); +}); + +it('returns empty when no zone matches', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + ShippingRate::factory()->for($zone, 'zone')->flat(799)->create(); + + $rates = $this->calc->getAvailableRates($this->store, ['country' => 'FR']); + + expect($rates)->toBeEmpty(); +}); + +it('calculates flat rate', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flat(799)->create(); + $cart = makeCartWithLine($this->store); + + expect($this->calc->calculate($rate, $cart))->toBe(799); +}); + +it('calculates weight-based rate using ranges', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->for($zone, 'zone')->weight([ + ['min_g' => 0, 'max_g' => 1000, 'amount' => 500], + ['min_g' => 1001, 'max_g' => 5000, 'amount' => 1000], + ])->create(); + + $lightCart = makeCartWithLine($this->store, weightGrams: 400, qty: 1); + expect($this->calc->calculate($rate, $lightCart))->toBe(500); + + $heavyCart = makeCartWithLine($this->store, weightGrams: 1500, qty: 2); + expect($this->calc->calculate($rate, $heavyCart))->toBe(1000); +}); + +it('calculates price-based rate using ranges', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->for($zone, 'zone')->price([ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799], + ['min_amount' => 5001, 'max_amount' => PHP_INT_MAX, 'amount' => 0], + ])->create(); + + $smallCart = makeCartWithLine($this->store, price: 3000, qty: 1); + expect($this->calc->calculate($rate, $smallCart))->toBe(799); + + $bigCart = makeCartWithLine($this->store, price: 6000, qty: 1); + expect($this->calc->calculate($rate, $bigCart))->toBe(0); +}); + +it('returns zero shipping when no items require shipping', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + ]); + $rate = ShippingRate::factory()->for($zone, 'zone')->flat(799)->create(); + $cart = makeCartWithLine($this->store, requiresShipping: false); + + expect($this->calc->calculate($rate, $cart))->toBe(0); +}); + +it('skips inactive rates in getAvailableRates', function (): void { + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + ]); + ShippingRate::factory()->for($zone, 'zone')->flat(799)->create(); + ShippingRate::factory()->for($zone, 'zone')->flat(1299)->inactive()->create(); + + $rates = $this->calc->getAvailableRates($this->store, ['country' => 'DE']); + + expect($rates)->toHaveCount(1); +}); + +it('returns all rates from multiple matching zones', function (): void { + $zone1 = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + ]); + $zone2 = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE', 'AT'], + ]); + ShippingRate::factory()->for($zone1, 'zone')->flat(499)->create(); + ShippingRate::factory()->for($zone2, 'zone')->flat(799)->create(); + + $rates = $this->calc->getAvailableRates($this->store, ['country' => 'DE']); + + expect($rates)->toHaveCount(2); +}); diff --git a/tests/Unit/Taxes/TaxCalculatorTest.php b/tests/Unit/Taxes/TaxCalculatorTest.php new file mode 100644 index 00000000..ced5506e --- /dev/null +++ b/tests/Unit/Taxes/TaxCalculatorTest.php @@ -0,0 +1,58 @@ +calc = new TaxCalculator; +}); + +it('calculates exclusive tax at 19%', function (): void { + expect($this->calc->addExclusive(10000, 1900))->toBe(1900); +}); + +it('extracts inclusive tax at 19%', function (): void { + expect($this->calc->extractInclusive(11900, 1900))->toBe(1900); +}); + +it('returns 0 when rate is 0 for exclusive', function (): void { + expect($this->calc->addExclusive(10000, 0))->toBe(0); +}); + +it('returns 0 when rate is 0 for inclusive', function (): void { + expect($this->calc->extractInclusive(10000, 0))->toBe(0); +}); + +it('returns 0 when amount is 0', function (): void { + expect($this->calc->addExclusive(0, 1900))->toBe(0) + ->and($this->calc->extractInclusive(0, 1900))->toBe(0); +}); + +it('handles non-standard rate 7% exclusive', function (): void { + expect($this->calc->addExclusive(8999, 700))->toBe(630); +}); + +it('handles small inclusive extraction', function (): void { + expect($this->calc->extractInclusive(119, 1900))->toBe(19); +}); + +it('calculates high 25% exclusive rate', function (): void { + expect($this->calc->addExclusive(10000, 2500))->toBe(2500); +}); + +it('produces tax_lines via calculate', function (): void { + $settings = new TaxSettings([ + 'store_id' => 1, + 'mode' => 'manual', + 'prices_include_tax' => false, + 'config_json' => ['name' => 'VAT', 'rate_basis_points' => 1900], + ]); + + $result = $this->calc->calculate(10000, $settings, []); + + expect($result['tax_total'])->toBe(1900) + ->and($result['tax_lines'])->toHaveCount(1) + ->and($result['tax_lines'][0]->name)->toBe('VAT') + ->and($result['tax_lines'][0]->rate)->toBe(1900) + ->and($result['tax_lines'][0]->amount)->toBe(1900); +}); From 937da60bd0550e7dc07be4f2bcf59953ba8f77dc Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 21:20:53 +0200 Subject: [PATCH 06/15] Phase 5: Customers, payments, orders, refunds, fulfillment - Migrations: customers, customer_addresses, customer_password_reset tokens, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines (CHECK constraints via triggers) - Models + factories: Customer (Authenticatable, password_hash override), CustomerAddress, Order (BelongsToStore), OrderLine, Payment, Refund, Fulfillment, FulfillmentLine - Enums: OrderStatus, FinancialStatus, FulfillmentStatus, PaymentMethod, PaymentStatus, RefundStatus, FulfillmentShipmentStatus - PaymentProvider contract + PaymentResult/RefundResult value objects - MockPaymentProvider with magic card numbers, paypal, bank_transfer - OrderService: createFromCheckout (sequential order numbers, snapshots), cancel (releases reserved inventory), confirmBankTransferPayment - RefundService: partial/full refunds with optional restock - FulfillmentService: create with payment guard, mark shipped/delivered, rolls up order fulfillment_status - Events: OrderCreated, OrderPaid, OrderFulfilled, OrderCancelled, OrderRefunded, FulfillmentDelivered - Exceptions: FulfillmentGuardException, PaymentFailedException - CheckoutService::complete updated to charge payment and create Order - CancelUnpaidBankTransferOrders job scheduled daily 04:00 - Tests: MockPaymentProviderTest, PaymentServiceTest, BankTransferConfirmationTest, OrderCreationTest, RefundTest, FulfillmentTest, CustomerAccountTest (29 new, 167 total passing) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Contracts/PaymentProvider.php | 19 ++ app/Enums/FinancialStatus.php | 13 ++ app/Enums/FulfillmentShipmentStatus.php | 10 + app/Enums/FulfillmentStatus.php | 10 + app/Enums/OrderStatus.php | 12 ++ app/Enums/PaymentMethod.php | 10 + app/Enums/PaymentStatus.php | 11 ++ app/Enums/RefundStatus.php | 10 + app/Events/FulfillmentDelivered.php | 14 ++ app/Events/OrderCancelled.php | 14 ++ app/Events/OrderCreated.php | 14 ++ app/Events/OrderFulfilled.php | 14 ++ app/Events/OrderPaid.php | 14 ++ app/Events/OrderRefunded.php | 18 ++ app/Exceptions/FulfillmentGuardException.php | 7 + app/Exceptions/PaymentFailedException.php | 7 + app/Jobs/CancelUnpaidBankTransferOrders.php | 28 +++ app/Models/Customer.php | 80 ++++++++ app/Models/CustomerAddress.php | 41 ++++ app/Models/Fulfillment.php | 55 ++++++ app/Models/FulfillmentLine.php | 37 ++++ app/Models/Order.php | 123 ++++++++++++ app/Models/OrderLine.php | 72 +++++++ app/Models/Payment.php | 57 ++++++ app/Models/Refund.php | 51 +++++ app/Providers/AppServiceProvider.php | 3 + app/Services/CheckoutService.php | 90 ++++++++- app/Services/FulfillmentService.php | 109 +++++++++++ app/Services/OrderService.php | 178 ++++++++++++++++++ app/Services/Payments/MockPaymentProvider.php | 93 +++++++++ app/Services/RefundService.php | 78 ++++++++ app/ValueObjects/PaymentResult.php | 31 +++ app/ValueObjects/RefundResult.php | 20 ++ database/factories/CustomerAddressFactory.php | 48 +++++ database/factories/CustomerFactory.php | 37 ++++ database/factories/FulfillmentFactory.php | 55 ++++++ database/factories/FulfillmentLineFactory.php | 28 +++ database/factories/OrderFactory.php | 62 ++++++ database/factories/OrderLineFactory.php | 38 ++++ database/factories/PaymentFactory.php | 56 ++++++ database/factories/RefundFactory.php | 33 ++++ ...26_04_12_104001_create_customers_table.php | 32 ++++ ...104002_create_customer_addresses_table.php | 29 +++ .../2026_04_12_104003_create_orders_table.php | 69 +++++++ ..._04_12_104004_create_order_lines_table.php | 42 +++++ ...026_04_12_104005_create_payments_table.php | 50 +++++ ...2026_04_12_104006_create_refunds_table.php | 41 ++++ ...04_12_104007_create_fulfillments_table.php | 40 ++++ ..._104008_create_fulfillment_lines_table.php | 30 +++ ...e_customer_password_reset_tokens_table.php | 22 +++ routes/console.php | 2 + specs/progress.md | 2 +- tests/Feature/Checkout/CheckoutFlowTest.php | 4 + tests/Feature/Checkout/CheckoutStateTest.php | 4 + .../Feature/Customers/CustomerAccountTest.php | 54 ++++++ tests/Feature/Orders/FulfillmentTest.php | 117 ++++++++++++ tests/Feature/Orders/OrderCreationTest.php | 145 ++++++++++++++ tests/Feature/Orders/RefundTest.php | 99 ++++++++++ .../Payments/BankTransferConfirmationTest.php | 126 +++++++++++++ .../Payments/MockPaymentProviderTest.php | 85 +++++++++ tests/Feature/Payments/PaymentServiceTest.php | 79 ++++++++ 61 files changed, 2767 insertions(+), 5 deletions(-) create mode 100644 app/Contracts/PaymentProvider.php create mode 100644 app/Enums/FinancialStatus.php create mode 100644 app/Enums/FulfillmentShipmentStatus.php create mode 100644 app/Enums/FulfillmentStatus.php create mode 100644 app/Enums/OrderStatus.php create mode 100644 app/Enums/PaymentMethod.php create mode 100644 app/Enums/PaymentStatus.php create mode 100644 app/Enums/RefundStatus.php create mode 100644 app/Events/FulfillmentDelivered.php create mode 100644 app/Events/OrderCancelled.php create mode 100644 app/Events/OrderCreated.php create mode 100644 app/Events/OrderFulfilled.php create mode 100644 app/Events/OrderPaid.php create mode 100644 app/Events/OrderRefunded.php create mode 100644 app/Exceptions/FulfillmentGuardException.php create mode 100644 app/Exceptions/PaymentFailedException.php create mode 100644 app/Jobs/CancelUnpaidBankTransferOrders.php create mode 100644 app/Models/Customer.php create mode 100644 app/Models/CustomerAddress.php create mode 100644 app/Models/Fulfillment.php create mode 100644 app/Models/FulfillmentLine.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderLine.php create mode 100644 app/Models/Payment.php create mode 100644 app/Models/Refund.php create mode 100644 app/Services/FulfillmentService.php create mode 100644 app/Services/OrderService.php create mode 100644 app/Services/Payments/MockPaymentProvider.php create mode 100644 app/Services/RefundService.php create mode 100644 app/ValueObjects/PaymentResult.php create mode 100644 app/ValueObjects/RefundResult.php create mode 100644 database/factories/CustomerAddressFactory.php create mode 100644 database/factories/CustomerFactory.php create mode 100644 database/factories/FulfillmentFactory.php create mode 100644 database/factories/FulfillmentLineFactory.php create mode 100644 database/factories/OrderFactory.php create mode 100644 database/factories/OrderLineFactory.php create mode 100644 database/factories/PaymentFactory.php create mode 100644 database/factories/RefundFactory.php create mode 100644 database/migrations/2026_04_12_104001_create_customers_table.php create mode 100644 database/migrations/2026_04_12_104002_create_customer_addresses_table.php create mode 100644 database/migrations/2026_04_12_104003_create_orders_table.php create mode 100644 database/migrations/2026_04_12_104004_create_order_lines_table.php create mode 100644 database/migrations/2026_04_12_104005_create_payments_table.php create mode 100644 database/migrations/2026_04_12_104006_create_refunds_table.php create mode 100644 database/migrations/2026_04_12_104007_create_fulfillments_table.php create mode 100644 database/migrations/2026_04_12_104008_create_fulfillment_lines_table.php create mode 100644 database/migrations/2026_04_12_104009_create_customer_password_reset_tokens_table.php create mode 100644 tests/Feature/Customers/CustomerAccountTest.php create mode 100644 tests/Feature/Orders/FulfillmentTest.php create mode 100644 tests/Feature/Orders/OrderCreationTest.php create mode 100644 tests/Feature/Orders/RefundTest.php create mode 100644 tests/Feature/Payments/BankTransferConfirmationTest.php create mode 100644 tests/Feature/Payments/MockPaymentProviderTest.php create mode 100644 tests/Feature/Payments/PaymentServiceTest.php diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..bb56fc8d --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,19 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Enums/FinancialStatus.php b/app/Enums/FinancialStatus.php new file mode 100644 index 00000000..1a56a06c --- /dev/null +++ b/app/Enums/FinancialStatus.php @@ -0,0 +1,13 @@ +withoutGlobalScopes() + ->where('payment_method', PaymentMethod::BankTransfer->value) + ->where('financial_status', FinancialStatus::Pending->value) + ->where('placed_at', '<', now()->subDays(7)) + ->get() + ->each(function (Order $order) use ($orderService): void { + $orderService->cancel($order, 'Bank transfer not received within 7 days.'); + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..aafa051f --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,80 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'email', + 'password_hash', + 'name', + 'marketing_opt_in', + ]; + + /** + * @var array + */ + protected $hidden = [ + 'password_hash', + 'remember_token', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'marketing_opt_in' => 'boolean', + 'password_hash' => 'hashed', + ]; + } + + public function getAuthPassword(): string + { + return (string) $this->password_hash; + } + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + public function getAuthIdentifierName(): string + { + return 'id'; + } + + /** + * @return HasMany + */ + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * @return HasMany + */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..604332aa --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'customer_id', + 'label', + 'address_json', + 'is_default', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'address_json' => 'array', + 'is_default' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..a3129da5 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + const UPDATED_AT = null; + + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + 'delivered_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..87ca4426 --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + /** + * @return BelongsTo + */ + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + /** + * @return BelongsTo + */ + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..d54d5849 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,123 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'customer_id', + 'order_number', + 'payment_method', + 'status', + 'financial_status', + 'fulfillment_status', + 'currency', + 'subtotal_amount', + 'discount_amount', + 'shipping_amount', + 'tax_amount', + 'total_amount', + 'email', + 'billing_address_json', + 'shipping_address_json', + 'placed_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payment_method' => PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + /** + * @return HasMany + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + /** + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + /** + * @return HasMany + */ + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + public function requiresShipping(): bool + { + if ((int) $this->shipping_amount > 0) { + return true; + } + + foreach ($this->lines as $line) { + if ($line->variant !== null && (bool) $line->variant->requires_shipping) { + return true; + } + } + + return false; + } + + public function refundedTotal(): int + { + return (int) $this->refunds() + ->where('status', RefundStatus::Processed->value) + ->sum('amount'); + } + + public function refundableAmount(): int + { + return max(0, (int) $this->total_amount - $this->refundedTotal()); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..b06a18d8 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'product_id', + 'variant_id', + 'title_snapshot', + 'sku_snapshot', + 'quantity', + 'unit_price_amount', + 'total_amount', + 'tax_lines_json', + 'discount_allocations_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * @return HasMany + */ + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..a306852a --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,57 @@ + */ + use HasFactory; + + const UPDATED_AT = null; + + protected $fillable = [ + 'order_id', + 'provider', + 'method', + 'provider_payment_id', + 'status', + 'amount', + 'currency', + 'raw_json_encrypted', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'raw_json_encrypted' => 'encrypted', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..dde9d850 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + const UPDATED_AT = null; + + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'provider_refund_id', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => RefundStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 55fa83cb..58fc6430 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Services\Payments\MockPaymentProvider; use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; @@ -22,6 +24,7 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(ThemeSettingsService::class); + $this->app->bind(PaymentProvider::class, MockPaymentProvider::class); } /** diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php index 0b3785ae..468c6fbc 100644 --- a/app/Services/CheckoutService.php +++ b/app/Services/CheckoutService.php @@ -2,9 +2,19 @@ namespace App\Services; +use App\Contracts\PaymentProvider; use App\Enums\CheckoutStatus; +use App\Enums\FinancialStatus; +use App\Enums\OrderStatus; +use App\Enums\PaymentMethod; +use App\Enums\PaymentStatus; +use App\Events\OrderPaid; +use App\Exceptions\PaymentFailedException; use App\Models\Cart; use App\Models\Checkout; +use App\Models\InventoryItem; +use App\Models\Order; +use App\Models\Payment; use App\Models\ShippingRate; use DomainException; use Illuminate\Support\Facades\DB; @@ -14,6 +24,8 @@ class CheckoutService public function __construct( private readonly PricingEngine $pricingEngine, private readonly InventoryService $inventoryService, + private readonly OrderService $orderService, + private readonly PaymentProvider $paymentProvider, ) {} public function start(Cart $cart): Checkout @@ -145,14 +157,84 @@ public function expire(Checkout $checkout): void /** * @param array $details */ - public function complete(Checkout $checkout, array $details): Checkout + public function complete(Checkout $checkout, array $details = []): Checkout { $this->assertTransitionAllowed($checkout, [CheckoutStatus::PaymentSelected]); - $checkout->status = CheckoutStatus::Completed->value; - $checkout->save(); + $methodString = $checkout->payment_method; + if ($methodString === null) { + throw new DomainException('Checkout has no payment method selected.'); + } + $method = PaymentMethod::from((string) $methodString); - return $checkout; + $result = $this->paymentProvider->charge($checkout, $method, $details); + + if ($result->failed()) { + $this->releaseReservedInventory($checkout); + throw new PaymentFailedException($result->errorMessage ?? 'Payment failed.'); + } + + return DB::transaction(function () use ($checkout, $method, $result): Checkout { + $order = $this->orderService->createFromCheckout($checkout); + + /** @var Payment $payment */ + $payment = Payment::create([ + 'order_id' => $order->id, + 'provider' => 'mock', + 'method' => $method->value, + 'provider_payment_id' => $result->providerPaymentId, + 'status' => $result->status->value, + 'amount' => $result->amount, + 'currency' => $result->currency, + 'raw_json_encrypted' => null, + ]); + + if ($result->successful()) { + $order->update([ + 'financial_status' => FinancialStatus::Paid->value, + 'status' => OrderStatus::Paid->value, + ]); + + $this->commitReservedInventory($order); + + OrderPaid::dispatch($order->fresh() ?? $order); + } elseif ($result->pending()) { + $payment->update(['status' => PaymentStatus::Pending->value]); + } + + return $checkout->fresh() ?? $checkout; + }); + } + + private function releaseReservedInventory(Checkout $checkout): void + { + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + if ($cart === null) { + return; + } + + foreach ($cart->lines as $line) { + $inventoryItem = $line->variant?->inventoryItem; + if ($inventoryItem !== null) { + $this->inventoryService->release($inventoryItem, (int) $line->quantity); + } + } + } + + private function commitReservedInventory(Order $order): void + { + $order->loadMissing('lines'); + foreach ($order->lines as $line) { + if ($line->variant_id === null) { + continue; + } + $item = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $line->variant_id) + ->first(); + if ($item !== null) { + $this->inventoryService->commit($item, (int) $line->quantity); + } + } } /** diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..38842a13 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,109 @@ + $lines Map of order_line_id => quantity. + * @param array|null $tracking Optional tracking info keyed by company/number/url. + */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + $financialStatus = $order->financial_status instanceof FinancialStatus + ? $order->financial_status + : FinancialStatus::from((string) $order->financial_status); + + if (! in_array($financialStatus, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw new FulfillmentGuardException('Cannot fulfill an unpaid order.'); + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + /** @var Fulfillment $fulfillment */ + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => FulfillmentShipmentStatus::Pending->value, + 'tracking_company' => $tracking['company'] ?? null, + 'tracking_number' => $tracking['number'] ?? null, + 'tracking_url' => $tracking['url'] ?? null, + ]); + + foreach ($lines as $orderLineId => $quantity) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $orderLineId, + 'quantity' => $quantity, + ]); + } + + $this->updateOrderFulfillmentStatus($order); + + return $fulfillment; + }); + } + + /** + * @param array|null $tracking + */ + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + $updates = [ + 'status' => FulfillmentShipmentStatus::Shipped->value, + 'shipped_at' => now(), + ]; + + if ($tracking !== null) { + $updates['tracking_company'] = $tracking['company'] ?? $fulfillment->tracking_company; + $updates['tracking_number'] = $tracking['number'] ?? $fulfillment->tracking_number; + $updates['tracking_url'] = $tracking['url'] ?? $fulfillment->tracking_url; + } + + $fulfillment->update($updates); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Delivered->value, + 'delivered_at' => now(), + ]); + + FulfillmentDelivered::dispatch($fulfillment); + } + + private function updateOrderFulfillmentStatus(Order $order): void + { + $order->loadMissing('lines'); + + $totalQty = (int) $order->lines->sum('quantity'); + $fulfilledQty = (int) FulfillmentLine::whereIn('order_line_id', $order->lines->pluck('id'))->sum('quantity'); + + if ($totalQty > 0 && $fulfilledQty >= $totalQty) { + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Fulfilled->value, + 'status' => OrderStatus::Fulfilled->value, + ]); + OrderFulfilled::dispatch($order); + + return; + } + + if ($fulfilledQty > 0) { + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Partial->value, + ]); + } + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..4643bb76 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,178 @@ +cart()->with('lines.variant.product')->first(); + /** @var Store $store */ + $store = Store::withoutGlobalScopes()->findOrFail($checkout->store_id); + $totals = $checkout->totals_json ?? []; + + $currency = $cart?->currency ?? ($totals['currency'] ?? 'USD'); + + /** @var Order $order */ + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'customer_id' => $checkout->customer_id, + 'order_number' => $this->generateOrderNumber($store), + 'payment_method' => $checkout->payment_method, + 'status' => OrderStatus::Pending->value, + 'financial_status' => FinancialStatus::Pending->value, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled->value, + 'currency' => $currency, + 'subtotal_amount' => (int) ($totals['subtotal'] ?? 0), + 'discount_amount' => (int) ($totals['discount'] ?? 0), + 'shipping_amount' => (int) ($totals['shipping'] ?? 0), + 'tax_amount' => (int) ($totals['tax_total'] ?? 0), + 'total_amount' => (int) ($totals['total'] ?? 0), + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + if ($cart !== null) { + foreach ($cart->lines as $line) { + OrderLine::create([ + 'order_id' => $order->id, + 'product_id' => $line->variant?->product_id, + 'variant_id' => $line->variant_id, + 'title_snapshot' => $line->variant?->product?->title ?? 'Removed product', + 'sku_snapshot' => $line->variant?->sku, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'total_amount' => (int) $line->line_total_amount, + 'tax_lines_json' => null, + 'discount_allocations_json' => null, + ]); + } + + $cart->update(['status' => 'converted']); + } + + $checkout->update(['status' => 'completed']); + + OrderCreated::dispatch($order); + + return $order; + }); + } + + public function generateOrderNumber(Store $store): string + { + /** @var string|null $last */ + $last = Order::withoutGlobalScopes() + ->where('store_id', $store->id) + ->orderByDesc('id') + ->value('order_number'); + + if ($last === null) { + return '#1001'; + } + + $num = (int) ltrim($last, '#'); + + return '#'.($num + 1); + } + + public function cancel(Order $order, ?string $reason = null): void + { + $fulfillmentStatus = $order->fulfillment_status instanceof FulfillmentStatus + ? $order->fulfillment_status + : FulfillmentStatus::from((string) $order->fulfillment_status); + + if ($fulfillmentStatus === FulfillmentStatus::Fulfilled) { + throw new DomainException('Cannot cancel a fulfilled order.'); + } + + DB::transaction(function () use ($order): void { + $order->loadMissing('lines'); + + foreach ($order->lines as $line) { + if ($line->variant_id === null) { + continue; + } + + $item = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $line->variant_id) + ->first(); + + if ($item !== null) { + $item->quantity_reserved = max(0, (int) $item->quantity_reserved - (int) $line->quantity); + $item->save(); + } + } + + $order->update(['status' => OrderStatus::Cancelled->value]); + + OrderCancelled::dispatch($order); + }); + } + + public function confirmBankTransferPayment(Order $order): void + { + $paymentMethod = $order->payment_method instanceof PaymentMethod + ? $order->payment_method + : PaymentMethod::from((string) $order->payment_method); + + $financialStatus = $order->financial_status instanceof FinancialStatus + ? $order->financial_status + : FinancialStatus::from((string) $order->financial_status); + + if ($paymentMethod !== PaymentMethod::BankTransfer || $financialStatus !== FinancialStatus::Pending) { + throw new DomainException('Order is not a pending bank transfer.'); + } + + DB::transaction(function () use ($order): void { + $order->update([ + 'financial_status' => FinancialStatus::Paid->value, + 'status' => OrderStatus::Paid->value, + ]); + + $payment = $order->payments()->latest('id')->first(); + if ($payment !== null) { + $payment->update(['status' => PaymentStatus::Captured->value]); + } + + $order->loadMissing('lines'); + + foreach ($order->lines as $line) { + if ($line->variant_id === null) { + continue; + } + + $item = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $line->variant_id) + ->first(); + + if ($item !== null) { + $item->quantity_on_hand = max(0, (int) $item->quantity_on_hand - (int) $line->quantity); + $item->quantity_reserved = max(0, (int) $item->quantity_reserved - (int) $line->quantity); + $item->save(); + } + } + + OrderPaid::dispatch($order); + }); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..c1717174 --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,93 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult + { + $totals = $checkout->totals_json ?? []; + $amount = (int) ($totals['total'] ?? 0); + $currency = (string) ($totals['currency'] ?? 'USD'); + + return match ($method) { + PaymentMethod::CreditCard => $this->chargeCreditCard($details, $amount, $currency), + PaymentMethod::Paypal => new PaymentResult( + status: PaymentStatus::Captured, + providerPaymentId: $this->generateId(), + amount: $amount, + currency: $currency, + ), + PaymentMethod::BankTransfer => new PaymentResult( + status: PaymentStatus::Pending, + providerPaymentId: $this->generateId(), + amount: $amount, + currency: $currency, + ), + }; + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return new RefundResult( + status: RefundStatus::Processed, + providerRefundId: 'mock_ref_'.Str::random(12), + amount: $amount, + ); + } + + /** + * @param array $details + */ + private function chargeCreditCard(array $details, int $amount, string $currency): PaymentResult + { + $cardNumber = preg_replace('/\s+/', '', (string) ($details['card_number'] ?? self::CARD_SUCCESS)); + + return match ($cardNumber) { + self::CARD_DECLINED => new PaymentResult( + status: PaymentStatus::Failed, + providerPaymentId: null, + amount: $amount, + currency: $currency, + errorMessage: 'card_declined', + ), + self::CARD_INSUFFICIENT => new PaymentResult( + status: PaymentStatus::Failed, + providerPaymentId: null, + amount: $amount, + currency: $currency, + errorMessage: 'insufficient_funds', + ), + default => new PaymentResult( + status: PaymentStatus::Captured, + providerPaymentId: $this->generateId(), + amount: $amount, + currency: $currency, + ), + }; + } + + private function generateId(): string + { + return 'mock_'.Str::random(12); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..9d1d43ab --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,78 @@ +refundedTotal(); + $maxRefundable = (int) $payment->amount - $alreadyRefunded; + + if ($amount <= 0 || $amount > $maxRefundable) { + throw new InvalidArgumentException('Invalid refund amount.'); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock): Refund { + $result = $this->provider->refund($payment, $amount); + + /** @var Refund $refund */ + $refund = Refund::create([ + 'order_id' => $order->id, + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $result->status->value, + 'provider_refund_id' => $result->providerRefundId, + ]); + + if ($result->status === RefundStatus::Processed) { + $order->refresh(); + $totalRefunded = $order->refundedTotal(); + + $newFinancialStatus = $totalRefunded >= (int) $order->total_amount + ? FinancialStatus::Refunded + : FinancialStatus::PartiallyRefunded; + + $order->update(['financial_status' => $newFinancialStatus->value]); + + if ($restock) { + $order->loadMissing('lines'); + foreach ($order->lines as $line) { + if ($line->variant_id === null) { + continue; + } + + $item = InventoryItem::withoutGlobalScopes() + ->where('variant_id', $line->variant_id) + ->first(); + + if ($item !== null) { + $this->inventoryService->restock($item, (int) $line->quantity); + } + } + } + + OrderRefunded::dispatch($order->fresh() ?? $order, $refund); + } + + return $refund; + }); + } +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..600d1a56 --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,31 @@ +status === PaymentStatus::Captured; + } + + public function pending(): bool + { + return $this->status === PaymentStatus::Pending; + } + + public function failed(): bool + { + return $this->status === PaymentStatus::Failed; + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..77c96933 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,20 @@ +status === RefundStatus::Processed; + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..e9bbb667 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,48 @@ + + */ +class CustomerAddressFactory extends Factory +{ + protected $model = CustomerAddress::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work']), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => '', + 'address1' => fake()->streetAddress(), + 'address2' => '', + 'city' => fake()->city(), + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ], + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_default' => true, + ]); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..a7c10daa --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,37 @@ + + */ +class CustomerFactory extends Factory +{ + protected $model = Customer::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password_hash' => Hash::make('password'), + 'name' => fake()->name(), + 'marketing_opt_in' => false, + ]; + } + + public function withoutPassword(): static + { + return $this->state(fn (array $attributes): array => [ + 'password_hash' => null, + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..8659b00a --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,55 @@ + + */ +class FulfillmentFactory extends Factory +{ + protected $model = Fulfillment::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending->value, + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => null, + 'delivered_at' => null, + ]; + } + + public function shipped(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => FulfillmentShipmentStatus::Shipped->value, + 'tracking_company' => 'DHL', + 'tracking_number' => fake()->bothify('TRACK########'), + 'tracking_url' => fake()->url(), + 'shipped_at' => now(), + ]); + } + + public function delivered(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => FulfillmentShipmentStatus::Delivered->value, + 'tracking_company' => 'DHL', + 'tracking_number' => fake()->bothify('TRACK########'), + 'tracking_url' => fake()->url(), + 'shipped_at' => now()->subDays(2), + 'delivered_at' => now(), + ]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..7b8a5105 --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,28 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + protected $model = FulfillmentLine::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'fulfillment_id' => Fulfillment::factory(), + 'order_line_id' => OrderLine::factory(), + 'quantity' => 1, + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..d9a005f2 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,62 @@ + + */ +class OrderFactory extends Factory +{ + protected $model = Order::class; + + /** + * @return array + */ + public function definition(): array + { + $subtotal = 5000; + + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'order_number' => '#'.fake()->unique()->numberBetween(1000, 999999), + 'payment_method' => PaymentMethod::CreditCard->value, + 'status' => OrderStatus::Pending->value, + 'financial_status' => FinancialStatus::Pending->value, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled->value, + 'currency' => 'EUR', + 'subtotal_amount' => $subtotal, + 'discount_amount' => 0, + 'shipping_amount' => 599, + 'tax_amount' => 0, + 'total_amount' => $subtotal + 599, + 'email' => fake()->safeEmail(), + 'billing_address_json' => null, + 'shipping_address_json' => null, + 'placed_at' => now(), + ]; + } + + public function paid(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Paid->value, + 'financial_status' => FinancialStatus::Paid->value, + ]); + } + + public function cancelled(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => OrderStatus::Cancelled->value, + ]); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..df244a18 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,38 @@ + + */ +class OrderLineFactory extends Factory +{ + protected $model = OrderLine::class; + + /** + * @return array + */ + public function definition(): array + { + $unitPrice = 2500; + $quantity = 2; + + return [ + 'order_id' => Order::factory(), + 'product_id' => null, + 'variant_id' => ProductVariant::factory(), + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => fake()->bothify('SKU-####'), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $unitPrice * $quantity, + 'tax_lines_json' => null, + 'discount_allocations_json' => null, + ]; + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..f15b4383 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,56 @@ + + */ +class PaymentFactory extends Factory +{ + protected $model = Payment::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => PaymentMethod::CreditCard->value, + 'provider_payment_id' => 'mock_'.Str::random(12), + 'status' => PaymentStatus::Captured->value, + 'amount' => 5000, + 'currency' => 'EUR', + 'raw_json_encrypted' => null, + ]; + } + + public function captured(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PaymentStatus::Captured->value, + ]); + } + + public function pending(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PaymentStatus::Pending->value, + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => PaymentStatus::Failed->value, + ]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..74cd85d3 --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,33 @@ + + */ +class RefundFactory extends Factory +{ + protected $model = Refund::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => 1000, + 'reason' => 'customer_request', + 'status' => RefundStatus::Processed->value, + 'provider_refund_id' => 'mock_ref_'.Str::random(12), + ]; + } +} diff --git a/database/migrations/2026_04_12_104001_create_customers_table.php b/database/migrations/2026_04_12_104001_create_customers_table.php new file mode 100644 index 00000000..a8eab2b1 --- /dev/null +++ b/database/migrations/2026_04_12_104001_create_customers_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('email'); + $table->text('password_hash')->nullable(); + $table->string('name')->nullable(); + $table->boolean('marketing_opt_in')->default(false); + $table->rememberToken(); + $table->timestamps(); + + $table->unique(['store_id', 'email'], 'idx_customers_store_email'); + $table->index('store_id', 'idx_customers_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_04_12_104002_create_customer_addresses_table.php b/database/migrations/2026_04_12_104002_create_customer_addresses_table.php new file mode 100644 index 00000000..09465bd3 --- /dev/null +++ b/database/migrations/2026_04_12_104002_create_customer_addresses_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('customer_id') + ->constrained('customers') + ->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + + $table->index('customer_id', 'idx_customer_addresses_customer_id'); + $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_04_12_104003_create_orders_table.php b/database/migrations/2026_04_12_104003_create_orders_table.php new file mode 100644 index 00000000..ced21a5b --- /dev/null +++ b/database/migrations/2026_04_12_104003_create_orders_table.php @@ -0,0 +1,69 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->foreignId('customer_id') + ->nullable() + ->constrained('customers') + ->nullOnDelete(); + $table->string('order_number'); + $table->string('payment_method'); + $table->string('status')->default('pending'); + $table->string('financial_status')->default('pending'); + $table->string('fulfillment_status')->default('unfulfilled'); + $table->string('currency', 3)->default('USD'); + $table->integer('subtotal_amount')->default(0); + $table->integer('discount_amount')->default(0); + $table->integer('shipping_amount')->default(0); + $table->integer('tax_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->string('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number'], 'idx_orders_store_order_number'); + $table->index('store_id', 'idx_orders_store_id'); + $table->index('customer_id', 'idx_orders_customer_id'); + $table->index(['store_id', 'status'], 'idx_orders_store_status'); + $table->index(['store_id', 'financial_status'], 'idx_orders_store_financial'); + $table->index(['store_id', 'fulfillment_status'], 'idx_orders_store_fulfillment'); + $table->index(['store_id', 'placed_at'], 'idx_orders_placed_at'); + }); + + DB::statement("CREATE TRIGGER orders_payment_method_check BEFORE INSERT ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.payment_method NOT IN ('credit_card','paypal','bank_transfer') THEN RAISE(ABORT, 'invalid payment_method') END; END"); + DB::statement("CREATE TRIGGER orders_payment_method_check_update BEFORE UPDATE ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.payment_method NOT IN ('credit_card','paypal','bank_transfer') THEN RAISE(ABORT, 'invalid payment_method') END; END"); + DB::statement("CREATE TRIGGER orders_status_check BEFORE INSERT ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','paid','fulfilled','cancelled','refunded') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER orders_status_check_update BEFORE UPDATE ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','paid','fulfilled','cancelled','refunded') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER orders_financial_status_check BEFORE INSERT ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.financial_status NOT IN ('pending','authorized','paid','partially_refunded','refunded','voided') THEN RAISE(ABORT, 'invalid financial_status') END; END"); + DB::statement("CREATE TRIGGER orders_financial_status_check_update BEFORE UPDATE ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.financial_status NOT IN ('pending','authorized','paid','partially_refunded','refunded','voided') THEN RAISE(ABORT, 'invalid financial_status') END; END"); + DB::statement("CREATE TRIGGER orders_fulfillment_status_check BEFORE INSERT ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.fulfillment_status NOT IN ('unfulfilled','partial','fulfilled') THEN RAISE(ABORT, 'invalid fulfillment_status') END; END"); + DB::statement("CREATE TRIGGER orders_fulfillment_status_check_update BEFORE UPDATE ON orders FOR EACH ROW BEGIN SELECT CASE WHEN NEW.fulfillment_status NOT IN ('unfulfilled','partial','fulfilled') THEN RAISE(ABORT, 'invalid fulfillment_status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS orders_payment_method_check'); + DB::statement('DROP TRIGGER IF EXISTS orders_payment_method_check_update'); + DB::statement('DROP TRIGGER IF EXISTS orders_status_check'); + DB::statement('DROP TRIGGER IF EXISTS orders_status_check_update'); + DB::statement('DROP TRIGGER IF EXISTS orders_financial_status_check'); + DB::statement('DROP TRIGGER IF EXISTS orders_financial_status_check_update'); + DB::statement('DROP TRIGGER IF EXISTS orders_fulfillment_status_check'); + DB::statement('DROP TRIGGER IF EXISTS orders_fulfillment_status_check_update'); + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_04_12_104004_create_order_lines_table.php b/database/migrations/2026_04_12_104004_create_order_lines_table.php new file mode 100644 index 00000000..abda70d6 --- /dev/null +++ b/database/migrations/2026_04_12_104004_create_order_lines_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('order_id') + ->constrained('orders') + ->cascadeOnDelete(); + $table->foreignId('product_id') + ->nullable() + ->constrained('products') + ->nullOnDelete(); + $table->foreignId('variant_id') + ->nullable() + ->constrained('product_variants') + ->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('sku_snapshot')->nullable(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('tax_lines_json')->nullable(); + $table->text('discount_allocations_json')->nullable(); + + $table->index('order_id', 'idx_order_lines_order_id'); + $table->index('product_id', 'idx_order_lines_product_id'); + $table->index('variant_id', 'idx_order_lines_variant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_04_12_104005_create_payments_table.php b/database/migrations/2026_04_12_104005_create_payments_table.php new file mode 100644 index 00000000..332feed9 --- /dev/null +++ b/database/migrations/2026_04_12_104005_create_payments_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('order_id') + ->constrained('orders') + ->cascadeOnDelete(); + $table->string('provider')->default('mock'); + $table->string('method'); + $table->string('provider_payment_id')->nullable(); + $table->string('status')->default('pending'); + $table->integer('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_payments_order_id'); + $table->index(['provider', 'provider_payment_id'], 'idx_payments_provider_id'); + $table->index('method', 'idx_payments_method'); + $table->index('status', 'idx_payments_status'); + }); + + DB::statement("CREATE TRIGGER payments_provider_check BEFORE INSERT ON payments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.provider NOT IN ('mock') THEN RAISE(ABORT, 'invalid provider') END; END"); + DB::statement("CREATE TRIGGER payments_provider_check_update BEFORE UPDATE ON payments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.provider NOT IN ('mock') THEN RAISE(ABORT, 'invalid provider') END; END"); + DB::statement("CREATE TRIGGER payments_method_check BEFORE INSERT ON payments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.method NOT IN ('credit_card','paypal','bank_transfer') THEN RAISE(ABORT, 'invalid method') END; END"); + DB::statement("CREATE TRIGGER payments_method_check_update BEFORE UPDATE ON payments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.method NOT IN ('credit_card','paypal','bank_transfer') THEN RAISE(ABORT, 'invalid method') END; END"); + DB::statement("CREATE TRIGGER payments_status_check BEFORE INSERT ON payments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','captured','failed','refunded') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER payments_status_check_update BEFORE UPDATE ON payments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','captured','failed','refunded') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS payments_provider_check'); + DB::statement('DROP TRIGGER IF EXISTS payments_provider_check_update'); + DB::statement('DROP TRIGGER IF EXISTS payments_method_check'); + DB::statement('DROP TRIGGER IF EXISTS payments_method_check_update'); + DB::statement('DROP TRIGGER IF EXISTS payments_status_check'); + DB::statement('DROP TRIGGER IF EXISTS payments_status_check_update'); + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_04_12_104006_create_refunds_table.php b/database/migrations/2026_04_12_104006_create_refunds_table.php new file mode 100644 index 00000000..1209e06a --- /dev/null +++ b/database/migrations/2026_04_12_104006_create_refunds_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('order_id') + ->constrained('orders') + ->cascadeOnDelete(); + $table->foreignId('payment_id') + ->constrained('payments') + ->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->string('reason')->nullable(); + $table->string('status')->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_refunds_order_id'); + $table->index('payment_id', 'idx_refunds_payment_id'); + $table->index('status', 'idx_refunds_status'); + }); + + DB::statement("CREATE TRIGGER refunds_status_check BEFORE INSERT ON refunds FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','processed','failed') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER refunds_status_check_update BEFORE UPDATE ON refunds FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','processed','failed') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS refunds_status_check'); + DB::statement('DROP TRIGGER IF EXISTS refunds_status_check_update'); + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_04_12_104007_create_fulfillments_table.php b/database/migrations/2026_04_12_104007_create_fulfillments_table.php new file mode 100644 index 00000000..b740d634 --- /dev/null +++ b/database/migrations/2026_04_12_104007_create_fulfillments_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id') + ->constrained('orders') + ->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_fulfillments_order_id'); + $table->index('status', 'idx_fulfillments_status'); + $table->index(['tracking_company', 'tracking_number'], 'idx_fulfillments_tracking'); + }); + + DB::statement("CREATE TRIGGER fulfillments_status_check BEFORE INSERT ON fulfillments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','shipped','delivered') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER fulfillments_status_check_update BEFORE UPDATE ON fulfillments FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('pending','shipped','delivered') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS fulfillments_status_check'); + DB::statement('DROP TRIGGER IF EXISTS fulfillments_status_check_update'); + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_04_12_104008_create_fulfillment_lines_table.php b/database/migrations/2026_04_12_104008_create_fulfillment_lines_table.php new file mode 100644 index 00000000..f0392bc5 --- /dev/null +++ b/database/migrations/2026_04_12_104008_create_fulfillment_lines_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('fulfillment_id') + ->constrained('fulfillments') + ->cascadeOnDelete(); + $table->foreignId('order_line_id') + ->constrained('order_lines') + ->cascadeOnDelete(); + $table->integer('quantity')->default(1); + + $table->index('fulfillment_id', 'idx_fulfillment_lines_fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id'], 'idx_fulfillment_lines_fulfillment_order_line'); + }); + } + + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_04_12_104009_create_customer_password_reset_tokens_table.php b/database/migrations/2026_04_12_104009_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..ba0f798d --- /dev/null +++ b/database/migrations/2026_04_12_104009_create_customer_password_reset_tokens_table.php @@ -0,0 +1,22 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + } +}; diff --git a/routes/console.php b/routes/console.php index 0c011335..a33a854c 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyFifteenMinutes(); Schedule::job(new CleanupAbandonedCarts)->dailyAt('03:00'); +Schedule::job(new CancelUnpaidBankTransferOrders)->dailyAt('04:00'); diff --git a/specs/progress.md b/specs/progress.md index 7d5490cb..0a9a6a99 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -8,7 +8,7 @@ Tracking progress for the full shop system implementation (team: shop-v2). - [x] Phase 2: Catalog (products, variants, inventory, collections, media) - 63 tests passing - [x] Phase 3: Themes, pages, navigation, storefront layout - 79 tests passing - [x] Phase 4: Cart, checkout, discounts, shipping, taxes - 138 tests passing -- [ ] Phase 5: Payments, orders, fulfillment +- [x] Phase 5: Payments, orders, fulfillment - 167 tests passing - [ ] Phase 6: Customer accounts - [ ] Phase 7: Admin panel - [ ] Phase 8: Search diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php index 4c7a76d0..f0074a58 100644 --- a/tests/Feature/Checkout/CheckoutFlowTest.php +++ b/tests/Feature/Checkout/CheckoutFlowTest.php @@ -11,6 +11,8 @@ use App\Services\CheckoutService; use App\Services\DiscountService; use App\Services\InventoryService; +use App\Services\OrderService; +use App\Services\Payments\MockPaymentProvider; use App\Services\PricingEngine; use App\Services\ShippingCalculator; use App\Services\TaxCalculator; @@ -31,6 +33,8 @@ new TaxCalculator, ), $this->inventoryService, + new OrderService, + new MockPaymentProvider, ); $product = Product::factory()->for($this->store)->create(); diff --git a/tests/Feature/Checkout/CheckoutStateTest.php b/tests/Feature/Checkout/CheckoutStateTest.php index ff91248f..6971b1e4 100644 --- a/tests/Feature/Checkout/CheckoutStateTest.php +++ b/tests/Feature/Checkout/CheckoutStateTest.php @@ -10,6 +10,8 @@ use App\Services\CheckoutService; use App\Services\DiscountService; use App\Services\InventoryService; +use App\Services\OrderService; +use App\Services\Payments\MockPaymentProvider; use App\Services\PricingEngine; use App\Services\ShippingCalculator; use App\Services\TaxCalculator; @@ -26,6 +28,8 @@ $this->checkoutService = new CheckoutService( new PricingEngine(new DiscountService, new ShippingCalculator, new TaxCalculator), $this->inventoryService, + new OrderService, + new MockPaymentProvider, ); }); diff --git a/tests/Feature/Customers/CustomerAccountTest.php b/tests/Feature/Customers/CustomerAccountTest.php new file mode 100644 index 00000000..26af400b --- /dev/null +++ b/tests/Feature/Customers/CustomerAccountTest.php @@ -0,0 +1,54 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates a customer with hashed password', function (): void { + $customer = Customer::factory()->for($this->store)->create([ + 'email' => 'jane@example.com', + 'password_hash' => 'secret-password', + ]); + + expect($customer->email)->toBe('jane@example.com') + ->and(Hash::check('secret-password', $customer->password_hash))->toBeTrue(); +}); + +it('enforces unique email per store but allows duplicates across stores', function (): void { + Customer::factory()->for($this->store)->create(['email' => 'same@example.com']); + + $otherStore = Store::factory()->create(); + Customer::withoutGlobalScopes()->create([ + 'store_id' => $otherStore->id, + 'email' => 'same@example.com', + 'name' => 'Dup', + ]); + + expect(Customer::withoutGlobalScopes()->where('email', 'same@example.com')->count())->toBe(2); + + expect(fn () => Customer::factory()->for($this->store)->create(['email' => 'same@example.com'])) + ->toThrow(QueryException::class); +}); + +it('can authenticate using the hashed password column', function (): void { + $customer = Customer::factory()->for($this->store)->create([ + 'email' => 'auth@example.com', + 'password_hash' => 'password', + ]); + + expect($customer->getAuthPassword())->toBe($customer->password_hash) + ->and(Hash::check('password', $customer->getAuthPassword()))->toBeTrue(); +}); diff --git a/tests/Feature/Orders/FulfillmentTest.php b/tests/Feature/Orders/FulfillmentTest.php new file mode 100644 index 00000000..10012d52 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentTest.php @@ -0,0 +1,117 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->fulfillmentService = new FulfillmentService; + + $this->order = Order::factory()->for($this->store)->paid()->create([ + 'subtotal_amount' => 6000, + 'total_amount' => 6000, + ]); + + $this->line1 = OrderLine::factory()->create([ + 'order_id' => $this->order->id, + 'title_snapshot' => 'Line A', + 'quantity' => 2, + 'unit_price_amount' => 2000, + 'total_amount' => 4000, + ]); + + $this->line2 = OrderLine::factory()->create([ + 'order_id' => $this->order->id, + 'title_snapshot' => 'Line B', + 'quantity' => 1, + 'unit_price_amount' => 2000, + 'total_amount' => 2000, + ]); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates a fulfillment for a paid order', function (): void { + $fulfillment = $this->fulfillmentService->create( + $this->order, + [$this->line1->id => 2, $this->line2->id => 1], + ['company' => 'DHL', 'number' => 'TRACK123', 'url' => 'https://example.com/track/TRACK123'], + ); + + expect($fulfillment->tracking_company)->toBe('DHL') + ->and($fulfillment->tracking_number)->toBe('TRACK123') + ->and($fulfillment->lines)->toHaveCount(2) + ->and($this->order->fresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($this->order->fresh()->status)->toBe(OrderStatus::Fulfilled); +}); + +it('marks partial fulfillment when only some lines are shipped', function (): void { + $this->fulfillmentService->create($this->order, [$this->line1->id => 1]); + + expect($this->order->fresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); +}); + +it('marks a fulfillment as shipped with tracking', function (): void { + $fulfillment = $this->fulfillmentService->create($this->order, [$this->line1->id => 2]); + + $this->fulfillmentService->markAsShipped($fulfillment, [ + 'company' => 'UPS', + 'number' => 'UPS9999', + 'url' => 'https://ups.com/track', + ]); + + expect($fulfillment->fresh()->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($fulfillment->fresh()->shipped_at)->not->toBeNull() + ->and($fulfillment->fresh()->tracking_company)->toBe('UPS'); +}); + +it('marks a fulfillment as delivered and dispatches event', function (): void { + \Illuminate\Support\Facades\Event::fake(\App\Events\FulfillmentDelivered::class); + + $fulfillment = $this->fulfillmentService->create($this->order, [$this->line1->id => 2]); + $this->fulfillmentService->markAsShipped($fulfillment, ['company' => 'DHL', 'number' => 'X']); + $this->fulfillmentService->markAsDelivered($fulfillment->fresh()); + + expect($fulfillment->fresh()->status)->toBe(FulfillmentShipmentStatus::Delivered) + ->and($fulfillment->fresh()->delivered_at)->not->toBeNull(); + + \Illuminate\Support\Facades\Event::assertDispatched(\App\Events\FulfillmentDelivered::class); +}); + +it('guard rejects fulfillment for an unpaid order', function (): void { + $unpaidOrder = Order::factory()->for($this->store)->create([ + 'subtotal_amount' => 1000, + 'total_amount' => 1000, + ]); + $line = OrderLine::factory()->create([ + 'order_id' => $unpaidOrder->id, + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'total_amount' => 1000, + ]); + + expect(fn () => $this->fulfillmentService->create($unpaidOrder, [$line->id => 1])) + ->toThrow(FulfillmentGuardException::class); +}); + +it('updates order to fulfilled after two partial fulfillments complete it', function (): void { + $this->fulfillmentService->create($this->order, [$this->line1->id => 2]); + expect($this->order->fresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); + + $this->fulfillmentService->create($this->order->fresh(), [$this->line2->id => 1]); + expect($this->order->fresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($this->order->fresh()->status)->toBe(OrderStatus::Fulfilled); +}); diff --git a/tests/Feature/Orders/OrderCreationTest.php b/tests/Feature/Orders/OrderCreationTest.php new file mode 100644 index 00000000..29e9177c --- /dev/null +++ b/tests/Feature/Orders/OrderCreationTest.php @@ -0,0 +1,145 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->inventoryService = new InventoryService; + $this->cartService = new CartService($this->inventoryService); + $this->orderService = new OrderService; + $this->checkoutService = new CheckoutService( + new PricingEngine(new DiscountService, new ShippingCalculator, new TaxCalculator), + $this->inventoryService, + $this->orderService, + new MockPaymentProvider, + ); + + $this->product = Product::factory()->for($this->store)->create(['title' => 'Herbal Tea']); + $this->variant = ProductVariant::factory()->for($this->product)->create([ + 'price_amount' => 2500, + 'requires_shipping' => true, + 'sku' => 'TEA-001', + ]); + InventoryItem::factory() + ->for($this->store) + ->for($this->variant, 'variant') + ->create(['quantity_on_hand' => 10]); + + $this->zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $this->rate = ShippingRate::factory()->for($this->zone, 'zone')->flat(599)->create(); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function runCheckoutToPayment(): \App\Models\Checkout +{ + $cart = test()->cartService->create(test()->store); + test()->cartService->addLine($cart, test()->variant->id, 2); + + $checkout = test()->checkoutService->start($cart->fresh()); + test()->checkoutService->setAddress($checkout, [ + 'email' => 'buyer@example.com', + 'shipping_address' => ['country' => 'DE'], + ]); + test()->checkoutService->setShippingMethod($checkout->fresh(), test()->rate->id); + test()->checkoutService->selectPaymentMethod($checkout->fresh(), 'credit_card'); + + return $checkout->fresh(); +} + +it('creates an order from a completed checkout', function (): void { + $checkout = runCheckoutToPayment(); + + $this->checkoutService->complete($checkout, ['card_number' => '4242424242424242']); + + $order = \App\Models\Order::withoutGlobalScopes()->first(); + + expect($order)->not->toBeNull() + ->and($order->store_id)->toBe($this->store->id) + ->and($order->email)->toBe('buyer@example.com') + ->and($order->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->payment_method)->toBe(PaymentMethod::CreditCard) + ->and($order->lines)->toHaveCount(1) + ->and($order->lines->first()->title_snapshot)->toBe('Herbal Tea') + ->and($order->lines->first()->sku_snapshot)->toBe('TEA-001') + ->and($order->lines->first()->quantity)->toBe(2); +}); + +it('generates sequential order numbers per store', function (): void { + $checkout = runCheckoutToPayment(); + $order1 = $this->orderService->createFromCheckout($checkout); + + // Second cart/checkout cycle in same store + $cart2 = $this->cartService->create($this->store); + $this->cartService->addLine($cart2, $this->variant->id, 1); + $checkout2 = $this->checkoutService->start($cart2->fresh()); + $this->checkoutService->setAddress($checkout2, [ + 'email' => 'b@c.d', + 'shipping_address' => ['country' => 'DE'], + ]); + $this->checkoutService->setShippingMethod($checkout2->fresh(), $this->rate->id); + $this->checkoutService->selectPaymentMethod($checkout2->fresh(), 'credit_card'); + + $order2 = $this->orderService->createFromCheckout($checkout2->fresh()); + + expect($order1->order_number)->toBe('#1001') + ->and($order2->order_number)->toBe('#1002'); +}); + +it('cancels an order and releases reserved inventory', function (): void { + $checkout = runCheckoutToPayment(); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $this->variant->id)->first(); + + expect($inventory->quantity_reserved)->toBe(2); + + $order = $this->orderService->createFromCheckout($checkout); + + $this->orderService->cancel($order->fresh(), 'Customer request'); + + expect($order->fresh()->status)->toBe(OrderStatus::Cancelled) + ->and($inventory->fresh()->quantity_reserved)->toBe(0); +}); + +it('rejects payment with declined magic card and releases inventory', function (): void { + $checkout = runCheckoutToPayment(); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $this->variant->id)->first(); + + expect($inventory->quantity_reserved)->toBe(2); + + try { + $this->checkoutService->complete($checkout, ['card_number' => '4000000000000002']); + } catch (\App\Exceptions\PaymentFailedException $e) { + // expected + } + + expect($inventory->fresh()->quantity_reserved)->toBe(0) + ->and(\App\Models\Order::withoutGlobalScopes()->count())->toBe(0); +}); diff --git a/tests/Feature/Orders/RefundTest.php b/tests/Feature/Orders/RefundTest.php new file mode 100644 index 00000000..1158de6b --- /dev/null +++ b/tests/Feature/Orders/RefundTest.php @@ -0,0 +1,99 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->refundService = new RefundService(new MockPaymentProvider, new InventoryService); + + $product = Product::factory()->for($this->store)->create(['title' => 'Candle']); + $this->variant = ProductVariant::factory()->for($product)->create([ + 'price_amount' => 1500, + 'requires_shipping' => true, + ]); + InventoryItem::factory() + ->for($this->store) + ->for($this->variant, 'variant') + ->create(['quantity_on_hand' => 8]); + + $this->order = Order::factory()->for($this->store)->paid()->create([ + 'subtotal_amount' => 3000, + 'total_amount' => 3000, + ]); + OrderLine::factory()->create([ + 'order_id' => $this->order->id, + 'variant_id' => $this->variant->id, + 'title_snapshot' => 'Candle', + 'quantity' => 2, + 'unit_price_amount' => 1500, + 'total_amount' => 3000, + ]); + $this->payment = Payment::factory()->captured()->create([ + 'order_id' => $this->order->id, + 'amount' => 3000, + ]); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('creates a partial refund and marks order as partially refunded', function (): void { + $refund = $this->refundService->create($this->order, $this->payment, 1000, 'customer_request', false); + + expect($refund->status)->toBe(RefundStatus::Processed) + ->and($refund->amount)->toBe(1000) + ->and($this->order->fresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); +}); + +it('creates a full refund and marks order as refunded', function (): void { + $this->refundService->create($this->order, $this->payment, 3000, 'customer_request', false); + + expect($this->order->fresh()->financial_status)->toBe(FinancialStatus::Refunded); +}); + +it('restocks inventory when restock flag is true', function (): void { + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $this->variant->id)->first(); + $startingStock = $inventory->quantity_on_hand; + + $this->refundService->create($this->order, $this->payment, 3000, 'customer_request', true); + + expect($inventory->fresh()->quantity_on_hand)->toBe($startingStock + 2); +}); + +it('does not restock inventory when restock flag is false', function (): void { + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $this->variant->id)->first(); + $startingStock = $inventory->quantity_on_hand; + + $this->refundService->create($this->order, $this->payment, 1500, null, false); + + expect($inventory->fresh()->quantity_on_hand)->toBe($startingStock); +}); + +it('rejects a refund amount exceeding the remaining payment balance', function (): void { + $this->refundService->create($this->order, $this->payment, 3000, null, false); + + expect(fn () => $this->refundService->create($this->order->fresh(), $this->payment->fresh(), 1, null, false)) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects refunds with zero or negative amount', function (): void { + expect(fn () => $this->refundService->create($this->order, $this->payment, 0, null, false)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/Payments/BankTransferConfirmationTest.php b/tests/Feature/Payments/BankTransferConfirmationTest.php new file mode 100644 index 00000000..72d7cf93 --- /dev/null +++ b/tests/Feature/Payments/BankTransferConfirmationTest.php @@ -0,0 +1,126 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->inventoryService = new InventoryService; + $this->cartService = new CartService($this->inventoryService); + $this->orderService = new OrderService; + $this->checkoutService = new CheckoutService( + new PricingEngine(new DiscountService, new ShippingCalculator, new TaxCalculator), + $this->inventoryService, + $this->orderService, + new MockPaymentProvider, + ); + + $product = Product::factory()->for($this->store)->create(['title' => 'Wool Scarf']); + $this->variant = ProductVariant::factory()->for($product)->create([ + 'price_amount' => 3000, + 'requires_shipping' => true, + ]); + InventoryItem::factory() + ->for($this->store) + ->for($this->variant, 'variant') + ->create(['quantity_on_hand' => 5]); + + $this->zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $this->rate = ShippingRate::factory()->for($this->zone, 'zone')->flat(599)->create(); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function completeWithBankTransfer(): \App\Models\Order +{ + $cart = test()->cartService->create(test()->store); + test()->cartService->addLine($cart, test()->variant->id, 1); + + $checkout = test()->checkoutService->start($cart->fresh()); + test()->checkoutService->setAddress($checkout, [ + 'email' => 'b@c.d', + 'shipping_address' => ['country' => 'DE'], + ]); + test()->checkoutService->setShippingMethod($checkout->fresh(), test()->rate->id); + test()->checkoutService->selectPaymentMethod($checkout->fresh(), 'bank_transfer'); + test()->checkoutService->complete($checkout->fresh()); + + return \App\Models\Order::withoutGlobalScopes()->latest('id')->firstOrFail(); +} + +it('creates a pending order for bank transfer without committing inventory', function (): void { + $order = completeWithBankTransfer(); + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $this->variant->id)->first(); + + expect($order->financial_status)->toBe(FinancialStatus::Pending) + ->and($order->status)->toBe(OrderStatus::Pending) + ->and($inventory->quantity_reserved)->toBe(1) + ->and($inventory->quantity_on_hand)->toBe(5); + + $payment = $order->payments()->first(); + expect($payment->status)->toBe(PaymentStatus::Pending); +}); + +it('confirms bank transfer payment and commits inventory', function (): void { + $order = completeWithBankTransfer(); + + $this->orderService->confirmBankTransferPayment($order->fresh()); + + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $this->variant->id)->first(); + + expect($order->fresh()->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->fresh()->status)->toBe(OrderStatus::Paid) + ->and($inventory->quantity_on_hand)->toBe(4) + ->and($inventory->quantity_reserved)->toBe(0); + + $payment = $order->payments()->first(); + expect($payment->status)->toBe(PaymentStatus::Captured); +}); + +it('cancels pending bank transfer orders older than 7 days via job', function (): void { + $order = completeWithBankTransfer(); + $order->update(['placed_at' => now()->subDays(8)]); + + app(CancelUnpaidBankTransferOrders::class)->handle($this->orderService); + + expect($order->fresh()->status)->toBe(OrderStatus::Cancelled); + + $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $this->variant->id)->first(); + expect($inventory->quantity_reserved)->toBe(0); +}); + +it('does not cancel bank transfer orders placed less than 7 days ago', function (): void { + $order = completeWithBankTransfer(); + $order->update(['placed_at' => now()->subDays(3)]); + + app(CancelUnpaidBankTransferOrders::class)->handle($this->orderService); + + expect($order->fresh()->status)->toBe(OrderStatus::Pending); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..866624ae --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,85 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->provider = new MockPaymentProvider; +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function buildCheckout(Store $store, int $total = 5000, string $currency = 'EUR'): Checkout +{ + $cart = \App\Models\Cart::factory()->for($store)->create(['currency' => $currency]); + + return Checkout::factory()->for($store)->for($cart)->create([ + 'payment_method' => PaymentMethod::CreditCard->value, + 'totals_json' => ['total' => $total, 'currency' => $currency], + ]); +} + +it('captures a credit card payment with success magic number', function (): void { + $checkout = buildCheckout($this->store); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + expect($result->status)->toBe(PaymentStatus::Captured) + ->and($result->providerPaymentId)->toStartWith('mock_') + ->and($result->amount)->toBe(5000) + ->and($result->currency)->toBe('EUR'); +}); + +it('fails a credit card payment when magic number is declined', function (): void { + $checkout = buildCheckout($this->store); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4000000000000002', + ]); + + expect($result->status)->toBe(PaymentStatus::Failed) + ->and($result->errorMessage)->toBe('card_declined') + ->and($result->providerPaymentId)->toBeNull(); +}); + +it('fails a credit card payment when insufficient funds', function (): void { + $checkout = buildCheckout($this->store); + + $result = $this->provider->charge($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4000000000009995', + ]); + + expect($result->status)->toBe(PaymentStatus::Failed) + ->and($result->errorMessage)->toBe('insufficient_funds') + ->and($result->providerPaymentId)->toBeNull(); +}); + +it('captures a paypal payment', function (): void { + $checkout = buildCheckout($this->store); + + $result = $this->provider->charge($checkout, PaymentMethod::Paypal, []); + + expect($result->status)->toBe(PaymentStatus::Captured) + ->and($result->providerPaymentId)->toStartWith('mock_'); +}); + +it('returns pending for a bank transfer payment', function (): void { + $checkout = buildCheckout($this->store); + + $result = $this->provider->charge($checkout, PaymentMethod::BankTransfer, []); + + expect($result->status)->toBe(PaymentStatus::Pending) + ->and($result->providerPaymentId)->toStartWith('mock_'); +}); diff --git a/tests/Feature/Payments/PaymentServiceTest.php b/tests/Feature/Payments/PaymentServiceTest.php new file mode 100644 index 00000000..4541d67b --- /dev/null +++ b/tests/Feature/Payments/PaymentServiceTest.php @@ -0,0 +1,79 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->inventoryService = new InventoryService; + $this->cartService = new CartService($this->inventoryService); + $this->orderService = new OrderService; + $this->checkoutService = new CheckoutService( + new PricingEngine(new DiscountService, new ShippingCalculator, new TaxCalculator), + $this->inventoryService, + $this->orderService, + new MockPaymentProvider, + ); + + $product = Product::factory()->for($this->store)->create(); + $this->variant = ProductVariant::factory()->for($product)->create([ + 'price_amount' => 4500, + 'requires_shipping' => true, + ]); + InventoryItem::factory() + ->for($this->store) + ->for($this->variant, 'variant') + ->create(['quantity_on_hand' => 10]); + + $this->zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $this->rate = ShippingRate::factory()->for($this->zone, 'zone')->flat(599)->create(); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('records a payment with method and captured status on successful complete', function (): void { + $cart = $this->cartService->create($this->store); + $this->cartService->addLine($cart, $this->variant->id, 1); + $checkout = $this->checkoutService->start($cart->fresh()); + $this->checkoutService->setAddress($checkout, [ + 'email' => 'a@b.c', + 'shipping_address' => ['country' => 'DE'], + ]); + $this->checkoutService->setShippingMethod($checkout->fresh(), $this->rate->id); + $this->checkoutService->selectPaymentMethod($checkout->fresh(), 'credit_card'); + + $this->checkoutService->complete($checkout->fresh(), ['card_number' => '4242424242424242']); + + $payment = \App\Models\Payment::query()->first(); + expect($payment)->not->toBeNull() + ->and($payment->method)->toBe(PaymentMethod::CreditCard) + ->and($payment->status)->toBe(PaymentStatus::Captured) + ->and($payment->amount)->toBeGreaterThan(0) + ->and($payment->provider)->toBe('mock') + ->and($payment->provider_payment_id)->toStartWith('mock_'); +}); From d005414264a28f0c814381a275717f02cc633be9 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 21:38:37 +0200 Subject: [PATCH 07/15] Phase 6: Storefront UI + customer accounts Storefront Livewire components: - Collections (Index, Show with filters, sort, pagination) - Products/Show (variant picker, add-to-cart, gallery) - Cart/Show (line items, qty controls, totals) - CartDrawer (Alpine slide-out) - Checkout/Show (multi-step: address, shipping, payment) - Checkout/Confirmation (order summary, totals, next steps) - Pages/Show (CMS page rendering) - Search/Index (basic LIKE search, FTS in Phase 8) Customer auth + account area: - Account/Auth/Login + Register (customer guard, per-store unique email) - Account/Dashboard (recent orders) - Account/Orders/Index, Show - Account/Addresses/Index (add, set default, delete) - /account/logout route Helpers: - Storefront\Concerns\EnsuresStore trait - Support\CartSession (session-bound cart with global scope bypass) Routes wired with auth:customer middleware for /account paths. bootstrap/app.php redirects /account guests to customer login. Tests: CollectionsTest, ProductDetailTest, CartTest, CheckoutTest, PageShowTest, AccountTest, CustomerLoginTest (25 new, 192 total). Worker also verified end-to-end purchase flow via Playwright against http://shop.test (collection -> product -> cart -> checkout -> paid order #1001). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Storefront/Account/Addresses/Index.php | 122 +++++++++ .../Storefront/Account/Auth/Login.php | 63 +++++ .../Storefront/Account/Auth/Register.php | 70 +++++ app/Livewire/Storefront/Account/Dashboard.php | 39 +++ .../Storefront/Account/Orders/Index.php | 38 +++ .../Storefront/Account/Orders/Show.php | 40 +++ app/Livewire/Storefront/Cart/Show.php | 79 ++++++ app/Livewire/Storefront/CartDrawer.php | 48 ++++ .../Storefront/Checkout/Confirmation.php | 34 +++ app/Livewire/Storefront/Checkout/Show.php | 259 ++++++++++++++++++ app/Livewire/Storefront/Collections/Index.php | 34 +++ app/Livewire/Storefront/Collections/Show.php | 59 ++++ .../Storefront/Concerns/EnsuresStore.php | 22 ++ app/Livewire/Storefront/Pages/Show.php | 36 +++ app/Livewire/Storefront/Products/Show.php | 93 +++++++ app/Livewire/Storefront/Search/Index.php | 50 ++++ app/Support/CartSession.php | 49 ++++ bootstrap/app.php | 8 + .../components/layouts/storefront.blade.php | 5 +- .../account/addresses/index.blade.php | 106 +++++++ .../storefront/account/auth/login.blade.php | 34 +++ .../account/auth/register.blade.php | 45 +++ .../storefront/account/dashboard.blade.php | 59 ++++ .../storefront/account/orders/index.blade.php | 43 +++ .../storefront/account/orders/show.blade.php | 63 +++++ .../livewire/storefront/cart-drawer.blade.php | 97 +++++++ .../livewire/storefront/cart/show.blade.php | 106 +++++++ .../checkout/confirmation.blade.php | 83 ++++++ .../storefront/checkout/show.blade.php | 222 +++++++++++++++ .../storefront/collections/index.blade.php | 35 +++ .../storefront/collections/show.blade.php | 48 ++++ .../livewire/storefront/pages/show.blade.php | 9 + .../storefront/products/show.blade.php | 82 ++++++ .../storefront/search/index.blade.php | 29 ++ routes/web.php | 44 +++ specs/progress.md | 2 +- tests/Feature/Customers/CustomerLoginTest.php | 82 ++++++ tests/Feature/Storefront/AccountTest.php | 87 ++++++ tests/Feature/Storefront/CartTest.php | 72 +++++ tests/Feature/Storefront/CheckoutTest.php | 118 ++++++++ tests/Feature/Storefront/CollectionsTest.php | 55 ++++ tests/Feature/Storefront/PageShowTest.php | 37 +++ .../Feature/Storefront/ProductDetailTest.php | 70 +++++ 43 files changed, 2773 insertions(+), 3 deletions(-) create mode 100644 app/Livewire/Storefront/Account/Addresses/Index.php create mode 100644 app/Livewire/Storefront/Account/Auth/Login.php create mode 100644 app/Livewire/Storefront/Account/Auth/Register.php create mode 100644 app/Livewire/Storefront/Account/Dashboard.php create mode 100644 app/Livewire/Storefront/Account/Orders/Index.php create mode 100644 app/Livewire/Storefront/Account/Orders/Show.php create mode 100644 app/Livewire/Storefront/Cart/Show.php create mode 100644 app/Livewire/Storefront/CartDrawer.php create mode 100644 app/Livewire/Storefront/Checkout/Confirmation.php create mode 100644 app/Livewire/Storefront/Checkout/Show.php create mode 100644 app/Livewire/Storefront/Collections/Index.php create mode 100644 app/Livewire/Storefront/Collections/Show.php create mode 100644 app/Livewire/Storefront/Concerns/EnsuresStore.php create mode 100644 app/Livewire/Storefront/Pages/Show.php create mode 100644 app/Livewire/Storefront/Products/Show.php create mode 100644 app/Livewire/Storefront/Search/Index.php create mode 100644 app/Support/CartSession.php create mode 100644 resources/views/livewire/storefront/account/addresses/index.blade.php create mode 100644 resources/views/livewire/storefront/account/auth/login.blade.php create mode 100644 resources/views/livewire/storefront/account/auth/register.blade.php create mode 100644 resources/views/livewire/storefront/account/dashboard.blade.php create mode 100644 resources/views/livewire/storefront/account/orders/index.blade.php create mode 100644 resources/views/livewire/storefront/account/orders/show.blade.php create mode 100644 resources/views/livewire/storefront/cart-drawer.blade.php create mode 100644 resources/views/livewire/storefront/cart/show.blade.php create mode 100644 resources/views/livewire/storefront/checkout/confirmation.blade.php create mode 100644 resources/views/livewire/storefront/checkout/show.blade.php create mode 100644 resources/views/livewire/storefront/collections/index.blade.php create mode 100644 resources/views/livewire/storefront/collections/show.blade.php create mode 100644 resources/views/livewire/storefront/pages/show.blade.php create mode 100644 resources/views/livewire/storefront/products/show.blade.php create mode 100644 resources/views/livewire/storefront/search/index.blade.php create mode 100644 tests/Feature/Customers/CustomerLoginTest.php create mode 100644 tests/Feature/Storefront/AccountTest.php create mode 100644 tests/Feature/Storefront/CartTest.php create mode 100644 tests/Feature/Storefront/CheckoutTest.php create mode 100644 tests/Feature/Storefront/CollectionsTest.php create mode 100644 tests/Feature/Storefront/PageShowTest.php create mode 100644 tests/Feature/Storefront/ProductDetailTest.php diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..5f46cdd7 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,122 @@ +ensureCurrentStore(); + } + + public function addAddress(): void + { + $this->validate([ + 'label' => 'required|string|max:60', + 'firstName' => 'required|string|max:120', + 'lastName' => 'required|string|max:120', + 'line1' => 'required|string|max:255', + 'city' => 'required|string|max:120', + 'postalCode' => 'required|string|max:30', + 'country' => 'required|string|size:2', + ]); + + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + if ($this->isDefault) { + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->update(['is_default' => false]); + } + + CustomerAddress::create([ + 'customer_id' => $customer->id, + 'label' => $this->label, + 'is_default' => $this->isDefault, + 'address_json' => [ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'address1' => $this->line1, + 'address2' => $this->line2, + 'city' => $this->city, + 'postal_code' => $this->postalCode, + 'country' => $this->country, + ], + ]); + + $this->reset(['label', 'firstName', 'lastName', 'line1', 'line2', 'city', 'postalCode', 'isDefault']); + $this->label = 'Home'; + $this->country = 'DE'; + } + + public function makeDefault(int $addressId): void + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->update(['is_default' => false]); + + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->where('id', $addressId) + ->update(['is_default' => true]); + } + + public function deleteAddress(int $addressId): void + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + CustomerAddress::query() + ->where('customer_id', $customer->id) + ->where('id', $addressId) + ->delete(); + } + + public function render(): View + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $addresses = CustomerAddress::query() + ->where('customer_id', $customer->id) + ->orderByDesc('is_default') + ->get(); + + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $addresses, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..9d96a26c --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,63 @@ +ensureCurrentStore(); + + if (Auth::guard('customer')->check()) { + $this->redirect(route('storefront.account.dashboard'), navigate: false); + } + } + + public function login(): void + { + $this->validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + + $store = $this->ensureCurrentStore(); + + $customer = Customer::query() + ->where('store_id', $store->id) + ->where('email', $this->email) + ->first(); + + if ($customer === null || ! Hash::check($this->password, (string) $customer->password_hash)) { + $this->addError('email', 'These credentials do not match our records.'); + + return; + } + + Auth::guard('customer')->login($customer, $this->remember); + session()->regenerate(); + + $this->redirect(route('storefront.account.dashboard'), navigate: false); + } + + public function render(): View + { + return view('livewire.storefront.account.auth.login'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..b1604393 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,70 @@ +ensureCurrentStore(); + + if (Auth::guard('customer')->check()) { + $this->redirect(route('storefront.account.dashboard'), navigate: false); + } + } + + public function register(): void + { + $store = $this->ensureCurrentStore(); + + $this->validate([ + 'name' => 'required|string|max:120', + 'email' => [ + 'required', + 'email', + Rule::unique('customers', 'email')->where(fn ($query) => $query->where('store_id', $store->id)), + ], + 'password' => 'required|string|min:8|confirmed', + ]); + + /** @var Customer $customer */ + $customer = Customer::create([ + 'store_id' => $store->id, + 'name' => $this->name, + 'email' => $this->email, + 'password_hash' => $this->password, + 'marketing_opt_in' => $this->marketing_opt_in, + ]); + + Auth::guard('customer')->login($customer); + session()->regenerate(); + + $this->redirect(route('storefront.account.dashboard'), navigate: false); + } + + public function render(): View + { + return view('livewire.storefront.account.auth.register'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..e14a43f0 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,39 @@ +ensureCurrentStore(); + } + + public function render(): View + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $recentOrders = Order::query() + ->where('customer_id', $customer->id) + ->latest('id') + ->limit(5) + ->get(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => $recentOrders, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..848647cd --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,38 @@ +ensureCurrentStore(); + } + + public function render(): View + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $orders = Order::query() + ->where('customer_id', $customer->id) + ->latest('id') + ->paginate(15); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $orders, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..3cd42442 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,40 @@ +ensureCurrentStore(); + + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $this->order = Order::query() + ->where('customer_id', $customer->id) + ->where('order_number', '#'.$orderNumber) + ->with('lines') + ->firstOrFail(); + } + + public function render(): View + { + return view('livewire.storefront.account.orders.show', [ + 'order' => $this->order, + ]); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..90c09bfa --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,79 @@ +ensureCurrentStore(); + } + + public function updateQty(int $lineId, int $qty): void + { + $cart = CartSession::current(); + if ($cart === null) { + return; + } + + $qty = max(1, $qty); + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $qty); + } catch (RuntimeException $exception) { + $this->addError('cart', $exception->getMessage()); + } + + $this->dispatch('cart-updated'); + } + + public function removeLine(int $lineId): void + { + $cart = CartSession::current(); + if ($cart === null) { + return; + } + + app(CartService::class)->removeLine($cart, $lineId); + $this->dispatch('cart-updated'); + } + + public function applyDiscount(): void + { + session(['discount_code' => $this->discountCode]); + $this->dispatch('cart-updated'); + } + + public function render(): View + { + $this->ensureCurrentStore(); + + $cart = CartSession::current(); + $cart?->load('lines.variant.product'); + + $subtotal = 0; + if ($cart !== null) { + foreach ($cart->lines as $line) { + $subtotal += (int) $line->line_subtotal_amount; + } + } + + return view('livewire.storefront.cart.show', [ + 'cart' => $cart, + 'subtotal' => $subtotal, + ]); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..a11445fd --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,48 @@ +ensureCurrentStore(); + } + + #[On('cart-updated')] + public function refreshCart(): void + { + // Triggers re-render when cart changes elsewhere. + } + + public function render(): View + { + $this->ensureCurrentStore(); + + $cart = CartSession::current(); + $cart?->load('lines.variant.product'); + + $count = 0; + $subtotal = 0; + if ($cart !== null) { + foreach ($cart->lines as $line) { + $count += (int) $line->quantity; + $subtotal += (int) $line->line_subtotal_amount; + } + } + + return view('livewire.storefront.cart-drawer', [ + 'cart' => $cart, + 'count' => $count, + 'subtotal' => $subtotal, + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..030b97d7 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,34 @@ +ensureCurrentStore(); + + $this->order = Order::query() + ->where('order_number', '#'.$order_number) + ->with('lines') + ->firstOrFail(); + } + + public function render(): View + { + return view('livewire.storefront.checkout.confirmation', [ + 'order' => $this->order, + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..8d03d62a --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,259 @@ + */ + public array $shippingAddress = [ + 'first_name' => '', + 'last_name' => '', + 'line1' => '', + 'line2' => '', + 'city' => '', + 'postal_code' => '', + 'country' => 'DE', + 'province_code' => null, + ]; + + /** @var array */ + public array $billingAddress = [ + 'first_name' => '', + 'last_name' => '', + 'line1' => '', + 'line2' => '', + 'city' => '', + 'postal_code' => '', + 'country' => 'DE', + 'province_code' => null, + ]; + + public bool $billingSameAsShipping = true; + + public ?int $shippingMethodId = null; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = '4242424242424242'; + + public string $cardExpiry = '12/30'; + + public string $cardCvc = '123'; + + public int $step = 1; + + public function mount(): void + { + $this->ensureCurrentStore(); + + $cart = CartSession::current(); + if ($cart === null || $cart->lines()->count() === 0) { + $this->redirect(route('storefront.cart.show'), navigate: false); + + return; + } + + $customer = Auth::guard('customer')->user(); + if ($customer !== null && $this->email === '') { + $this->email = (string) $customer->email; + } + } + + public function continueToShipping(): void + { + $this->validate([ + 'email' => 'required|email', + 'shippingAddress.first_name' => 'required|string|max:100', + 'shippingAddress.last_name' => 'required|string|max:100', + 'shippingAddress.line1' => 'required|string|max:255', + 'shippingAddress.city' => 'required|string|max:120', + 'shippingAddress.postal_code' => 'required|string|max:30', + 'shippingAddress.country' => 'required|string|size:2', + ]); + + $billing = $this->billingSameAsShipping ? $this->shippingAddress : $this->billingAddress; + + $checkout = $this->getOrCreateCheckout(); + app(CheckoutService::class)->setAddress($checkout, [ + 'email' => $this->email, + 'shipping_address' => $this->shippingAddressPayload(), + 'billing_address' => $this->addressToPayload($billing), + ]); + + $this->step = 2; + } + + public function continueToPayment(): void + { + $this->validate([ + 'shippingMethodId' => 'required|integer', + ]); + + $checkout = $this->getOrCreateCheckout(); + app(CheckoutService::class)->setShippingMethod($checkout, (int) $this->shippingMethodId); + + $this->step = 3; + } + + public function backToAddress(): void + { + $this->step = 1; + } + + public function backToShipping(): void + { + $this->step = 2; + } + + public function placeOrder(): void + { + $this->validate([ + 'paymentMethod' => 'required|in:credit_card,paypal,bank_transfer', + ]); + + $checkout = $this->getOrCreateCheckout(); + $service = app(CheckoutService::class); + + $service->selectPaymentMethod($checkout, $this->paymentMethod); + $checkout->refresh(); + + try { + $service->complete($checkout, [ + 'card_number' => $this->cardNumber, + 'card_expiry' => $this->cardExpiry, + 'card_cvc' => $this->cardCvc, + ]); + } catch (PaymentFailedException $exception) { + $this->addError('payment', $exception->getMessage()); + + return; + } + + $storeId = (int) $checkout->store_id; + $order = Order::withoutGlobalScopes() + ->where('store_id', $storeId) + ->latest('id') + ->first(); + + CartSession::clear(); + + if ($order !== null) { + $this->redirect( + route('storefront.checkout.confirmation', ['order_number' => ltrim((string) $order->order_number, '#')]), + navigate: false + ); + } + } + + public function render(): View + { + $store = $this->ensureCurrentStore(); + + $cart = CartSession::current(); + $cart?->load('lines.variant.product'); + + $shippingRates = collect(); + if ($this->step >= 2 && $cart !== null) { + $shippingRates = app(ShippingCalculator::class)->getAvailableRates( + $store, + $this->shippingAddressPayload() + ); + } + + $totals = $this->computeTotals($cart, $shippingRates); + + return view('livewire.storefront.checkout.show', [ + 'cart' => $cart, + 'shippingRates' => $shippingRates, + 'totals' => $totals, + ]); + } + + private function getOrCreateCheckout(): Checkout + { + $cart = CartSession::current(); + + if ($cart === null) { + $this->redirect(route('storefront.cart.show'), navigate: false); + abort(404); + } + + $existing = $cart->checkouts()->latest('id')->first(); + if ($existing !== null) { + return $existing; + } + + return app(CheckoutService::class)->start($cart); + } + + /** + * @return array + */ + private function shippingAddressPayload(): array + { + return $this->addressToPayload($this->shippingAddress); + } + + /** + * @param array $address + * @return array + */ + private function addressToPayload(array $address): array + { + return [ + 'first_name' => $address['first_name'] ?? '', + 'last_name' => $address['last_name'] ?? '', + 'address1' => $address['line1'] ?? '', + 'address2' => $address['line2'] ?? '', + 'city' => $address['city'] ?? '', + 'postal_code' => $address['postal_code'] ?? '', + 'country' => $address['country'] ?? '', + 'province_code' => $address['province_code'] ?? null, + ]; + } + + /** + * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Collection $rates + * @return array + */ + private function computeTotals(?\App\Models\Cart $cart, $rates): array + { + $subtotal = 0; + if ($cart !== null) { + foreach ($cart->lines as $line) { + $subtotal += (int) $line->line_subtotal_amount; + } + } + + $shipping = 0; + if ($this->shippingMethodId !== null && $cart !== null) { + $rate = $rates->firstWhere('id', $this->shippingMethodId); + if ($rate !== null) { + $shipping = app(ShippingCalculator::class)->calculate($rate, $cart); + } + } + + return [ + 'subtotal' => $subtotal, + 'shipping' => $shipping, + 'total' => $subtotal + $shipping, + ]; + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..5d8bd15c --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,34 @@ +ensureCurrentStore(); + } + + public function render(): View + { + $collections = CollectionModel::query() + ->where('status', CollectionStatus::Active->value) + ->withCount('products') + ->orderBy('title') + ->get(); + + return view('livewire.storefront.collections.index', [ + 'collections' => $collections, + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..a04f4d04 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,59 @@ +ensureCurrentStore(); + $this->handle = $handle; + } + + public function updatedSort(): void + { + $this->resetPage(); + } + + public function render(): View + { + $collection = CollectionModel::query() + ->where('handle', $this->handle) + ->firstOrFail(); + + $query = $collection->products() + ->where('products.status', ProductStatus::Active->value); + + if ($this->sort === 'title_asc') { + $query->orderBy('products.title'); + } elseif ($this->sort === 'newest') { + $query->orderByDesc('products.id'); + } else { + $query->orderBy('collection_products.position'); + } + + $products = $query->with('variants')->paginate(12); + + return view('livewire.storefront.collections.show', [ + 'collection' => $collection, + 'products' => $products, + ]); + } +} diff --git a/app/Livewire/Storefront/Concerns/EnsuresStore.php b/app/Livewire/Storefront/Concerns/EnsuresStore.php new file mode 100644 index 00000000..807c0186 --- /dev/null +++ b/app/Livewire/Storefront/Concerns/EnsuresStore.php @@ -0,0 +1,22 @@ +bound('current_store')) { + /** @var Store $store */ + $store = Store::first() ?? Store::factory()->create(); + app()->instance('current_store', $store); + } + + /** @var Store $current */ + $current = app('current_store'); + + return $current; + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..8bcd50da --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,36 @@ +ensureCurrentStore(); + $this->handle = $handle; + } + + public function render(): View + { + $page = Page::query() + ->where('handle', $this->handle) + ->where('status', PageStatus::Published->value) + ->firstOrFail(); + + return view('livewire.storefront.pages.show', [ + 'page' => $page, + ]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..10e51738 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,93 @@ +ensureCurrentStore(); + $this->handle = $handle; + } + + public function incrementQuantity(): void + { + $this->quantity = min(99, $this->quantity + 1); + } + + public function decrementQuantity(): void + { + $this->quantity = max(1, $this->quantity - 1); + } + + public function selectVariant(int $variantId): void + { + $this->selectedVariantId = $variantId; + } + + public function addToCart(): void + { + if ($this->selectedVariantId === null) { + $this->addError('cart', 'Please select a variant.'); + + return; + } + + $store = $this->ensureCurrentStore(); + $cart = CartSession::getOrCreate($store); + + try { + app(CartService::class)->addLine($cart, $this->selectedVariantId, $this->quantity); + } catch (RuntimeException $exception) { + $this->addError('cart', $exception->getMessage()); + + return; + } + + $this->dispatch('cart-updated'); + session()->flash('cart-success', 'Added to cart'); + } + + public function render(): View + { + $product = Product::query() + ->where('handle', $this->handle) + ->with(['variants', 'media']) + ->firstOrFail(); + + $activeVariants = $product->variants->filter( + fn ($variant): bool => $variant->status === VariantStatus::Active + )->values(); + + if ($this->selectedVariantId === null && $activeVariants->isNotEmpty()) { + $this->selectedVariantId = (int) $activeVariants->first()->id; + } + + $selectedVariant = $activeVariants->firstWhere('id', $this->selectedVariantId); + + return view('livewire.storefront.products.show', [ + 'product' => $product, + 'activeVariants' => $activeVariants, + 'selectedVariant' => $selectedVariant, + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..02f20d92 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,50 @@ +ensureCurrentStore(); + } + + public function updatedQ(): void + { + $this->resetPage(); + } + + public function render(): View + { + $query = Product::query() + ->where('status', ProductStatus::Active->value) + ->with('variants'); + + if (trim($this->q) !== '') { + $query->where('title', 'like', '%'.$this->q.'%'); + } else { + $query->whereRaw('1 = 0'); + } + + $products = $query->orderBy('title')->paginate(12); + + return view('livewire.storefront.search.index', [ + 'products' => $products, + ]); + } +} diff --git a/app/Support/CartSession.php b/app/Support/CartSession.php new file mode 100644 index 00000000..878669ad --- /dev/null +++ b/app/Support/CartSession.php @@ -0,0 +1,49 @@ +find($cartId); + if ($cart !== null && $cart->status === CartStatus::Active) { + return $cart; + } + } + + $customer = Auth::guard('customer')->user(); + $cart = app(CartService::class)->create($store, $customer, session()->getId()); + session(['cart_id' => $cart->id]); + + return $cart; + } + + public static function current(): ?Cart + { + $id = session('cart_id'); + if ($id === null) { + return null; + } + + $cart = Cart::withoutGlobalScopes()->find($id); + if ($cart === null || $cart->status !== CartStatus::Active) { + return null; + } + + return $cart; + } + + public static function clear(): void + { + session()->forget('cart_id'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 34a0bb98..e73cb135 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,14 @@ $middleware->alias([ 'store.resolve' => \App\Http\Middleware\ResolveStore::class, ]); + + $middleware->redirectGuestsTo(function (\Illuminate\Http\Request $request): ?string { + if ($request->is('account*') || $request->routeIs('storefront.account.*')) { + return route('storefront.account.login'); + } + + return null; + }); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/resources/views/components/layouts/storefront.blade.php b/resources/views/components/layouts/storefront.blade.php index d8c27ecc..3096c807 100644 --- a/resources/views/components/layouts/storefront.blade.php +++ b/resources/views/components/layouts/storefront.blade.php @@ -33,8 +33,9 @@ @include('storefront.partials.footer', ['footerText' => $footerText]) - {{-- Cart drawer placeholder for Phase 4 --}} -
+
+ +
@livewireScripts @fluxScripts diff --git a/resources/views/livewire/storefront/account/addresses/index.blade.php b/resources/views/livewire/storefront/account/addresses/index.blade.php new file mode 100644 index 00000000..24c1377d --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,106 @@ +
+
+ Back to account +

Addresses

+
+ +
+

Saved addresses

+ + @if ($addresses->isEmpty()) +
+

No addresses saved yet.

+
+ @else +
+ @foreach ($addresses as $address) + @php($data = $address->address_json ?? []) +
+
+

{{ $address->label }}

+ @if ($address->is_default) + Default + @endif +
+
+ {{ trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? '')) }}
+ @if (! empty($data['address1'])) {{ $data['address1'] }}
@endif + @if (! empty($data['city'])) {{ $data['city'] }} {{ $data['postal_code'] ?? '' }}
@endif + {{ $data['country'] ?? '' }} +
+
+ @unless ($address->is_default) + + @endunless + +
+
+ @endforeach +
+ @endif +
+ +
+

Add a new address

+ +
+
+ + + + + + + + +
+ + + + +
+
+
diff --git a/resources/views/livewire/storefront/account/auth/login.blade.php b/resources/views/livewire/storefront/account/auth/login.blade.php new file mode 100644 index 00000000..6323026d --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,34 @@ +
+
+

Sign in

+

Access your orders and saved addresses.

+
+ +
+ + + + + + + +
+ +

+ New customer? + Create an account +

+
diff --git a/resources/views/livewire/storefront/account/auth/register.blade.php b/resources/views/livewire/storefront/account/auth/register.blade.php new file mode 100644 index 00000000..8c40442f --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,45 @@ +
+
+

Create account

+

Sign up to track orders and check out faster.

+
+ +
+ + + + + + + + + + + +
+ +

+ Already have an account? + Sign in +

+
diff --git a/resources/views/livewire/storefront/account/dashboard.blade.php b/resources/views/livewire/storefront/account/dashboard.blade.php new file mode 100644 index 00000000..41e8d46b --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,59 @@ +
+
+
+

Account

+

Hi, {{ $customer->name }}

+

{{ $customer->email }}

+
+
+ @csrf + +
+
+ + + +
+

Recent orders

+ + @if ($recentOrders->isEmpty()) +
+

You have not placed any orders yet.

+
+ @else +
    + @foreach ($recentOrders as $order) +
  • +
    +

    {{ $order->order_number }}

    +

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

    +
    +
    + + + + + View + +
    +
  • + @endforeach +
+ @endif +
+
diff --git a/resources/views/livewire/storefront/account/orders/index.blade.php b/resources/views/livewire/storefront/account/orders/index.blade.php new file mode 100644 index 00000000..f3484291 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,43 @@ +
+
+ Back to account +

Orders

+
+ + @if ($orders->isEmpty()) +
+

You have not placed any orders yet.

+
+ @else +
+ + + + + + + + + + + + @foreach ($orders as $order) + + + + + + + + @endforeach + +
OrderDateTotalStatus
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ $order->status?->value ?? 'pending' }} + View +
+
+ +
+ {{ $orders->links() }} +
+ @endif +
diff --git a/resources/views/livewire/storefront/account/orders/show.blade.php b/resources/views/livewire/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..5751f2bb --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,63 @@ +
+
+ Back to orders +

Order {{ $order->order_number }}

+

Placed on {{ $order->placed_at?->format('F j, Y') }}

+
+ +
+
+

Items

+
    + @foreach ($order->lines as $line) +
  • +
    +
    +
    +

    {{ $line->title_snapshot }}

    +

    Qty {{ $line->quantity }}

    +
    +
    +

    + +

    +
  • + @endforeach +
+ +
+
+ Subtotal + +
+
+ Shipping + +
+
+ Total + +
+
+
+ + +
+
diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php new file mode 100644 index 00000000..1ebc8c18 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,97 @@ +
+ + +
+ + +
diff --git a/resources/views/livewire/storefront/cart/show.blade.php b/resources/views/livewire/storefront/cart/show.blade.php new file mode 100644 index 00000000..bd923368 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,106 @@ +
+
+

Your cart

+

Review the items you are about to purchase.

+
+ + @if ($cart === null || $cart->lines->isEmpty()) +
+

Your cart is empty.

+ + Browse collections + +
+ @else +
+
+
    + @foreach ($cart->lines as $line) +
  • +
    +
    +
    +
    +

    + {{ $line->variant?->product?->title ?? 'Product' }} +

    +

    {{ $line->variant?->sku }}

    +
    +

    + +

    +
    + +
    +
    + + {{ $line->quantity }} + +
    + + +
    +
    +
  • + @endforeach +
+ + @error('cart') +
+ {{ $message }} +
+ @enderror +
+ + +
+ @endif +
diff --git a/resources/views/livewire/storefront/checkout/confirmation.blade.php b/resources/views/livewire/storefront/checkout/confirmation.blade.php new file mode 100644 index 00000000..f90561be --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,83 @@ +
+
+
+ +
+

Thank you for your order

+

Order {{ $order->order_number }} is confirmed. A receipt has been sent to {{ $order->email }}.

+
+ +
+
+

Order details

+
    + @foreach ($order->lines as $line) +
  • +
    +
    +
    +

    {{ $line->title_snapshot }}

    +

    Qty {{ $line->quantity }}

    +
    +
    +

    + +

    +
  • + @endforeach +
+ +
+
+ Subtotal + +
+
+ Shipping + +
+ @if ($order->tax_amount > 0) +
+ Tax + +
+ @endif +
+ Total + +
+
+
+ + +
+
diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php new file mode 100644 index 00000000..0d910310 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,222 @@ +
+
+

Checkout

+
+ +
    + @foreach ([1 => 'Address', 2 => 'Shipping', 3 => 'Payment'] as $stepNumber => $label) +
  1. + + {{ $stepNumber }} + + {{ $label }} + @if ($stepNumber < 3) + + @endif +
  2. + @endforeach +
+ +
+
+ @if ($step === 1) +
+

Contact and shipping address

+ +
+ + +
+ + +
+ + + + + +
+ + +
+ + + + +
+ + +
+ @elseif ($step === 2) +
+

Shipping method

+ + @if ($shippingRates->isEmpty()) +

+ No shipping options available for the selected country. +

+ @else +
+ @foreach ($shippingRates as $rate) + + @endforeach +
+ @error('shippingMethodId') {{ $message }} @enderror + @endif + +
+ + +
+
+ @else +
+

Payment

+ +
+ + + +
+ + @if ($paymentMethod === 'credit_card') +
+ Magic test card: 4242 4242 4242 4242, expiry 12/30, CVC 123. +
+ + +
+ + +
+ @endif + + @error('payment') +
+ {{ $message }} +
+ @enderror + +
+ + +
+
+ @endif +
+ + +
+
diff --git a/resources/views/livewire/storefront/collections/index.blade.php b/resources/views/livewire/storefront/collections/index.blade.php new file mode 100644 index 00000000..2d6d7576 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,35 @@ +
+
+

Shop

+

All collections

+

Browse every curated edit across the store.

+
+ + @if ($collections->isEmpty()) +
+

No collections yet.

+
+ @else + + @endif +
diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php new file mode 100644 index 00000000..48691a11 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,48 @@ +
+
+ +

{{ $collection->title }}

+ @if ($collection->description_html) +
+ {!! $collection->description_html !!} +
+ @endif +
+ +
+

+ {{ $products->total() }} {{ \Illuminate\Support\Str::plural('product', $products->total()) }} +

+ +
+ + @if ($products->isEmpty()) +
+

No products in this collection yet.

+
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ +
+ {{ $products->links() }} +
+ @endif +
diff --git a/resources/views/livewire/storefront/pages/show.blade.php b/resources/views/livewire/storefront/pages/show.blade.php new file mode 100644 index 00000000..772543b8 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,9 @@ +
+
+

{{ $page->title }}

+
+ +
+ {!! $page->body_html !!} +
+
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php new file mode 100644 index 00000000..4ea2b77b --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,82 @@ +
+
+
+
+ @if ($product->media && $product->media->count() > 1) +
+ @foreach ($product->media->take(4) as $media) +
+ @endforeach +
+ @endif +
+ +
+
+ @if ($product->vendor) +

{{ $product->vendor }}

+ @endif +

{{ $product->title }}

+ @if ($selectedVariant) +

+ +

+ @endif +
+ + @if ($activeVariants->count() > 1) +
+ Variant +
+ @foreach ($activeVariants as $variant) + + @endforeach +
+
+ @endif + +
+ +
+ + {{ $quantity }} + +
+
+ + @if (session('cart-success')) +
+ {{ session('cart-success') }} +
+ @endif + + @error('cart') +
+ {{ $message }} +
+ @enderror + + + + @if ($product->description_html) +
+ {!! $product->description_html !!} +
+ @endif +
+
+
diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php new file mode 100644 index 00000000..991a2cf6 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,29 @@ +
+
+

Search

+
+ +
+
+ + @if (trim($q) === '') +

Start typing to search the catalogue.

+ @elseif ($products->isEmpty()) +

No products found for "{{ $q }}".

+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ +
+ {{ $products->links() }} +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 9de8cc24..e8e4dc81 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,21 @@ name('home'); Route::get('/storefront', StorefrontHome::class)->name('storefront.home'); +Route::get('/collections', CollectionsIndex::class)->name('storefront.collections.index'); +Route::get('/collections/{handle}', CollectionsShow::class)->name('storefront.collections.show'); +Route::get('/products/{handle}', ProductsShow::class)->name('storefront.products.show'); +Route::get('/cart', CartShow::class)->name('storefront.cart.show'); +Route::get('/checkout', CheckoutShow::class)->name('storefront.checkout.show'); +Route::get('/checkout/confirmation/{order_number}', CheckoutConfirmation::class)->name('storefront.checkout.confirmation'); +Route::get('/pages/{handle}', PagesShow::class)->name('storefront.pages.show'); +Route::get('/search', SearchIndex::class)->name('storefront.search'); + +Route::get('/account/login', AccountLogin::class)->name('storefront.account.login'); +Route::get('/account/register', AccountRegister::class)->name('storefront.account.register'); + +Route::post('/account/logout', function (Request $request) { + Auth::guard('customer')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('storefront.account.login'); +})->name('storefront.account.logout'); + +Route::middleware('auth:customer') + ->prefix('account') + ->name('storefront.account.') + ->group(function (): void { + Route::get('/', AccountDashboard::class)->name('dashboard'); + Route::get('/orders', AccountOrdersIndex::class)->name('orders.index'); + Route::get('/orders/{orderNumber}', AccountOrdersShow::class)->name('orders.show'); + Route::get('/addresses', AccountAddressesIndex::class)->name('addresses.index'); + }); + Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) ->name('dashboard'); diff --git a/specs/progress.md b/specs/progress.md index 0a9a6a99..2c425970 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -9,7 +9,7 @@ Tracking progress for the full shop system implementation (team: shop-v2). - [x] Phase 3: Themes, pages, navigation, storefront layout - 79 tests passing - [x] Phase 4: Cart, checkout, discounts, shipping, taxes - 138 tests passing - [x] Phase 5: Payments, orders, fulfillment - 167 tests passing -- [ ] Phase 6: Customer accounts +- [x] Phase 6: Customer accounts + storefront UI - 192 tests passing - [ ] Phase 7: Admin panel - [ ] Phase 8: Search - [ ] Phase 9: Analytics diff --git a/tests/Feature/Customers/CustomerLoginTest.php b/tests/Feature/Customers/CustomerLoginTest.php new file mode 100644 index 00000000..435df0e9 --- /dev/null +++ b/tests/Feature/Customers/CustomerLoginTest.php @@ -0,0 +1,82 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('registers a new customer and logs them in', function (): void { + Livewire::test(Register::class) + ->set('name', 'Ada Lovelace') + ->set('email', 'ada@example.com') + ->set('password', 'secretpass') + ->set('password_confirmation', 'secretpass') + ->call('register') + ->assertHasNoErrors(); + + expect(Auth::guard('customer')->check())->toBeTrue(); + expect(Customer::query()->where('email', 'ada@example.com')->exists())->toBeTrue(); +}); + +it('logs in an existing customer', function (): void { + Customer::factory()->for($this->store)->create([ + 'email' => 'grace@example.com', + 'password_hash' => 'hoppers', + ]); + + Livewire::test(Login::class) + ->set('email', 'grace@example.com') + ->set('password', 'hoppers') + ->call('login') + ->assertHasNoErrors(); + + expect(Auth::guard('customer')->check())->toBeTrue(); +}); + +it('rejects invalid credentials', function (): void { + Customer::factory()->for($this->store)->create([ + 'email' => 'fail@example.com', + 'password_hash' => 'rightone', + ]); + + Livewire::test(Login::class) + ->set('email', 'fail@example.com') + ->set('password', 'wrongone') + ->call('login') + ->assertHasErrors('email'); + + expect(Auth::guard('customer')->check())->toBeFalse(); +}); + +it('blocks unauthenticated access to account dashboard via route', function (): void { + $this->get(route('storefront.account.dashboard')) + ->assertRedirect(route('storefront.account.login')); +}); + +it('logs out a customer via the logout route', function (): void { + $customer = Customer::factory()->for($this->store)->create([ + 'password_hash' => 'password', + ]); + + Auth::guard('customer')->login($customer); + expect(Auth::guard('customer')->check())->toBeTrue(); + + $this->post(route('storefront.account.logout')) + ->assertRedirect(route('storefront.account.login')); + + expect(Auth::guard('customer')->check())->toBeFalse(); +}); diff --git a/tests/Feature/Storefront/AccountTest.php b/tests/Feature/Storefront/AccountTest.php new file mode 100644 index 00000000..6e52c15d --- /dev/null +++ b/tests/Feature/Storefront/AccountTest.php @@ -0,0 +1,87 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $this->customer = Customer::factory()->for($this->store)->create([ + 'name' => 'Grace Hopper', + 'email' => 'grace@example.com', + ]); + + Auth::guard('customer')->login($this->customer); +}); + +afterEach(function (): void { + Auth::guard('customer')->logout(); + app()->forgetInstance('current_store'); +}); + +it('renders the account dashboard for a logged-in customer', function (): void { + Livewire::test(Dashboard::class) + ->assertStatus(200) + ->assertSee('Grace Hopper') + ->assertSee('grace@example.com'); +}); + +it('lists the customer orders', function (): void { + Order::factory()->for($this->store)->create([ + 'customer_id' => $this->customer->id, + 'order_number' => '#3001', + ]); + Order::factory()->for($this->store)->create([ + 'customer_id' => $this->customer->id, + 'order_number' => '#3002', + ]); + + Livewire::test(OrdersIndex::class) + ->assertStatus(200) + ->assertSee('#3001') + ->assertSee('#3002'); +}); + +it('shows a single customer order', function (): void { + Order::factory()->for($this->store)->create([ + 'customer_id' => $this->customer->id, + 'order_number' => '#4001', + 'email' => 'grace@example.com', + ]); + + Livewire::test(OrdersShow::class, ['orderNumber' => '4001']) + ->assertStatus(200) + ->assertSee('#4001'); +}); + +it('supports adding and deleting addresses', function (): void { + $component = Livewire::test(AddressesIndex::class) + ->set('label', 'Work') + ->set('firstName', 'Grace') + ->set('lastName', 'Hopper') + ->set('line1', 'Harvard Yard') + ->set('city', 'Cambridge') + ->set('postalCode', '02138') + ->set('country', 'US') + ->call('addAddress') + ->assertHasNoErrors(); + + expect(CustomerAddress::query()->where('customer_id', $this->customer->id)->count())->toBe(1); + + $addressId = CustomerAddress::query()->where('customer_id', $this->customer->id)->value('id'); + $component->call('deleteAddress', $addressId); + + expect(CustomerAddress::query()->where('customer_id', $this->customer->id)->count())->toBe(0); +}); diff --git a/tests/Feature/Storefront/CartTest.php b/tests/Feature/Storefront/CartTest.php new file mode 100644 index 00000000..2b8d3f2d --- /dev/null +++ b/tests/Feature/Storefront/CartTest.php @@ -0,0 +1,72 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function seedCartWithOneLine(Store $store): array +{ + $product = Product::factory()->for($store)->create(['title' => 'Ceramic Mug']); + $variant = ProductVariant::factory()->for($product)->create(['price_amount' => 1800]); + InventoryItem::factory() + ->for($store) + ->for($variant, 'variant') + ->create(['quantity_on_hand' => 10]); + + $cart = CartSession::getOrCreate($store); + app(CartService::class)->addLine($cart, $variant->id, 1); + + return [$cart, $variant]; +} + +it('renders an empty cart state', function (): void { + Livewire::test(Show::class) + ->assertStatus(200) + ->assertSee('Your cart is empty'); +}); + +it('renders cart lines with totals', function (): void { + [$cart, $variant] = seedCartWithOneLine($this->store); + + Livewire::test(Show::class) + ->assertStatus(200) + ->assertSee('Ceramic Mug'); +}); + +it('updates the quantity of a cart line', function (): void { + [$cart, $variant] = seedCartWithOneLine($this->store); + $line = $cart->lines()->first(); + + Livewire::test(Show::class) + ->call('updateQty', $line->id, 3) + ->assertHasNoErrors(); + + expect((int) $cart->lines()->first()->quantity)->toBe(3); +}); + +it('removes a cart line', function (): void { + [$cart, $variant] = seedCartWithOneLine($this->store); + $line = $cart->lines()->first(); + + Livewire::test(Show::class) + ->call('removeLine', $line->id); + + expect($cart->lines()->count())->toBe(0); +}); diff --git a/tests/Feature/Storefront/CheckoutTest.php b/tests/Feature/Storefront/CheckoutTest.php new file mode 100644 index 00000000..5b63048b --- /dev/null +++ b/tests/Feature/Storefront/CheckoutTest.php @@ -0,0 +1,118 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + + $product = Product::factory()->for($this->store)->create(['title' => 'Cotton Tee']); + $this->variant = ProductVariant::factory()->for($product)->create([ + 'price_amount' => 2500, + 'weight_g' => 300, + 'requires_shipping' => true, + ]); + InventoryItem::factory() + ->for($this->store) + ->for($this->variant, 'variant') + ->create(['quantity_on_hand' => 10]); + + $zone = ShippingZone::factory()->for($this->store)->create([ + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $this->rate = ShippingRate::factory()->for($zone, 'zone')->flat(599)->create(); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +function seedCheckoutCart(Store $store, ProductVariant $variant): void +{ + $cart = CartSession::getOrCreate($store); + app(CartService::class)->addLine($cart, $variant->id, 2); +} + +it('redirects to the cart when the cart is empty', function (): void { + Livewire::test(Show::class) + ->assertRedirect(route('storefront.cart.show')); +}); + +it('completes a credit card checkout end-to-end', function (): void { + seedCheckoutCart($this->store, $this->variant); + + Livewire::test(Show::class) + ->set('email', 'buyer@example.com') + ->set('shippingAddress.first_name', 'Jane') + ->set('shippingAddress.last_name', 'Doe') + ->set('shippingAddress.line1', 'Karl-Marx-Allee 1') + ->set('shippingAddress.city', 'Berlin') + ->set('shippingAddress.postal_code', '10178') + ->set('shippingAddress.country', 'DE') + ->call('continueToShipping') + ->assertHasNoErrors() + ->set('shippingMethodId', $this->rate->id) + ->call('continueToPayment') + ->assertHasNoErrors() + ->set('paymentMethod', 'credit_card') + ->call('placeOrder') + ->assertHasNoErrors(); + + $order = Order::query()->latest('id')->first(); + expect($order)->not->toBeNull() + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->email)->toBe('buyer@example.com'); + + expect(CartSession::current())->toBeNull(); +}); + +it('leaves the order pending for bank transfer', function (): void { + seedCheckoutCart($this->store, $this->variant); + + Livewire::test(Show::class) + ->set('email', 'bank@example.com') + ->set('shippingAddress.first_name', 'Max') + ->set('shippingAddress.last_name', 'Muster') + ->set('shippingAddress.line1', 'Hauptstr 1') + ->set('shippingAddress.city', 'Berlin') + ->set('shippingAddress.postal_code', '10115') + ->set('shippingAddress.country', 'DE') + ->call('continueToShipping') + ->set('shippingMethodId', $this->rate->id) + ->call('continueToPayment') + ->set('paymentMethod', 'bank_transfer') + ->call('placeOrder') + ->assertHasNoErrors(); + + $order = Order::query()->latest('id')->first(); + expect($order)->not->toBeNull() + ->and($order->financial_status)->toBe(FinancialStatus::Pending); +}); + +it('renders the confirmation page for an order', function (): void { + $order = Order::factory()->for($this->store)->create([ + 'order_number' => '#2001', + 'email' => 'confirm@example.com', + ]); + + Livewire::test(Confirmation::class, ['order_number' => '2001']) + ->assertStatus(200) + ->assertSee('Thank you') + ->assertSee('#2001'); +}); diff --git a/tests/Feature/Storefront/CollectionsTest.php b/tests/Feature/Storefront/CollectionsTest.php new file mode 100644 index 00000000..ba5af8d3 --- /dev/null +++ b/tests/Feature/Storefront/CollectionsTest.php @@ -0,0 +1,55 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('renders the collection index with active collections', function (): void { + $collection = Collection::factory()->for($this->store)->create([ + 'title' => 'Summer Picks', + 'handle' => 'summer-picks', + ]); + + Livewire::test(Index::class) + ->assertStatus(200) + ->assertSee('All collections') + ->assertSee('Summer Picks'); + + expect(Collection::query()->count())->toBe(1); +}); + +it('renders a collection detail with its products', function (): void { + $collection = Collection::factory()->for($this->store)->create([ + 'title' => 'Essentials', + 'handle' => 'essentials', + ]); + + $product = Product::factory()->for($this->store)->create(['title' => 'Linen Shirt']); + ProductVariant::factory()->for($product)->create(['price_amount' => 4999]); + $collection->products()->attach($product->id, ['position' => 0]); + + Livewire::test(Show::class, ['handle' => 'essentials']) + ->assertStatus(200) + ->assertSee('Essentials') + ->assertSee('Linen Shirt'); +}); + +it('aborts 404 for missing collection handle', function (): void { + Livewire::test(Show::class, ['handle' => 'missing']); +})->throws(Illuminate\Database\Eloquent\ModelNotFoundException::class); diff --git a/tests/Feature/Storefront/PageShowTest.php b/tests/Feature/Storefront/PageShowTest.php new file mode 100644 index 00000000..aa2f5380 --- /dev/null +++ b/tests/Feature/Storefront/PageShowTest.php @@ -0,0 +1,37 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('renders a published page body', function (): void { + Page::factory()->for($this->store)->create([ + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

We make good things.

', + ]); + + Livewire::test(Show::class, ['handle' => 'about']) + ->assertStatus(200) + ->assertSee('About Us') + ->assertSee('We make good things.', false); +}); + +it('fails for a draft page', function (): void { + Page::factory()->for($this->store)->draft()->create(['handle' => 'hidden']); + + Livewire::test(Show::class, ['handle' => 'hidden']); +})->throws(Illuminate\Database\Eloquent\ModelNotFoundException::class); diff --git a/tests/Feature/Storefront/ProductDetailTest.php b/tests/Feature/Storefront/ProductDetailTest.php new file mode 100644 index 00000000..3e384fd2 --- /dev/null +++ b/tests/Feature/Storefront/ProductDetailTest.php @@ -0,0 +1,70 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('renders a product with its variants', function (): void { + $product = Product::factory()->for($this->store)->create([ + 'title' => 'Wool Coat', + 'handle' => 'wool-coat', + ]); + ProductVariant::factory()->for($product)->create(['price_amount' => 12999]); + + Livewire::test(Show::class, ['handle' => 'wool-coat']) + ->assertStatus(200) + ->assertSee('Wool Coat') + ->assertSee('Add to cart'); +}); + +it('adds the selected variant to the cart', function (): void { + $product = Product::factory()->for($this->store)->create([ + 'title' => 'Canvas Bag', + 'handle' => 'canvas-bag', + ]); + $variant = ProductVariant::factory()->for($product)->create(['price_amount' => 3200]); + InventoryItem::factory() + ->for($this->store) + ->for($variant, 'variant') + ->create(['quantity_on_hand' => 5]); + + Livewire::test(Show::class, ['handle' => 'canvas-bag']) + ->set('quantity', 2) + ->call('addToCart') + ->assertHasNoErrors(); + + $cart = CartSession::current(); + expect($cart)->not->toBeNull() + ->and($cart->lines()->count())->toBe(1) + ->and((int) $cart->lines()->first()->quantity)->toBe(2); +}); + +it('shows an error when inventory is insufficient', function (): void { + $product = Product::factory()->for($this->store)->create(['handle' => 'rare-item']); + $variant = ProductVariant::factory()->for($product)->create(['price_amount' => 1000]); + InventoryItem::factory() + ->for($this->store) + ->for($variant, 'variant') + ->create(['quantity_on_hand' => 1]); + + Livewire::test(Show::class, ['handle' => 'rare-item']) + ->set('quantity', 5) + ->call('addToCart') + ->assertHasErrors('cart'); +}); From ce7cd9dface4012b8f34961ff42d674682f01413 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 21:52:51 +0200 Subject: [PATCH 08/15] Phase 7a: Admin panel core (layout, dashboard, products, orders, customers, collections, discounts) - Admin layout: components/layouts/admin.blade.php using Flux sidebar with grouped navigation, brand, user menu, logout - 13 admin routes protected by auth + store.resolve:admin middleware - bootstrap/app.php redirects /admin guests to admin.login - Livewire admin components: * Dashboard (KPI tiles, recent orders) * Products (Index with search/filter/bulk, Form with single variant) * Orders (Index, Show with fulfillment + refund modals, bank transfer confirm, mark shipped/delivered, cancel) * Customers (Index, Show) * Collections (Index, Form with product picker) * Discounts (Index, Form for code/automatic with rules) - Pest helper loginAsAdmin() in tests/Pest.php - Tests: DashboardTest, ProductManagementTest, OrderManagementTest, CustomerManagementTest, CollectionManagementTest, DiscountManagementTest (20 new, 212 total passing) Worker also browser-verified the admin pages render with no console errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Livewire/Admin/Collections/Form.php | 132 ++++++++++++++ app/Livewire/Admin/Collections/Index.php | 39 ++++ app/Livewire/Admin/Customers/Index.php | 45 +++++ app/Livewire/Admin/Customers/Show.php | 34 ++++ app/Livewire/Admin/Dashboard.php | 65 +++++++ app/Livewire/Admin/Discounts/Form.php | 109 +++++++++++ app/Livewire/Admin/Discounts/Index.php | 47 +++++ app/Livewire/Admin/Orders/Index.php | 71 ++++++++ app/Livewire/Admin/Orders/Show.php | 153 ++++++++++++++++ app/Livewire/Admin/Products/Form.php | 129 +++++++++++++ app/Livewire/Admin/Products/Index.php | 80 +++++++++ bootstrap/app.php | 4 + .../views/components/layouts/admin.blade.php | 112 ++++++++++++ .../livewire/admin/collections/form.blade.php | 89 +++++++++ .../admin/collections/index.blade.php | 46 +++++ .../livewire/admin/customers/index.blade.php | 37 ++++ .../livewire/admin/customers/show.blade.php | 82 +++++++++ .../views/livewire/admin/dashboard.blade.php | 80 +++++++++ .../livewire/admin/discounts/form.blade.php | 93 ++++++++++ .../livewire/admin/discounts/index.blade.php | 69 +++++++ .../livewire/admin/orders/index.blade.php | 62 +++++++ .../livewire/admin/orders/show.blade.php | 170 ++++++++++++++++++ .../livewire/admin/products/form.blade.php | 98 ++++++++++ .../livewire/admin/products/index.blade.php | 67 +++++++ routes/web.php | 25 +++ .../Admin/CollectionManagementTest.php | 42 +++++ .../Feature/Admin/CustomerManagementTest.php | 39 ++++ tests/Feature/Admin/DashboardTest.php | 44 +++++ .../Feature/Admin/DiscountManagementTest.php | 63 +++++++ tests/Feature/Admin/OrderManagementTest.php | 163 +++++++++++++++++ tests/Feature/Admin/ProductManagementTest.php | 95 ++++++++++ tests/Pest.php | 22 ++- 32 files changed, 2404 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Admin/Collections/Form.php create mode 100644 app/Livewire/Admin/Collections/Index.php create mode 100644 app/Livewire/Admin/Customers/Index.php create mode 100644 app/Livewire/Admin/Customers/Show.php create mode 100644 app/Livewire/Admin/Dashboard.php create mode 100644 app/Livewire/Admin/Discounts/Form.php create mode 100644 app/Livewire/Admin/Discounts/Index.php create mode 100644 app/Livewire/Admin/Orders/Index.php create mode 100644 app/Livewire/Admin/Orders/Show.php create mode 100644 app/Livewire/Admin/Products/Form.php create mode 100644 app/Livewire/Admin/Products/Index.php create mode 100644 resources/views/components/layouts/admin.blade.php create mode 100644 resources/views/livewire/admin/collections/form.blade.php create mode 100644 resources/views/livewire/admin/collections/index.blade.php create mode 100644 resources/views/livewire/admin/customers/index.blade.php create mode 100644 resources/views/livewire/admin/customers/show.blade.php create mode 100644 resources/views/livewire/admin/dashboard.blade.php create mode 100644 resources/views/livewire/admin/discounts/form.blade.php create mode 100644 resources/views/livewire/admin/discounts/index.blade.php create mode 100644 resources/views/livewire/admin/orders/index.blade.php create mode 100644 resources/views/livewire/admin/orders/show.blade.php create mode 100644 resources/views/livewire/admin/products/form.blade.php create mode 100644 resources/views/livewire/admin/products/index.blade.php create mode 100644 tests/Feature/Admin/CollectionManagementTest.php create mode 100644 tests/Feature/Admin/CustomerManagementTest.php create mode 100644 tests/Feature/Admin/DashboardTest.php create mode 100644 tests/Feature/Admin/DiscountManagementTest.php create mode 100644 tests/Feature/Admin/OrderManagementTest.php create mode 100644 tests/Feature/Admin/ProductManagementTest.php diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..c2fee0ea --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,132 @@ + */ + public array $productIds = []; + + public string $productSearch = ''; + + public function mount(?Collection $collection = null): void + { + if ($collection !== null && $collection->exists) { + $this->collection = $collection; + $this->mode = 'edit'; + $this->title = (string) $collection->title; + $this->handle = (string) $collection->handle; + $this->description = (string) ($collection->description_html ?? ''); + $this->type = $collection->type->value; + $this->status = $collection->status->value; + $this->productIds = $collection->products()->pluck('products.id')->map(fn ($id) => (int) $id)->all(); + } + } + + public function addProduct(int $productId): void + { + if (! in_array($productId, $this->productIds, true)) { + $this->productIds[] = $productId; + } + } + + public function removeProduct(int $productId): void + { + $this->productIds = array_values(array_filter($this->productIds, fn (int $id): bool => $id !== $productId)); + } + + public function save(): mixed + { + $this->validate(); + + /** @var Store $store */ + $store = app('current_store'); + + if ($this->mode === 'create') { + $handle = $this->handle !== '' + ? HandleGenerator::generate($this->handle, 'collections', $store->id) + : HandleGenerator::generate($this->title, 'collections', $store->id); + + $collection = Collection::create([ + 'store_id' => $store->id, + 'title' => $this->title, + 'handle' => $handle, + 'description_html' => $this->description !== '' ? $this->description : null, + 'type' => $this->type, + 'status' => $this->status, + ]); + } else { + $collection = $this->collection; + $handle = $this->handle !== '' && $this->handle !== $collection->handle + ? HandleGenerator::generate($this->handle, 'collections', $store->id, $collection->id) + : $collection->handle; + + $collection->update([ + 'title' => $this->title, + 'handle' => $handle, + 'description_html' => $this->description !== '' ? $this->description : null, + 'type' => $this->type, + 'status' => $this->status, + ]); + } + + $syncData = []; + foreach ($this->productIds as $position => $id) { + $syncData[$id] = ['position' => $position]; + } + $collection->products()->sync($syncData); + + session()->flash('status', 'Collection saved.'); + + return redirect()->route('admin.collections.index'); + } + + public function render(): View + { + $searchResults = $this->productSearch !== '' + ? Product::query() + ->where('title', 'like', '%'.$this->productSearch.'%') + ->whereNotIn('id', $this->productIds) + ->limit(10) + ->get() + : collect(); + + $assignedProducts = $this->productIds === [] + ? collect() + : Product::query()->whereIn('id', $this->productIds)->get(); + + return view('livewire.admin.collections.form', [ + 'searchResults' => $searchResults, + 'assignedProducts' => $assignedProducts, + ]); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..4689e944 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,39 @@ +resetPage(); + } + + public function render(): View + { + $collections = Collection::query() + ->withCount('products') + ->when($this->search !== '', fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')) + ->latest() + ->paginate($this->perPage); + + return view('livewire.admin.collections.index', [ + 'collections' => $collections, + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..f22f6490 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,45 @@ +resetPage(); + } + + public function render(): View + { + $customers = Customer::query() + ->withCount('orders') + ->withSum('orders as total_spent', 'total_amount') + ->when($this->search !== '', function ($q): void { + $q->where(function ($query): void { + $query->where('email', 'like', '%'.$this->search.'%') + ->orWhere('name', 'like', '%'.$this->search.'%'); + }); + }) + ->latest() + ->paginate($this->perPage); + + return view('livewire.admin.customers.index', [ + 'customers' => $customers, + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..72a13848 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,34 @@ +customer = $customer->load(['addresses', 'orders' => fn ($q) => $q->latest()->limit(20)]); + } + + public function render(): View + { + $orders = $this->customer->orders; + + $stats = [ + 'orders_count' => $orders->count(), + 'total_spent' => (int) $orders->sum('total_amount'), + 'average' => $orders->count() > 0 ? (int) round($orders->sum('total_amount') / $orders->count()) : 0, + ]; + + return view('livewire.admin.customers.show', [ + 'stats' => $stats, + ]); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..aa474e8b --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,65 @@ +periodStart(); + + $query = Order::query()->where('placed_at', '>=', $since); + + $totalSales = (int) $query->clone()->sum('total_amount'); + $ordersCount = (int) $query->clone()->count(); + $aov = $ordersCount > 0 ? (int) round($totalSales / $ordersCount) : 0; + + return [ + 'total_sales' => $totalSales, + 'orders_count' => $ordersCount, + 'aov' => $aov, + 'conversion_rate' => null, + ]; + } + + /** + * @return \Illuminate\Support\Collection + */ + #[Computed] + public function recentOrders(): \Illuminate\Support\Collection + { + return Order::query() + ->with('customer') + ->latest('placed_at') + ->limit(10) + ->get(); + } + + protected function periodStart(): DateTimeInterface + { + return match ($this->period) { + '7d' => now()->subDays(7), + '90d' => now()->subDays(90), + default => now()->subDays(30), + }; + } + + public function render(): View + { + return view('livewire.admin.dashboard'); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..cc7bfa1e --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,109 @@ +exists) { + $this->discount = $discount; + $this->mode = 'edit'; + $this->type = $discount->type->value; + $this->code = (string) ($discount->code ?? ''); + $this->valueType = $discount->value_type->value; + $this->valueAmount = (int) $discount->value_amount; + $this->startsAt = $discount->starts_at?->format('Y-m-d\TH:i'); + $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i'); + $this->usageLimit = $discount->usage_limit; + $this->status = $discount->status->value; + $this->minimumPurchase = $discount->rules_json['minimum_purchase'] ?? null; + } + } + + public function save(): mixed + { + $this->validate(); + + if ($this->type === 'code' && $this->code === '') { + $this->addError('code', 'Code is required for code-based discounts.'); + + return null; + } + + /** @var Store $store */ + $store = app('current_store'); + + $rules = []; + if ($this->minimumPurchase !== null && $this->minimumPurchase > 0) { + $rules['minimum_purchase'] = $this->minimumPurchase; + } + + $data = [ + 'store_id' => $store->id, + 'type' => $this->type, + 'code' => $this->type === 'code' ? $this->code : null, + 'value_type' => $this->valueType, + 'value_amount' => $this->valueAmount, + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt, + 'usage_limit' => $this->usageLimit, + 'rules_json' => $rules !== [] ? $rules : null, + 'status' => $this->status, + ]; + + if ($this->mode === 'create') { + Discount::create($data); + } else { + $this->discount->update($data); + } + + session()->flash('status', 'Discount saved.'); + + return redirect()->route('admin.discounts.index'); + } + + public function render(): View + { + return view('livewire.admin.discounts.form'); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..e3b22c20 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,47 @@ +resetPage(); + } + + public function updatingTypeFilter(): void + { + $this->resetPage(); + } + + public function render(): View + { + $discounts = Discount::query() + ->when($this->statusFilter !== '', fn ($q) => $q->where('status', $this->statusFilter)) + ->when($this->typeFilter !== '', fn ($q) => $q->where('type', $this->typeFilter)) + ->latest() + ->paginate($this->perPage); + + return view('livewire.admin.discounts.index', [ + 'discounts' => $discounts, + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..6b04b280 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,71 @@ +resetPage(); + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + } + + public function updatingFinancialFilter(): void + { + $this->resetPage(); + } + + public function updatingFulfillmentFilter(): void + { + $this->resetPage(); + } + + public function render(): View + { + $orders = Order::query() + ->with('customer') + ->when($this->search !== '', function ($q): void { + $q->where(function ($query): void { + $query->where('order_number', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + }); + }) + ->when($this->statusFilter !== '', fn ($q) => $q->where('status', $this->statusFilter)) + ->when($this->financialFilter !== '', fn ($q) => $q->where('financial_status', $this->financialFilter)) + ->when($this->fulfillmentFilter !== '', fn ($q) => $q->where('fulfillment_status', $this->fulfillmentFilter)) + ->latest('placed_at') + ->paginate($this->perPage); + + return view('livewire.admin.orders.index', [ + 'orders' => $orders, + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..e51d55f0 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,153 @@ + */ + public array $fulfillLines = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + public int $refundAmount = 0; + + public string $refundReason = ''; + + public bool $refundRestock = false; + + public string $cancelReason = ''; + + public function mount(Order $order): void + { + $this->order = $order->load(['lines', 'customer', 'payments', 'refunds', 'fulfillments.lines']); + $this->refundAmount = (int) $order->refundableAmount(); + + foreach ($this->order->lines as $line) { + $this->fulfillLines[$line->id] = (int) $line->quantity; + } + } + + public function openFulfillModal(): void + { + $this->showFulfillModal = true; + } + + public function openRefundModal(): void + { + $this->showRefundModal = true; + } + + public function createFulfillment(FulfillmentService $service): void + { + try { + $lines = array_filter(array_map('intval', $this->fulfillLines), fn (int $qty): bool => $qty > 0); + + if ($lines === []) { + $this->addError('fulfill', 'Select at least one line.'); + + return; + } + + $service->create($this->order, $lines, [ + 'company' => $this->trackingCompany !== '' ? $this->trackingCompany : null, + 'number' => $this->trackingNumber !== '' ? $this->trackingNumber : null, + 'url' => $this->trackingUrl !== '' ? $this->trackingUrl : null, + ]); + + $this->showFulfillModal = false; + $this->order->refresh()->load(['lines', 'fulfillments.lines']); + session()->flash('status', 'Fulfillment created.'); + } catch (\Throwable $exception) { + $this->addError('fulfill', $exception->getMessage()); + } + } + + public function markShipped(int $fulfillmentId, FulfillmentService $service): void + { + $fulfillment = $this->order->fulfillments()->findOrFail($fulfillmentId); + $service->markAsShipped($fulfillment); + $this->order->refresh()->load('fulfillments.lines'); + } + + public function markDelivered(int $fulfillmentId, FulfillmentService $service): void + { + $fulfillment = $this->order->fulfillments()->findOrFail($fulfillmentId); + $service->markAsDelivered($fulfillment); + $this->order->refresh()->load('fulfillments.lines'); + } + + public function createRefund(RefundService $service): void + { + $payment = $this->order->payments()->latest('id')->first(); + + if ($payment === null) { + $this->addError('refund', 'No payment to refund.'); + + return; + } + + try { + $service->create( + $this->order, + $payment, + $this->refundAmount, + $this->refundReason !== '' ? $this->refundReason : null, + $this->refundRestock, + ); + + $this->showRefundModal = false; + $this->order->refresh()->load(['lines', 'payments', 'refunds']); + session()->flash('status', 'Refund processed.'); + } catch (InvalidArgumentException $exception) { + $this->addError('refund', $exception->getMessage()); + } + } + + public function confirmBankTransfer(OrderService $service): void + { + try { + $service->confirmBankTransferPayment($this->order); + $this->order->refresh(); + session()->flash('status', 'Payment confirmed.'); + } catch (DomainException $exception) { + $this->addError('order', $exception->getMessage()); + } + } + + public function cancelOrder(OrderService $service): void + { + try { + $service->cancel($this->order, $this->cancelReason !== '' ? $this->cancelReason : null); + $this->order->refresh(); + session()->flash('status', 'Order cancelled.'); + } catch (DomainException $exception) { + $this->addError('order', $exception->getMessage()); + } + } + + public function render(): View + { + return view('livewire.admin.orders.show'); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..7a634e70 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,129 @@ +exists) { + $this->product = $product; + $this->mode = 'edit'; + $this->title = (string) $product->title; + $this->handle = (string) $product->handle; + $this->description = (string) ($product->description_html ?? ''); + $this->vendor = (string) ($product->vendor ?? ''); + $this->productType = (string) ($product->product_type ?? ''); + $this->status = $product->status->value; + $this->tagsInput = implode(', ', $product->tags ?? []); + + $defaultVariant = $product->variants()->where('is_default', true)->first() + ?? $product->variants()->first(); + + if ($defaultVariant !== null) { + $this->priceAmount = (int) $defaultVariant->price_amount; + $this->sku = (string) ($defaultVariant->sku ?? ''); + } + } + } + + public function save(ProductService $service): mixed + { + $this->validate(); + + /** @var Store $store */ + $store = app('current_store'); + + $tags = array_values(array_filter(array_map('trim', explode(',', $this->tagsInput)))); + + $data = [ + 'title' => $this->title, + 'handle' => $this->handle !== '' ? $this->handle : null, + 'description_html' => $this->description !== '' ? $this->description : null, + 'vendor' => $this->vendor !== '' ? $this->vendor : null, + 'product_type' => $this->productType !== '' ? $this->productType : null, + 'status' => $this->status, + 'tags' => $tags, + ]; + + if ($this->mode === 'create') { + $product = $service->create($store, $data); + + $variant = $product->variants()->where('is_default', true)->first(); + if ($variant !== null) { + $variant->update([ + 'price_amount' => $this->priceAmount, + 'sku' => $this->sku !== '' ? $this->sku : null, + ]); + } + } else { + $product = $service->update($this->product, $data); + $product->update(['status' => $this->status]); + + $variant = $product->variants()->where('is_default', true)->first() + ?? $product->variants()->first(); + + if ($variant !== null) { + $variant->update([ + 'price_amount' => $this->priceAmount, + 'sku' => $this->sku !== '' ? $this->sku : null, + ]); + } + } + + session()->flash('status', 'Product saved.'); + + return redirect()->route('admin.products.index'); + } + + public function render(): View + { + return view('livewire.admin.products.form', [ + 'statuses' => ProductStatus::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..bdf680c9 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,80 @@ + */ + public array $selectedIds = []; + + public function updatingSearch(): void + { + $this->resetPage(); + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + } + + public function bulkArchive(): void + { + if ($this->selectedIds === []) { + return; + } + + Product::query() + ->whereIn('id', $this->selectedIds) + ->update(['status' => ProductStatus::Archived->value]); + + $this->selectedIds = []; + } + + public function bulkDelete(): void + { + if ($this->selectedIds === []) { + return; + } + + Product::query() + ->whereIn('id', $this->selectedIds) + ->where('status', ProductStatus::Draft->value) + ->delete(); + + $this->selectedIds = []; + } + + public function render(): View + { + $products = Product::query() + ->with(['variants' => fn ($q) => $q->where('is_default', true)]) + ->withCount('variants') + ->when($this->search !== '', fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')) + ->when($this->statusFilter !== '', fn ($q) => $q->where('status', $this->statusFilter)) + ->latest() + ->paginate($this->perPage); + + return view('livewire.admin.products.index', [ + 'products' => $products, + ]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index e73cb135..51c5121c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -20,6 +20,10 @@ return route('storefront.account.login'); } + if ($request->is('admin') || $request->is('admin/*')) { + return route('admin.login'); + } + return null; }); }) diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php new file mode 100644 index 00000000..86803467 --- /dev/null +++ b/resources/views/components/layouts/admin.blade.php @@ -0,0 +1,112 @@ +@php + /** @var \App\Models\Store|null $currentStore */ + $currentStore = app()->bound('current_store') ? app('current_store') : null; + $user = auth()->user(); +@endphp + + + + + + + {{ $title ?? 'Shop Admin' }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + @fluxAppearance + + + + + +
+ +
+
+ Shop Admin + @if ($currentStore !== null) + {{ $currentStore->name }} + @endif +
+
+ +
+ + + + + Dashboard + + + Products + + + Collections + + + Customers + + + Discounts + + + + + + Orders + + + + + + + @if ($user !== null) + + + +
+
{{ $user->name }}
+
{{ $user->email }}
+
+ +
+ @csrf + + Log out + +
+
+
+ @endif +
+ + + + + @if ($user !== null) + + + +
+
{{ $user->name }}
+
{{ $user->email }}
+
+ +
+ @csrf + + Log out + +
+
+
+ @endif +
+ + + {{ $slot }} + + + @livewireScripts + @fluxScripts + + diff --git a/resources/views/livewire/admin/collections/form.blade.php b/resources/views/livewire/admin/collections/form.blade.php new file mode 100644 index 00000000..4d80431d --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,89 @@ +
+
+ {{ $mode === 'create' ? 'New collection' : 'Edit collection' }} + Back +
+ +
+
+
+ + Title + + + +
+ + Handle + + + +
+
+ + Description + + +
+
+ +
+ Products +
+ + @if ($searchResults->isNotEmpty()) +
+ @foreach ($searchResults as $product) +
+ {{ $product->title }} + Add +
+ @endforeach +
+ @endif +
+
+ @if ($assignedProducts->isEmpty()) +

No products assigned.

+ @else +
    + @foreach ($assignedProducts as $product) +
  • + {{ $product->title }} + Remove +
  • + @endforeach +
+ @endif +
+
+
+ +
+
+ Settings +
+ + Type + + Manual + Smart + + + + Status + + Draft + Active + + +
+
+
+ +
+ Cancel + Save collection +
+
+
diff --git a/resources/views/livewire/admin/collections/index.blade.php b/resources/views/livewire/admin/collections/index.blade.php new file mode 100644 index 00000000..c49f64a5 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,46 @@ +
+
+ Collections + New collection +
+ + + +
+ @if ($collections->isEmpty()) +
No collections yet.
+ @else + + + Title + Type + Status + Products + + + + @foreach ($collections as $collection) + + + + {{ $collection->title }} + + + {{ $collection->type->value }} + + + {{ $collection->status->value }} + + + {{ $collection->products_count }} + + Edit + + + @endforeach + + +
{{ $collections->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/customers/index.blade.php b/resources/views/livewire/admin/customers/index.blade.php new file mode 100644 index 00000000..e530784c --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,37 @@ +
+ Customers + + + +
+ @if ($customers->isEmpty()) +
No customers found.
+ @else + + + Name + Email + Orders + Total spent + Joined + + + @foreach ($customers as $customer) + + + + {{ $customer->name ?? 'Guest' }} + + + {{ $customer->email }} + {{ $customer->orders_count }} + {{ number_format((int) ($customer->total_spent ?? 0) / 100, 2) }} + {{ $customer->created_at?->format('M d, Y') }} + + @endforeach + + +
{{ $customers->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/customers/show.blade.php b/resources/views/livewire/admin/customers/show.blade.php new file mode 100644 index 00000000..65144e7e --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,82 @@ +
+
+ {{ $customer->name ?? $customer->email }} + Back +
+ +
+
+
+ Contact +
+
{{ $customer->email }}
+
Member since {{ $customer->created_at?->format('M d, Y') }}
+
+
+ +
+ Addresses + @if ($customer->addresses->isEmpty()) +

No addresses on file.

+ @else +
+ @foreach ($customer->addresses as $address) +
+
{{ $address->first_name }} {{ $address->last_name }}
+
{{ $address->address1 }}, {{ $address->city }}, {{ $address->country_code }}
+
+ @endforeach +
+ @endif +
+ +
+ Recent orders + @if ($customer->orders->isEmpty()) +

No orders yet.

+ @else + + + Order + Total + Status + Date + + + @foreach ($customer->orders as $order) + + + {{ $order->order_number }} + + {{ number_format($order->total_amount / 100, 2) }} + {{ $order->financial_status->value }} + {{ $order->placed_at?->format('M d, Y') }} + + @endforeach + + + @endif +
+
+ +
+
+ Lifetime stats +
+
+
Orders
+
{{ $stats['orders_count'] }}
+
+
+
Total spent
+
{{ number_format($stats['total_spent'] / 100, 2) }}
+
+
+
Average order
+
{{ number_format($stats['average'] / 100, 2) }}
+
+
+
+
+
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..4669c134 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,80 @@ +
+
+ Dashboard + + Last 7 days + Last 30 days + Last 90 days + +
+ + @php($kpis = $this->kpis) + +
+
+
Total sales
+
+ {{ number_format($kpis['total_sales'] / 100, 2) }} +
+
+
+
Orders
+
+ {{ $kpis['orders_count'] }} +
+
+
+
Average order value
+
+ {{ number_format($kpis['aov'] / 100, 2) }} +
+
+
+
Conversion rate
+
N/A
+
+
+ +
+ Sales over time +
+ Charts coming soon +
+
+ +
+ Recent orders +
+ @if ($this->recentOrders->isEmpty()) +

No orders yet.

+ @else + + + Order + Customer + Total + Status + + + @foreach ($this->recentOrders as $order) + + + + {{ $order->order_number }} + + + {{ $order->email }} + {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }} + + + {{ $order->financial_status->value }} + + + + @endforeach + + + @endif +
+
+
diff --git a/resources/views/livewire/admin/discounts/form.blade.php b/resources/views/livewire/admin/discounts/form.blade.php new file mode 100644 index 00000000..32394f56 --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,93 @@ +
+
+ {{ $mode === 'create' ? 'New discount' : 'Edit discount' }} + Back +
+ +
+
+
+ Details +
+ + Type + + Code + Automatic + + + + @if ($type === 'code') + + Code + + + + @endif + + + Value type + + Percentage + Fixed amount + Free shipping + + + + @if ($valueType !== 'free_shipping') + + Value {{ $valueType === 'percent' ? '(%)' : '(cents)' }} + + + + @endif + + + Minimum purchase (cents) + + +
+
+ +
+ Active dates +
+ + Starts at + + + + Ends at + + +
+
+
+ +
+
+ Status +
+ + Status + + Draft + Active + Disabled + Expired + + + + Usage limit + + +
+
+
+ +
+ Cancel + Save discount +
+
+
diff --git a/resources/views/livewire/admin/discounts/index.blade.php b/resources/views/livewire/admin/discounts/index.blade.php new file mode 100644 index 00000000..79929ce1 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,69 @@ +
+
+ Discounts + New discount +
+ +
+ + All statuses + Draft + Active + Disabled + Expired + + + All types + Code + Automatic + +
+ +
+ @if ($discounts->isEmpty()) +
No discounts yet.
+ @else + + + Code + Type + Value + Status + Usage + + + + @foreach ($discounts as $discount) + + + + {{ $discount->code ?? 'Automatic' }} + + + {{ $discount->type->value }} + + @if ($discount->value_type->value === 'percent') + {{ $discount->value_amount }}% + @elseif ($discount->value_type->value === 'free_shipping') + Free shipping + @else + {{ number_format($discount->value_amount / 100, 2) }} + @endif + + + + {{ $discount->status->value }} + + + {{ $discount->usage_count }}{{ $discount->usage_limit !== null ? '/'.$discount->usage_limit : '' }} + + Edit + + + @endforeach + + +
{{ $discounts->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/orders/index.blade.php b/resources/views/livewire/admin/orders/index.blade.php new file mode 100644 index 00000000..9c0eb79c --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,62 @@ +
+ Orders + +
+ + + All payments + Pending + Paid + Refunded + Partially refunded + + + All fulfillment + Unfulfilled + Partial + Fulfilled + +
+ +
+ @if ($orders->isEmpty()) +
No orders found.
+ @else + + + Order + Customer + Total + Payment + Fulfillment + Date + + + @foreach ($orders as $order) + + + + {{ $order->order_number }} + + + {{ $order->email }} + {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }} + + + {{ $order->financial_status->value }} + + + + + {{ $order->fulfillment_status->value }} + + + {{ $order->placed_at?->format('M d, Y') }} + + @endforeach + + +
{{ $orders->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php new file mode 100644 index 00000000..79ad90bf --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,170 @@ +
+
+
+ Order {{ $order->order_number }} +
+ + {{ $order->financial_status->value }} + + + {{ $order->fulfillment_status->value }} + + {{ $order->placed_at?->format('M d, Y H:i') }} +
+
+
+ @if ($order->payment_method->value === 'bank_transfer' && $order->financial_status->value === 'pending') + + Confirm payment + + @endif + @if (in_array($order->financial_status->value, ['paid', 'partially_refunded'], true) && $order->fulfillment_status->value !== 'fulfilled') + + Fulfill items + + @endif + @if (in_array($order->financial_status->value, ['paid', 'partially_refunded'], true) && $order->refundableAmount() > 0) + + Refund + + @endif +
+
+ + @if (session()->has('status')) +
+ {{ session('status') }} +
+ @endif + + @error('order') +
{{ $message }}
+ @enderror + +
+
+
+ Items + + + Product + SKU + Qty + Total + + + @foreach ($order->lines as $line) + + {{ $line->title_snapshot }} + {{ $line->sku_snapshot ?? '-' }} + {{ $line->quantity }} + {{ number_format($line->total_amount / 100, 2) }} {{ $order->currency }} + + @endforeach + + +
+
Subtotal{{ number_format($order->subtotal_amount / 100, 2) }}
+
Shipping{{ number_format($order->shipping_amount / 100, 2) }}
+
Tax{{ number_format($order->tax_amount / 100, 2) }}
+
Total{{ number_format($order->total_amount / 100, 2) }}
+
+
+ +
+ Fulfillments + @if ($order->fulfillments->isEmpty()) +

No fulfillments yet.

+ @else +
+ @foreach ($order->fulfillments as $fulfillment) +
+
+
Fulfillment #{{ $fulfillment->id }} {{ $fulfillment->status }}
+
+ @if ($fulfillment->status === 'pending') + Mark shipped + @endif + @if ($fulfillment->status === 'shipped') + Mark delivered + @endif +
+
+
+ @endforeach +
+ @endif +
+
+ +
+
+ Customer +
+
{{ $order->customer?->name ?? $order->email }}
+
{{ $order->email }}
+
+
+ +
+ Payment +
+
Method: {{ $order->payment_method->value }}
+
Status: {{ $order->financial_status->value }}
+ @if ($order->refundedTotal() > 0) +
Refunded: {{ number_format($order->refundedTotal() / 100, 2) }} {{ $order->currency }}
+ @endif +
+
+
+
+ + +
+ Create fulfillment + @error('fulfill') +
{{ $message }}
+ @enderror +
+ @foreach ($order->lines as $line) + + {{ $line->title_snapshot }} (max {{ $line->quantity }}) + + + @endforeach +
+ + Tracking number + + +
+ Cancel + Create +
+
+
+ + +
+ Refund order + @error('refund') +
{{ $message }}
+ @enderror + + Amount (cents) + + + + Reason + + + + + +
+ Cancel + Refund +
+
+
+
diff --git a/resources/views/livewire/admin/products/form.blade.php b/resources/views/livewire/admin/products/form.blade.php new file mode 100644 index 00000000..a2fdb0ff --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,98 @@ +
+
+ {{ $mode === 'create' ? 'New product' : 'Edit product' }} + Back +
+ +
+
+
+ + Title + + + + +
+ + Handle + + + +
+ +
+ + Description + + + +
+
+ +
+ Pricing & Inventory +
+ + Price (cents) + + + + + SKU + + + + + Inventory + + + +
+
+
+ +
+
+ Status +
+ + Status + + @foreach ($statuses as $statusOption) + {{ ucfirst($statusOption->value) }} + @endforeach + + + +
+
+ +
+ Organization +
+ + Vendor + + + + + Product type + + + + + Tags (comma separated) + + + +
+
+
+ +
+ Cancel + Save product +
+
+
diff --git a/resources/views/livewire/admin/products/index.blade.php b/resources/views/livewire/admin/products/index.blade.php new file mode 100644 index 00000000..201c9626 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,67 @@ +
+
+ Products + New product +
+ +
+ + + All statuses + Draft + Active + Archived + +
+ + @if (count($selectedIds) > 0) +
+ {{ count($selectedIds) }} selected + Archive + Delete drafts +
+ @endif + +
+ @if ($products->isEmpty()) +
+ No products match your filters. +
+ @else + + + Title + Status + Vendor + Variants + + + + @foreach ($products as $product) + + + + {{ $product->title }} + + + + + {{ $product->status->value }} + + + {{ $product->vendor ?? '-' }} + {{ $product->variants_count }} + + Edit + + + @endforeach + + + +
+ {{ $products->links() }} +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index e8e4dc81..34654253 100644 --- a/routes/web.php +++ b/routes/web.php @@ -69,4 +69,29 @@ return redirect()->route('admin.login'); })->middleware('auth')->name('admin.logout'); +Route::prefix('admin') + ->middleware(['auth', 'store.resolve:admin']) + ->name('admin.') + ->group(function (): void { + Route::get('/', \App\Livewire\Admin\Dashboard::class)->name('dashboard'); + + Route::get('/products', \App\Livewire\Admin\Products\Index::class)->name('products.index'); + Route::get('/products/create', \App\Livewire\Admin\Products\Form::class)->name('products.create'); + Route::get('/products/{product}/edit', \App\Livewire\Admin\Products\Form::class)->name('products.edit'); + + Route::get('/orders', \App\Livewire\Admin\Orders\Index::class)->name('orders.index'); + Route::get('/orders/{order}', \App\Livewire\Admin\Orders\Show::class)->name('orders.show'); + + Route::get('/customers', \App\Livewire\Admin\Customers\Index::class)->name('customers.index'); + Route::get('/customers/{customer}', \App\Livewire\Admin\Customers\Show::class)->name('customers.show'); + + Route::get('/collections', \App\Livewire\Admin\Collections\Index::class)->name('collections.index'); + Route::get('/collections/create', \App\Livewire\Admin\Collections\Form::class)->name('collections.create'); + Route::get('/collections/{collection}/edit', \App\Livewire\Admin\Collections\Form::class)->name('collections.edit'); + + Route::get('/discounts', \App\Livewire\Admin\Discounts\Index::class)->name('discounts.index'); + Route::get('/discounts/create', \App\Livewire\Admin\Discounts\Form::class)->name('discounts.create'); + Route::get('/discounts/{discount}/edit', \App\Livewire\Admin\Discounts\Form::class)->name('discounts.edit'); + }); + require __DIR__.'/settings.php'; diff --git a/tests/Feature/Admin/CollectionManagementTest.php b/tests/Feature/Admin/CollectionManagementTest.php new file mode 100644 index 00000000..e2cff740 --- /dev/null +++ b/tests/Feature/Admin/CollectionManagementTest.php @@ -0,0 +1,42 @@ +forgetInstance('current_store'); +}); + +it('creates a collection', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(CollectionForm::class) + ->set('title', 'Summer 2026') + ->set('status', 'active') + ->call('save') + ->assertRedirect(route('admin.collections.index')); + + $collection = Collection::where('title', 'Summer 2026')->first(); + expect($collection)->not->toBeNull() + ->and($collection->store_id)->toBe($store->id); +}); + +it('adds products to a collection', function (): void { + [$user, $store] = loginAsAdmin(); + + $product = Product::factory()->create(['store_id' => $store->id]); + + Livewire::test(CollectionForm::class) + ->set('title', 'Featured') + ->set('productIds', [$product->id]) + ->call('save') + ->assertRedirect(route('admin.collections.index')); + + $collection = Collection::where('title', 'Featured')->first(); + expect($collection->products()->count())->toBe(1); +}); diff --git a/tests/Feature/Admin/CustomerManagementTest.php b/tests/Feature/Admin/CustomerManagementTest.php new file mode 100644 index 00000000..7953525b --- /dev/null +++ b/tests/Feature/Admin/CustomerManagementTest.php @@ -0,0 +1,39 @@ +forgetInstance('current_store'); +}); + +it('lists customers for the current store', function (): void { + [$user, $store] = loginAsAdmin(); + + Customer::factory()->create([ + 'store_id' => $store->id, + 'email' => 'one@example.com', + ]); + + Livewire::test(CustomersIndex::class) + ->assertSee('one@example.com'); +}); + +it('shows customer detail', function (): void { + [$user, $store] = loginAsAdmin(); + + $customer = Customer::factory()->create([ + 'store_id' => $store->id, + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + ]); + + Livewire::test(CustomersShow::class, ['customer' => $customer]) + ->assertSee('Jane Doe') + ->assertSee('jane@example.com'); +}); diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..f55a072a --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,44 @@ +forgetInstance('current_store'); +}); + +it('redirects guests away from the admin dashboard', function (): void { + $this->get('/admin')->assertRedirect(); +}); + +it('renders the admin dashboard for an authenticated admin', function (): void { + loginAsAdmin(); + + $this->get('/admin') + ->assertOk() + ->assertSeeLivewire(Dashboard::class) + ->assertSee('Dashboard'); +}); + +it('computes KPIs from recent orders', function (): void { + [$user, $store] = loginAsAdmin(); + + Order::factory()->count(3)->create([ + 'store_id' => $store->id, + 'total_amount' => 5000, + 'placed_at' => now()->subDays(2), + ]); + + $component = Livewire::test(Dashboard::class) + ->assertSet('period', '30d'); + + $kpis = $component->instance()->kpis(); + + expect($kpis['total_sales'])->toBe(15000) + ->and($kpis['orders_count'])->toBe(3) + ->and($kpis['aov'])->toBe(5000); +}); diff --git a/tests/Feature/Admin/DiscountManagementTest.php b/tests/Feature/Admin/DiscountManagementTest.php new file mode 100644 index 00000000..b8a37295 --- /dev/null +++ b/tests/Feature/Admin/DiscountManagementTest.php @@ -0,0 +1,63 @@ +forgetInstance('current_store'); +}); + +it('creates a percent discount', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(DiscountForm::class) + ->set('type', 'code') + ->set('code', 'SAVE10') + ->set('valueType', 'percent') + ->set('valueAmount', 10) + ->set('status', 'active') + ->call('save') + ->assertRedirect(route('admin.discounts.index')); + + $discount = Discount::where('code', 'SAVE10')->first(); + expect($discount)->not->toBeNull() + ->and($discount->value_type->value)->toBe('percent') + ->and($discount->value_amount)->toBe(10); +}); + +it('creates a free shipping discount', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(DiscountForm::class) + ->set('type', 'code') + ->set('code', 'FREESHIP') + ->set('valueType', 'free_shipping') + ->set('valueAmount', 0) + ->set('status', 'active') + ->call('save') + ->assertRedirect(route('admin.discounts.index')); + + $discount = Discount::where('code', 'FREESHIP')->first(); + expect($discount)->not->toBeNull() + ->and($discount->value_type->value)->toBe('free_shipping'); +}); + +it('disables a discount', function (): void { + [$user, $store] = loginAsAdmin(); + + $discount = Discount::factory()->create([ + 'store_id' => $store->id, + 'status' => 'active', + ]); + + Livewire::test(DiscountForm::class, ['discount' => $discount]) + ->set('status', 'disabled') + ->call('save') + ->assertRedirect(route('admin.discounts.index')); + + expect($discount->fresh()->status->value)->toBe('disabled'); +}); diff --git a/tests/Feature/Admin/OrderManagementTest.php b/tests/Feature/Admin/OrderManagementTest.php new file mode 100644 index 00000000..a238145a --- /dev/null +++ b/tests/Feature/Admin/OrderManagementTest.php @@ -0,0 +1,163 @@ +forgetInstance('current_store'); +}); + +function setupPaidOrder(int $storeId): Order +{ + $product = Product::factory()->create(['store_id' => $storeId]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 2500, + 'currency' => 'EUR', + 'is_default' => true, + ]); + InventoryItem::create([ + 'store_id' => $storeId, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $order = Order::factory()->paid()->create([ + 'store_id' => $storeId, + 'total_amount' => 5000, + 'subtotal_amount' => 5000, + ]); + + OrderLine::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + Payment::factory()->captured()->create([ + 'order_id' => $order->id, + 'amount' => 5000, + ]); + + return $order->fresh(['lines', 'payments']); +} + +it('lists orders in the admin index', function (): void { + [$user, $store] = loginAsAdmin(); + + Order::factory()->create([ + 'store_id' => $store->id, + 'order_number' => '#9001', + 'email' => 'buyer@example.com', + ]); + + Livewire::test(OrdersIndex::class) + ->assertSee('#9001') + ->assertSee('buyer@example.com'); +}); + +it('shows an order detail page', function (): void { + [$user, $store] = loginAsAdmin(); + + $order = setupPaidOrder($store->id); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->assertSee($order->order_number); +}); + +it('creates a fulfillment for a paid order', function (): void { + [$user, $store] = loginAsAdmin(); + + $order = setupPaidOrder($store->id); + $line = $order->lines->first(); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->set('fulfillLines.'.$line->id, (int) $line->quantity) + ->call('createFulfillment'); + + expect($order->fresh()->fulfillments()->count())->toBe(1); +}); + +it('refunds an order', function (): void { + [$user, $store] = loginAsAdmin(); + + $order = setupPaidOrder($store->id); + + Livewire::test(OrdersShow::class, ['order' => $order]) + ->set('refundAmount', 1000) + ->set('refundReason', 'Customer request') + ->call('createRefund'); + + expect($order->fresh()->refunds()->count())->toBe(1) + ->and($order->fresh()->refundedTotal())->toBe(1000); +}); + +it('confirms a bank transfer payment', function (): void { + [$user, $store] = loginAsAdmin(); + + $product = Product::factory()->create(['store_id' => $store->id]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'is_default' => true, + ]); + InventoryItem::create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 5, + 'quantity_reserved' => 1, + 'policy' => 'deny', + ]); + + $order = Order::factory()->create([ + 'store_id' => $store->id, + 'payment_method' => PaymentMethod::BankTransfer->value, + 'status' => OrderStatus::Pending->value, + 'financial_status' => FinancialStatus::Pending->value, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled->value, + ]); + + OrderLine::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + ]); + + Payment::factory()->create([ + 'order_id' => $order->id, + 'method' => PaymentMethod::BankTransfer->value, + 'status' => PaymentStatus::Pending->value, + 'amount' => 2500, + ]); + + Livewire::test(OrdersShow::class, ['order' => $order->fresh(['lines', 'payments'])]) + ->call('confirmBankTransfer'); + + expect($order->fresh()->financial_status->value)->toBe('paid'); +}); diff --git a/tests/Feature/Admin/ProductManagementTest.php b/tests/Feature/Admin/ProductManagementTest.php new file mode 100644 index 00000000..802e39ef --- /dev/null +++ b/tests/Feature/Admin/ProductManagementTest.php @@ -0,0 +1,95 @@ +forgetInstance('current_store'); +}); + +it('lists products for the current store only', function (): void { + [$user, $store] = loginAsAdmin(); + + Product::factory()->create(['store_id' => $store->id, 'title' => 'Alpha Shirt']); + + $otherStore = \App\Models\Store::factory()->create(); + Product::factory()->create(['store_id' => $otherStore->id, 'title' => 'Omega Shoes']); + + Livewire::test(ProductIndex::class) + ->assertSee('Alpha Shirt') + ->assertDontSee('Omega Shoes'); +}); + +it('filters products by search term', function (): void { + [$user, $store] = loginAsAdmin(); + + Product::factory()->create(['store_id' => $store->id, 'title' => 'Red Hat']); + Product::factory()->create(['store_id' => $store->id, 'title' => 'Blue Jacket']); + + Livewire::test(ProductIndex::class) + ->set('search', 'Red') + ->assertSee('Red Hat') + ->assertDontSee('Blue Jacket'); +}); + +it('creates a product via the form', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(ProductForm::class) + ->set('title', 'Test Sneaker') + ->set('status', 'active') + ->set('priceAmount', 1999) + ->set('sku', 'TS-001') + ->call('save') + ->assertRedirect(route('admin.products.index')); + + $product = Product::where('title', 'Test Sneaker')->first(); + expect($product)->not->toBeNull() + ->and($product->store_id)->toBe($store->id) + ->and($product->variants()->first()->price_amount)->toBe(1999); +}); + +it('edits an existing product', function (): void { + [$user, $store] = loginAsAdmin(); + + $product = Product::factory()->create([ + 'store_id' => $store->id, + 'title' => 'Old Name', + ]); + $product->variants()->create([ + 'price_amount' => 1000, + 'currency' => 'EUR', + 'is_default' => true, + 'position' => 0, + 'status' => 'active', + ]); + + Livewire::test(ProductForm::class, ['product' => $product]) + ->set('title', 'New Name') + ->set('priceAmount', 2500) + ->call('save') + ->assertRedirect(route('admin.products.index')); + + expect($product->fresh()->title)->toBe('New Name'); +}); + +it('archives selected products in bulk', function (): void { + [$user, $store] = loginAsAdmin(); + + $product = Product::factory()->create([ + 'store_id' => $store->id, + 'status' => ProductStatus::Active->value, + ]); + + Livewire::test(ProductIndex::class) + ->set('selectedIds', [$product->id]) + ->call('bulkArchive'); + + expect($product->fresh()->status->value)->toBe('archived'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 98fcd398..e0cb22d6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -43,7 +43,25 @@ | */ -function something() +/** + * @return array{0: \App\Models\User, 1: \App\Models\Store} + */ +function loginAsAdmin(): array { - // .. + $org = \App\Models\Organization::factory()->create(); + $store = \App\Models\Store::factory()->for($org)->create(); + $user = \App\Models\User::factory()->create(); + + \Illuminate\Support\Facades\DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => 'owner', + 'created_at' => now(), + ]); + + \Illuminate\Support\Facades\Auth::guard('web')->login($user); + session(['current_store_id' => $store->id]); + app()->instance('current_store', $store); + + return [$user, $store]; } From 18ff1e69710e8c6ae5b43493bccda62f3adeeeda Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 22:02:57 +0200 Subject: [PATCH 09/15] Phase 8/9/10 backend: search, analytics, apps + webhooks Phase 8 - Search: - Migrations: search_settings, search_queries, products_fts (FTS5 virtual table) - SearchService: syncProduct, removeProduct, search (prefix tokens), autocomplete - ProductObserver registered to keep FTS in sync on create/update/delete - Storefront\Search\Index updated to use SearchService - Tests: SearchTest (7), AutocompleteTest (2) Phase 9 - Analytics: - Migrations: analytics_events (indexed), analytics_daily (composite PK) - Models: AnalyticsEvent (BelongsToStore), AnalyticsDaily (composite key via setKeysForSaveQuery override) - AnalyticsService: track, getDailyMetrics - AggregateAnalytics job: per-store aggregation of orders + events into analytics_daily, scheduled daily 02:00 - Tests: EventIngestionTest, AggregationTest Phase 10 - Apps + Webhooks: - Installed laravel/sanctum, published config + migrations, added HasApiTokens trait to User - Migrations: apps, app_installations, webhook_subscriptions, webhook_deliveries - Models: App, AppInstallation, WebhookSubscription, WebhookDelivery - WebhookService: dispatch, sign (HMAC SHA256), verify - DeliverWebhook job (ShouldQueue, exponential backoff, circuit breaker pauses subscription after 5 failures) - DispatchOrderWebhooks listener wired to OrderCreated, OrderPaid, OrderFulfilled events via Event::listen - API routes: /api/admin/{user,products,orders} protected by auth:sanctum - Tests: WebhookSignatureTest, WebhookDeliveryTest, SanctumTokenTest Total: 234 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Jobs/AggregateAnalytics.php | 73 +++++++++++ app/Jobs/DeliverWebhook.php | 103 ++++++++++++++++ app/Listeners/DispatchOrderWebhooks.php | 39 ++++++ app/Livewire/Storefront/Search/Index.php | 16 +-- app/Models/AnalyticsDaily.php | 68 +++++++++++ app/Models/AnalyticsEvent.php | 38 ++++++ app/Models/App.php | 41 +++++++ app/Models/AppInstallation.php | 56 +++++++++ app/Models/User.php | 3 +- app/Models/WebhookDelivery.php | 46 +++++++ app/Models/WebhookSubscription.php | 43 +++++++ app/Observers/ProductObserver.php | 26 ++++ app/Providers/AppServiceProvider.php | 17 +++ app/Services/AnalyticsService.php | 39 ++++++ app/Services/SearchService.php | 113 ++++++++++++++++++ app/Services/WebhookService.php | 36 ++++++ bootstrap/app.php | 1 + composer.json | 1 + composer.lock | 65 +++++++++- config/sanctum.php | 84 +++++++++++++ database/factories/AnalyticsDailyFactory.php | 33 +++++ database/factories/AnalyticsEventFactory.php | 31 +++++ database/factories/AppFactory.php | 31 +++++ database/factories/AppInstallationFactory.php | 30 +++++ database/factories/WebhookDeliveryFactory.php | 31 +++++ .../factories/WebhookSubscriptionFactory.php | 32 +++++ ...12_105001_create_search_settings_table.php | 26 ++++ ..._12_105002_create_search_queries_table.php | 30 +++++ ...04_12_105003_create_products_fts_table.php | 17 +++ ...2_106001_create_analytics_events_table.php | 35 ++++++ ...12_106002_create_analytics_daily_table.php | 32 +++++ ...01_create_personal_access_tokens_table.php | 33 +++++ .../2026_04_12_107002_create_apps_table.php | 32 +++++ ..._107003_create_app_installations_table.php | 39 ++++++ ...004_create_webhook_subscriptions_table.php | 42 +++++++ ...107005_create_webhook_deliveries_table.php | 32 +++++ routes/api.php | 14 +++ routes/console.php | 2 + specs/progress.md | 9 +- tests/Feature/Analytics/AggregationTest.php | 96 +++++++++++++++ .../Feature/Analytics/EventIngestionTest.php | 36 ++++++ tests/Feature/Auth/SanctumTokenTest.php | 32 +++++ tests/Feature/Search/AutocompleteTest.php | 36 ++++++ tests/Feature/Search/SearchTest.php | 90 ++++++++++++++ .../Feature/Webhooks/WebhookDeliveryTest.php | 102 ++++++++++++++++ .../Feature/Webhooks/WebhookSignatureTest.php | 25 ++++ 46 files changed, 1839 insertions(+), 17 deletions(-) create mode 100644 app/Jobs/AggregateAnalytics.php create mode 100644 app/Jobs/DeliverWebhook.php create mode 100644 app/Listeners/DispatchOrderWebhooks.php create mode 100644 app/Models/AnalyticsDaily.php create mode 100644 app/Models/AnalyticsEvent.php create mode 100644 app/Models/App.php create mode 100644 app/Models/AppInstallation.php create mode 100644 app/Models/WebhookDelivery.php create mode 100644 app/Models/WebhookSubscription.php create mode 100644 app/Observers/ProductObserver.php create mode 100644 app/Services/AnalyticsService.php create mode 100644 app/Services/SearchService.php create mode 100644 app/Services/WebhookService.php create mode 100644 config/sanctum.php create mode 100644 database/factories/AnalyticsDailyFactory.php create mode 100644 database/factories/AnalyticsEventFactory.php create mode 100644 database/factories/AppFactory.php create mode 100644 database/factories/AppInstallationFactory.php create mode 100644 database/factories/WebhookDeliveryFactory.php create mode 100644 database/factories/WebhookSubscriptionFactory.php create mode 100644 database/migrations/2026_04_12_105001_create_search_settings_table.php create mode 100644 database/migrations/2026_04_12_105002_create_search_queries_table.php create mode 100644 database/migrations/2026_04_12_105003_create_products_fts_table.php create mode 100644 database/migrations/2026_04_12_106001_create_analytics_events_table.php create mode 100644 database/migrations/2026_04_12_106002_create_analytics_daily_table.php create mode 100644 database/migrations/2026_04_12_107001_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_04_12_107002_create_apps_table.php create mode 100644 database/migrations/2026_04_12_107003_create_app_installations_table.php create mode 100644 database/migrations/2026_04_12_107004_create_webhook_subscriptions_table.php create mode 100644 database/migrations/2026_04_12_107005_create_webhook_deliveries_table.php create mode 100644 routes/api.php create mode 100644 tests/Feature/Analytics/AggregationTest.php create mode 100644 tests/Feature/Analytics/EventIngestionTest.php create mode 100644 tests/Feature/Auth/SanctumTokenTest.php create mode 100644 tests/Feature/Search/AutocompleteTest.php create mode 100644 tests/Feature/Search/SearchTest.php create mode 100644 tests/Feature/Webhooks/WebhookDeliveryTest.php create mode 100644 tests/Feature/Webhooks/WebhookSignatureTest.php diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..09ee1440 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,73 @@ +date !== null + ? CarbonImmutable::parse($this->date)->startOfDay() + : CarbonImmutable::yesterday()->startOfDay(); + + $dateString = $date->toDateString(); + $start = $date->startOfDay(); + $end = $date->endOfDay(); + + Store::query()->each(function (Store $store) use ($dateString, $start, $end): void { + $orders = Order::query() + ->where('store_id', $store->id) + ->whereNotNull('placed_at') + ->whereBetween('placed_at', [$start, $end]) + ->get(['id', 'total_amount']); + + $ordersCount = $orders->count(); + $revenue = (int) $orders->sum('total_amount'); + $aov = $ordersCount > 0 ? (int) round($revenue / $ordersCount) : 0; + + $events = AnalyticsEvent::query() + ->where('store_id', $store->id) + ->whereBetween('occurred_at', [$start, $end]) + ->get(['type', 'session_id']); + + $visits = (int) $events + ->where('type', 'page_view') + ->pluck('session_id') + ->filter() + ->unique() + ->count(); + + $addToCart = (int) $events->where('type', 'add_to_cart')->count(); + $checkoutStarted = (int) $events->where('type', 'checkout_started')->count(); + $checkoutCompleted = (int) $events->where('type', 'checkout_completed')->count(); + + DB::table('analytics_daily')->updateOrInsert( + ['store_id' => $store->id, 'date' => $dateString], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenue, + 'aov_amount' => $aov, + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkoutStarted, + 'checkout_completed_count' => $checkoutCompleted, + ] + ); + }); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..87b0f785 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,103 @@ + + */ + public array $backoff = [60, 300, 1800, 7200, 43200]; + + /** + * @param array $payload + */ + public function __construct( + public readonly WebhookSubscription $subscription, + public readonly string $eventType, + public readonly array $payload, + ) {} + + public function handle(WebhookService $webhookService): void + { + $subscription = $this->subscription->fresh() ?? $this->subscription; + + if ($subscription->status !== 'active') { + return; + } + + $body = json_encode($this->payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''; + $signature = $webhookService->sign($body, (string) $subscription->secret); + $deliveryId = (string) Str::uuid(); + $timestamp = (string) now()->getTimestamp(); + + $delivery = WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_type' => $this->eventType, + 'payload_json' => $this->payload, + 'attempts' => $this->attempts(), + ]); + + $response = null; + + try { + /** @var Response $response */ + $response = Http::timeout(10) + ->withHeaders([ + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $deliveryId, + 'X-Platform-Timestamp' => $timestamp, + 'Content-Type' => 'application/json', + ]) + ->withBody($body, 'application/json') + ->post((string) $subscription->url); + } catch (Throwable $exception) { + $delivery->update([ + 'response_body' => mb_substr($exception->getMessage(), 0, 2000), + ]); + $this->recordFailure($subscription); + throw $exception; + } + + $delivery->update([ + 'response_status' => $response->status(), + 'response_body' => mb_substr((string) $response->body(), 0, 2000), + 'delivered_at' => now(), + ]); + + if ($response->successful()) { + return; + } + + $this->recordFailure($subscription); + throw new \RuntimeException('Webhook responded with non-2xx status: '.$response->status()); + } + + private function recordFailure(WebhookSubscription $subscription): void + { + $subscription->increment('failed_count'); + $subscription->refresh(); + + if ($subscription->failed_count >= 5 && $subscription->status === 'active') { + $subscription->update(['status' => 'paused']); + } + } +} diff --git a/app/Listeners/DispatchOrderWebhooks.php b/app/Listeners/DispatchOrderWebhooks.php new file mode 100644 index 00000000..14ca3991 --- /dev/null +++ b/app/Listeners/DispatchOrderWebhooks.php @@ -0,0 +1,39 @@ +dispatch('order.created', $event->order); + } + + public function handlePaid(OrderPaid $event): void + { + $this->dispatch('order.paid', $event->order); + } + + public function handleFulfilled(OrderFulfilled $event): void + { + $this->dispatch('order.fulfilled', $event->order); + } + + private function dispatch(string $eventType, Order $order): void + { + $this->webhookService->dispatch($order->store, $eventType, [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php index 02f20d92..c437bf97 100644 --- a/app/Livewire/Storefront/Search/Index.php +++ b/app/Livewire/Storefront/Search/Index.php @@ -2,9 +2,9 @@ namespace App\Livewire\Storefront\Search; -use App\Enums\ProductStatus; use App\Livewire\Storefront\Concerns\EnsuresStore; use App\Models\Product; +use App\Services\SearchService; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; @@ -31,17 +31,11 @@ public function updatedQ(): void public function render(): View { - $query = Product::query() - ->where('status', ProductStatus::Active->value) - ->with('variants'); + $store = $this->ensureCurrentStore(); - if (trim($this->q) !== '') { - $query->where('title', 'like', '%'.$this->q.'%'); - } else { - $query->whereRaw('1 = 0'); - } - - $products = $query->orderBy('title')->paginate(12); + $products = trim($this->q) !== '' + ? app(SearchService::class)->search($store, $this->q, [], 12) + : Product::query()->whereRaw('1 = 0')->paginate(12); return view('livewire.storefront.search.index', [ 'products' => $products, diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..aac423a2 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,68 @@ + */ + use BelongsToStore, HasFactory; + + protected $table = 'analytics_daily'; + + public $incrementing = false; + + public $timestamps = false; + + /** + * @var list + */ + protected $primaryKey = ['store_id', 'date']; + + protected $fillable = [ + 'store_id', + 'date', + 'orders_count', + 'revenue_amount', + 'aov_amount', + 'visits_count', + 'add_to_cart_count', + 'checkout_started_count', + 'checkout_completed_count', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'date' => 'date', + ]; + } + + /** + * @param Builder $query + * @return Builder + */ + protected function setKeysForSaveQuery($query) + { + $query->where('store_id', $this->getAttribute('store_id')) + ->where('date', $this->getAttribute('date')); + + return $query; + } + + /** + * @param Builder $query + * @return Builder + */ + protected function setKeysForSelectQuery($query) + { + return $this->setKeysForSaveQuery($query); + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..6e3e6379 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,38 @@ + */ + use BelongsToStore, HasFactory; + + protected $table = 'analytics_events'; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'store_id', + 'type', + 'session_id', + 'customer_id', + 'properties_json', + 'client_event_id', + 'occurred_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + ]; + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..04f28901 --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + protected $table = 'apps'; + + protected $fillable = [ + 'name', + 'slug', + 'description', + 'scopes_json', + 'type', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + ]; + } + + /** + * @return HasMany + */ + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..1dbb12e6 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,56 @@ + */ + use BelongsToStore, HasFactory; + + protected $table = 'app_installations'; + + public const UPDATED_AT = 'updated_at'; + + public const CREATED_AT = null; + + protected $fillable = [ + 'store_id', + 'app_id', + 'status', + 'settings_json', + 'installed_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'installed_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + /** + * @return HasMany + */ + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 34d1c156..8639975f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,11 +10,12 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..9664b700 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + protected $table = 'webhook_deliveries'; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'subscription_id', + 'event_type', + 'payload_json', + 'response_status', + 'response_body', + 'attempts', + 'delivered_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload_json' => 'array', + 'delivered_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..6ff8493e --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,43 @@ + */ + use BelongsToStore, HasFactory; + + protected $table = 'webhook_subscriptions'; + + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'event_type', + 'url', + 'secret', + 'status', + 'failed_count', + ]; + + /** + * @return BelongsTo + */ + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + /** + * @return HasMany + */ + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..7f73064b --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,26 @@ +searchService->syncProduct($product); + } + + public function updated(Product $product): void + { + $this->searchService->syncProduct($product); + } + + public function deleted(Product $product): void + { + $this->searchService->removeProduct($product->id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 58fc6430..bd551751 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,12 @@ use App\Auth\CustomerUserProvider; use App\Contracts\PaymentProvider; +use App\Events\OrderCreated; +use App\Events\OrderFulfilled; +use App\Events\OrderPaid; +use App\Listeners\DispatchOrderWebhooks; +use App\Models\Product; +use App\Observers\ProductObserver; use App\Services\Payments\MockPaymentProvider; use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; @@ -12,6 +18,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -35,6 +42,16 @@ public function boot(): void $this->configureDefaults(); $this->configureRateLimiters(); $this->configureCustomerAuthProvider(); + $this->configureWebhookListeners(); + + Product::observe(ProductObserver::class); + } + + protected function configureWebhookListeners(): void + { + Event::listen(OrderCreated::class, [DispatchOrderWebhooks::class, 'handleCreated']); + Event::listen(OrderPaid::class, [DispatchOrderWebhooks::class, 'handlePaid']); + Event::listen(OrderFulfilled::class, [DispatchOrderWebhooks::class, 'handleFulfilled']); } protected function configureRateLimiters(): void diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..d8cb4289 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,39 @@ + $properties + */ + public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null): AnalyticsEvent + { + return AnalyticsEvent::create([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $properties['client_event_id'] ?? null, + 'occurred_at' => now(), + ]); + } + + /** + * @return Collection + */ + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::query() + ->where('store_id', $store->id) + ->whereBetween('date', [$startDate, $endDate]) + ->orderBy('date') + ->get(); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..5a827589 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,113 @@ +removeProduct($product->id); + + $tags = $product->tags; + $tagString = is_array($tags) ? implode(' ', $tags) : (string) ($tags ?? ''); + + DB::statement( + 'INSERT INTO products_fts (rowid, title, description, vendor, product_type, tags) VALUES (?, ?, ?, ?, ?, ?)', + [ + $product->id, + (string) ($product->title ?? ''), + strip_tags((string) ($product->description_html ?? '')), + (string) ($product->vendor ?? ''), + (string) ($product->product_type ?? ''), + $tagString, + ] + ); + } + + public function removeProduct(int $productId): void + { + DB::statement('DELETE FROM products_fts WHERE rowid = ?', [$productId]); + } + + /** + * @param array $filters + * @return LengthAwarePaginator + */ + public function search(Store $store, string $query, array $filters = [], int $perPage = 12): LengthAwarePaginator + { + $ftsQuery = $this->buildFtsQuery($query); + + if ($ftsQuery === null) { + return Product::query()->whereRaw('1 = 0')->paginate($perPage); + } + + $ids = DB::table('products_fts') + ->whereRaw('products_fts MATCH ?', [$ftsQuery]) + ->pluck('rowid') + ->all(); + + if ($ids === []) { + return Product::query()->whereRaw('1 = 0')->paginate($perPage); + } + + return Product::query() + ->whereIn('id', $ids) + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active->value) + ->orderBy('title') + ->paginate($perPage); + } + + /** + * @return Collection + */ + public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection + { + if (mb_strlen(trim($prefix)) < 2) { + return collect(); + } + + $ftsQuery = $this->buildFtsQuery($prefix); + + if ($ftsQuery === null) { + return collect(); + } + + $ids = DB::table('products_fts') + ->whereRaw('products_fts MATCH ?', [$ftsQuery]) + ->limit($limit * 3) + ->pluck('rowid') + ->all(); + + if ($ids === []) { + return collect(); + } + + return Product::query() + ->whereIn('id', $ids) + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active->value) + ->limit($limit) + ->get(); + } + + private function buildFtsQuery(string $query): ?string + { + $sanitized = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $query) ?? ''; + $tokens = preg_split('/\s+/', trim($sanitized)) ?: []; + $tokens = array_values(array_filter($tokens, fn (string $token): bool => $token !== '')); + + if ($tokens === []) { + return null; + } + + return implode(' ', array_map(fn (string $token): string => $token.'*', $tokens)); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..3a806eef --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,36 @@ + $payload + */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + $subscriptions = WebhookSubscription::query() + ->where('store_id', $store->id) + ->where('event_type', $eventType) + ->where('status', 'active') + ->get(); + + foreach ($subscriptions as $subscription) { + DeliverWebhook::dispatch($subscription, $eventType, $payload); + } + } + + public function sign(string $payload, string $secret): string + { + return 'sha256='.hash_hmac('sha256', $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + return hash_equals($this->sign($payload, $secret), $signature); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 51c5121c..77b29108 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index a578e1d1..5150f1e1 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.9.0", "livewire/livewire": "^4.0" diff --git a/composer.lock b/composer.lock index a1febb14..92524aaa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a73f62d24e65543e17c317a1e9b580fa", + "content-hash": "0d57e8f92f66c4a9fab1ad2cc5623cd8", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1501,6 +1501,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-02-07T17:19:31+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..44527d68 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..fbe3b9e6 --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,33 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + protected $model = AnalyticsDaily::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'date' => now()->toDateString(), + 'orders_count' => 0, + 'revenue_amount' => 0, + 'aov_amount' => 0, + 'visits_count' => 0, + 'add_to_cart_count' => 0, + 'checkout_started_count' => 0, + 'checkout_completed_count' => 0, + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..4ce5095a --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,31 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + protected $model = AnalyticsEvent::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => fake()->randomElement(['page_view', 'add_to_cart', 'checkout_started', 'checkout_completed']), + 'session_id' => fake()->uuid(), + 'customer_id' => null, + 'properties_json' => [], + 'client_event_id' => null, + 'occurred_at' => now(), + ]; + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..4de82a83 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,31 @@ + + */ +class AppFactory extends Factory +{ + protected $model = App::class; + + /** + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->company(); + + return [ + 'name' => $name, + 'slug' => Str::slug($name).'-'.fake()->unique()->randomNumber(5), + 'description' => fake()->sentence(), + 'scopes_json' => ['read_orders', 'write_orders'], + 'type' => fake()->randomElement(['first_party', 'third_party']), + ]; + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..062f4ca0 --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,30 @@ + + */ +class AppInstallationFactory extends Factory +{ + protected $model = AppInstallation::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_id' => App::factory(), + 'status' => 'active', + 'settings_json' => [], + 'installed_at' => now(), + ]; + } +} diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..a0196e20 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,31 @@ + + */ +class WebhookDeliveryFactory extends Factory +{ + protected $model = WebhookDelivery::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_type' => 'order.created', + 'payload_json' => ['order_id' => 1], + 'response_status' => null, + 'response_body' => null, + 'attempts' => 0, + 'delivered_at' => null, + ]; + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..79e2b2a0 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,32 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + protected $model = WebhookSubscription::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => fake()->randomElement(['order.created', 'order.paid', 'order.fulfilled']), + 'url' => fake()->url(), + 'secret' => Str::random(40), + 'status' => 'active', + 'failed_count' => 0, + ]; + } +} diff --git a/database/migrations/2026_04_12_105001_create_search_settings_table.php b/database/migrations/2026_04_12_105001_create_search_settings_table.php new file mode 100644 index 00000000..771cf943 --- /dev/null +++ b/database/migrations/2026_04_12_105001_create_search_settings_table.php @@ -0,0 +1,26 @@ +foreignId('store_id') + ->primary() + ->constrained('stores') + ->cascadeOnDelete(); + $table->text('synonyms_json')->nullable(); + $table->text('stop_words_json')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/migrations/2026_04_12_105002_create_search_queries_table.php b/database/migrations/2026_04_12_105002_create_search_queries_table.php new file mode 100644 index 00000000..b90da15a --- /dev/null +++ b/database/migrations/2026_04_12_105002_create_search_queries_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('query'); + $table->integer('results_count')->default(0); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index('customer_id', 'idx_search_queries_customer_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_04_12_105003_create_products_fts_table.php b/database/migrations/2026_04_12_105003_create_products_fts_table.php new file mode 100644 index 00000000..e0cf85d8 --- /dev/null +++ b/database/migrations/2026_04_12_105003_create_products_fts_table.php @@ -0,0 +1,17 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->text('properties_json')->nullable(); + $table->string('client_event_id')->nullable(); + $table->timestamp('occurred_at'); + $table->timestamp('created_at')->nullable(); + + $table->index(['store_id', 'type', 'occurred_at'], 'idx_analytics_events_store_type_at'); + $table->index(['store_id', 'occurred_at'], 'idx_analytics_events_store_at'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_04_12_106002_create_analytics_daily_table.php b/database/migrations/2026_04_12_106002_create_analytics_daily_table.php new file mode 100644 index 00000000..3971244d --- /dev/null +++ b/database/migrations/2026_04_12_106002_create_analytics_daily_table.php @@ -0,0 +1,32 @@ +foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->date('date'); + $table->integer('orders_count')->default(0); + $table->integer('revenue_amount')->default(0); + $table->integer('aov_amount')->default(0); + $table->integer('visits_count')->default(0); + $table->integer('add_to_cart_count')->default(0); + $table->integer('checkout_started_count')->default(0); + $table->integer('checkout_completed_count')->default(0); + + $table->primary(['store_id', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_04_12_107001_create_personal_access_tokens_table.php b/database/migrations/2026_04_12_107001_create_personal_access_tokens_table.php new file mode 100644 index 00000000..40ff706e --- /dev/null +++ b/database/migrations/2026_04_12_107001_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_04_12_107002_create_apps_table.php b/database/migrations/2026_04_12_107002_create_apps_table.php new file mode 100644 index 00000000..a8bc84c5 --- /dev/null +++ b/database/migrations/2026_04_12_107002_create_apps_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->text('scopes_json')->nullable(); + $table->string('type')->default('first_party'); + $table->timestamps(); + }); + + DB::statement("CREATE TRIGGER apps_type_check BEFORE INSERT ON apps FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('first_party','third_party') THEN RAISE(ABORT, 'invalid type') END; END"); + DB::statement("CREATE TRIGGER apps_type_check_update BEFORE UPDATE ON apps FOR EACH ROW BEGIN SELECT CASE WHEN NEW.type NOT IN ('first_party','third_party') THEN RAISE(ABORT, 'invalid type') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS apps_type_check'); + DB::statement('DROP TRIGGER IF EXISTS apps_type_check_update'); + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_04_12_107003_create_app_installations_table.php b/database/migrations/2026_04_12_107003_create_app_installations_table.php new file mode 100644 index 00000000..a22e0c21 --- /dev/null +++ b/database/migrations/2026_04_12_107003_create_app_installations_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->foreignId('app_id') + ->constrained('apps') + ->cascadeOnDelete(); + $table->string('status')->default('active'); + $table->text('settings_json')->nullable(); + $table->timestamp('installed_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + }); + + DB::statement("CREATE TRIGGER app_installations_status_check BEFORE INSERT ON app_installations FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','paused','uninstalled') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER app_installations_status_check_update BEFORE UPDATE ON app_installations FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','paused','uninstalled') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS app_installations_status_check'); + DB::statement('DROP TRIGGER IF EXISTS app_installations_status_check_update'); + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_04_12_107004_create_webhook_subscriptions_table.php b/database/migrations/2026_04_12_107004_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..b1992c0f --- /dev/null +++ b/database/migrations/2026_04_12_107004_create_webhook_subscriptions_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('store_id') + ->constrained('stores') + ->cascadeOnDelete(); + $table->foreignId('app_installation_id') + ->nullable() + ->constrained('app_installations') + ->nullOnDelete(); + $table->string('event_type'); + $table->string('url'); + $table->string('secret'); + $table->string('status')->default('active'); + $table->integer('failed_count')->default(0); + $table->timestamps(); + + $table->index(['store_id', 'event_type', 'status'], 'idx_webhook_subs_store_event_status'); + $table->index('app_installation_id', 'idx_webhook_subs_installation'); + }); + + DB::statement("CREATE TRIGGER webhook_subs_status_check BEFORE INSERT ON webhook_subscriptions FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','paused','disabled') THEN RAISE(ABORT, 'invalid status') END; END"); + DB::statement("CREATE TRIGGER webhook_subs_status_check_update BEFORE UPDATE ON webhook_subscriptions FOR EACH ROW BEGIN SELECT CASE WHEN NEW.status NOT IN ('active','paused','disabled') THEN RAISE(ABORT, 'invalid status') END; END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS webhook_subs_status_check'); + DB::statement('DROP TRIGGER IF EXISTS webhook_subs_status_check_update'); + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_04_12_107005_create_webhook_deliveries_table.php b/database/migrations/2026_04_12_107005_create_webhook_deliveries_table.php new file mode 100644 index 00000000..b3917973 --- /dev/null +++ b/database/migrations/2026_04_12_107005_create_webhook_deliveries_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('subscription_id') + ->constrained('webhook_subscriptions') + ->cascadeOnDelete(); + $table->string('event_type'); + $table->text('payload_json'); + $table->integer('response_status')->nullable(); + $table->text('response_body')->nullable(); + $table->integer('attempts')->default(0); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['subscription_id', 'created_at'], 'idx_webhook_deliveries_sub_created'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..86f193a5 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,14 @@ +prefix('admin')->group(function (): void { + Route::get('/user', fn (Request $request) => $request->user()); + + Route::get('/products', fn () => Product::query()->paginate()); + + Route::get('/orders', fn () => Order::query()->paginate()); +}); diff --git a/routes/console.php b/routes/console.php index a33a854c..bb9603fc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyFifteenMinutes(); Schedule::job(new CleanupAbandonedCarts)->dailyAt('03:00'); Schedule::job(new CancelUnpaidBankTransferOrders)->dailyAt('04:00'); +Schedule::job(new AggregateAnalytics)->dailyAt('02:00'); diff --git a/specs/progress.md b/specs/progress.md index 2c425970..cfcfe81d 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -10,10 +10,11 @@ Tracking progress for the full shop system implementation (team: shop-v2). - [x] Phase 4: Cart, checkout, discounts, shipping, taxes - 138 tests passing - [x] Phase 5: Payments, orders, fulfillment - 167 tests passing - [x] Phase 6: Customer accounts + storefront UI - 192 tests passing -- [ ] Phase 7: Admin panel -- [ ] Phase 8: Search -- [ ] Phase 9: Analytics -- [ ] Phase 10: Apps and webhooks +- [~] Phase 7a: Admin panel core (dashboard, products, orders, customers, collections, discounts) - 212 tests passing +- [ ] Phase 7b: Admin panel (settings, themes, pages, navigation, analytics, search, apps, developers) +- [~] Phase 8: Search FTS5 backend + storefront wired - 234 tests; admin search settings UI in 7b +- [~] Phase 9: Analytics events + daily aggregator backend; admin analytics UI in 7b +- [~] Phase 10: Apps and webhooks backend (Sanctum, WebhookService, DeliverWebhook job); admin apps/developers UI in 7b - [ ] Phase 11: Polish - [ ] Phase 12: Full test suite execution + browser review diff --git a/tests/Feature/Analytics/AggregationTest.php b/tests/Feature/Analytics/AggregationTest.php new file mode 100644 index 00000000..84e425a7 --- /dev/null +++ b/tests/Feature/Analytics/AggregationTest.php @@ -0,0 +1,96 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->date = CarbonImmutable::parse('2026-04-10'); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('aggregates raw events into a daily row', function (): void { + $at = $this->date->setTime(12, 0, 0); + + Order::factory()->for($this->store)->create([ + 'placed_at' => $at, + 'total_amount' => 5000, + ]); + Order::factory()->for($this->store)->create([ + 'placed_at' => $at, + 'total_amount' => 3000, + ]); + + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'page_view', + 'session_id' => 'sess-1', + 'occurred_at' => $at, + ]); + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'page_view', + 'session_id' => 'sess-2', + 'occurred_at' => $at, + ]); + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'page_view', + 'session_id' => 'sess-1', + 'occurred_at' => $at, + ]); + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'add_to_cart', + 'session_id' => 'sess-1', + 'occurred_at' => $at, + ]); + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'checkout_started', + 'session_id' => 'sess-1', + 'occurred_at' => $at, + ]); + AnalyticsEvent::factory()->for($this->store)->create([ + 'type' => 'checkout_completed', + 'session_id' => 'sess-1', + 'occurred_at' => $at, + ]); + + (new AggregateAnalytics($this->date->toDateString()))->handle(); + + $daily = AnalyticsDaily::query() + ->where('store_id', $this->store->id) + ->where('date', $this->date->toDateString()) + ->first(); + + expect($daily)->not->toBeNull() + ->and((int) $daily->orders_count)->toBe(2) + ->and((int) $daily->revenue_amount)->toBe(8000) + ->and((int) $daily->aov_amount)->toBe(4000) + ->and((int) $daily->visits_count)->toBe(2) + ->and((int) $daily->add_to_cart_count)->toBe(1) + ->and((int) $daily->checkout_started_count)->toBe(1) + ->and((int) $daily->checkout_completed_count)->toBe(1); +}); + +it('handles zero events for a store gracefully', function (): void { + (new AggregateAnalytics($this->date->toDateString()))->handle(); + + $daily = AnalyticsDaily::query() + ->where('store_id', $this->store->id) + ->where('date', $this->date->toDateString()) + ->first(); + + expect($daily)->not->toBeNull() + ->and((int) $daily->orders_count)->toBe(0) + ->and((int) $daily->revenue_amount)->toBe(0) + ->and((int) $daily->aov_amount)->toBe(0) + ->and((int) $daily->visits_count)->toBe(0); +}); diff --git a/tests/Feature/Analytics/EventIngestionTest.php b/tests/Feature/Analytics/EventIngestionTest.php new file mode 100644 index 00000000..f7f65d6f --- /dev/null +++ b/tests/Feature/Analytics/EventIngestionTest.php @@ -0,0 +1,36 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('tracks a page view event', function (): void { + $event = app(AnalyticsService::class)->track($this->store, 'page_view', [], 'session-abc'); + + expect($event)->toBeInstanceOf(AnalyticsEvent::class) + ->and($event->type)->toBe('page_view') + ->and($event->session_id)->toBe('session-abc') + ->and($event->store_id)->toBe($this->store->id); +}); + +it('tracks an add_to_cart event with properties', function (): void { + $properties = ['variant_id' => 42, 'quantity' => 2, 'client_event_id' => 'evt-xyz']; + + $event = app(AnalyticsService::class)->track($this->store, 'add_to_cart', $properties, 'session-xyz'); + + expect($event->type)->toBe('add_to_cart') + ->and($event->properties_json)->toBe($properties) + ->and($event->client_event_id)->toBe('evt-xyz'); +}); diff --git a/tests/Feature/Auth/SanctumTokenTest.php b/tests/Feature/Auth/SanctumTokenTest.php new file mode 100644 index 00000000..74d90cfe --- /dev/null +++ b/tests/Feature/Auth/SanctumTokenTest.php @@ -0,0 +1,32 @@ +create(); + + $token = $user->createToken('test-token'); + + expect($token->plainTextToken)->toBeString() + ->and($user->tokens()->count())->toBe(1); +}); + +it('authenticates an API request with a valid bearer token', function (): void { + $user = User::factory()->create(); + $token = $user->createToken('api')->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/admin/user'); + + $response->assertOk() + ->assertJsonPath('id', $user->id); +}); + +it('rejects an API request without a token', function (): void { + $response = $this->getJson('/api/admin/user'); + + $response->assertStatus(401); +}); diff --git a/tests/Feature/Search/AutocompleteTest.php b/tests/Feature/Search/AutocompleteTest.php new file mode 100644 index 00000000..af02404a --- /dev/null +++ b/tests/Feature/Search/AutocompleteTest.php @@ -0,0 +1,36 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('returns empty collection for a short prefix', function (): void { + Product::factory()->for($this->store)->create(['title' => 'Amazing Product']); + + $results = app(SearchService::class)->autocomplete($this->store, 'A'); + + expect($results)->toHaveCount(0); +}); + +it('returns matches for a valid prefix', function (): void { + Product::factory()->for($this->store)->create(['title' => 'Arctic Parka']); + Product::factory()->for($this->store)->create(['title' => 'Aviator Jacket']); + Product::factory()->for($this->store)->create(['title' => 'Beach Towel']); + + $results = app(SearchService::class)->autocomplete($this->store, 'Ar'); + + expect($results)->toHaveCount(1) + ->and($results->first()->title)->toBe('Arctic Parka'); +}); diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..d639e380 --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,90 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('indexes a product on create', function (): void { + $product = Product::factory()->for($this->store)->create([ + 'title' => 'Arctic Wool Coat', + ]); + + $results = app(SearchService::class)->search($this->store, 'Arctic'); + + expect($results->total())->toBe(1) + ->and($results->first()->id)->toBe($product->id); +}); + +it('removes a product from index on delete', function (): void { + $product = Product::factory()->for($this->store)->create([ + 'title' => 'Desert Sandals', + ]); + + expect(app(SearchService::class)->search($this->store, 'Desert')->total())->toBe(1); + + $product->delete(); + + expect(app(SearchService::class)->search($this->store, 'Desert')->total())->toBe(0); +}); + +it('finds products by title', function (): void { + Product::factory()->for($this->store)->create(['title' => 'Blue Leather Wallet']); + Product::factory()->for($this->store)->create(['title' => 'Red Canvas Bag']); + + $results = app(SearchService::class)->search($this->store, 'Leather'); + + expect($results->total())->toBe(1) + ->and($results->first()->title)->toBe('Blue Leather Wallet'); +}); + +it('finds products by partial (prefix) match', function (): void { + Product::factory()->for($this->store)->create(['title' => 'Mountaineer Jacket']); + + $results = app(SearchService::class)->search($this->store, 'Mount'); + + expect($results->total())->toBe(1); +}); + +it('scopes results to the current store', function (): void { + $otherStore = Store::factory()->create(); + + Product::factory()->for($this->store)->create(['title' => 'Shared Title']); + Product::factory()->for($otherStore)->create(['title' => 'Shared Title']); + + $results = app(SearchService::class)->search($this->store, 'Shared'); + + expect($results->total())->toBe(1) + ->and($results->first()->store_id)->toBe($this->store->id); +}); + +it('returns no results for an empty query', function (): void { + Product::factory()->for($this->store)->create(['title' => 'Anything']); + + $results = app(SearchService::class)->search($this->store, ''); + + expect($results->total())->toBe(0); +}); + +it('excludes non-active products from search results', function (): void { + Product::factory()->for($this->store)->create([ + 'title' => 'Hidden Draft', + 'status' => ProductStatus::Draft->value, + ]); + + $results = app(SearchService::class)->search($this->store, 'Hidden'); + + expect($results->total())->toBe(0); +}); diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..e1aa2514 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,102 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +afterEach(function (): void { + app()->forgetInstance('current_store'); +}); + +it('dispatches a delivery job for each active subscription', function (): void { + Queue::fake(); + + WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'status' => 'active', + ]); + WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'status' => 'active', + ]); + WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'status' => 'paused', + ]); + WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.paid', + 'status' => 'active', + ]); + + app(WebhookService::class)->dispatch($this->store, 'order.created', ['order_id' => 1]); + + Queue::assertPushed(DeliverWebhook::class, 2); +}); + +it('records a successful delivery row', function (): void { + Http::fake([ + '*' => Http::response(['ok' => true], 200), + ]); + + $subscription = WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'url' => 'https://example.test/webhooks/orders', + 'secret' => 'shh', + 'status' => 'active', + ]); + + (new DeliverWebhook($subscription, 'order.created', ['order_id' => 1])) + ->handle(app(WebhookService::class)); + + $delivery = WebhookDelivery::query()->first(); + + expect($delivery)->not->toBeNull() + ->and($delivery->event_type)->toBe('order.created') + ->and($delivery->response_status)->toBe(200) + ->and($delivery->delivered_at)->not->toBeNull(); + + Http::assertSent(function ($request) { + return $request->hasHeader('X-Platform-Signature') + && $request->hasHeader('X-Platform-Event', 'order.created') + && $request->hasHeader('X-Platform-Delivery-Id') + && $request->hasHeader('X-Platform-Timestamp'); + }); +}); + +it('pauses a subscription after five consecutive failures', function (): void { + Http::fake([ + '*' => Http::response('server down', 500), + ]); + + $subscription = WebhookSubscription::factory()->for($this->store)->create([ + 'event_type' => 'order.created', + 'url' => 'https://example.test/broken', + 'secret' => 'shh', + 'status' => 'active', + 'failed_count' => 4, + ]); + + try { + (new DeliverWebhook($subscription, 'order.created', ['x' => 1])) + ->handle(app(WebhookService::class)); + } catch (\Throwable $exception) { + // expected failure from non-2xx response + } + + $subscription->refresh(); + + expect($subscription->failed_count)->toBe(5) + ->and($subscription->status)->toBe('paused'); +}); diff --git a/tests/Feature/Webhooks/WebhookSignatureTest.php b/tests/Feature/Webhooks/WebhookSignatureTest.php new file mode 100644 index 00000000..ed51a5ae --- /dev/null +++ b/tests/Feature/Webhooks/WebhookSignatureTest.php @@ -0,0 +1,25 @@ +sign('{"hello":"world"}', 'secret'); + + expect($signature)->toStartWith('sha256=') + ->and(strlen($signature))->toBe(7 + 64); +}); + +it('verifies a valid signature', function (): void { + $service = app(WebhookService::class); + $payload = '{"a":1}'; + $signature = $service->sign($payload, 'topsecret'); + + expect($service->verify($payload, $signature, 'topsecret'))->toBeTrue(); +}); + +it('rejects an invalid signature', function (): void { + $service = app(WebhookService::class); + $payload = '{"a":1}'; + + expect($service->verify($payload, 'sha256=bogus', 'topsecret'))->toBeFalse(); +}); From 0cc170712e2b47d3c82b497d4f2f62725eafc2e3 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 22:20:14 +0200 Subject: [PATCH 10/15] Phase 7b + seeders: admin completeness, settings, content, dev tools, demo data Admin layout sidebar extended with Content (Pages, Navigation, Themes), Marketing (Analytics), Configuration (Settings, Apps, Developers). Admin Livewire components: - Pages (Index, Form for create/edit, soft delete) - Navigation (Index with menu + items, item type modal, reorder via up/down) - Themes (Index with publish, duplicate, delete) - Analytics (KPI tiles + daily breakdown table over date range, uses AnalyticsService::getDailyMetrics) - Settings (Index general settings, Shipping zones + rates CRUD, Taxes single-row form) - Apps (Index with install/uninstall, Marketplace + Installed sections) - Developers (Sanctum API tokens with create/revoke, webhook subscriptions CRUD) Tests: PagesTest, NavigationTest, SettingsTest, AnalyticsAdminTest, AppsTest, DevelopersTest (35 new, 247 total passing). Seeders (Phase 11): - AdminUserSeeder - admin@shop.test / password - DemoStoreSeeder - Demo Org + Demo Store, shop.test domain, owner admin, default theme, Europe shipping zone, manual VAT - CatalogSeeder - 6 products with variants + inventory, 2 collections - ContentSeeder - 3 pages, main menu with 4 items, 2 discounts - CustomersAndOrdersSeeder - 5 customers, 3 sample orders - DatabaseSeeder orchestrator, all idempotent Verified migrate:fresh --seed runs cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Livewire/Admin/Analytics/Index.php | 49 ++++++ app/Livewire/Admin/Apps/Index.php | 71 ++++++++ app/Livewire/Admin/Developers/Index.php | 100 +++++++++++ app/Livewire/Admin/Navigation/Index.php | 144 ++++++++++++++++ app/Livewire/Admin/Pages/Form.php | 99 +++++++++++ app/Livewire/Admin/Pages/Index.php | 46 +++++ app/Livewire/Admin/Settings/Index.php | 58 +++++++ app/Livewire/Admin/Settings/Shipping.php | 127 ++++++++++++++ app/Livewire/Admin/Settings/Taxes.php | 66 ++++++++ app/Livewire/Admin/Themes/Index.php | 78 +++++++++ database/seeders/AdminUserSeeder.php | 23 +++ database/seeders/CatalogSeeder.php | 160 ++++++++++++++++++ database/seeders/ContentSeeder.php | 98 +++++++++++ database/seeders/CustomersAndOrdersSeeder.php | 107 ++++++++++++ database/seeders/DatabaseSeeder.php | 16 +- database/seeders/DemoStoreSeeder.php | 124 ++++++++++++++ .../views/components/layouts/admin.blade.php | 30 ++++ .../livewire/admin/analytics/index.blade.php | 66 ++++++++ .../views/livewire/admin/apps/index.blade.php | 53 ++++++ .../livewire/admin/developers/index.blade.php | 107 ++++++++++++ .../livewire/admin/navigation/index.blade.php | 101 +++++++++++ .../views/livewire/admin/pages/form.blade.php | 57 +++++++ .../livewire/admin/pages/index.blade.php | 55 ++++++ .../livewire/admin/settings/index.blade.php | 48 ++++++ .../admin/settings/shipping.blade.php | 113 +++++++++++++ .../livewire/admin/settings/taxes.blade.php | 44 +++++ .../livewire/admin/themes/index.blade.php | 48 ++++++ routes/web.php | 16 ++ specs/progress.md | 6 +- tests/Feature/Admin/AnalyticsAdminTest.php | 33 ++++ tests/Feature/Admin/AppsTest.php | 60 +++++++ tests/Feature/Admin/DevelopersTest.php | 38 +++++ tests/Feature/Admin/NavigationTest.php | 48 ++++++ tests/Feature/Admin/PagesTest.php | 58 +++++++ tests/Feature/Admin/SettingsTest.php | 73 ++++++++ 35 files changed, 2407 insertions(+), 13 deletions(-) create mode 100644 app/Livewire/Admin/Analytics/Index.php create mode 100644 app/Livewire/Admin/Apps/Index.php create mode 100644 app/Livewire/Admin/Developers/Index.php create mode 100644 app/Livewire/Admin/Navigation/Index.php create mode 100644 app/Livewire/Admin/Pages/Form.php create mode 100644 app/Livewire/Admin/Pages/Index.php create mode 100644 app/Livewire/Admin/Settings/Index.php create mode 100644 app/Livewire/Admin/Settings/Shipping.php create mode 100644 app/Livewire/Admin/Settings/Taxes.php create mode 100644 app/Livewire/Admin/Themes/Index.php create mode 100644 database/seeders/AdminUserSeeder.php create mode 100644 database/seeders/CatalogSeeder.php create mode 100644 database/seeders/ContentSeeder.php create mode 100644 database/seeders/CustomersAndOrdersSeeder.php create mode 100644 database/seeders/DemoStoreSeeder.php create mode 100644 resources/views/livewire/admin/analytics/index.blade.php create mode 100644 resources/views/livewire/admin/apps/index.blade.php create mode 100644 resources/views/livewire/admin/developers/index.blade.php create mode 100644 resources/views/livewire/admin/navigation/index.blade.php create mode 100644 resources/views/livewire/admin/pages/form.blade.php create mode 100644 resources/views/livewire/admin/pages/index.blade.php create mode 100644 resources/views/livewire/admin/settings/index.blade.php create mode 100644 resources/views/livewire/admin/settings/shipping.blade.php create mode 100644 resources/views/livewire/admin/settings/taxes.blade.php create mode 100644 resources/views/livewire/admin/themes/index.blade.php create mode 100644 tests/Feature/Admin/AnalyticsAdminTest.php create mode 100644 tests/Feature/Admin/AppsTest.php create mode 100644 tests/Feature/Admin/DevelopersTest.php create mode 100644 tests/Feature/Admin/NavigationTest.php create mode 100644 tests/Feature/Admin/PagesTest.php create mode 100644 tests/Feature/Admin/SettingsTest.php diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..32686a95 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,49 @@ +endDate = now()->toDateString(); + $this->startDate = now()->subDays(29)->toDateString(); + } + + public function render(AnalyticsService $analytics): View + { + /** @var Store $store */ + $store = app('current_store'); + + $metrics = $analytics->getDailyMetrics($store, $this->startDate, $this->endDate); + + $totals = [ + 'revenue' => (int) $metrics->sum('revenue_amount'), + 'orders' => (int) $metrics->sum('orders_count'), + 'visits' => (int) $metrics->sum('visits_count'), + 'checkouts_started' => (int) $metrics->sum('checkout_started_count'), + 'checkouts_completed' => (int) $metrics->sum('checkout_completed_count'), + ]; + + $totals['aov'] = $totals['orders'] > 0 + ? (int) round($totals['revenue'] / $totals['orders']) + : 0; + + return view('livewire.admin.analytics.index', [ + 'metrics' => $metrics, + 'totals' => $totals, + 'currency' => $store->default_currency ?? 'EUR', + ]); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..951859af --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,71 @@ +updateOrCreate( + ['store_id' => $store->id, 'app_id' => $appId], + [ + 'status' => 'active', + 'installed_at' => now(), + ] + ); + + session()->flash('status', 'App installed.'); + } + + public function uninstall(int $appId): void + { + /** @var Store $store */ + $store = app('current_store'); + + AppInstallation::query() + ->where('store_id', $store->id) + ->where('app_id', $appId) + ->update(['status' => 'uninstalled']); + + session()->flash('status', 'App uninstalled.'); + } + + public function render(): View + { + /** @var Store $store */ + $store = app('current_store'); + + $installations = AppInstallation::query() + ->where('store_id', $store->id) + ->pluck('status', 'app_id'); + + $apps = App::query() + ->orderBy('name') + ->get() + ->map(function (App $app) use ($installations): App { + $status = $installations->get($app->id); + $app->setAttribute('installation_status', $status); + + return $app; + }); + + $installedApps = $apps->filter(fn (App $app) => $app->getAttribute('installation_status') === 'active')->values(); + $marketplaceApps = $apps->filter(fn (App $app) => $app->getAttribute('installation_status') !== 'active')->values(); + + return view('livewire.admin.apps.index', [ + 'installedApps' => $installedApps, + 'marketplaceApps' => $marketplaceApps, + ]); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..7f54adbe --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,100 @@ +validateOnly('newTokenName'); + + /** @var User $user */ + $user = Auth::user(); + + $token = $user->createToken($this->newTokenName); + $this->plaintextToken = $token->plainTextToken; + $this->newTokenName = ''; + + session()->flash('status', 'API token created. Copy it now; it will not be shown again.'); + } + + public function revokeToken(int $tokenId): void + { + /** @var User $user */ + $user = Auth::user(); + + $user->tokens()->where('id', $tokenId)->delete(); + + session()->flash('status', 'Token revoked.'); + } + + public function createWebhook(): void + { + $this->validate([ + 'webhookEventType' => 'required|string|max:255', + 'webhookUrl' => 'required|string|max:2048', + ]); + + /** @var Store $store */ + $store = app('current_store'); + + WebhookSubscription::create([ + 'store_id' => $store->id, + 'event_type' => $this->webhookEventType, + 'url' => $this->webhookUrl, + 'secret' => Str::random(40), + 'status' => 'active', + 'failed_count' => 0, + ]); + + $this->reset('webhookEventType', 'webhookUrl'); + session()->flash('status', 'Webhook subscription created.'); + } + + public function deleteWebhook(int $webhookId): void + { + $webhook = WebhookSubscription::query()->findOrFail($webhookId); + $webhook->delete(); + + session()->flash('status', 'Webhook deleted.'); + } + + public function render(): View + { + /** @var User $user */ + $user = Auth::user(); + + $tokens = $user->tokens()->orderByDesc('created_at')->get(); + + $webhooks = WebhookSubscription::query() + ->orderByDesc('created_at') + ->get(); + + return view('livewire.admin.developers.index', [ + 'tokens' => $tokens, + 'webhooks' => $webhooks, + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..b4215967 --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,144 @@ +validateOnly('newMenuTitle'); + + /** @var Store $store */ + $store = app('current_store'); + + NavigationMenu::create([ + 'store_id' => $store->id, + 'title' => $this->newMenuTitle, + 'handle' => Str::slug($this->newMenuTitle), + ]); + + $this->reset('newMenuTitle'); + session()->flash('status', 'Menu created.'); + } + + public function deleteMenu(int $menuId): void + { + $menu = NavigationMenu::query()->findOrFail($menuId); + $menu->delete(); + session()->flash('status', 'Menu deleted.'); + } + + public function openItemModal(int $menuId): void + { + $this->activeMenuId = $menuId; + $this->reset('newItemType', 'newItemLabel', 'newItemUrl', 'newItemResourceId'); + $this->newItemType = 'link'; + $this->showItemModal = true; + } + + public function closeItemModal(): void + { + $this->showItemModal = false; + $this->activeMenuId = null; + } + + public function addItem(): void + { + $this->validate([ + 'newItemType' => 'required|string|in:link,page,collection,product', + 'newItemLabel' => 'required|string|max:255', + 'newItemUrl' => 'nullable|string|max:2048', + 'newItemResourceId' => 'nullable|integer', + ]); + + if ($this->activeMenuId === null) { + return; + } + + $menu = NavigationMenu::query()->findOrFail($this->activeMenuId); + + $position = (int) ($menu->items()->max('position') ?? -1) + 1; + + NavigationItem::create([ + 'menu_id' => $menu->id, + 'type' => $this->newItemType, + 'label' => $this->newItemLabel, + 'url' => $this->newItemType === 'link' ? ($this->newItemUrl !== '' ? $this->newItemUrl : null) : null, + 'resource_id' => $this->newItemType !== 'link' ? $this->newItemResourceId : null, + 'position' => $position, + ]); + + $this->closeItemModal(); + session()->flash('status', 'Item added.'); + } + + public function deleteItem(int $itemId): void + { + $item = NavigationItem::query()->findOrFail($itemId); + $item->delete(); + session()->flash('status', 'Item deleted.'); + } + + public function moveItem(int $itemId, string $direction): void + { + $item = NavigationItem::query()->findOrFail($itemId); + + $sibling = NavigationItem::query() + ->where('menu_id', $item->menu_id) + ->when( + $direction === 'up', + fn ($q) => $q->where('position', '<', $item->position)->orderByDesc('position'), + fn ($q) => $q->where('position', '>', $item->position)->orderBy('position'), + ) + ->first(); + + if ($sibling === null) { + return; + } + + $itemPosition = $item->position; + $item->update(['position' => $sibling->position]); + $sibling->update(['position' => $itemPosition]); + } + + public function render(): View + { + $menus = NavigationMenu::query() + ->with('items') + ->orderBy('title') + ->get(); + + return view('livewire.admin.navigation.index', [ + 'menus' => $menus, + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..54e46731 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,99 @@ +exists) { + $this->page = $page; + $this->mode = 'edit'; + $this->title = (string) $page->title; + $this->handle = (string) $page->handle; + $this->bodyHtml = (string) ($page->body_html ?? ''); + $this->status = $page->status->value; + $this->publishedAt = $page->published_at?->format('Y-m-d'); + } + } + + public function save(): mixed + { + $this->validate(); + + /** @var Store $store */ + $store = app('current_store'); + + $publishedAt = null; + if ($this->status === PageStatus::Published->value) { + $publishedAt = $this->publishedAt !== null && $this->publishedAt !== '' + ? $this->publishedAt + : now(); + } + + if ($this->mode === 'create') { + $handle = $this->handle !== '' + ? HandleGenerator::generate($this->handle, 'pages', $store->id) + : HandleGenerator::generate($this->title, 'pages', $store->id); + + Page::create([ + 'store_id' => $store->id, + 'title' => $this->title, + 'handle' => $handle, + 'body_html' => $this->bodyHtml !== '' ? $this->bodyHtml : null, + 'status' => $this->status, + 'published_at' => $publishedAt, + ]); + } else { + $handle = $this->handle !== '' && $this->handle !== $this->page->handle + ? HandleGenerator::generate($this->handle, 'pages', $store->id, $this->page->id) + : $this->page->handle; + + $this->page->update([ + 'title' => $this->title, + 'handle' => $handle, + 'body_html' => $this->bodyHtml !== '' ? $this->bodyHtml : null, + 'status' => $this->status, + 'published_at' => $publishedAt, + ]); + } + + session()->flash('status', 'Page saved.'); + + return redirect()->route('admin.pages.index'); + } + + public function render(): View + { + return view('livewire.admin.pages.form'); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..ab265e25 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,46 @@ +resetPage(); + } + + public function delete(int $pageId): void + { + $page = Page::query()->findOrFail($pageId); + $page->delete(); + + session()->flash('status', 'Page deleted.'); + } + + public function render(): View + { + $pages = Page::query() + ->when($this->search !== '', fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')) + ->latest('updated_at') + ->paginate($this->perPage); + + return view('livewire.admin.pages.index', [ + 'pages' => $pages, + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..4c3887a7 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,58 @@ +name = (string) $store->name; + $this->defaultCurrency = (string) $store->default_currency; + $this->defaultLocale = (string) $store->default_locale; + $this->timezone = (string) $store->timezone; + } + + public function save(): void + { + $this->validate(); + + /** @var Store $store */ + $store = app('current_store'); + + $store->update([ + 'name' => $this->name, + 'default_currency' => strtoupper($this->defaultCurrency), + 'default_locale' => $this->defaultLocale, + 'timezone' => $this->timezone, + ]); + + session()->flash('status', 'Settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.settings.index'); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..469f80b8 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,127 @@ +reset('zoneName', 'zoneCountries'); + $this->showZoneModal = true; + } + + public function createZone(): void + { + $this->validate([ + 'zoneName' => 'required|string|max:255', + 'zoneCountries' => 'nullable|string', + ]); + + /** @var Store $store */ + $store = app('current_store'); + + $countries = array_values(array_filter(array_map( + fn (string $code): string => strtoupper(trim($code)), + explode(',', $this->zoneCountries) + ))); + + ShippingZone::create([ + 'store_id' => $store->id, + 'name' => $this->zoneName, + 'countries_json' => $countries, + 'regions_json' => [], + ]); + + $this->showZoneModal = false; + session()->flash('status', 'Zone created.'); + } + + public function deleteZone(int $zoneId): void + { + $zone = ShippingZone::query()->findOrFail($zoneId); + $zone->delete(); + session()->flash('status', 'Zone deleted.'); + } + + public function openRateModal(int $zoneId): void + { + $this->activeZoneId = $zoneId; + $this->reset('rateName', 'rateType', 'rateAmount'); + $this->rateType = 'flat'; + $this->showRateModal = true; + } + + public function createRate(): void + { + $this->validate([ + 'rateName' => 'required|string|max:255', + 'rateType' => 'required|string|in:flat,weight,price,carrier', + 'rateAmount' => 'nullable|integer|min:0', + ]); + + if ($this->activeZoneId === null) { + return; + } + + ShippingRate::create([ + 'zone_id' => $this->activeZoneId, + 'name' => $this->rateName, + 'type' => $this->rateType, + 'config_json' => ['amount' => $this->rateAmount], + 'is_active' => true, + ]); + + $this->showRateModal = false; + $this->activeZoneId = null; + session()->flash('status', 'Rate added.'); + } + + public function deleteRate(int $rateId): void + { + $rate = ShippingRate::query()->findOrFail($rateId); + $rate->delete(); + session()->flash('status', 'Rate deleted.'); + } + + public function render(): View + { + $zones = ShippingZone::query() + ->with('rates') + ->orderBy('name') + ->get(); + + return view('livewire.admin.settings.shipping', [ + 'zones' => $zones, + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..9c871ead --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,66 @@ +firstOrNew(['store_id' => $store->id]); + + $this->mode = $settings->mode?->value ?? 'manual'; + $this->pricesIncludeTax = (bool) $settings->prices_include_tax; + $config = (array) ($settings->config_json ?? []); + $this->taxName = (string) ($config['name'] ?? ''); + $this->rateBasisPoints = (int) ($config['rate_basis_points'] ?? 0); + } + + public function save(): void + { + $this->validate(); + + /** @var Store $store */ + $store = app('current_store'); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => $this->mode, + 'prices_include_tax' => $this->pricesIncludeTax, + 'config_json' => [ + 'name' => $this->taxName, + 'rate_basis_points' => $this->rateBasisPoints, + ], + ] + ); + + session()->flash('status', 'Tax settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.settings.taxes'); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..51c2b17e --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,78 @@ +where('store_id', $store->id) + ->where('status', ThemeStatus::Published->value) + ->update(['status' => ThemeStatus::Draft->value, 'published_at' => null]); + + Theme::query() + ->where('store_id', $store->id) + ->where('id', $themeId) + ->update(['status' => ThemeStatus::Published->value, 'published_at' => now()]); + }); + + session()->flash('status', 'Theme published.'); + } + + public function duplicate(int $themeId): void + { + /** @var Store $store */ + $store = app('current_store'); + + $source = Theme::query()->where('store_id', $store->id)->findOrFail($themeId); + + Theme::create([ + 'store_id' => $store->id, + 'name' => $source->name.' Copy', + 'version' => $source->version, + 'status' => ThemeStatus::Draft->value, + ]); + + session()->flash('status', 'Theme duplicated.'); + } + + public function delete(int $themeId): void + { + $theme = Theme::query()->findOrFail($themeId); + + if ($theme->status === ThemeStatus::Published) { + session()->flash('status', 'Cannot delete the published theme.'); + + return; + } + + $theme->delete(); + session()->flash('status', 'Theme deleted.'); + } + + public function render(): View + { + $themes = Theme::query() + ->orderByDesc('status') + ->orderBy('name') + ->get(); + + return view('livewire.admin.themes.index', [ + 'themes' => $themes, + ]); + } +} diff --git a/database/seeders/AdminUserSeeder.php b/database/seeders/AdminUserSeeder.php new file mode 100644 index 00000000..a0461ad0 --- /dev/null +++ b/database/seeders/AdminUserSeeder.php @@ -0,0 +1,23 @@ +firstOrCreate( + ['email' => 'admin@shop.test'], + [ + 'name' => 'Shop Admin', + 'password' => Hash::make('password'), + 'status' => 'active', + 'email_verified_at' => now(), + ] + ); + } +} diff --git a/database/seeders/CatalogSeeder.php b/database/seeders/CatalogSeeder.php new file mode 100644 index 00000000..0673bbc8 --- /dev/null +++ b/database/seeders/CatalogSeeder.php @@ -0,0 +1,160 @@ +}> */ + private array $products = [ + [ + 'title' => 'Classic Tee', + 'handle' => 'classic-tee', + 'vendor' => 'Demo Brand', + 'type' => 'Apparel', + 'variants' => [ + ['sku' => 'TEE-S', 'price' => 1999], + ['sku' => 'TEE-M', 'price' => 1999], + ['sku' => 'TEE-L', 'price' => 1999], + ], + ], + [ + 'title' => 'Hoodie', + 'handle' => 'hoodie', + 'vendor' => 'Demo Brand', + 'type' => 'Apparel', + 'variants' => [ + ['sku' => 'HOOD-M', 'price' => 4999], + ['sku' => 'HOOD-L', 'price' => 4999], + ], + ], + [ + 'title' => 'Cap', + 'handle' => 'cap', + 'vendor' => 'Demo Brand', + 'type' => 'Accessories', + 'variants' => [ + ['sku' => 'CAP-001', 'price' => 2499], + ], + ], + [ + 'title' => 'Tote Bag', + 'handle' => 'tote-bag', + 'vendor' => 'Demo Brand', + 'type' => 'Accessories', + 'variants' => [ + ['sku' => 'TOTE-001', 'price' => 1499], + ], + ], + [ + 'title' => 'Sneakers', + 'handle' => 'sneakers', + 'vendor' => 'Demo Brand', + 'type' => 'Footwear', + 'variants' => [ + ['sku' => 'SNK-42', 'price' => 7999], + ['sku' => 'SNK-43', 'price' => 7999], + ['sku' => 'SNK-44', 'price' => 7999], + ], + ], + [ + 'title' => 'Mug', + 'handle' => 'mug', + 'vendor' => 'Demo Brand', + 'type' => 'Home', + 'variants' => [ + ['sku' => 'MUG-001', 'price' => 999], + ], + ], + ]; + + public function run(): void + { + /** @var Store $store */ + $store = app('current_store'); + + $createdProducts = []; + + foreach ($this->products as $data) { + $product = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('handle', $data['handle']) + ->first(); + + if ($product === null) { + $product = Product::create([ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $data['handle'], + 'status' => 'active', + 'description_html' => '

'.$data['title'].' description.

', + 'vendor' => $data['vendor'], + 'product_type' => $data['type'], + 'tags' => [$data['type'], $data['vendor']], + 'published_at' => now(), + ]); + + foreach ($data['variants'] as $index => $variantData) { + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'sku' => $variantData['sku'], + 'price_amount' => $variantData['price'], + 'currency' => 'EUR', + 'weight_g' => 250, + 'requires_shipping' => true, + 'is_default' => $index === 0, + 'position' => $index, + 'status' => 'active', + ]); + + InventoryItem::create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 50, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + } + } + + $createdProducts[] = $product; + } + + $featured = Collection::query()->firstOrCreate( + ['store_id' => $store->id, 'handle' => 'featured'], + [ + 'title' => 'Featured', + 'type' => 'manual', + 'status' => 'active', + ] + ); + + $sale = Collection::query()->firstOrCreate( + ['store_id' => $store->id, 'handle' => 'sale'], + [ + 'title' => 'Sale', + 'type' => 'manual', + 'status' => 'active', + ] + ); + + $featuredIds = array_slice(array_map(fn (Product $p): int => $p->id, $createdProducts), 0, 4); + $saleIds = array_slice(array_map(fn (Product $p): int => $p->id, $createdProducts), 2, 4); + + $featured->products()->syncWithoutDetaching(array_combine( + $featuredIds, + array_map(fn (int $position): array => ['position' => $position], array_keys($featuredIds)) + )); + + $sale->products()->syncWithoutDetaching(array_combine( + $saleIds, + array_map(fn (int $position): array => ['position' => $position], array_keys($saleIds)) + )); + } +} diff --git a/database/seeders/ContentSeeder.php b/database/seeders/ContentSeeder.php new file mode 100644 index 00000000..bbca6b6c --- /dev/null +++ b/database/seeders/ContentSeeder.php @@ -0,0 +1,98 @@ + 'about-us', + 'title' => 'About Us', + 'body_html' => '

Welcome

We are a demo store built on the shop platform.

', + ], + [ + 'handle' => 'contact', + 'title' => 'Contact', + 'body_html' => '

Get in touch

Email us at support@shop.test.

', + ], + [ + 'handle' => 'faq', + 'title' => 'FAQ', + 'body_html' => '

Frequently Asked Questions

How do I place an order? Just add products to your cart and check out.

', + ], + ]; + + foreach ($pages as $data) { + Page::query()->firstOrCreate( + ['store_id' => $store->id, 'handle' => $data['handle']], + [ + 'title' => $data['title'], + 'body_html' => $data['body_html'], + 'status' => 'published', + 'published_at' => now(), + ] + ); + } + + $menu = NavigationMenu::query()->firstOrCreate( + ['store_id' => $store->id, 'handle' => 'main-menu'], + ['title' => 'Main Menu'] + ); + + $items = [ + ['label' => 'Home', 'type' => 'link', 'url' => '/', 'resource_id' => null], + ['label' => 'Collections', 'type' => 'link', 'url' => '/collections', 'resource_id' => null], + ['label' => 'About', 'type' => 'link', 'url' => '/pages/about-us', 'resource_id' => null], + ['label' => 'Contact', 'type' => 'link', 'url' => '/pages/contact', 'resource_id' => null], + ]; + + foreach ($items as $position => $item) { + $exists = $menu->items()->where('label', $item['label'])->exists(); + + if (! $exists) { + NavigationItem::create([ + 'menu_id' => $menu->id, + 'type' => $item['type'], + 'label' => $item['label'], + 'url' => $item['url'], + 'resource_id' => $item['resource_id'], + 'position' => $position, + ]); + } + } + + Discount::query()->firstOrCreate( + ['store_id' => $store->id, 'code' => 'WELCOME10'], + [ + 'type' => 'code', + 'value_type' => 'percent', + 'value_amount' => 10, + 'status' => 'active', + 'usage_count' => 0, + ] + ); + + Discount::query()->firstOrCreate( + ['store_id' => $store->id, 'code' => 'FREESHIP'], + [ + 'type' => 'code', + 'value_type' => 'free_shipping', + 'value_amount' => 0, + 'status' => 'active', + 'usage_count' => 0, + ] + ); + } +} diff --git a/database/seeders/CustomersAndOrdersSeeder.php b/database/seeders/CustomersAndOrdersSeeder.php new file mode 100644 index 00000000..e0705cad --- /dev/null +++ b/database/seeders/CustomersAndOrdersSeeder.php @@ -0,0 +1,107 @@ + 'alice@shop.test', 'name' => 'Alice Example'], + ['email' => 'bob@shop.test', 'name' => 'Bob Example'], + ['email' => 'carol@shop.test', 'name' => 'Carol Example'], + ['email' => 'dan@shop.test', 'name' => 'Dan Example'], + ['email' => 'eve@shop.test', 'name' => 'Eve Example'], + ]; + + $customers = []; + + foreach ($customerData as $data) { + $customers[] = Customer::query()->firstOrCreate( + ['store_id' => $store->id, 'email' => $data['email']], + [ + 'name' => $data['name'], + 'password_hash' => Hash::make('password'), + 'marketing_opt_in' => false, + ] + ); + } + + $products = Product::query()->where('store_id', $store->id)->with('variants')->take(3)->get(); + + if ($products->isEmpty()) { + return; + } + + $orderStates = [ + ['number' => 'D-1001', 'status' => 'pending', 'financial' => 'pending', 'fulfillment' => 'unfulfilled'], + ['number' => 'D-1002', 'status' => 'fulfilled', 'financial' => 'paid', 'fulfillment' => 'fulfilled'], + ['number' => 'D-1003', 'status' => 'refunded', 'financial' => 'refunded', 'fulfillment' => 'fulfilled'], + ]; + + foreach ($orderStates as $index => $state) { + $existing = Order::query() + ->where('store_id', $store->id) + ->where('order_number', $state['number']) + ->first(); + + if ($existing !== null) { + continue; + } + + $customer = $customers[$index]; + $product = $products[$index % $products->count()]; + $variant = $product->variants->first(); + + if ($variant === null) { + continue; + } + + $unitPrice = (int) $variant->price_amount; + $quantity = 1; + $lineTotal = $unitPrice * $quantity; + $shipping = 599; + $total = $lineTotal + $shipping; + + $order = Order::create([ + 'store_id' => $store->id, + 'customer_id' => $customer->id, + 'order_number' => $state['number'], + 'payment_method' => 'credit_card', + 'status' => $state['status'], + 'financial_status' => $state['financial'], + 'fulfillment_status' => $state['fulfillment'], + 'currency' => 'EUR', + 'subtotal_amount' => $lineTotal, + 'discount_amount' => 0, + 'shipping_amount' => $shipping, + 'tax_amount' => 0, + 'total_amount' => $total, + 'email' => $customer->email, + 'placed_at' => now()->subDays($index + 1), + ]); + + OrderLine::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $lineTotal, + ]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..be7c0009 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,18 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + AdminUserSeeder::class, + DemoStoreSeeder::class, + CatalogSeeder::class, + ContentSeeder::class, + CustomersAndOrdersSeeder::class, ]); } } diff --git a/database/seeders/DemoStoreSeeder.php b/database/seeders/DemoStoreSeeder.php new file mode 100644 index 00000000..e86da081 --- /dev/null +++ b/database/seeders/DemoStoreSeeder.php @@ -0,0 +1,124 @@ +firstOrCreate( + ['billing_email' => 'demo@shop.test'], + ['name' => 'Demo Org'] + ); + + $store = Store::query()->firstOrCreate( + ['handle' => 'demo'], + [ + 'organization_id' => $organization->id, + 'name' => 'Demo Store', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ] + ); + + app()->instance('current_store', $store); + + StoreDomain::query()->firstOrCreate( + ['hostname' => 'shop.test'], + [ + 'store_id' => $store->id, + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => now(), + ] + ); + + $admin = User::query()->where('email', 'admin@shop.test')->first(); + if ($admin !== null) { + $exists = DB::table('store_users') + ->where('store_id', $store->id) + ->where('user_id', $admin->id) + ->exists(); + + if (! $exists) { + DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $admin->id, + 'role' => 'owner', + 'created_at' => now(), + ]); + } + } + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->id], + ['settings_json' => [ + 'support_email' => 'support@shop.test', + 'brand_color' => '#18181b', + ]] + ); + + $theme = Theme::query()->firstOrCreate( + ['store_id' => $store->id, 'name' => 'Default Theme'], + [ + 'version' => '1.0.0', + 'status' => 'published', + 'published_at' => now(), + ] + ); + + ThemeSettings::query()->updateOrCreate( + ['theme_id' => $theme->id], + ['settings_json' => [ + 'primary_color' => '#18181b', + 'accent_color' => '#059669', + 'font_family' => 'Inter', + ]] + ); + + $zone = ShippingZone::query()->firstOrCreate( + ['store_id' => $store->id, 'name' => 'Europe'], + [ + 'countries_json' => ['DE', 'AT', 'CH'], + 'regions_json' => [], + ] + ); + + ShippingRate::query()->firstOrCreate( + ['zone_id' => $zone->id, 'name' => 'Standard'], + [ + 'type' => 'flat', + 'config_json' => ['amount' => 599], + 'is_active' => true, + ] + ); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => 'manual', + 'prices_include_tax' => true, + 'config_json' => [ + 'name' => 'VAT', + 'rate_basis_points' => 1900, + ], + ] + ); + } +} diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index 86803467..3dceff1d 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -55,6 +55,36 @@ Orders + + + + Pages + + + Navigation + + + Themes + + + + + + Analytics + + + + + + Settings + + + Apps + + + Developers + + diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php new file mode 100644 index 00000000..67a04913 --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,66 @@ +
+
+ Analytics +
+ + Start date + + + + End date + + +
+
+ +
+
+

Revenue

+

{{ number_format($totals['revenue'] / 100, 2) }} {{ $currency }}

+
+
+

Orders

+

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

+
+
+

AOV

+

{{ number_format($totals['aov'] / 100, 2) }} {{ $currency }}

+
+
+

Visits

+

{{ number_format($totals['visits']) }}

+
+
+ +
+
+ Daily breakdown +
+ @if ($metrics->isEmpty()) +
No data for this range.
+ @else + + + Date + Orders + Revenue + AOV + Visits + Add to cart + + + @foreach ($metrics as $row) + + {{ $row->date?->toDateString() }} + {{ number_format($row->orders_count) }} + {{ number_format($row->revenue_amount / 100, 2) }} {{ $currency }} + {{ number_format($row->aov_amount / 100, 2) }} {{ $currency }} + {{ number_format($row->visits_count) }} + {{ number_format($row->add_to_cart_count) }} + + @endforeach + + + @endif +
+
diff --git a/resources/views/livewire/admin/apps/index.blade.php b/resources/views/livewire/admin/apps/index.blade.php new file mode 100644 index 00000000..1e18aa0f --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,53 @@ +
+ Apps + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ Installed ({{ $installedApps->count() }}) + @if ($installedApps->isEmpty()) +

No apps installed.

+ @else +
+ @foreach ($installedApps as $app) +
+
+ {{ $app->name }} + installed +
+

{{ $app->description }}

+
+ Uninstall +
+
+ @endforeach +
+ @endif +
+ +
+ Marketplace ({{ $marketplaceApps->count() }}) + @if ($marketplaceApps->isEmpty()) +

No apps available in the marketplace.

+ @else +
+ @foreach ($marketplaceApps as $app) +
+
+ {{ $app->name }} + {{ $app->type }} +
+

{{ $app->description }}

+
+ Install +
+
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php new file mode 100644 index 00000000..e37b44e5 --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,107 @@ +
+ Developers + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ API tokens +

Personal access tokens for the Admin API.

+ +
+ + Token name + + + + Create token +
+ + @if ($plaintextToken !== null) +
+

Copy this token now. It will not be shown again.

+ {{ $plaintextToken }} +
+ @endif + +
+ @if ($tokens->isEmpty()) +

No tokens yet.

+ @else + + + Name + Created + Last used + + + + @foreach ($tokens as $token) + + {{ $token->name }} + {{ $token->created_at?->diffForHumans() }} + {{ $token->last_used_at?->diffForHumans() ?? 'never' }} + + Revoke + + + @endforeach + + + @endif +
+
+ +
+ Webhook subscriptions +

HTTP endpoints notified when events occur.

+ +
+ + Event type + + + + + URL + + + +
+ Add webhook +
+
+ +
+ @if ($webhooks->isEmpty()) +

No webhooks yet.

+ @else + + + Event + URL + Status + + + + @foreach ($webhooks as $webhook) + + {{ $webhook->event_type }} + {{ $webhook->url }} + + {{ $webhook->status }} + + + Delete + + + @endforeach + + + @endif +
+
+
diff --git a/resources/views/livewire/admin/navigation/index.blade.php b/resources/views/livewire/admin/navigation/index.blade.php new file mode 100644 index 00000000..615631de --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,101 @@ +
+
+ Navigation +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ Create menu +
+ + Title + + + + Add menu +
+
+ +
+ @forelse ($menus as $menu) +
+
+
+ {{ $menu->title }} +

{{ $menu->handle }}

+
+
+ Add item + Delete +
+
+ +
+ @if ($menu->items->isEmpty()) +

No items yet.

+ @else +
    + @foreach ($menu->items as $item) +
  • +
    + {{ $item->label }} + ({{ $item->type->value }}) +
    +
    + + + Remove +
    +
  • + @endforeach +
+ @endif +
+
+ @empty +
+ No menus yet. Create one above. +
+ @endforelse +
+ + +
+ Add menu item + + Type + + Link + Page + Collection + Product + + + + Label + + + + @if ($newItemType === 'link') + + URL + + + @else + + Resource ID + + + @endif +
+ Cancel + Add +
+
+
+
diff --git a/resources/views/livewire/admin/pages/form.blade.php b/resources/views/livewire/admin/pages/form.blade.php new file mode 100644 index 00000000..6ab15ce3 --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,57 @@ +
+
+ {{ $mode === 'create' ? 'New page' : 'Edit page' }} + Back +
+ +
+
+
+ + Title + + + +
+ + Handle + + + +
+
+ + Body + + + +
+
+
+ +
+
+ Settings +
+ + Status + + Draft + Published + Archived + + + + Publish date + + +
+
+
+ +
+ Cancel + Save page +
+
+
diff --git a/resources/views/livewire/admin/pages/index.blade.php b/resources/views/livewire/admin/pages/index.blade.php new file mode 100644 index 00000000..14ff3a5c --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,55 @@ +
+
+ Pages + New page +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + + +
+ @if ($pages->isEmpty()) +
No pages yet.
+ @else + + + Title + Handle + Status + Updated + + + + @foreach ($pages as $page) + + + + {{ $page->title }} + + + {{ $page->handle }} + + + {{ $page->status->value }} + + + {{ $page->updated_at?->diffForHumans() }} + +
+ Edit + Delete +
+
+
+ @endforeach +
+
+
{{ $pages->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/settings/index.blade.php b/resources/views/livewire/admin/settings/index.blade.php new file mode 100644 index 00000000..612b6771 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,48 @@ +
+ Settings + + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ General + Shipping + Taxes +
+ +
+ + Store name + + + +
+ + Currency + + + + + Locale + + + + + Timezone + + + +
+
+ Save settings +
+
+ +
+ Notifications +

Notification channel configuration is coming soon.

+
+
diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php new file mode 100644 index 00000000..f3839cf7 --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,113 @@ +
+
+ Shipping +
+ Back + New zone +
+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ @forelse ($zones as $zone) +
+
+
+ {{ $zone->name }} +
+ @foreach (($zone->countries_json ?? []) as $country) + {{ $country }} + @endforeach +
+
+
+ Add rate + Delete +
+
+ +
+ @if ($zone->rates->isEmpty()) +

No rates yet.

+ @else + + + Name + Type + Amount + + + + @foreach ($zone->rates as $rate) + + {{ $rate->name }} + {{ $rate->type->value }} + {{ number_format(($rate->config_json['amount'] ?? 0) / 100, 2) }} + + Remove + + + @endforeach + + + @endif +
+
+ @empty +
+ No shipping zones configured. +
+ @endforelse +
+ + +
+ New shipping zone + + Name + + + + + Countries (comma separated ISO codes) + + +
+ Cancel + Create +
+
+
+ + +
+ New shipping rate + + Name + + + + + Type + + Flat + Weight + Price + + + + Amount (in cents) + + +
+ Cancel + Create +
+
+
+
diff --git a/resources/views/livewire/admin/settings/taxes.blade.php b/resources/views/livewire/admin/settings/taxes.blade.php new file mode 100644 index 00000000..ab7aa0a1 --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,44 @@ +
+
+ Taxes + Back +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ + Mode + + Manual + Provider + + + + + + Tax name + + + + + Rate (basis points) + + 1900 = 19%. Basis points are 1/100 of a percent. + + + + + + Prices include tax + + +
+ Save taxes +
+
+
diff --git a/resources/views/livewire/admin/themes/index.blade.php b/resources/views/livewire/admin/themes/index.blade.php new file mode 100644 index 00000000..d818038c --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,48 @@ +
+
+ Themes +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($themes->isEmpty()) +
+ No themes installed. +
+ @else +
+ @foreach ($themes as $theme) +
$theme->status->value === 'published', + 'border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900' => $theme->status->value !== 'published', + ])> +
+ {{ $theme->name }} + + {{ $theme->status->value }} + +
+

Version {{ $theme->version ?? '1.0' }}

+ @if ($theme->published_at !== null) +

Published {{ $theme->published_at->diffForHumans() }}

+ @endif + +
+ @if ($theme->status->value !== 'published') + Publish + @endif + Duplicate + @if ($theme->status->value !== 'published') + Delete + @endif +
+
+ @endforeach +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 34654253..fa5e7a7a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -92,6 +92,22 @@ Route::get('/discounts', \App\Livewire\Admin\Discounts\Index::class)->name('discounts.index'); Route::get('/discounts/create', \App\Livewire\Admin\Discounts\Form::class)->name('discounts.create'); Route::get('/discounts/{discount}/edit', \App\Livewire\Admin\Discounts\Form::class)->name('discounts.edit'); + + Route::get('/pages', \App\Livewire\Admin\Pages\Index::class)->name('pages.index'); + Route::get('/pages/create', \App\Livewire\Admin\Pages\Form::class)->name('pages.create'); + Route::get('/pages/{page}/edit', \App\Livewire\Admin\Pages\Form::class)->name('pages.edit'); + + Route::get('/navigation', \App\Livewire\Admin\Navigation\Index::class)->name('navigation.index'); + Route::get('/themes', \App\Livewire\Admin\Themes\Index::class)->name('themes.index'); + + Route::get('/analytics', \App\Livewire\Admin\Analytics\Index::class)->name('analytics.index'); + + Route::get('/settings', \App\Livewire\Admin\Settings\Index::class)->name('settings.index'); + Route::get('/settings/shipping', \App\Livewire\Admin\Settings\Shipping::class)->name('settings.shipping'); + Route::get('/settings/taxes', \App\Livewire\Admin\Settings\Taxes::class)->name('settings.taxes'); + + Route::get('/apps', \App\Livewire\Admin\Apps\Index::class)->name('apps.index'); + Route::get('/developers', \App\Livewire\Admin\Developers\Index::class)->name('developers.index'); }); require __DIR__.'/settings.php'; diff --git a/specs/progress.md b/specs/progress.md index cfcfe81d..96eb8f6e 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -10,12 +10,12 @@ Tracking progress for the full shop system implementation (team: shop-v2). - [x] Phase 4: Cart, checkout, discounts, shipping, taxes - 138 tests passing - [x] Phase 5: Payments, orders, fulfillment - 167 tests passing - [x] Phase 6: Customer accounts + storefront UI - 192 tests passing -- [~] Phase 7a: Admin panel core (dashboard, products, orders, customers, collections, discounts) - 212 tests passing -- [ ] Phase 7b: Admin panel (settings, themes, pages, navigation, analytics, search, apps, developers) +- [x] Phase 7a: Admin panel core - 212 tests passing +- [x] Phase 7b: Admin panel (settings, themes, pages, navigation, analytics, apps, developers) - 247 tests passing - [~] Phase 8: Search FTS5 backend + storefront wired - 234 tests; admin search settings UI in 7b - [~] Phase 9: Analytics events + daily aggregator backend; admin analytics UI in 7b - [~] Phase 10: Apps and webhooks backend (Sanctum, WebhookService, DeliverWebhook job); admin apps/developers UI in 7b -- [ ] Phase 11: Polish +- [~] Phase 11: Polish - seeders done; dark mode + accessibility audit pending - [ ] Phase 12: Full test suite execution + browser review ## Log diff --git a/tests/Feature/Admin/AnalyticsAdminTest.php b/tests/Feature/Admin/AnalyticsAdminTest.php new file mode 100644 index 00000000..658a7a38 --- /dev/null +++ b/tests/Feature/Admin/AnalyticsAdminTest.php @@ -0,0 +1,33 @@ +forgetInstance('current_store'); +}); + +it('renders analytics for a date range', function (): void { + [$user, $store] = loginAsAdmin(); + + AnalyticsDaily::create([ + 'store_id' => $store->id, + 'date' => now()->subDays(1)->toDateString(), + 'orders_count' => 5, + 'revenue_amount' => 25000, + 'aov_amount' => 5000, + 'visits_count' => 200, + 'add_to_cart_count' => 30, + 'checkout_started_count' => 10, + 'checkout_completed_count' => 5, + ]); + + Livewire::test(AnalyticsIndex::class) + ->assertSet('endDate', now()->toDateString()) + ->assertSee('Analytics') + ->assertSee('Daily breakdown'); +}); diff --git a/tests/Feature/Admin/AppsTest.php b/tests/Feature/Admin/AppsTest.php new file mode 100644 index 00000000..8841854b --- /dev/null +++ b/tests/Feature/Admin/AppsTest.php @@ -0,0 +1,60 @@ +forgetInstance('current_store'); +}); + +it('installs an app', function (): void { + [$user, $store] = loginAsAdmin(); + + $app = App::create([ + 'name' => 'Analytics Pro', + 'slug' => 'analytics-pro', + 'description' => 'Advanced analytics', + 'type' => 'first_party', + ]); + + Livewire::test(AppsIndex::class) + ->call('install', $app->id); + + $installation = AppInstallation::where('store_id', $store->id) + ->where('app_id', $app->id) + ->first(); + + expect($installation)->not->toBeNull() + ->and($installation->status)->toBe('active'); +}); + +it('uninstalls an app', function (): void { + [$user, $store] = loginAsAdmin(); + + $app = App::create([ + 'name' => 'Demo App', + 'slug' => 'demo-app', + 'type' => 'first_party', + ]); + + AppInstallation::create([ + 'store_id' => $store->id, + 'app_id' => $app->id, + 'status' => 'active', + 'installed_at' => now(), + ]); + + Livewire::test(AppsIndex::class) + ->call('uninstall', $app->id); + + $installation = AppInstallation::where('store_id', $store->id) + ->where('app_id', $app->id) + ->first(); + + expect($installation->status)->toBe('uninstalled'); +}); diff --git a/tests/Feature/Admin/DevelopersTest.php b/tests/Feature/Admin/DevelopersTest.php new file mode 100644 index 00000000..9c3855a9 --- /dev/null +++ b/tests/Feature/Admin/DevelopersTest.php @@ -0,0 +1,38 @@ +forgetInstance('current_store'); +}); + +it('creates an API token', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(DevelopersIndex::class) + ->set('newTokenName', 'CI integration') + ->call('createToken') + ->assertSet('newTokenName', ''); + + expect($user->fresh()->tokens()->count())->toBe(1) + ->and($user->fresh()->tokens()->first()->name)->toBe('CI integration'); +}); + +it('creates a webhook subscription', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(DevelopersIndex::class) + ->set('webhookEventType', 'order.placed') + ->set('webhookUrl', 'https://example.com/webhook') + ->call('createWebhook'); + + $webhook = WebhookSubscription::where('event_type', 'order.placed')->first(); + expect($webhook)->not->toBeNull() + ->and($webhook->store_id)->toBe($store->id) + ->and($webhook->url)->toBe('https://example.com/webhook'); +}); diff --git a/tests/Feature/Admin/NavigationTest.php b/tests/Feature/Admin/NavigationTest.php new file mode 100644 index 00000000..b0acfa2d --- /dev/null +++ b/tests/Feature/Admin/NavigationTest.php @@ -0,0 +1,48 @@ +forgetInstance('current_store'); +}); + +it('creates a navigation menu', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(NavigationIndex::class) + ->set('newMenuTitle', 'Main menu') + ->call('createMenu'); + + $menu = NavigationMenu::where('title', 'Main menu')->first(); + expect($menu)->not->toBeNull() + ->and($menu->store_id)->toBe($store->id) + ->and($menu->handle)->toBe('main-menu'); +}); + +it('adds an item to a menu', function (): void { + [$user, $store] = loginAsAdmin(); + + $menu = NavigationMenu::create([ + 'store_id' => $store->id, + 'title' => 'Main', + 'handle' => 'main', + ]); + + Livewire::test(NavigationIndex::class) + ->call('openItemModal', $menu->id) + ->set('newItemType', 'link') + ->set('newItemLabel', 'Home') + ->set('newItemUrl', '/') + ->call('addItem'); + + $item = NavigationItem::where('menu_id', $menu->id)->first(); + expect($item)->not->toBeNull() + ->and($item->label)->toBe('Home') + ->and($item->url)->toBe('/'); +}); diff --git a/tests/Feature/Admin/PagesTest.php b/tests/Feature/Admin/PagesTest.php new file mode 100644 index 00000000..16fed236 --- /dev/null +++ b/tests/Feature/Admin/PagesTest.php @@ -0,0 +1,58 @@ +forgetInstance('current_store'); +}); + +it('creates a page', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(PageForm::class) + ->set('title', 'About Us') + ->set('bodyHtml', '

Welcome

') + ->set('status', 'published') + ->call('save') + ->assertRedirect(route('admin.pages.index')); + + $page = Page::where('title', 'About Us')->first(); + expect($page)->not->toBeNull() + ->and($page->store_id)->toBe($store->id) + ->and($page->handle)->toBe('about-us') + ->and($page->status->value)->toBe('published'); +}); + +it('edits a page', function (): void { + [$user, $store] = loginAsAdmin(); + + $page = Page::factory()->create([ + 'store_id' => $store->id, + 'title' => 'Draft Title', + 'status' => 'draft', + ]); + + Livewire::test(PageForm::class, ['page' => $page]) + ->set('title', 'New Title') + ->call('save') + ->assertRedirect(route('admin.pages.index')); + + expect($page->fresh()->title)->toBe('New Title'); +}); + +it('deletes a page', function (): void { + [$user, $store] = loginAsAdmin(); + + $page = Page::factory()->create(['store_id' => $store->id]); + + Livewire::test(PageIndex::class) + ->call('delete', $page->id); + + expect(Page::find($page->id))->toBeNull(); +}); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..7ac4c7a2 --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,73 @@ +forgetInstance('current_store'); +}); + +it('saves general settings', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(SettingsIndex::class) + ->set('name', 'Renamed Store') + ->set('defaultCurrency', 'USD') + ->set('defaultLocale', 'en') + ->set('timezone', 'UTC') + ->call('save'); + + expect($store->fresh()->name)->toBe('Renamed Store') + ->and($store->fresh()->default_currency)->toBe('USD'); +}); + +it('creates a shipping zone with a rate', function (): void { + [$user, $store] = loginAsAdmin(); + + $component = Livewire::test(SettingsShipping::class) + ->call('openZoneModal') + ->set('zoneName', 'EU') + ->set('zoneCountries', 'DE, AT, CH') + ->call('createZone'); + + $zone = ShippingZone::where('name', 'EU')->first(); + expect($zone)->not->toBeNull() + ->and($zone->countries_json)->toBe(['DE', 'AT', 'CH']); + + $component + ->call('openRateModal', $zone->id) + ->set('rateName', 'Standard') + ->set('rateType', 'flat') + ->set('rateAmount', 599) + ->call('createRate'); + + $rate = ShippingRate::where('zone_id', $zone->id)->first(); + expect($rate)->not->toBeNull() + ->and($rate->config_json['amount'])->toBe(599); +}); + +it('configures tax settings', function (): void { + [$user, $store] = loginAsAdmin(); + + Livewire::test(SettingsTaxes::class) + ->set('mode', 'manual') + ->set('taxName', 'VAT') + ->set('rateBasisPoints', 1900) + ->set('pricesIncludeTax', true) + ->call('save'); + + $settings = TaxSettings::where('store_id', $store->id)->first(); + expect($settings)->not->toBeNull() + ->and($settings->mode->value)->toBe('manual') + ->and($settings->prices_include_tax)->toBeTrue() + ->and($settings->config_json['rate_basis_points'])->toBe(1900) + ->and($settings->config_json['name'])->toBe('VAT'); +}); From 4af5986e98e68dc0d9324739384cbfe7a527691a Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 22:30:12 +0200 Subject: [PATCH 11/15] Fix bugs surfaced during browser review - OrderService::generateOrderNumber now extracts numeric portion from the # prefix only, ignoring D-prefixed seeded order numbers. New orders for the demo store now correctly start at #1001. - Storefront header Account, Search, and Cart icons are now real links to /account/login (or /account dashboard if logged in), /search, and /cart instead of href="#" or button stubs. - /dashboard route now redirects to /admin (the legacy Fortify view is no longer used). - config/fortify.php home redirect points to /admin so login, registration, and email verification land on the merchant dashboard. - Updated legacy Fortify auth tests to assert the new /admin redirect target. All 247 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Services/OrderService.php | 16 ++++++---------- config/fortify.php | 2 +- .../views/storefront/partials/header.blade.php | 10 +++++----- routes/web.php | 4 +--- tests/Feature/Auth/AuthenticationTest.php | 4 ++-- tests/Feature/Auth/EmailVerificationTest.php | 6 +++--- tests/Feature/Auth/RegistrationTest.php | 4 ++-- tests/Feature/DashboardTest.php | 10 +++++----- 8 files changed, 25 insertions(+), 31 deletions(-) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 4643bb76..d1438d20 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -80,19 +80,15 @@ public function createFromCheckout(Checkout $checkout): Order public function generateOrderNumber(Store $store): string { - /** @var string|null $last */ - $last = Order::withoutGlobalScopes() + $maxNumeric = (int) Order::withoutGlobalScopes() ->where('store_id', $store->id) - ->orderByDesc('id') - ->value('order_number'); + ->where('order_number', 'like', '#%') + ->whereRaw('CAST(SUBSTR(order_number, 2) AS INTEGER) > 0') + ->max(DB::raw('CAST(SUBSTR(order_number, 2) AS INTEGER)')); - if ($last === null) { - return '#1001'; - } - - $num = (int) ltrim($last, '#'); + $next = max(1000, $maxNumeric) + 1; - return '#'.($num + 1); + return '#'.$next; } public function cancel(Order $order, ?string $reason = null): void diff --git a/config/fortify.php b/config/fortify.php index ce67e2c3..555d34fb 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -73,7 +73,7 @@ | */ - 'home' => '/dashboard', + 'home' => '/admin', /* |-------------------------------------------------------------------------- diff --git a/resources/views/storefront/partials/header.blade.php b/resources/views/storefront/partials/header.blade.php index 32ecdb51..e5568be7 100644 --- a/resources/views/storefront/partials/header.blade.php +++ b/resources/views/storefront/partials/header.blade.php @@ -29,18 +29,18 @@ diff --git a/routes/web.php b/routes/web.php index fa5e7a7a..f53f4fee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -53,9 +53,7 @@ Route::get('/addresses', AccountAddressesIndex::class)->name('addresses.index'); }); -Route::view('dashboard', 'dashboard') - ->middleware(['auth', 'verified']) - ->name('dashboard'); +Route::redirect('dashboard', '/admin')->name('dashboard'); Route::get('/admin/login', AdminLogin::class) ->middleware('guest') diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd7..06b152aa 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -21,7 +21,7 @@ $response ->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); + ->assertRedirect('/admin'); $this->assertAuthenticated(); }); @@ -66,4 +66,4 @@ $response->assertRedirect(route('home')); $this->assertGuest(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 66f58e36..3e8866d6 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -31,7 +31,7 @@ Event::assertDispatched(Verified::class); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + $response->assertRedirect('/admin?verified=1'); }); test('email is not verified with invalid hash', function () { @@ -62,8 +62,8 @@ ); $this->actingAs($user)->get($verificationUrl) - ->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + ->assertRedirect('/admin?verified=1'); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); Event::assertNotDispatched(Verified::class); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index c22ea5e1..f429ae4b 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -17,7 +17,7 @@ ]); $response->assertSessionHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); + ->assertRedirect('/admin'); $this->assertAuthenticated(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index fcd0258d..d3a2bb1e 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -4,15 +4,15 @@ uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); -test('guests are redirected to the login page', function () { +test('the legacy dashboard route redirects to the admin home', function () { $response = $this->get(route('dashboard')); - $response->assertRedirect(route('login')); + $response->assertRedirect('/admin'); }); -test('authenticated users can visit the dashboard', function () { +test('authenticated users land on the admin home from the legacy dashboard route', function () { $user = User::factory()->create(); $this->actingAs($user); $response = $this->get(route('dashboard')); - $response->assertOk(); -}); \ No newline at end of file + $response->assertRedirect('/admin'); +}); From 7668351900a63c703dc73ecd1761933cad0893a5 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Apr 2026 22:38:03 +0200 Subject: [PATCH 12/15] Phase 12: full review complete Final test suite: 247 passing (583 assertions). End-to-end browser review (Playwright) verified: - Storefront home with seeded products - Product detail and add-to-cart - Multi-step checkout (address, shipping, payment) - Confirmed order placement creates Order #1001 with correct totals - Customer login and account dashboard - Admin dashboard with KPIs - Admin products, orders, shipping settings - All admin sections accessible without console errors Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/progress.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/progress.md b/specs/progress.md index 96eb8f6e..f2d833d7 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -15,8 +15,8 @@ Tracking progress for the full shop system implementation (team: shop-v2). - [~] Phase 8: Search FTS5 backend + storefront wired - 234 tests; admin search settings UI in 7b - [~] Phase 9: Analytics events + daily aggregator backend; admin analytics UI in 7b - [~] Phase 10: Apps and webhooks backend (Sanctum, WebhookService, DeliverWebhook job); admin apps/developers UI in 7b -- [~] Phase 11: Polish - seeders done; dark mode + accessibility audit pending -- [ ] Phase 12: Full test suite execution + browser review +- [~] Phase 11: Polish - seeders done; dark mode classes applied throughout; deeper accessibility audit pending +- [x] Phase 12: Full test suite + browser review - 247 tests passing, all key flows verified end-to-end via Playwright ## Log From 8a7c3752d4f94afc9be0779afafea79ef50790b2 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Mon, 13 Apr 2026 10:13:10 +0200 Subject: [PATCH 13/15] ... --- .claude/settings.local.json | 16 +- .../console-2026-04-12T19-33-49-701Z.log | 2 + .../console-2026-04-12T19-33-56-875Z.log | 1 + .../console-2026-04-12T19-34-21-759Z.log | 1 + .../console-2026-04-12T19-34-27-319Z.log | 1 + .../console-2026-04-12T19-34-39-764Z.log | 2 + .../console-2026-04-12T19-35-29-489Z.log | 1 + .../console-2026-04-12T19-37-32-349Z.log | 1 + .../console-2026-04-12T19-37-37-035Z.log | 1 + .../console-2026-04-12T19-37-39-399Z.log | 1 + .../console-2026-04-12T19-50-11-319Z.log | 2 + .../console-2026-04-12T19-50-43-086Z.log | 1 + .../console-2026-04-12T19-50-46-483Z.log | 1 + .../console-2026-04-12T19-51-05-175Z.log | 1 + .../console-2026-04-12T19-51-11-228Z.log | 1 + .../console-2026-04-12T19-51-19-642Z.log | 1 + .../console-2026-04-12T19-51-22-100Z.log | 1 + .../console-2026-04-12T19-51-26-071Z.log | 1 + .../console-2026-04-12T20-16-49-621Z.log | 1 + .../console-2026-04-12T20-16-52-734Z.log | 1 + .../console-2026-04-12T20-16-59-169Z.log | 1 + .../console-2026-04-12T20-17-01-728Z.log | 1 + .../console-2026-04-12T20-17-04-224Z.log | 1 + .../console-2026-04-12T20-17-06-673Z.log | 1 + .../console-2026-04-12T20-20-44-469Z.log | 2 + .../console-2026-04-12T20-21-14-027Z.log | 2 + .../console-2026-04-12T20-22-31-874Z.log | 1 + .../console-2026-04-12T20-22-59-809Z.log | 1 + .../console-2026-04-12T20-23-09-968Z.log | 1 + .../console-2026-04-12T20-23-24-602Z.log | 1 + .../console-2026-04-12T20-23-34-126Z.log | 1 + .../console-2026-04-12T20-23-49-243Z.log | 1 + .../console-2026-04-12T20-23-59-855Z.log | 1 + .../console-2026-04-12T20-24-08-314Z.log | 1 + .../console-2026-04-12T20-24-13-024Z.log | 1 + .../console-2026-04-12T20-28-09-852Z.log | 1 + .../console-2026-04-12T20-28-14-286Z.log | 1 + .../console-2026-04-12T20-28-27-005Z.log | 1 + .../console-2026-04-12T20-28-32-463Z.log | 2 + .../console-2026-04-12T20-29-06-914Z.log | 1 + .../console-2026-04-12T20-29-18-849Z.log | 1 + .../console-2026-04-12T20-29-29-648Z.log | 1 + .../console-2026-04-12T20-32-45-747Z.log | 1 + .../console-2026-04-12T20-32-58-546Z.log | 1 + .../console-2026-04-12T20-33-11-871Z.log | 1 + .../console-2026-04-12T20-33-43-718Z.log | 1 + .../console-2026-04-12T20-33-53-668Z.log | 1 + .../console-2026-04-12T20-34-07-281Z.log | 1 + .../console-2026-04-12T20-34-19-135Z.log | 1 + .../console-2026-04-12T20-34-35-623Z.log | 1 + .../console-2026-04-12T20-34-55-233Z.log | 1 + .../console-2026-04-12T20-36-14-122Z.log | 8 + .../console-2026-04-12T20-37-25-505Z.log | 1 + .../page-2026-04-12T19-33-50-195Z.yml | 37 + .../page-2026-04-12T19-33-56-957Z.yml | 28 + .../page-2026-04-12T19-34-21-832Z.yml | 33 + .../page-2026-04-12T19-34-27-395Z.yml | 36 + .../page-2026-04-12T19-34-34-736Z.yml | 58 + .../page-2026-04-12T19-34-39-853Z.yml | 93 + .../page-2026-04-12T19-35-00-685Z.yml | 63 + .../page-2026-04-12T19-35-08-184Z.yml | 63 + .../page-2026-04-12T19-35-12-839Z.yml | 80 + .../page-2026-04-12T19-35-20-454Z.yml | 60 + .../page-2026-04-12T19-35-29-561Z.yml | 47 + .../page-2026-04-12T19-37-32-440Z.yml | 47 + .../page-2026-04-12T19-37-37-107Z.yml | 41 + .../page-2026-04-12T19-37-39-481Z.yml | 41 + .../page-2026-04-12T19-50-11-405Z.yml | 17 + .../page-2026-04-12T19-50-22-913Z.yml | 41 + .../page-2026-04-12T19-50-43-197Z.yml | 32 + .../page-2026-04-12T19-50-46-885Z.yml | 112 + .../page-2026-04-12T19-51-05-269Z.yml | 87 + .../page-2026-04-12T19-51-11-341Z.yml | 83 + .../page-2026-04-12T19-51-19-748Z.yml | 105 + .../page-2026-04-12T19-51-22-195Z.yml | 100 + .../page-2026-04-12T19-51-26-178Z.yml | 101 + .../page-2026-04-12T20-16-49-750Z.yml | 32 + .../page-2026-04-12T20-16-52-856Z.yml | 147 + .../page-2026-04-12T20-16-59-264Z.yml | 106 + .../page-2026-04-12T20-17-01-819Z.yml | 104 + .../page-2026-04-12T20-17-04-313Z.yml | 110 + .../page-2026-04-12T20-17-06-775Z.yml | 153 + .../page-2026-04-12T20-20-44-557Z.yml | 82 + .../page-2026-04-12T20-20-57-839Z.yml | 42 + .../page-2026-04-12T20-21-07-187Z.yml | 64 + .../page-2026-04-12T20-21-14-109Z.yml | 93 + .../page-2026-04-12T20-21-36-535Z.yml | 63 + .../page-2026-04-12T20-21-51-215Z.yml | 63 + .../page-2026-04-12T20-21-56-826Z.yml | 80 + .../page-2026-04-12T20-22-07-655Z.yml | 63 + .../page-2026-04-12T20-22-31-968Z.yml | 32 + .../page-2026-04-12T20-22-59-910Z.yml | 147 + .../page-2026-04-12T20-23-10-091Z.yml | 174 + .../page-2026-04-12T20-23-24-715Z.yml | 156 + .../page-2026-04-12T20-23-34-236Z.yml | 123 + .../page-2026-04-12T20-23-49-336Z.yml | 110 + .../page-2026-04-12T20-23-59-972Z.yml | 147 + .../page-2026-04-12T20-24-08-408Z.yml | 104 + .../page-2026-04-12T20-24-13-117Z.yml | 58 + .../page-2026-04-12T20-28-09-973Z.yml | 147 + .../page-2026-04-12T20-28-14-370Z.yml | 84 + .../page-2026-04-12T20-28-27-083Z.yml | 43 + .../page-2026-04-12T20-28-32-540Z.yml | 43 + .../page-2026-04-12T20-28-59-050Z.yml | 55 + .../page-2026-04-12T20-29-07-015Z.yml | 46 + .../page-2026-04-12T20-29-18-970Z.yml | 156 + .../page-2026-04-12T20-29-29-752Z.yml | 133 + .../page-2026-04-12T20-32-45-824Z.yml | 84 + .../page-2026-04-12T20-32-58-626Z.yml | 44 + .../page-2026-04-12T20-33-11-977Z.yml | 139 + .../page-2026-04-12T20-33-43-842Z.yml | 174 + .../page-2026-04-12T20-33-53-778Z.yml | 137 + .../page-2026-04-12T20-34-07-387Z.yml | 123 + .../page-2026-04-12T20-34-19-216Z.yml | 38 + .../page-2026-04-12T20-34-31-378Z.yml | 38 + .../page-2026-04-12T20-34-35-711Z.yml | 32 + .../page-2026-04-12T20-34-55-308Z.yml | 38 + .../page-2026-04-12T20-35-09-693Z.yml | 38 + .../page-2026-04-12T20-36-14-207Z.yml | 95 + .../page-2026-04-12T20-37-25-632Z.yml | 156 + composer.json | 3 +- composer.lock | 72 +- report/all.html | 7386 ++++++++++ report/classes.js | 11646 ++++++++++++++++ report/complexity.html | 5262 +++++++ report/composer.html | 230 + report/coupling.html | 2963 ++++ report/css/clusterize.css | 37 + report/css/material-icons.css | 20 + report/css/milligram.min.css | 12 + report/css/milligram.min.css.map | 12 + report/css/normalize.css | 424 + report/css/roboto.css | 12 + report/css/style.css | 705 + report/favicon.ico | Bin 0 -> 15406 bytes report/fonts/material-icons.ttf | Bin 0 -> 140112 bytes report/fonts/roboto-bold.ttf | Bin 0 -> 33440 bytes report/fonts/roboto-light.ttf | Bin 0 -> 33676 bytes report/images/logo-git.png | Bin 0 -> 581 bytes report/images/logo.png | Bin 0 -> 40320 bytes report/images/phpmetrics-maintenability.png | Bin 0 -> 40320 bytes report/index.html | 1816 +++ report/js/FileSaver.min.js | 3 + report/js/FileSaver.min.js.map | 1 + report/js/clusterize.min.js | 16 + report/js/d3.hexbin.v0.js | 110 + report/js/d3.v3.js | 9554 +++++++++++++ report/js/functions.js | 40 + report/js/graph-licenses.js | 52 + report/js/graph-maintainability.js | 124 + report/js/history-1.json | 42 + report/js/latest.json | 42 + report/js/sort-table.min.js | 8 + report/junit.html | 56 + report/loc.html | 3656 +++++ report/oop.html | 4125 ++++++ report/package_relations.html | 1016 ++ report/packages.html | 1301 ++ report/panel.html | 243 + report/relations.html | 1882 +++ report/violations.html | 999 ++ review-01-storefront-home.png | Bin 0 -> 58142 bytes review-02-product-detail.png | Bin 0 -> 28967 bytes review-03-admin-dashboard.png | Bin 0 -> 73281 bytes review-04-admin-products.png | Bin 0 -> 70599 bytes review-05-admin-order-detail.png | Bin 0 -> 69981 bytes review-06-admin-shipping.png | Bin 0 -> 50137 bytes review-07-order-confirmation.png | Bin 0 -> 50872 bytes 168 files changed, 59442 insertions(+), 10 deletions(-) create mode 100644 .playwright-mcp/console-2026-04-12T19-33-49-701Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-33-56-875Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-34-21-759Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-34-27-319Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-34-39-764Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-35-29-489Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-37-32-349Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-37-37-035Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-37-39-399Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-50-11-319Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-50-43-086Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-50-46-483Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-51-05-175Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-51-11-228Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-51-19-642Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-51-22-100Z.log create mode 100644 .playwright-mcp/console-2026-04-12T19-51-26-071Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-16-49-621Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-16-52-734Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-16-59-169Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-17-01-728Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-17-04-224Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-17-06-673Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-20-44-469Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-21-14-027Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-22-31-874Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-22-59-809Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-23-09-968Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-23-24-602Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-23-34-126Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-23-49-243Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-23-59-855Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-24-08-314Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-24-13-024Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-28-09-852Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-28-14-286Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-28-27-005Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-28-32-463Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-29-06-914Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-29-18-849Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-29-29-648Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-32-45-747Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-32-58-546Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-33-11-871Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-33-43-718Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-33-53-668Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-34-07-281Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-34-19-135Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-34-35-623Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-34-55-233Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-36-14-122Z.log create mode 100644 .playwright-mcp/console-2026-04-12T20-37-25-505Z.log create mode 100644 .playwright-mcp/page-2026-04-12T19-33-50-195Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-33-56-957Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-34-21-832Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-34-27-395Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-34-34-736Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-34-39-853Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-35-00-685Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-35-08-184Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-35-12-839Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-35-20-454Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-35-29-561Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-37-32-440Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-37-37-107Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-37-39-481Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-50-11-405Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-50-22-913Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-50-43-197Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-50-46-885Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-51-05-269Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-51-11-341Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-51-19-748Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-51-22-195Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T19-51-26-178Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-16-49-750Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-16-52-856Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-16-59-264Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-17-01-819Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-17-04-313Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-17-06-775Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-20-44-557Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-20-57-839Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-21-07-187Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-21-14-109Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-21-36-535Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-21-51-215Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-21-56-826Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-22-07-655Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-22-31-968Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-22-59-910Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-23-10-091Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-23-24-715Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-23-34-236Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-23-49-336Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-23-59-972Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-24-08-408Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-24-13-117Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-28-09-973Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-28-14-370Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-28-27-083Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-28-32-540Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-28-59-050Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-29-07-015Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-29-18-970Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-29-29-752Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-32-45-824Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-32-58-626Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-33-11-977Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-33-43-842Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-33-53-778Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-34-07-387Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-34-19-216Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-34-31-378Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-34-35-711Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-34-55-308Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-35-09-693Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-36-14-207Z.yml create mode 100644 .playwright-mcp/page-2026-04-12T20-37-25-632Z.yml create mode 100644 report/all.html create mode 100644 report/classes.js create mode 100644 report/complexity.html create mode 100644 report/composer.html create mode 100644 report/coupling.html create mode 100644 report/css/clusterize.css create mode 100644 report/css/material-icons.css create mode 100644 report/css/milligram.min.css create mode 100644 report/css/milligram.min.css.map create mode 100644 report/css/normalize.css create mode 100644 report/css/roboto.css create mode 100644 report/css/style.css create mode 100644 report/favicon.ico create mode 100644 report/fonts/material-icons.ttf create mode 100644 report/fonts/roboto-bold.ttf create mode 100644 report/fonts/roboto-light.ttf create mode 100644 report/images/logo-git.png create mode 100644 report/images/logo.png create mode 100644 report/images/phpmetrics-maintenability.png create mode 100644 report/index.html create mode 100644 report/js/FileSaver.min.js create mode 100644 report/js/FileSaver.min.js.map create mode 100644 report/js/clusterize.min.js create mode 100644 report/js/d3.hexbin.v0.js create mode 100644 report/js/d3.v3.js create mode 100644 report/js/functions.js create mode 100644 report/js/graph-licenses.js create mode 100644 report/js/graph-maintainability.js create mode 100644 report/js/history-1.json create mode 100644 report/js/latest.json create mode 100644 report/js/sort-table.min.js create mode 100644 report/junit.html create mode 100644 report/loc.html create mode 100644 report/oop.html create mode 100644 report/package_relations.html create mode 100644 report/packages.html create mode 100644 report/panel.html create mode 100644 report/relations.html create mode 100644 report/violations.html create mode 100644 review-01-storefront-home.png create mode 100644 review-02-product-detail.png create mode 100644 review-03-admin-dashboard.png create mode 100644 review-04-admin-products.png create mode 100644 review-05-admin-order-detail.png create mode 100644 review-06-admin-shipping.png create mode 100644 review-07-order-confirmation.png diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 101f3c3e..cbd22839 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,17 +1,17 @@ { + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)" + ] + }, "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "laravel-boost", "herd" ], "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true - }, - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)" - ] + "enabled": false, + "autoAllowBashIfSandboxed": false } } diff --git a/.playwright-mcp/console-2026-04-12T19-33-49-701Z.log b/.playwright-mcp/console-2026-04-12T19-33-49-701Z.log new file mode 100644 index 00000000..ef88e0aa --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-33-49-701Z.log @@ -0,0 +1,2 @@ +[ 422ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:50 +[ 491ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-04-12T19-33-56-875Z.log b/.playwright-mcp/console-2026-04-12T19-33-56-875Z.log new file mode 100644 index 00000000..115497d3 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-33-56-875Z.log @@ -0,0 +1 @@ +[ 59ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections:50 diff --git a/.playwright-mcp/console-2026-04-12T19-34-21-759Z.log b/.playwright-mcp/console-2026-04-12T19-34-21-759Z.log new file mode 100644 index 00000000..7402d2d8 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-34-21-759Z.log @@ -0,0 +1 @@ +[ 54ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections:50 diff --git a/.playwright-mcp/console-2026-04-12T19-34-27-319Z.log b/.playwright-mcp/console-2026-04-12T19-34-27-319Z.log new file mode 100644 index 00000000..4307b2a3 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-34-27-319Z.log @@ -0,0 +1 @@ +[ 56ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/demo-shirt:50 diff --git a/.playwright-mcp/console-2026-04-12T19-34-39-764Z.log b/.playwright-mcp/console-2026-04-12T19-34-39-764Z.log new file mode 100644 index 00000000..644bda8d --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-34-39-764Z.log @@ -0,0 +1,2 @@ +[ 58ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:50 +[ 39675ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:50 diff --git a/.playwright-mcp/console-2026-04-12T19-35-29-489Z.log b/.playwright-mcp/console-2026-04-12T19-35-29-489Z.log new file mode 100644 index 00000000..e03d705a --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-35-29-489Z.log @@ -0,0 +1 @@ +[ 48ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/register:50 diff --git a/.playwright-mcp/console-2026-04-12T19-37-32-349Z.log b/.playwright-mcp/console-2026-04-12T19-37-32-349Z.log new file mode 100644 index 00000000..e0457e1e --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-37-32-349Z.log @@ -0,0 +1 @@ +[ 60ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/register:50 diff --git a/.playwright-mcp/console-2026-04-12T19-37-37-035Z.log b/.playwright-mcp/console-2026-04-12T19-37-37-035Z.log new file mode 100644 index 00000000..5a1307a7 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-37-37-035Z.log @@ -0,0 +1 @@ +[ 51ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:50 diff --git a/.playwright-mcp/console-2026-04-12T19-37-39-399Z.log b/.playwright-mcp/console-2026-04-12T19-37-39-399Z.log new file mode 100644 index 00000000..9fe30e11 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-37-39-399Z.log @@ -0,0 +1 @@ +[ 60ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:50 diff --git a/.playwright-mcp/console-2026-04-12T19-50-11-319Z.log b/.playwright-mcp/console-2026-04-12T19-50-11-319Z.log new file mode 100644 index 00000000..d47e04da --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-50-11-319Z.log @@ -0,0 +1,2 @@ +[ 59ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/login:45 +[ 10581ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:50 diff --git a/.playwright-mcp/console-2026-04-12T19-50-43-086Z.log b/.playwright-mcp/console-2026-04-12T19-50-43-086Z.log new file mode 100644 index 00000000..33d73b7f --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-50-43-086Z.log @@ -0,0 +1 @@ +[ 73ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/dashboard:52 diff --git a/.playwright-mcp/console-2026-04-12T19-50-46-483Z.log b/.playwright-mcp/console-2026-04-12T19-50-46-483Z.log new file mode 100644 index 00000000..49ccb477 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-50-46-483Z.log @@ -0,0 +1 @@ +[ 103ms] [ERROR] Failed to load resource: the server responded with a status of 405 (Method Not Allowed) @ http://shop.test/logout:0 diff --git a/.playwright-mcp/console-2026-04-12T19-51-05-175Z.log b/.playwright-mcp/console-2026-04-12T19-51-05-175Z.log new file mode 100644 index 00000000..8601fff0 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-51-05-175Z.log @@ -0,0 +1 @@ +[ 61ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin:45 diff --git a/.playwright-mcp/console-2026-04-12T19-51-11-228Z.log b/.playwright-mcp/console-2026-04-12T19-51-11-228Z.log new file mode 100644 index 00000000..4782b81c --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-51-11-228Z.log @@ -0,0 +1 @@ +[ 59ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products:45 diff --git a/.playwright-mcp/console-2026-04-12T19-51-19-642Z.log b/.playwright-mcp/console-2026-04-12T19-51-19-642Z.log new file mode 100644 index 00000000..6d13237c --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-51-19-642Z.log @@ -0,0 +1 @@ +[ 66ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders/1:45 diff --git a/.playwright-mcp/console-2026-04-12T19-51-22-100Z.log b/.playwright-mcp/console-2026-04-12T19-51-22-100Z.log new file mode 100644 index 00000000..e6450a37 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-51-22-100Z.log @@ -0,0 +1 @@ +[ 59ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products/create:45 diff --git a/.playwright-mcp/console-2026-04-12T19-51-26-071Z.log b/.playwright-mcp/console-2026-04-12T19-51-26-071Z.log new file mode 100644 index 00000000..5bf68a25 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T19-51-26-071Z.log @@ -0,0 +1 @@ +[ 66ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/discounts/create:45 diff --git a/.playwright-mcp/console-2026-04-12T20-16-49-621Z.log b/.playwright-mcp/console-2026-04-12T20-16-49-621Z.log new file mode 100644 index 00000000..f76cd380 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-16-49-621Z.log @@ -0,0 +1 @@ +[ 91ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/dashboard:52 diff --git a/.playwright-mcp/console-2026-04-12T20-16-52-734Z.log b/.playwright-mcp/console-2026-04-12T20-16-52-734Z.log new file mode 100644 index 00000000..32d8d3f4 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-16-52-734Z.log @@ -0,0 +1 @@ +[ 65ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/pages:45 diff --git a/.playwright-mcp/console-2026-04-12T20-16-59-169Z.log b/.playwright-mcp/console-2026-04-12T20-16-59-169Z.log new file mode 100644 index 00000000..d421edd1 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-16-59-169Z.log @@ -0,0 +1 @@ +[ 60ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/settings:45 diff --git a/.playwright-mcp/console-2026-04-12T20-17-01-728Z.log b/.playwright-mcp/console-2026-04-12T20-17-01-728Z.log new file mode 100644 index 00000000..b1e3db67 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-17-01-728Z.log @@ -0,0 +1 @@ +[ 56ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/analytics:45 diff --git a/.playwright-mcp/console-2026-04-12T20-17-04-224Z.log b/.playwright-mcp/console-2026-04-12T20-17-04-224Z.log new file mode 100644 index 00000000..d465c13d --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-17-04-224Z.log @@ -0,0 +1 @@ +[ 57ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/developers:45 diff --git a/.playwright-mcp/console-2026-04-12T20-17-06-673Z.log b/.playwright-mcp/console-2026-04-12T20-17-06-673Z.log new file mode 100644 index 00000000..3797c10f --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-17-06-673Z.log @@ -0,0 +1 @@ +[ 59ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/navigation:45 diff --git a/.playwright-mcp/console-2026-04-12T20-20-44-469Z.log b/.playwright-mcp/console-2026-04-12T20-20-44-469Z.log new file mode 100644 index 00000000..54393d21 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-20-44-469Z.log @@ -0,0 +1,2 @@ +[ 61ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:50 +[ 12344ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/classic-tee:50 diff --git a/.playwright-mcp/console-2026-04-12T20-21-14-027Z.log b/.playwright-mcp/console-2026-04-12T20-21-14-027Z.log new file mode 100644 index 00000000..01dda443 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-21-14-027Z.log @@ -0,0 +1,2 @@ +[ 54ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:50 +[ 52615ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1:50 diff --git a/.playwright-mcp/console-2026-04-12T20-22-31-874Z.log b/.playwright-mcp/console-2026-04-12T20-22-31-874Z.log new file mode 100644 index 00000000..b8dbdb29 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-22-31-874Z.log @@ -0,0 +1 @@ +[ 65ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/dashboard:52 diff --git a/.playwright-mcp/console-2026-04-12T20-22-59-809Z.log b/.playwright-mcp/console-2026-04-12T20-22-59-809Z.log new file mode 100644 index 00000000..b86b6be4 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-22-59-809Z.log @@ -0,0 +1 @@ +[ 64ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin:45 diff --git a/.playwright-mcp/console-2026-04-12T20-23-09-968Z.log b/.playwright-mcp/console-2026-04-12T20-23-09-968Z.log new file mode 100644 index 00000000..3f8458ad --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-23-09-968Z.log @@ -0,0 +1 @@ +[ 64ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products:45 diff --git a/.playwright-mcp/console-2026-04-12T20-23-24-602Z.log b/.playwright-mcp/console-2026-04-12T20-23-24-602Z.log new file mode 100644 index 00000000..91e6ada9 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-23-24-602Z.log @@ -0,0 +1 @@ +[ 58ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders:45 diff --git a/.playwright-mcp/console-2026-04-12T20-23-34-126Z.log b/.playwright-mcp/console-2026-04-12T20-23-34-126Z.log new file mode 100644 index 00000000..c94ee1cd --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-23-34-126Z.log @@ -0,0 +1 @@ +[ 70ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/settings/shipping:45 diff --git a/.playwright-mcp/console-2026-04-12T20-23-49-243Z.log b/.playwright-mcp/console-2026-04-12T20-23-49-243Z.log new file mode 100644 index 00000000..53b98925 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-23-49-243Z.log @@ -0,0 +1 @@ +[ 58ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/developers:45 diff --git a/.playwright-mcp/console-2026-04-12T20-23-59-855Z.log b/.playwright-mcp/console-2026-04-12T20-23-59-855Z.log new file mode 100644 index 00000000..e13da19e --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-23-59-855Z.log @@ -0,0 +1 @@ +[ 62ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/pages:45 diff --git a/.playwright-mcp/console-2026-04-12T20-24-08-314Z.log b/.playwright-mcp/console-2026-04-12T20-24-08-314Z.log new file mode 100644 index 00000000..fe3c8dc3 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-24-08-314Z.log @@ -0,0 +1 @@ +[ 59ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/analytics:45 diff --git a/.playwright-mcp/console-2026-04-12T20-24-13-024Z.log b/.playwright-mcp/console-2026-04-12T20-24-13-024Z.log new file mode 100644 index 00000000..d3aae899 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-24-13-024Z.log @@ -0,0 +1 @@ +[ 53ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/collections/featured:50 diff --git a/.playwright-mcp/console-2026-04-12T20-28-09-852Z.log b/.playwright-mcp/console-2026-04-12T20-28-09-852Z.log new file mode 100644 index 00000000..2563eec6 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-28-09-852Z.log @@ -0,0 +1 @@ +[ 83ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin:45 diff --git a/.playwright-mcp/console-2026-04-12T20-28-14-286Z.log b/.playwright-mcp/console-2026-04-12T20-28-14-286Z.log new file mode 100644 index 00000000..f3f3b84b --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-28-14-286Z.log @@ -0,0 +1 @@ +[ 59ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:50 diff --git a/.playwright-mcp/console-2026-04-12T20-28-27-005Z.log b/.playwright-mcp/console-2026-04-12T20-28-27-005Z.log new file mode 100644 index 00000000..c1a42533 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-28-27-005Z.log @@ -0,0 +1 @@ +[ 54ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/hoodie:50 diff --git a/.playwright-mcp/console-2026-04-12T20-28-32-463Z.log b/.playwright-mcp/console-2026-04-12T20-28-32-463Z.log new file mode 100644 index 00000000..709efa2d --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-28-32-463Z.log @@ -0,0 +1,2 @@ +[ 56ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/login:50 +[ 25573ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account:50 diff --git a/.playwright-mcp/console-2026-04-12T20-29-06-914Z.log b/.playwright-mcp/console-2026-04-12T20-29-06-914Z.log new file mode 100644 index 00000000..ef20cd7c --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-29-06-914Z.log @@ -0,0 +1 @@ +[ 54ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/account/orders:50 diff --git a/.playwright-mcp/console-2026-04-12T20-29-18-849Z.log b/.playwright-mcp/console-2026-04-12T20-29-18-849Z.log new file mode 100644 index 00000000..77978b28 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-29-18-849Z.log @@ -0,0 +1 @@ +[ 65ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders:45 diff --git a/.playwright-mcp/console-2026-04-12T20-29-29-648Z.log b/.playwright-mcp/console-2026-04-12T20-29-29-648Z.log new file mode 100644 index 00000000..b355add5 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-29-29-648Z.log @@ -0,0 +1 @@ +[ 64ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders/1:45 diff --git a/.playwright-mcp/console-2026-04-12T20-32-45-747Z.log b/.playwright-mcp/console-2026-04-12T20-32-45-747Z.log new file mode 100644 index 00000000..5f25608c --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-32-45-747Z.log @@ -0,0 +1 @@ +[ 56ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:50 diff --git a/.playwright-mcp/console-2026-04-12T20-32-58-546Z.log b/.playwright-mcp/console-2026-04-12T20-32-58-546Z.log new file mode 100644 index 00000000..cf7b7baa --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-32-58-546Z.log @@ -0,0 +1 @@ +[ 56ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/sneakers:50 diff --git a/.playwright-mcp/console-2026-04-12T20-33-11-871Z.log b/.playwright-mcp/console-2026-04-12T20-33-11-871Z.log new file mode 100644 index 00000000..a24ac338 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-33-11-871Z.log @@ -0,0 +1 @@ +[ 68ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin:45 diff --git a/.playwright-mcp/console-2026-04-12T20-33-43-718Z.log b/.playwright-mcp/console-2026-04-12T20-33-43-718Z.log new file mode 100644 index 00000000..bd2d02b4 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-33-43-718Z.log @@ -0,0 +1 @@ +[ 65ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/products:45 diff --git a/.playwright-mcp/console-2026-04-12T20-33-53-668Z.log b/.playwright-mcp/console-2026-04-12T20-33-53-668Z.log new file mode 100644 index 00000000..54c8dcdd --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-33-53-668Z.log @@ -0,0 +1 @@ +[ 70ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders/2:45 diff --git a/.playwright-mcp/console-2026-04-12T20-34-07-281Z.log b/.playwright-mcp/console-2026-04-12T20-34-07-281Z.log new file mode 100644 index 00000000..0e90e9a1 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-34-07-281Z.log @@ -0,0 +1 @@ +[ 67ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/settings/shipping:45 diff --git a/.playwright-mcp/console-2026-04-12T20-34-19-135Z.log b/.playwright-mcp/console-2026-04-12T20-34-19-135Z.log new file mode 100644 index 00000000..c64cb149 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-34-19-135Z.log @@ -0,0 +1 @@ +[ 57ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/mug:50 diff --git a/.playwright-mcp/console-2026-04-12T20-34-35-623Z.log b/.playwright-mcp/console-2026-04-12T20-34-35-623Z.log new file mode 100644 index 00000000..359ce201 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-34-35-623Z.log @@ -0,0 +1 @@ +[ 69ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/cart:50 diff --git a/.playwright-mcp/console-2026-04-12T20-34-55-233Z.log b/.playwright-mcp/console-2026-04-12T20-34-55-233Z.log new file mode 100644 index 00000000..c4f0b197 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-34-55-233Z.log @@ -0,0 +1 @@ +[ 53ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/products/mug:50 diff --git a/.playwright-mcp/console-2026-04-12T20-36-14-122Z.log b/.playwright-mcp/console-2026-04-12T20-36-14-122Z.log new file mode 100644 index 00000000..c0eef855 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-36-14-122Z.log @@ -0,0 +1,8 @@ +[ 58ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout:50 +[ 13042ms] [LOG] 0 @ http://shop.test/checkout:109 +[ 13044ms] [LOG] 1 @ http://shop.test/checkout:109 +[ 13044ms] [LOG] 2 @ http://shop.test/checkout:109 +[ 13044ms] [LOG] 3 @ http://shop.test/checkout:109 +[ 13044ms] [LOG] 4 @ http://shop.test/checkout:109 +[ 13044ms] [LOG] 5 @ http://shop.test/checkout:109 +[ 48604ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/checkout/confirmation/1001:50 diff --git a/.playwright-mcp/console-2026-04-12T20-37-25-505Z.log b/.playwright-mcp/console-2026-04-12T20-37-25-505Z.log new file mode 100644 index 00000000..63345d68 --- /dev/null +++ b/.playwright-mcp/console-2026-04-12T20-37-25-505Z.log @@ -0,0 +1 @@ +[ 69ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/admin/orders:45 diff --git a/.playwright-mcp/page-2026-04-12T19-33-50-195Z.yml b/.playwright-mcp/page-2026-04-12T19-33-50-195Z.yml new file mode 100644 index 00000000..e8c51a3b --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-33-50-195Z.yml @@ -0,0 +1,37 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Laravel" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - paragraph [ref=e18]: New season + - heading "Thoughtfully made, honestly priced." [level=1] [ref=e19] + - paragraph [ref=e20]: A curated collection of timeless goods designed to last. Explore our latest arrivals and find something you will love. + - generic [ref=e21]: + - link "Shop the collection" [ref=e22] [cursor=pointer]: + - /url: "#featured" + - link "What is new" [ref=e23] [cursor=pointer]: + - /url: "#recent" + - generic [ref=e24]: + - generic [ref=e26]: + - heading "New arrivals" [level=2] [ref=e27] + - paragraph [ref=e28]: Fresh goods, just in. + - paragraph [ref=e30]: No products yet. Check back soon. + - contentinfo [ref=e31]: + - generic [ref=e33]: + - navigation "Footer navigation" + - paragraph [ref=e34]: (c) Shop + - button "Open cart" [ref=e37]: + - img [ref=e38] + - generic [ref=e40]: Cart + - generic [ref=e41]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-33-56-957Z.yml b/.playwright-mcp/page-2026-04-12T19-33-56-957Z.yml new file mode 100644 index 00000000..1dbd4d32 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-33-56-957Z.yml @@ -0,0 +1,28 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - paragraph [ref=e18]: Shop + - heading "All collections" [level=1] [ref=e19] + - paragraph [ref=e20]: Browse every curated edit across the store. + - paragraph [ref=e22]: No collections yet. + - contentinfo [ref=e23]: + - generic [ref=e25]: + - navigation "Footer navigation" + - paragraph [ref=e26]: (c) Shop + - button "Open cart" [ref=e29]: + - img [ref=e30] + - generic [ref=e32]: Cart + - generic [ref=e33]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-34-21-832Z.yml b/.playwright-mcp/page-2026-04-12T19-34-21-832Z.yml new file mode 100644 index 00000000..4ad42d57 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-34-21-832Z.yml @@ -0,0 +1,33 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - paragraph [ref=e18]: Shop + - heading "All collections" [level=1] [ref=e19] + - paragraph [ref=e20]: Browse every curated edit across the store. + - link "Demo Edit Demo collection 1 product" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/collections/demo-edit + - generic [ref=e23]: + - heading "Demo Edit" [level=2] [ref=e24] + - generic [ref=e25]: Demo collection + - paragraph [ref=e26]: 1 product + - contentinfo [ref=e27]: + - generic [ref=e29]: + - navigation "Footer navigation" + - paragraph [ref=e30]: (c) Shop + - button "Open cart" [ref=e33]: + - img [ref=e34] + - generic [ref=e36]: Cart + - generic [ref=e37]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-34-27-395Z.yml b/.playwright-mcp/page-2026-04-12T19-34-27-395Z.yml new file mode 100644 index 00000000..bb346881 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-34-27-395Z.yml @@ -0,0 +1,36 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Co + - heading "Demo Shirt" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 29.99 + - generic [ref=e25]: + - generic [ref=e26]: Quantity + - generic [ref=e27]: + - button "Decrease quantity" [ref=e28]: "-" + - generic [ref=e29]: "1" + - button "Increase quantity" [ref=e30]: + + - button "Add to cart" [ref=e31]: + - generic [ref=e32]: Add to cart + - paragraph [ref=e34]: A nice demo. + - contentinfo [ref=e35]: + - generic [ref=e37]: + - navigation "Footer navigation" + - paragraph [ref=e38]: (c) Shop + - button "Open cart" [ref=e41]: + - img [ref=e42] + - generic [ref=e44]: Cart + - generic [ref=e45]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-34-34-736Z.yml b/.playwright-mcp/page-2026-04-12T19-34-34-736Z.yml new file mode 100644 index 00000000..0ffae611 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-34-34-736Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Co + - heading "Demo Shirt" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 29.99 + - generic [ref=e25]: + - generic [ref=e26]: Quantity + - generic [ref=e27]: + - button "Decrease quantity" [ref=e28]: "-" + - generic [ref=e29]: "1" + - button "Increase quantity" [ref=e30]: + + - generic [ref=e46]: Added to cart + - button "Add to cart" [ref=e31]: + - generic [ref=e32]: Add to cart + - paragraph [ref=e34]: A nice demo. + - contentinfo [ref=e35]: + - generic [ref=e37]: + - navigation "Footer navigation" + - paragraph [ref=e38]: (c) Shop + - generic [ref=e40]: + - button "Open cart" [ref=e41]: + - img [ref=e42] + - generic [ref=e44]: Cart + - generic [ref=e45]: "1" + - dialog "Shopping cart" [ref=e48]: + - banner [ref=e49]: + - heading "Your cart" [level=2] [ref=e50] + - button "Close cart" [ref=e51]: + - img [ref=e52] + - list [ref=e55]: + - listitem [ref=e56]: + - generic [ref=e58]: + - paragraph [ref=e59]: Demo Shirt + - paragraph [ref=e60]: Qty 1 + - paragraph [ref=e61]: EUR 29.99 + - contentinfo [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: Subtotal + - generic [ref=e65]: EUR 29.99 + - generic [ref=e66]: + - link "View cart" [ref=e67] [cursor=pointer]: + - /url: http://shop.test/cart + - link "Checkout" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/checkout \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-34-39-853Z.yml b/.playwright-mcp/page-2026-04-12T19-34-39-853Z.yml new file mode 100644 index 00000000..b11734ed --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-34-39-853Z.yml @@ -0,0 +1,93 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Contact and shipping address" [level=2] [ref=e34] + - generic [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: Email + - textbox "Email" [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - generic [ref=e41]: First name + - textbox "First name" [ref=e42] + - generic [ref=e43]: + - generic [ref=e44]: Last name + - textbox "Last name" [ref=e45] + - generic [ref=e46]: + - generic [ref=e47]: Address + - textbox "Address" [ref=e48] + - generic [ref=e49]: + - generic [ref=e50]: Apartment, suite (optional) + - textbox "Apartment, suite (optional)" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: + - generic [ref=e54]: City + - textbox "City" [ref=e55] + - generic [ref=e56]: + - generic [ref=e57]: Postal code + - textbox "Postal code" [ref=e58] + - generic [ref=e59]: + - generic [ref=e60]: Country + - combobox "Country" [ref=e61]: + - option "Germany" [selected] + - option "Austria" + - option "Switzerland" + - option "France" + - option "Netherlands" + - option "United States" + - option "United Kingdom" + - generic [ref=e62]: + - checkbox "Billing address is the same as shipping" [checked] [ref=e63] + - generic [ref=e64]: Billing address is the same as shipping + - button "Continue to shipping" [ref=e65] + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Demo Shirt + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 29.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 29.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 0.00 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 29.99 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-35-00-685Z.yml b/.playwright-mcp/page-2026-04-12T19-35-00-685Z.yml new file mode 100644 index 00000000..6e73047c --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-35-00-685Z.yml @@ -0,0 +1,63 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Shipping method" [level=2] [ref=e96] + - generic [ref=e98] [cursor=pointer]: + - generic [ref=e99]: + - radio "Standard EUR 5.99" [ref=e100] + - generic [ref=e101]: Standard + - generic [ref=e102]: EUR 5.99 + - generic [ref=e103]: + - button "Back" [ref=e104] + - button "Continue to payment" [ref=e105] + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Demo Shirt + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 29.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 29.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 0.00 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 29.99 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-35-08-184Z.yml b/.playwright-mcp/page-2026-04-12T19-35-08-184Z.yml new file mode 100644 index 00000000..1b0ebf43 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-35-08-184Z.yml @@ -0,0 +1,63 @@ +- generic [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Shipping method" [level=2] [ref=e96] + - generic [ref=e98] [cursor=pointer]: + - generic [ref=e99]: + - radio "Standard EUR 5.99" [checked] [active] [ref=e100] + - generic [ref=e101]: Standard + - generic [ref=e102]: EUR 5.99 + - generic [ref=e103]: + - button "Back" [ref=e104] + - button "Continue to payment" [ref=e105] + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Demo Shirt + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 29.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 29.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 0.00 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 29.99 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-35-12-839Z.yml b/.playwright-mcp/page-2026-04-12T19-35-12-839Z.yml new file mode 100644 index 00000000..a47e7e85 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-35-12-839Z.yml @@ -0,0 +1,80 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Payment" [level=2] [ref=e106] + - generic [ref=e107]: + - generic [ref=e108]: + - radio "Credit card" [checked] [ref=e109] + - generic [ref=e110]: Credit card + - generic [ref=e111]: + - radio "PayPal" [ref=e112] + - generic [ref=e113]: PayPal + - generic [ref=e114]: + - radio "Bank transfer" [ref=e115] + - generic [ref=e116]: Bank transfer + - generic [ref=e117]: "Magic test card: 4242 4242 4242 4242, expiry 12/30, CVC 123." + - generic [ref=e118]: + - generic [ref=e119]: Card number + - textbox "Card number" [ref=e120]: "4242424242424242" + - generic [ref=e121]: + - generic [ref=e122]: + - generic [ref=e123]: Expiry (MM/YY) + - textbox "Expiry (MM/YY)" [ref=e124]: 12/30 + - generic [ref=e125]: + - generic [ref=e126]: CVC + - textbox "CVC" [ref=e127]: "123" + - generic [ref=e128]: + - button "Back" [ref=e129] + - button "Place order" [ref=e130]: + - generic [ref=e131]: Place order + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Demo Shirt + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 29.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 29.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 5.99 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 35.98 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-35-20-454Z.yml b/.playwright-mcp/page-2026-04-12T19-35-20-454Z.yml new file mode 100644 index 00000000..01f4e36b --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-35-20-454Z.yml @@ -0,0 +1,60 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - img [ref=e19] + - heading "Thank you for your order" [level=1] [ref=e21] + - paragraph [ref=e22]: "Order #1001 is confirmed. A receipt has been sent to buyer@example.com." + - generic [ref=e23]: + - generic [ref=e24]: + - heading "Order details" [level=2] [ref=e25] + - list [ref=e26]: + - listitem [ref=e27]: + - generic [ref=e30]: + - paragraph [ref=e31]: Demo Shirt + - paragraph [ref=e32]: Qty 1 + - paragraph [ref=e33]: EUR 29.99 + - generic [ref=e34]: + - generic [ref=e35]: + - generic [ref=e36]: Subtotal + - generic [ref=e37]: EUR 29.99 + - generic [ref=e38]: + - generic [ref=e39]: Shipping + - generic [ref=e40]: EUR 5.99 + - generic [ref=e41]: + - generic [ref=e42]: Total + - generic [ref=e43]: EUR 35.98 + - complementary [ref=e44]: + - generic [ref=e45]: + - heading "Shipping address" [level=3] [ref=e46] + - generic [ref=e47]: + - text: Jane Doe + - text: Alexanderplatz 1 + - text: Berlin 10178 + - text: DE + - generic [ref=e48]: + - heading "Status" [level=3] [ref=e49] + - paragraph [ref=e50]: "Payment: paid" + - paragraph [ref=e51]: "Fulfillment: unfulfilled" + - link "Continue shopping" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/storefront + - contentinfo [ref=e53]: + - generic [ref=e55]: + - navigation "Footer navigation" + - paragraph [ref=e56]: (c) Shop + - button "Open cart" [ref=e59]: + - img [ref=e60] + - generic [ref=e62]: Cart + - generic [ref=e63]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-35-29-561Z.yml b/.playwright-mcp/page-2026-04-12T19-35-29-561Z.yml new file mode 100644 index 00000000..bf7a34bb --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-35-29-561Z.yml @@ -0,0 +1,47 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - heading "Create account" [level=1] [ref=e18] + - paragraph [ref=e19]: Sign up to track orders and check out faster. + - generic [ref=e20]: + - generic [ref=e21]: + - generic [ref=e22]: Name + - textbox "Name" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Email + - textbox "Email" [ref=e26] + - generic [ref=e27]: + - generic [ref=e28]: Password + - textbox "Password" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Confirm password + - textbox "Confirm password" [ref=e32] + - generic [ref=e33]: + - checkbox "Send me product news and offers" [ref=e34] + - generic [ref=e35]: Send me product news and offers + - button "Create account" [ref=e36] + - paragraph [ref=e37]: + - text: Already have an account? + - link "Sign in" [ref=e38] [cursor=pointer]: + - /url: http://shop.test/account/login + - contentinfo [ref=e39]: + - generic [ref=e41]: + - navigation "Footer navigation" + - paragraph [ref=e42]: (c) Shop + - button "Open cart" [ref=e45]: + - img [ref=e46] + - generic [ref=e48]: Cart + - generic [ref=e49]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-37-32-440Z.yml b/.playwright-mcp/page-2026-04-12T19-37-32-440Z.yml new file mode 100644 index 00000000..bf7a34bb --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-37-32-440Z.yml @@ -0,0 +1,47 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - heading "Create account" [level=1] [ref=e18] + - paragraph [ref=e19]: Sign up to track orders and check out faster. + - generic [ref=e20]: + - generic [ref=e21]: + - generic [ref=e22]: Name + - textbox "Name" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Email + - textbox "Email" [ref=e26] + - generic [ref=e27]: + - generic [ref=e28]: Password + - textbox "Password" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Confirm password + - textbox "Confirm password" [ref=e32] + - generic [ref=e33]: + - checkbox "Send me product news and offers" [ref=e34] + - generic [ref=e35]: Send me product news and offers + - button "Create account" [ref=e36] + - paragraph [ref=e37]: + - text: Already have an account? + - link "Sign in" [ref=e38] [cursor=pointer]: + - /url: http://shop.test/account/login + - contentinfo [ref=e39]: + - generic [ref=e41]: + - navigation "Footer navigation" + - paragraph [ref=e42]: (c) Shop + - button "Open cart" [ref=e45]: + - img [ref=e46] + - generic [ref=e48]: Cart + - generic [ref=e49]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-37-37-107Z.yml b/.playwright-mcp/page-2026-04-12T19-37-37-107Z.yml new file mode 100644 index 00000000..b3428855 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-37-37-107Z.yml @@ -0,0 +1,41 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - heading "Sign in" [level=1] [ref=e18] + - paragraph [ref=e19]: Access your orders and saved addresses. + - generic [ref=e20]: + - generic [ref=e21]: + - generic [ref=e22]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - textbox "Password" [ref=e26] + - generic [ref=e27]: + - checkbox "Remember me" [ref=e28] + - generic [ref=e29]: Remember me + - button "Sign in" [ref=e30] + - paragraph [ref=e31]: + - text: New customer? + - link "Create an account" [ref=e32] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e33]: + - generic [ref=e35]: + - navigation "Footer navigation" + - paragraph [ref=e36]: (c) Shop + - button "Open cart" [ref=e39]: + - img [ref=e40] + - generic [ref=e42]: Cart + - generic [ref=e43]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-37-39-481Z.yml b/.playwright-mcp/page-2026-04-12T19-37-39-481Z.yml new file mode 100644 index 00000000..b3428855 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-37-39-481Z.yml @@ -0,0 +1,41 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - heading "Sign in" [level=1] [ref=e18] + - paragraph [ref=e19]: Access your orders and saved addresses. + - generic [ref=e20]: + - generic [ref=e21]: + - generic [ref=e22]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - textbox "Password" [ref=e26] + - generic [ref=e27]: + - checkbox "Remember me" [ref=e28] + - generic [ref=e29]: Remember me + - button "Sign in" [ref=e30] + - paragraph [ref=e31]: + - text: New customer? + - link "Create an account" [ref=e32] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e33]: + - generic [ref=e35]: + - navigation "Footer navigation" + - paragraph [ref=e36]: (c) Shop + - button "Open cart" [ref=e39]: + - img [ref=e40] + - generic [ref=e42]: Cart + - generic [ref=e43]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-50-11-405Z.yml b/.playwright-mcp/page-2026-04-12T19-50-11-405Z.yml new file mode 100644 index 00000000..99bbc029 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-50-11-405Z.yml @@ -0,0 +1,17 @@ +- generic [ref=e6]: + - generic [ref=e7]: + - heading "Admin sign in" [level=1] [ref=e8] + - paragraph [ref=e9]: Enter your email and password to access the admin panel + - generic [ref=e10]: + - generic [ref=e11]: + - generic [ref=e12]: Email address + - textbox "Email address" [active] [ref=e14] + - generic [ref=e15]: + - generic [ref=e16]: Password + - textbox "Password" [ref=e18] + - generic [ref=e19]: + - checkbox "Remember me" [ref=e20] + - generic [ref=e22]: Remember me + - button "Log in" [ref=e23]: + - img [ref=e25] + - generic [ref=e28]: Log in \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-50-22-913Z.yml b/.playwright-mcp/page-2026-04-12T19-50-22-913Z.yml new file mode 100644 index 00000000..b3428855 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-50-22-913Z.yml @@ -0,0 +1,41 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - heading "Sign in" [level=1] [ref=e18] + - paragraph [ref=e19]: Access your orders and saved addresses. + - generic [ref=e20]: + - generic [ref=e21]: + - generic [ref=e22]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - textbox "Password" [ref=e26] + - generic [ref=e27]: + - checkbox "Remember me" [ref=e28] + - generic [ref=e29]: Remember me + - button "Sign in" [ref=e30] + - paragraph [ref=e31]: + - text: New customer? + - link "Create an account" [ref=e32] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e33]: + - generic [ref=e35]: + - navigation "Footer navigation" + - paragraph [ref=e36]: (c) Shop + - button "Open cart" [ref=e39]: + - img [ref=e40] + - generic [ref=e42]: Cart + - generic [ref=e43]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-50-43-197Z.yml b/.playwright-mcp/page-2026-04-12T19-50-43-197Z.yml new file mode 100644 index 00000000..3dfc1d08 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-50-43-197Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Laravel Starter Kit" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/dashboard + - img [ref=e6] + - generic [ref=e8]: Laravel Starter Kit + - navigation [ref=e9]: + - generic [ref=e10]: + - generic [ref=e12]: Platform + - link "Dashboard" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/dashboard + - img [ref=e17] + - generic [ref=e19]: Dashboard + - navigation [ref=e21]: + - link "Repository" [ref=e23] [cursor=pointer]: + - /url: https://github.com/laravel/livewire-starter-kit + - img [ref=e25] + - generic [ref=e30]: Repository + - link "Documentation" [ref=e32] [cursor=pointer]: + - /url: https://laravel.com/docs/starter-kits#livewire + - img [ref=e34] + - generic [ref=e36]: Documentation + - button "TU Test User" [ref=e38]: + - generic [ref=e41]: TU + - generic [ref=e42]: Test User + - img [ref=e44] + - generic [ref=e48]: + - generic [ref=e49]: + - img [ref=e51] + - img [ref=e54] + - img [ref=e57] + - img [ref=e60] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-50-46-885Z.yml b/.playwright-mcp/page-2026-04-12T19-50-46-885Z.yml new file mode 100644 index 00000000..d40d64c7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-50-46-885Z.yml @@ -0,0 +1,112 @@ +- generic [ref=e2]: + - generic [ref=e4]: + - generic [ref=e5]: + - img [ref=e7] + - generic [ref=e10]: Method Not Allowed + - button "Copy as Markdown" [ref=e11] [cursor=pointer]: + - img [ref=e12] + - generic [ref=e15]: Copy as Markdown + - generic [ref=e18]: + - generic [ref=e19]: + - heading "Symfony\\Component\\HttpKernel\\Exception\\MethodNotAllowedHttpException" [level=1] [ref=e20] + - generic [ref=e22]: vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php:131 + - paragraph [ref=e23]: "The GET method is not supported for route logout. Supported methods: POST." + - generic [ref=e24]: + - generic [ref=e25]: + - generic [ref=e26]: + - generic [ref=e27]: LARAVEL + - generic [ref=e28]: 12.51.0 + - generic [ref=e29]: + - generic [ref=e30]: PHP + - generic [ref=e31]: 8.4.17 + - generic [ref=e32]: + - img [ref=e33] + - text: UNHANDLED + - generic [ref=e36]: CODE 0 + - generic [ref=e38]: + - generic [ref=e39]: + - img [ref=e40] + - text: "405" + - generic [ref=e43]: + - img [ref=e44] + - text: GET + - generic [ref=e47]: http://shop.test/logout + - button [ref=e48] [cursor=pointer]: + - img [ref=e49] + - generic [ref=e53]: + - generic [ref=e54]: + - generic [ref=e55]: + - img [ref=e57] + - heading "Exception trace" [level=3] [ref=e60] + - generic [ref=e61]: + - generic [ref=e63] [cursor=pointer]: + - img [ref=e64] + - generic [ref=e68]: 33 vendor frames + - button [ref=e69]: + - img [ref=e70] + - generic [ref=e74]: + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e78]: + - code [ref=e82]: + - generic [ref=e83]: public/index.php + - generic [ref=e85]: public/index.php:20 + - button [ref=e87]: + - img [ref=e88] + - code [ref=e96]: + - generic [ref=e97]: "15" + - generic [ref=e98]: 16// Bootstrap Laravel and handle the request... + - generic [ref=e99]: 17/** @var Application $app */ + - generic [ref=e100]: 18$app = require_once __DIR__.'/../bootstrap/app.php'; + - generic [ref=e101]: "19" + - generic [ref=e102]: 20$app->handleRequest(Request::capture()); + - generic [ref=e103]: "21" + - generic [ref=e105] [cursor=pointer]: + - img [ref=e106] + - generic [ref=e110]: 1 vendor frame + - button [ref=e111]: + - img [ref=e112] + - generic [ref=e116]: + - generic [ref=e118]: + - img [ref=e120] + - heading "Queries" [level=3] [ref=e122] + - generic [ref=e124]: // No queries executed + - generic [ref=e126]: + - generic [ref=e127]: + - heading "Headers" [level=2] [ref=e128] + - generic [ref=e129]: + - generic [ref=e130]: + - generic [ref=e131]: cookie + - generic [ref=e133]: XSRF-TOKEN=eyJpdiI6IjhjMENyZVlaMmtCa1paZ3FoS0pYOFE9PSIsInZhbHVlIjoiTWJNTHJoK0xmVHlISHlMUmdPTllwcWd3ZC83VXdtQ3BsQ0g1cVoxZnczTndDUXVxNjdiZTgzMnJDLzgxOFJVcmowb3dtVkc5UWVmSVpIL2V3VVU4NkJCazl5ZXVTUEk4bXR6SHo5T3Q3Z3RiczN3M2FoanNJODVCdHFjWjhmY0oiLCJtYWMiOiJiZjUyNmI4MTVhY2Q2ODVkYzQ5ZmZkYTk3NDBjYTk3NDE1MGRlZDFmYTcyYzcxZTA4MjI2OGE1OWJmZjk4NDJjIiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6IjFGOWxRdlVkWEJMbkRuc0FoeUdYOFE9PSIsInZhbHVlIjoiSWREbzZaOWdLV0lIVjVWeGFTV2FzYjl3UmoraVo4Q1JrQjdicGMxaEJRRUxEbnVGQnp4aW5pQmRoRXMvSDRaMTFsbDhFQ2NlYzNKemgxM2lFeGJFcXZERmt0YjZTS2NlUkxJUG9uOHJUYm9nWUlkWUk3aG1mR3JrVitRSlNkYkQiLCJtYWMiOiIxMzdlYWI3YmZlYmM2NmE4Y2Q5NjBhOTNjOTJiZThhMWU2OWYyYmI1NDk5YTE2NDk5NTZhY2Y4NzRlM2U5YjdlIiwidGFnIjoiIn0%3D + - generic [ref=e134]: + - generic [ref=e135]: accept-language + - generic [ref=e137]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=e138]: + - generic [ref=e139]: accept-encoding + - generic [ref=e141]: gzip, deflate + - generic [ref=e142]: + - generic [ref=e143]: accept + - generic [ref=e145]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 + - generic [ref=e146]: + - generic [ref=e147]: user-agent + - generic [ref=e149]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 + - generic [ref=e150]: + - generic [ref=e151]: upgrade-insecure-requests + - generic [ref=e153]: "1" + - generic [ref=e154]: + - generic [ref=e155]: connection + - generic [ref=e157]: keep-alive + - generic [ref=e158]: + - generic [ref=e159]: host + - generic [ref=e161]: shop.test + - generic [ref=e162]: + - heading "Body" [level=2] [ref=e163] + - generic [ref=e164]: // No request body + - generic [ref=e165]: + - heading "Routing" [level=2] [ref=e166] + - generic [ref=e168]: // No routing context + - generic [ref=e169]: + - heading "Routing parameters" [level=2] [ref=e170] + - generic [ref=e171]: // No routing parameters + - generic [ref=e174]: + - img [ref=e176] + - img [ref=e3214] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-51-05-269Z.yml b/.playwright-mcp/page-2026-04-12T19-51-05-269Z.yml new file mode 100644 index 00000000..0b706ccd --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-51-05-269Z.yml @@ -0,0 +1,87 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Nolan Inc Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - button "TU Test User" [ref=e59]: + - generic [ref=e62]: TU + - generic [ref=e63]: Test User + - img [ref=e65] + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: Dashboard + - combobox [ref=e72]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=e73]: + - generic [ref=e74]: + - generic [ref=e75]: Total sales + - generic [ref=e76]: "35.98" + - generic [ref=e77]: + - generic [ref=e78]: Orders + - generic [ref=e79]: "1" + - generic [ref=e80]: + - generic [ref=e81]: Average order value + - generic [ref=e82]: "35.98" + - generic [ref=e83]: + - generic [ref=e84]: Conversion rate + - generic [ref=e85]: N/A + - generic [ref=e86]: + - generic [ref=e87]: Sales over time + - generic [ref=e88]: Charts coming soon + - generic [ref=e89]: + - generic [ref=e90]: Recent orders + - table [ref=e94]: + - rowgroup [ref=e95]: + - row "Order Customer Total Status" [ref=e96]: + - columnheader "Order" [ref=e97]: + - generic [ref=e98]: Order + - columnheader "Customer" [ref=e99]: + - generic [ref=e100]: Customer + - columnheader "Total" [ref=e101]: + - generic [ref=e102]: Total + - columnheader "Status" [ref=e103]: + - generic [ref=e104]: Status + - rowgroup [ref=e105]: + - row "#1001 buyer@example.com 35.98 EUR paid" [ref=e106]: + - cell "#1001" [ref=e107]: + - link "#1001" [ref=e108] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "buyer@example.com" [ref=e109] + - cell "35.98 EUR" [ref=e110] + - cell "paid" [ref=e111]: + - generic [ref=e112]: paid \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-51-11-341Z.yml b/.playwright-mcp/page-2026-04-12T19-51-11-341Z.yml new file mode 100644 index 00000000..de631467 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-51-11-341Z.yml @@ -0,0 +1,83 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Nolan Inc Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - button "TU Test User" [ref=e59]: + - generic [ref=e62]: TU + - generic [ref=e63]: Test User + - img [ref=e65] + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: Products + - link "New product" [ref=e72] [cursor=pointer]: + - /url: http://shop.test/admin/products/create + - img [ref=e73] + - generic [ref=e75]: New product + - generic [ref=e76]: + - generic [ref=e77]: + - generic: + - img + - textbox "Search products..." [ref=e78] + - combobox [ref=e80]: + - option "All statuses" [selected] + - option "Draft" + - option "Active" + - option "Archived" + - table [ref=e84]: + - rowgroup [ref=e85]: + - row "Title Status Vendor Variants" [ref=e86]: + - columnheader "Title" [ref=e87]: + - generic [ref=e88]: Title + - columnheader "Status" [ref=e89]: + - generic [ref=e90]: Status + - columnheader "Vendor" [ref=e91]: + - generic [ref=e92]: Vendor + - columnheader "Variants" [ref=e93]: + - generic [ref=e94]: Variants + - columnheader [ref=e95] + - rowgroup [ref=e96]: + - row "Demo Shirt active Demo Co 1 Edit" [ref=e97]: + - cell "Demo Shirt" [ref=e98]: + - link "Demo Shirt" [ref=e99] [cursor=pointer]: + - /url: http://shop.test/admin/products/1/edit + - cell "active" [ref=e100]: + - generic [ref=e101]: active + - cell "Demo Co" [ref=e102] + - cell "1" [ref=e103] + - cell "Edit" [ref=e104]: + - link "Edit" [ref=e105] [cursor=pointer]: + - /url: http://shop.test/admin/products/1/edit \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-51-19-748Z.yml b/.playwright-mcp/page-2026-04-12T19-51-19-748Z.yml new file mode 100644 index 00000000..da1a9612 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-51-19-748Z.yml @@ -0,0 +1,105 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Nolan Inc Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - button "TU Test User" [ref=e59]: + - generic [ref=e62]: TU + - generic [ref=e63]: Test User + - img [ref=e65] + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: + - generic [ref=e72]: "Order #1001" + - generic [ref=e73]: + - generic [ref=e74]: paid + - generic [ref=e75]: unfulfilled + - generic [ref=e76]: Apr 12, 2026 19:35 + - generic [ref=e77]: + - button "Fulfill items" [ref=e78]: + - img [ref=e80] + - generic [ref=e83]: Fulfill items + - button "Refund" [ref=e84]: + - img [ref=e86] + - generic [ref=e89]: Refund + - generic [ref=e90]: + - generic [ref=e91]: + - generic [ref=e92]: + - generic [ref=e93]: Items + - table [ref=e96]: + - rowgroup [ref=e97]: + - row "Product SKU Qty Total" [ref=e98]: + - columnheader "Product" [ref=e99]: + - generic [ref=e100]: Product + - columnheader "SKU" [ref=e101]: + - generic [ref=e102]: SKU + - columnheader "Qty" [ref=e103]: + - generic [ref=e104]: Qty + - columnheader "Total" [ref=e105]: + - generic [ref=e106]: Total + - rowgroup [ref=e107]: + - row "Demo Shirt DEMO-001 1 29.99 EUR" [ref=e108]: + - cell "Demo Shirt" [ref=e109] + - cell "DEMO-001" [ref=e110] + - cell "1" [ref=e111] + - cell "29.99 EUR" [ref=e112] + - generic [ref=e113]: + - generic [ref=e114]: + - generic [ref=e115]: Subtotal + - generic [ref=e116]: "29.99" + - generic [ref=e117]: + - generic [ref=e118]: Shipping + - generic [ref=e119]: "5.99" + - generic [ref=e120]: + - generic [ref=e121]: Tax + - generic [ref=e122]: "0.00" + - generic [ref=e123]: + - generic [ref=e124]: Total + - generic [ref=e125]: "35.98" + - generic [ref=e126]: + - generic [ref=e127]: Fulfillments + - paragraph [ref=e128]: No fulfillments yet. + - generic [ref=e129]: + - generic [ref=e130]: + - generic [ref=e131]: Customer + - generic [ref=e132]: + - generic [ref=e133]: buyer@example.com + - generic [ref=e134]: buyer@example.com + - generic [ref=e135]: + - generic [ref=e136]: Payment + - generic [ref=e137]: + - generic [ref=e138]: "Method: credit_card" + - generic [ref=e139]: "Status: paid" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-51-22-195Z.yml b/.playwright-mcp/page-2026-04-12T19-51-22-195Z.yml new file mode 100644 index 00000000..34629e56 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-51-22-195Z.yml @@ -0,0 +1,100 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Nolan Inc Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - button "TU Test User" [ref=e59]: + - generic [ref=e62]: TU + - generic [ref=e63]: Test User + - img [ref=e65] + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: New product + - link "Back" [ref=e72] [cursor=pointer]: + - /url: http://shop.test/admin/products + - generic [ref=e73]: + - generic [ref=e74]: + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Title + - textbox "Title" [ref=e79]: + - /placeholder: Short sleeve t-shirt + - generic [ref=e81]: + - generic [ref=e82]: Handle + - textbox "Handle" [ref=e84]: + - /placeholder: short-sleeve-t-shirt + - generic [ref=e86]: + - generic [ref=e87]: Description + - textbox "Description" [ref=e88] + - generic [ref=e89]: + - generic [ref=e90]: Pricing & Inventory + - generic [ref=e91]: + - generic [ref=e92]: + - generic [ref=e93]: Price (cents) + - spinbutton "Price (cents)" [ref=e95]: "0" + - generic [ref=e96]: + - generic [ref=e97]: SKU + - textbox "SKU" [ref=e99] + - generic [ref=e100]: + - generic [ref=e101]: Inventory + - spinbutton "Inventory" [ref=e103]: "0" + - generic [ref=e104]: + - generic [ref=e105]: + - generic [ref=e106]: Status + - generic [ref=e108]: + - generic [ref=e109]: Status + - combobox "Status" [ref=e110]: + - option "Draft" [selected] + - option "Active" + - option "Archived" + - generic [ref=e111]: + - generic [ref=e112]: Organization + - generic [ref=e113]: + - generic [ref=e114]: + - generic [ref=e115]: Vendor + - textbox "Vendor" [ref=e117] + - generic [ref=e118]: + - generic [ref=e119]: Product type + - textbox "Product type" [ref=e121] + - generic [ref=e122]: + - generic [ref=e123]: Tags (comma separated) + - textbox "Tags (comma separated)" [ref=e125] + - generic [ref=e126]: + - link "Cancel" [ref=e127] [cursor=pointer]: + - /url: http://shop.test/admin/products + - button "Save product" [ref=e128]: + - img [ref=e130] + - generic [ref=e133]: Save product \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T19-51-26-178Z.yml b/.playwright-mcp/page-2026-04-12T19-51-26-178Z.yml new file mode 100644 index 00000000..f969e4be --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T19-51-26-178Z.yml @@ -0,0 +1,101 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Nolan Inc Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Nolan Inc Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - button "TU Test User" [ref=e59]: + - generic [ref=e62]: TU + - generic [ref=e63]: Test User + - img [ref=e65] + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: New discount + - link "Back" [ref=e72] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - generic [ref=e73]: + - generic [ref=e74]: + - generic [ref=e75]: + - generic [ref=e76]: Details + - generic [ref=e77]: + - generic [ref=e78]: + - generic [ref=e79]: Type + - combobox "Type" [ref=e80]: + - option "Code" [selected] + - option "Automatic" + - generic [ref=e81]: + - generic [ref=e82]: Code + - textbox "Code" [ref=e84]: + - /placeholder: SAVE20 + - generic [ref=e85]: + - generic [ref=e86]: Value type + - combobox "Value type" [ref=e87]: + - option "Percentage" [selected] + - option "Fixed amount" + - option "Free shipping" + - generic [ref=e88]: + - generic [ref=e89]: Value (%) + - spinbutton "Value (%)" [ref=e91]: "0" + - generic [ref=e92]: + - generic [ref=e93]: Minimum purchase (cents) + - spinbutton "Minimum purchase (cents)" [ref=e95] + - generic [ref=e96]: + - generic [ref=e97]: Active dates + - generic [ref=e98]: + - generic [ref=e99]: + - generic [ref=e100]: Starts at + - textbox "Starts at" [ref=e102] + - generic [ref=e103]: + - generic [ref=e104]: Ends at + - textbox "Ends at" [ref=e106] + - generic [ref=e108]: + - generic [ref=e109]: Status + - generic [ref=e110]: + - generic [ref=e111]: + - generic [ref=e112]: Status + - combobox "Status" [ref=e113]: + - option "Draft" [selected] + - option "Active" + - option "Disabled" + - option "Expired" + - generic [ref=e114]: + - generic [ref=e115]: Usage limit + - spinbutton "Usage limit" [ref=e117] + - generic [ref=e118]: + - link "Cancel" [ref=e119] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - button "Save discount" [ref=e120]: + - img [ref=e122] + - generic [ref=e125]: Save discount \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-16-49-750Z.yml b/.playwright-mcp/page-2026-04-12T20-16-49-750Z.yml new file mode 100644 index 00000000..87220a4c --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-16-49-750Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Laravel Starter Kit" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/dashboard + - img [ref=e6] + - generic [ref=e8]: Laravel Starter Kit + - navigation [ref=e9]: + - generic [ref=e10]: + - generic [ref=e12]: Platform + - link "Dashboard" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/dashboard + - img [ref=e17] + - generic [ref=e19]: Dashboard + - navigation [ref=e21]: + - link "Repository" [ref=e23] [cursor=pointer]: + - /url: https://github.com/laravel/livewire-starter-kit + - img [ref=e25] + - generic [ref=e30]: Repository + - link "Documentation" [ref=e32] [cursor=pointer]: + - /url: https://laravel.com/docs/starter-kits#livewire + - img [ref=e34] + - generic [ref=e36]: Documentation + - button "SA Shop Admin" [ref=e38]: + - generic [ref=e41]: SA + - generic [ref=e42]: Shop Admin + - img [ref=e44] + - generic [ref=e48]: + - generic [ref=e49]: + - img [ref=e51] + - img [ref=e54] + - img [ref=e57] + - img [ref=e60] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-16-52-856Z.yml b/.playwright-mcp/page-2026-04-12T20-16-52-856Z.yml new file mode 100644 index 00000000..723ccd4f --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-16-52-856Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Pages + - link "New page" [ref=e127] [cursor=pointer]: + - /url: http://shop.test/admin/pages/create + - img [ref=e128] + - generic [ref=e130]: New page + - generic [ref=e131]: + - generic: + - img + - textbox "Search pages..." [ref=e132] + - table [ref=e137]: + - rowgroup [ref=e138]: + - row "Title Handle Status Updated" [ref=e139]: + - columnheader "Title" [ref=e140]: + - generic [ref=e141]: Title + - columnheader "Handle" [ref=e142]: + - generic [ref=e143]: Handle + - columnheader "Status" [ref=e144]: + - generic [ref=e145]: Status + - columnheader "Updated" [ref=e146]: + - generic [ref=e147]: Updated + - columnheader [ref=e148] + - rowgroup [ref=e149]: + - row "About Us about-us published 1 minute ago Edit Delete" [ref=e150]: + - cell "About Us" [ref=e151]: + - link "About Us" [ref=e152] [cursor=pointer]: + - /url: http://shop.test/admin/pages/1/edit + - cell "about-us" [ref=e153] + - cell "published" [ref=e154]: + - generic [ref=e155]: published + - cell "1 minute ago" [ref=e156] + - cell "Edit Delete" [ref=e157]: + - generic [ref=e158]: + - link "Edit" [ref=e159] [cursor=pointer]: + - /url: http://shop.test/admin/pages/1/edit + - button "Delete" [ref=e160]: + - img [ref=e162] + - generic [ref=e165]: Delete + - row "Contact contact published 1 minute ago Edit Delete" [ref=e166]: + - cell "Contact" [ref=e167]: + - link "Contact" [ref=e168] [cursor=pointer]: + - /url: http://shop.test/admin/pages/2/edit + - cell "contact" [ref=e169] + - cell "published" [ref=e170]: + - generic [ref=e171]: published + - cell "1 minute ago" [ref=e172] + - cell "Edit Delete" [ref=e173]: + - generic [ref=e174]: + - link "Edit" [ref=e175] [cursor=pointer]: + - /url: http://shop.test/admin/pages/2/edit + - button "Delete" [ref=e176]: + - img [ref=e178] + - generic [ref=e181]: Delete + - row "FAQ faq published 1 minute ago Edit Delete" [ref=e182]: + - cell "FAQ" [ref=e183]: + - link "FAQ" [ref=e184] [cursor=pointer]: + - /url: http://shop.test/admin/pages/3/edit + - cell "faq" [ref=e185] + - cell "published" [ref=e186]: + - generic [ref=e187]: published + - cell "1 minute ago" [ref=e188] + - cell "Edit Delete" [ref=e189]: + - generic [ref=e190]: + - link "Edit" [ref=e191] [cursor=pointer]: + - /url: http://shop.test/admin/pages/3/edit + - button "Delete" [ref=e192]: + - img [ref=e194] + - generic [ref=e197]: Delete \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-16-59-264Z.yml b/.playwright-mcp/page-2026-04-12T20-16-59-264Z.yml new file mode 100644 index 00000000..2b969d83 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-16-59-264Z.yml @@ -0,0 +1,106 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: Settings + - generic [ref=e126]: + - button "General" [ref=e127] + - link "Shipping" [ref=e128] [cursor=pointer]: + - /url: http://shop.test/admin/settings/shipping + - link "Taxes" [ref=e129] [cursor=pointer]: + - /url: http://shop.test/admin/settings/taxes + - generic [ref=e130]: + - generic [ref=e131]: + - generic [ref=e132]: Store name + - textbox "Store name" [ref=e134]: Demo Store + - generic [ref=e135]: + - generic [ref=e136]: + - generic [ref=e137]: Currency + - textbox "Currency" [ref=e139]: EUR + - generic [ref=e140]: + - generic [ref=e141]: Locale + - textbox "Locale" [ref=e143]: en + - generic [ref=e144]: + - generic [ref=e145]: Timezone + - textbox "Timezone" [ref=e147]: Europe/Berlin + - button "Save settings" [ref=e149]: + - img [ref=e151] + - generic [ref=e154]: Save settings + - generic [ref=e155]: + - generic [ref=e156]: Notifications + - paragraph [ref=e157]: Notification channel configuration is coming soon. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-17-01-819Z.yml b/.playwright-mcp/page-2026-04-12T20-17-01-819Z.yml new file mode 100644 index 00000000..71fba4a2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-17-01-819Z.yml @@ -0,0 +1,104 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Analytics + - generic [ref=e127]: + - generic [ref=e128]: + - generic [ref=e129]: Start date + - textbox "Start date" [ref=e131]: 2026-03-14 + - generic [ref=e133]: + - generic [ref=e134]: End date + - textbox "End date" [ref=e136]: 2026-04-12 + - generic [ref=e138]: + - generic [ref=e139]: + - paragraph [ref=e140]: Revenue + - paragraph [ref=e141]: 0.00 EUR + - generic [ref=e142]: + - paragraph [ref=e143]: Orders + - paragraph [ref=e144]: "0" + - generic [ref=e145]: + - paragraph [ref=e146]: AOV + - paragraph [ref=e147]: 0.00 EUR + - generic [ref=e148]: + - paragraph [ref=e149]: Visits + - paragraph [ref=e150]: "0" + - generic [ref=e151]: + - generic [ref=e153]: Daily breakdown + - generic [ref=e154]: No data for this range. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-17-04-313Z.yml b/.playwright-mcp/page-2026-04-12T20-17-04-313Z.yml new file mode 100644 index 00000000..75998fca --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-17-04-313Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: Developers + - generic [ref=e126]: + - generic [ref=e127]: API tokens + - paragraph [ref=e128]: Personal access tokens for the Admin API. + - generic [ref=e129]: + - generic [ref=e130]: + - generic [ref=e131]: Token name + - textbox "Token name" [ref=e133]: + - /placeholder: My integration + - button "Create token" [ref=e134]: + - img [ref=e136] + - img [ref=e139] + - generic [ref=e141]: Create token + - paragraph [ref=e143]: No tokens yet. + - generic [ref=e144]: + - generic [ref=e145]: Webhook subscriptions + - paragraph [ref=e146]: HTTP endpoints notified when events occur. + - generic [ref=e147]: + - generic [ref=e148]: + - generic [ref=e149]: Event type + - textbox "Event type" [ref=e151]: + - /placeholder: order.placed + - generic [ref=e152]: + - generic [ref=e153]: URL + - textbox "URL" [ref=e155]: + - /placeholder: https://example.com/webhook + - button "Add webhook" [ref=e157]: + - img [ref=e159] + - img [ref=e162] + - generic [ref=e164]: Add webhook + - paragraph [ref=e166]: No webhooks yet. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-17-06-775Z.yml b/.playwright-mcp/page-2026-04-12T20-17-06-775Z.yml new file mode 100644 index 00000000..10d27131 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-17-06-775Z.yml @@ -0,0 +1,153 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e126]: Navigation + - generic [ref=e127]: + - generic [ref=e128]: Create menu + - generic [ref=e129]: + - generic [ref=e130]: + - generic [ref=e131]: Title + - textbox "Title" [ref=e133]: + - /placeholder: Main menu + - button "Add menu" [ref=e134]: + - img [ref=e136] + - img [ref=e139] + - generic [ref=e141]: Add menu + - generic [ref=e143]: + - generic [ref=e144]: + - generic [ref=e145]: + - generic [ref=e146]: Main Menu + - paragraph [ref=e147]: main-menu + - generic [ref=e148]: + - button "Add item" [ref=e149]: + - img [ref=e151] + - img [ref=e154] + - generic [ref=e156]: Add item + - button "Delete" [ref=e157]: + - img [ref=e159] + - generic [ref=e162]: Delete + - list [ref=e164]: + - listitem [ref=e165]: + - generic [ref=e166]: Home (link) + - generic [ref=e167]: + - button [ref=e168]: + - img [ref=e170] + - img [ref=e173] + - button [ref=e175]: + - img [ref=e177] + - img [ref=e180] + - button "Remove" [ref=e182]: + - img [ref=e184] + - generic [ref=e187]: Remove + - listitem [ref=e188]: + - generic [ref=e189]: Collections (link) + - generic [ref=e190]: + - button [ref=e191]: + - img [ref=e193] + - img [ref=e196] + - button [ref=e198]: + - img [ref=e200] + - img [ref=e203] + - button "Remove" [ref=e205]: + - img [ref=e207] + - generic [ref=e210]: Remove + - listitem [ref=e211]: + - generic [ref=e212]: About (link) + - generic [ref=e213]: + - button [ref=e214]: + - img [ref=e216] + - img [ref=e219] + - button [ref=e221]: + - img [ref=e223] + - img [ref=e226] + - button "Remove" [ref=e228]: + - img [ref=e230] + - generic [ref=e233]: Remove + - listitem [ref=e234]: + - generic [ref=e235]: Contact (link) + - generic [ref=e236]: + - button [ref=e237]: + - img [ref=e239] + - img [ref=e242] + - button [ref=e244]: + - img [ref=e246] + - img [ref=e249] + - button "Remove" [ref=e251]: + - img [ref=e253] + - generic [ref=e256]: Remove \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-20-44-557Z.yml b/.playwright-mcp/page-2026-04-12T20-20-44-557Z.yml new file mode 100644 index 00000000..db185f62 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-20-44-557Z.yml @@ -0,0 +1,82 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - paragraph [ref=e18]: New season + - heading "Thoughtfully made, honestly priced." [level=1] [ref=e19] + - paragraph [ref=e20]: A curated collection of timeless goods designed to last. Explore our latest arrivals and find something you will love. + - generic [ref=e21]: + - link "Shop the collection" [ref=e22] [cursor=pointer]: + - /url: "#featured" + - link "What is new" [ref=e23] [cursor=pointer]: + - /url: "#recent" + - generic [ref=e24]: + - generic [ref=e26]: + - heading "Featured collections" [level=2] [ref=e27] + - paragraph [ref=e28]: Hand-picked edits for every occasion. + - generic [ref=e29]: + - link "Featured Shop now" [ref=e30] [cursor=pointer]: + - /url: /collections/featured + - generic [ref=e31]: + - heading "Featured" [level=3] [ref=e32] + - generic [ref=e33]: Shop now + - link "Sale Shop now" [ref=e34] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e35]: + - heading "Sale" [level=3] [ref=e36] + - generic [ref=e37]: Shop now + - generic [ref=e38]: + - generic [ref=e40]: + - heading "New arrivals" [level=2] [ref=e41] + - paragraph [ref=e42]: Fresh goods, just in. + - generic [ref=e43]: + - link "Cap EUR 24.99" [ref=e44] [cursor=pointer]: + - /url: /products/cap + - generic [ref=e46]: + - heading "Cap" [level=3] [ref=e47] + - paragraph [ref=e48]: EUR 24.99 + - link "Tote Bag EUR 14.99" [ref=e49] [cursor=pointer]: + - /url: /products/tote-bag + - generic [ref=e51]: + - heading "Tote Bag" [level=3] [ref=e52] + - paragraph [ref=e53]: EUR 14.99 + - link "Classic Tee EUR 19.99" [ref=e54] [cursor=pointer]: + - /url: /products/classic-tee + - generic [ref=e56]: + - heading "Classic Tee" [level=3] [ref=e57] + - paragraph [ref=e58]: EUR 19.99 + - link "Hoodie EUR 49.99" [ref=e59] [cursor=pointer]: + - /url: /products/hoodie + - generic [ref=e61]: + - heading "Hoodie" [level=3] [ref=e62] + - paragraph [ref=e63]: EUR 49.99 + - link "Sneakers EUR 79.99" [ref=e64] [cursor=pointer]: + - /url: /products/sneakers + - generic [ref=e66]: + - heading "Sneakers" [level=3] [ref=e67] + - paragraph [ref=e68]: EUR 79.99 + - link "Mug EUR 9.99" [ref=e69] [cursor=pointer]: + - /url: /products/mug + - generic [ref=e71]: + - heading "Mug" [level=3] [ref=e72] + - paragraph [ref=e73]: EUR 9.99 + - contentinfo [ref=e74]: + - generic [ref=e76]: + - navigation "Footer navigation" + - paragraph [ref=e77]: (c) Shop + - button "Open cart" [ref=e80]: + - img [ref=e81] + - generic [ref=e83]: Cart + - generic [ref=e84]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-20-57-839Z.yml b/.playwright-mcp/page-2026-04-12T20-20-57-839Z.yml new file mode 100644 index 00000000..c02b5ec6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-20-57-839Z.yml @@ -0,0 +1,42 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Classic Tee" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 19.99 + - group "Variant" [ref=e25]: + - generic [ref=e26]: Variant + - generic [ref=e27]: + - button "TEE-M" [ref=e28] + - button "TEE-L" [ref=e29] + - button "TEE-S" [ref=e30] + - generic [ref=e31]: + - generic [ref=e32]: Quantity + - generic [ref=e33]: + - button "Decrease quantity" [ref=e34]: "-" + - generic [ref=e35]: "1" + - button "Increase quantity" [ref=e36]: + + - button "Add to cart" [ref=e37]: + - generic [ref=e38]: Add to cart + - paragraph [ref=e40]: Classic Tee description. + - contentinfo [ref=e41]: + - generic [ref=e43]: + - navigation "Footer navigation" + - paragraph [ref=e44]: (c) Shop + - button "Open cart" [ref=e47]: + - img [ref=e48] + - generic [ref=e50]: Cart + - generic [ref=e51]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-21-07-187Z.yml b/.playwright-mcp/page-2026-04-12T20-21-07-187Z.yml new file mode 100644 index 00000000..eaca9122 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-21-07-187Z.yml @@ -0,0 +1,64 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Classic Tee" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 19.99 + - group "Variant" [ref=e25]: + - generic [ref=e26]: Variant + - generic [ref=e27]: + - button "TEE-M" [ref=e28] + - button "TEE-L" [ref=e29] + - button "TEE-S" [ref=e30] + - generic [ref=e31]: + - generic [ref=e32]: Quantity + - generic [ref=e33]: + - button "Decrease quantity" [ref=e34]: "-" + - generic [ref=e35]: "1" + - button "Increase quantity" [ref=e36]: + + - generic [ref=e52]: Added to cart + - button "Add to cart" [ref=e37]: + - generic [ref=e38]: Add to cart + - paragraph [ref=e40]: Classic Tee description. + - contentinfo [ref=e41]: + - generic [ref=e43]: + - navigation "Footer navigation" + - paragraph [ref=e44]: (c) Shop + - generic [ref=e46]: + - button "Open cart" [ref=e47]: + - img [ref=e48] + - generic [ref=e50]: Cart + - generic [ref=e51]: "1" + - dialog "Shopping cart" [ref=e54]: + - banner [ref=e55]: + - heading "Your cart" [level=2] [ref=e56] + - button "Close cart" [ref=e57]: + - img [ref=e58] + - list [ref=e61]: + - listitem [ref=e62]: + - generic [ref=e64]: + - paragraph [ref=e65]: Classic Tee + - paragraph [ref=e66]: Qty 1 + - paragraph [ref=e67]: EUR 19.99 + - contentinfo [ref=e68]: + - generic [ref=e69]: + - generic [ref=e70]: Subtotal + - generic [ref=e71]: EUR 19.99 + - generic [ref=e72]: + - link "View cart" [ref=e73] [cursor=pointer]: + - /url: http://shop.test/cart + - link "Checkout" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/checkout \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-21-14-109Z.yml b/.playwright-mcp/page-2026-04-12T20-21-14-109Z.yml new file mode 100644 index 00000000..876cfd1b --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-21-14-109Z.yml @@ -0,0 +1,93 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Contact and shipping address" [level=2] [ref=e34] + - generic [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: Email + - textbox "Email" [ref=e38] + - generic [ref=e39]: + - generic [ref=e40]: + - generic [ref=e41]: First name + - textbox "First name" [ref=e42] + - generic [ref=e43]: + - generic [ref=e44]: Last name + - textbox "Last name" [ref=e45] + - generic [ref=e46]: + - generic [ref=e47]: Address + - textbox "Address" [ref=e48] + - generic [ref=e49]: + - generic [ref=e50]: Apartment, suite (optional) + - textbox "Apartment, suite (optional)" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: + - generic [ref=e54]: City + - textbox "City" [ref=e55] + - generic [ref=e56]: + - generic [ref=e57]: Postal code + - textbox "Postal code" [ref=e58] + - generic [ref=e59]: + - generic [ref=e60]: Country + - combobox "Country" [ref=e61]: + - option "Germany" [selected] + - option "Austria" + - option "Switzerland" + - option "France" + - option "Netherlands" + - option "United States" + - option "United Kingdom" + - generic [ref=e62]: + - checkbox "Billing address is the same as shipping" [checked] [ref=e63] + - generic [ref=e64]: Billing address is the same as shipping + - button "Continue to shipping" [ref=e65] + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Classic Tee + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 19.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 19.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 0.00 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 19.99 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-21-36-535Z.yml b/.playwright-mcp/page-2026-04-12T20-21-36-535Z.yml new file mode 100644 index 00000000..a5ec3f6a --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-21-36-535Z.yml @@ -0,0 +1,63 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Shipping method" [level=2] [ref=e96] + - generic [ref=e98] [cursor=pointer]: + - generic [ref=e99]: + - radio "Standard EUR 5.99" [ref=e100] + - generic [ref=e101]: Standard + - generic [ref=e102]: EUR 5.99 + - generic [ref=e103]: + - button "Back" [ref=e104] + - button "Continue to payment" [ref=e105] + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Classic Tee + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 19.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 19.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 0.00 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 19.99 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-21-51-215Z.yml b/.playwright-mcp/page-2026-04-12T20-21-51-215Z.yml new file mode 100644 index 00000000..2f37234c --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-21-51-215Z.yml @@ -0,0 +1,63 @@ +- generic [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Shipping method" [level=2] [ref=e96] + - generic [ref=e98] [cursor=pointer]: + - generic [ref=e99]: + - radio "Standard EUR 5.99" [checked] [active] [ref=e100] + - generic [ref=e101]: Standard + - generic [ref=e102]: EUR 5.99 + - generic [ref=e103]: + - button "Back" [ref=e104] + - button "Continue to payment" [ref=e105] + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Classic Tee + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 19.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 19.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 0.00 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 19.99 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-21-56-826Z.yml b/.playwright-mcp/page-2026-04-12T20-21-56-826Z.yml new file mode 100644 index 00000000..37d12f06 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-21-56-826Z.yml @@ -0,0 +1,80 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Payment" [level=2] [ref=e106] + - generic [ref=e107]: + - generic [ref=e108]: + - radio "Credit card" [checked] [ref=e109] + - generic [ref=e110]: Credit card + - generic [ref=e111]: + - radio "PayPal" [ref=e112] + - generic [ref=e113]: PayPal + - generic [ref=e114]: + - radio "Bank transfer" [ref=e115] + - generic [ref=e116]: Bank transfer + - generic [ref=e117]: "Magic test card: 4242 4242 4242 4242, expiry 12/30, CVC 123." + - generic [ref=e118]: + - generic [ref=e119]: Card number + - textbox "Card number" [ref=e120]: "4242424242424242" + - generic [ref=e121]: + - generic [ref=e122]: + - generic [ref=e123]: Expiry (MM/YY) + - textbox "Expiry (MM/YY)" [ref=e124]: 12/30 + - generic [ref=e125]: + - generic [ref=e126]: CVC + - textbox "CVC" [ref=e127]: "123" + - generic [ref=e128]: + - button "Back" [ref=e129] + - button "Place order" [ref=e130]: + - generic [ref=e131]: Place order + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Classic Tee + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 19.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 19.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 5.99 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 25.98 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-22-07-655Z.yml b/.playwright-mcp/page-2026-04-12T20-22-07-655Z.yml new file mode 100644 index 00000000..b18fe956 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-22-07-655Z.yml @@ -0,0 +1,63 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - img [ref=e19] + - heading "Thank you for your order" [level=1] [ref=e21] + - paragraph [ref=e22]: "Order #1 is confirmed. A receipt has been sent to review@shop.test." + - generic [ref=e23]: + - generic [ref=e24]: + - heading "Order details" [level=2] [ref=e25] + - list [ref=e26]: + - listitem [ref=e27]: + - generic [ref=e30]: + - paragraph [ref=e31]: Classic Tee + - paragraph [ref=e32]: Qty 1 + - paragraph [ref=e33]: EUR 19.99 + - generic [ref=e34]: + - generic [ref=e35]: + - generic [ref=e36]: Subtotal + - generic [ref=e37]: EUR 19.99 + - generic [ref=e38]: + - generic [ref=e39]: Shipping + - generic [ref=e40]: EUR 5.99 + - generic [ref=e41]: + - generic [ref=e42]: Tax + - generic [ref=e43]: EUR 4.15 + - generic [ref=e44]: + - generic [ref=e45]: Total + - generic [ref=e46]: EUR 25.98 + - complementary [ref=e47]: + - generic [ref=e48]: + - heading "Shipping address" [level=3] [ref=e49] + - generic [ref=e50]: + - text: Review Tester + - text: Demo Strasse 1 + - text: Berlin 10115 + - text: DE + - generic [ref=e51]: + - heading "Status" [level=3] [ref=e52] + - paragraph [ref=e53]: "Payment: paid" + - paragraph [ref=e54]: "Fulfillment: unfulfilled" + - link "Continue shopping" [ref=e55] [cursor=pointer]: + - /url: http://shop.test/storefront + - contentinfo [ref=e56]: + - generic [ref=e58]: + - navigation "Footer navigation" + - paragraph [ref=e59]: (c) Shop + - button "Open cart" [ref=e62]: + - img [ref=e63] + - generic [ref=e65]: Cart + - generic [ref=e66]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-22-31-968Z.yml b/.playwright-mcp/page-2026-04-12T20-22-31-968Z.yml new file mode 100644 index 00000000..87220a4c --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-22-31-968Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Laravel Starter Kit" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/dashboard + - img [ref=e6] + - generic [ref=e8]: Laravel Starter Kit + - navigation [ref=e9]: + - generic [ref=e10]: + - generic [ref=e12]: Platform + - link "Dashboard" [ref=e15] [cursor=pointer]: + - /url: http://shop.test/dashboard + - img [ref=e17] + - generic [ref=e19]: Dashboard + - navigation [ref=e21]: + - link "Repository" [ref=e23] [cursor=pointer]: + - /url: https://github.com/laravel/livewire-starter-kit + - img [ref=e25] + - generic [ref=e30]: Repository + - link "Documentation" [ref=e32] [cursor=pointer]: + - /url: https://laravel.com/docs/starter-kits#livewire + - img [ref=e34] + - generic [ref=e36]: Documentation + - button "SA Shop Admin" [ref=e38]: + - generic [ref=e41]: SA + - generic [ref=e42]: Shop Admin + - img [ref=e44] + - generic [ref=e48]: + - generic [ref=e49]: + - img [ref=e51] + - img [ref=e54] + - img [ref=e57] + - img [ref=e60] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-22-59-910Z.yml b/.playwright-mcp/page-2026-04-12T20-22-59-910Z.yml new file mode 100644 index 00000000..08d41c6b --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-22-59-910Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Dashboard + - combobox [ref=e127]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=e128]: + - generic [ref=e129]: + - generic [ref=e130]: Total sales + - generic [ref=e131]: "103.92" + - generic [ref=e132]: + - generic [ref=e133]: Orders + - generic [ref=e134]: "4" + - generic [ref=e135]: + - generic [ref=e136]: Average order value + - generic [ref=e137]: "25.98" + - generic [ref=e138]: + - generic [ref=e139]: Conversion rate + - generic [ref=e140]: N/A + - generic [ref=e141]: + - generic [ref=e142]: Sales over time + - generic [ref=e143]: Charts coming soon + - generic [ref=e144]: + - generic [ref=e145]: Recent orders + - table [ref=e149]: + - rowgroup [ref=e150]: + - row "Order Customer Total Status" [ref=e151]: + - columnheader "Order" [ref=e152]: + - generic [ref=e153]: Order + - columnheader "Customer" [ref=e154]: + - generic [ref=e155]: Customer + - columnheader "Total" [ref=e156]: + - generic [ref=e157]: Total + - columnheader "Status" [ref=e158]: + - generic [ref=e159]: Status + - rowgroup [ref=e160]: + - row "#1 review@shop.test 25.98 EUR paid" [ref=e161]: + - cell "#1" [ref=e162]: + - link "#1" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/orders/4 + - cell "review@shop.test" [ref=e164] + - cell "25.98 EUR" [ref=e165] + - cell "paid" [ref=e166]: + - generic [ref=e167]: paid + - row "D-1001 alice@shop.test 30.98 EUR pending" [ref=e168]: + - cell "D-1001" [ref=e169]: + - link "D-1001" [ref=e170] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "alice@shop.test" [ref=e171] + - cell "30.98 EUR" [ref=e172] + - cell "pending" [ref=e173]: + - generic [ref=e174]: pending + - row "D-1002 bob@shop.test 20.98 EUR paid" [ref=e175]: + - cell "D-1002" [ref=e176]: + - link "D-1002" [ref=e177] [cursor=pointer]: + - /url: http://shop.test/admin/orders/2 + - cell "bob@shop.test" [ref=e178] + - cell "20.98 EUR" [ref=e179] + - cell "paid" [ref=e180]: + - generic [ref=e181]: paid + - row "D-1003 carol@shop.test 25.98 EUR refunded" [ref=e182]: + - cell "D-1003" [ref=e183]: + - link "D-1003" [ref=e184] [cursor=pointer]: + - /url: http://shop.test/admin/orders/3 + - cell "carol@shop.test" [ref=e185] + - cell "25.98 EUR" [ref=e186] + - cell "refunded" [ref=e187]: + - generic [ref=e188]: refunded \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-23-10-091Z.yml b/.playwright-mcp/page-2026-04-12T20-23-10-091Z.yml new file mode 100644 index 00000000..c9c07aee --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-23-10-091Z.yml @@ -0,0 +1,174 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Products + - link "New product" [ref=e127] [cursor=pointer]: + - /url: http://shop.test/admin/products/create + - img [ref=e128] + - generic [ref=e130]: New product + - generic [ref=e131]: + - generic [ref=e132]: + - generic: + - img + - textbox "Search products..." [ref=e133] + - combobox [ref=e135]: + - option "All statuses" [selected] + - option "Draft" + - option "Active" + - option "Archived" + - table [ref=e139]: + - rowgroup [ref=e140]: + - row "Title Status Vendor Variants" [ref=e141]: + - columnheader "Title" [ref=e142]: + - generic [ref=e143]: Title + - columnheader "Status" [ref=e144]: + - generic [ref=e145]: Status + - columnheader "Vendor" [ref=e146]: + - generic [ref=e147]: Vendor + - columnheader "Variants" [ref=e148]: + - generic [ref=e149]: Variants + - columnheader [ref=e150] + - rowgroup [ref=e151]: + - row "Cap active Demo Brand 1 Edit" [ref=e152]: + - cell "Cap" [ref=e153]: + - link "Cap" [ref=e154] [cursor=pointer]: + - /url: http://shop.test/admin/products/3/edit + - cell "active" [ref=e155]: + - generic [ref=e156]: active + - cell "Demo Brand" [ref=e157] + - cell "1" [ref=e158] + - cell "Edit" [ref=e159]: + - link "Edit" [ref=e160] [cursor=pointer]: + - /url: http://shop.test/admin/products/3/edit + - row "Tote Bag active Demo Brand 1 Edit" [ref=e161]: + - cell "Tote Bag" [ref=e162]: + - link "Tote Bag" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/products/4/edit + - cell "active" [ref=e164]: + - generic [ref=e165]: active + - cell "Demo Brand" [ref=e166] + - cell "1" [ref=e167] + - cell "Edit" [ref=e168]: + - link "Edit" [ref=e169] [cursor=pointer]: + - /url: http://shop.test/admin/products/4/edit + - row "Classic Tee active Demo Brand 3 Edit" [ref=e170]: + - cell "Classic Tee" [ref=e171]: + - link "Classic Tee" [ref=e172] [cursor=pointer]: + - /url: http://shop.test/admin/products/1/edit + - cell "active" [ref=e173]: + - generic [ref=e174]: active + - cell "Demo Brand" [ref=e175] + - cell "3" [ref=e176] + - cell "Edit" [ref=e177]: + - link "Edit" [ref=e178] [cursor=pointer]: + - /url: http://shop.test/admin/products/1/edit + - row "Hoodie active Demo Brand 2 Edit" [ref=e179]: + - cell "Hoodie" [ref=e180]: + - link "Hoodie" [ref=e181] [cursor=pointer]: + - /url: http://shop.test/admin/products/2/edit + - cell "active" [ref=e182]: + - generic [ref=e183]: active + - cell "Demo Brand" [ref=e184] + - cell "2" [ref=e185] + - cell "Edit" [ref=e186]: + - link "Edit" [ref=e187] [cursor=pointer]: + - /url: http://shop.test/admin/products/2/edit + - row "Sneakers active Demo Brand 3 Edit" [ref=e188]: + - cell "Sneakers" [ref=e189]: + - link "Sneakers" [ref=e190] [cursor=pointer]: + - /url: http://shop.test/admin/products/5/edit + - cell "active" [ref=e191]: + - generic [ref=e192]: active + - cell "Demo Brand" [ref=e193] + - cell "3" [ref=e194] + - cell "Edit" [ref=e195]: + - link "Edit" [ref=e196] [cursor=pointer]: + - /url: http://shop.test/admin/products/5/edit + - row "Mug active Demo Brand 1 Edit" [ref=e197]: + - cell "Mug" [ref=e198]: + - link "Mug" [ref=e199] [cursor=pointer]: + - /url: http://shop.test/admin/products/6/edit + - cell "active" [ref=e200]: + - generic [ref=e201]: active + - cell "Demo Brand" [ref=e202] + - cell "1" [ref=e203] + - cell "Edit" [ref=e204]: + - link "Edit" [ref=e205] [cursor=pointer]: + - /url: http://shop.test/admin/products/6/edit \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-23-24-715Z.yml b/.playwright-mcp/page-2026-04-12T20-23-24-715Z.yml new file mode 100644 index 00000000..2b825ce1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-23-24-715Z.yml @@ -0,0 +1,156 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: Orders + - generic [ref=e126]: + - generic [ref=e127]: + - generic: + - img + - textbox "Search orders..." [ref=e128] + - combobox [ref=e130]: + - option "All payments" [selected] + - option "Pending" + - option "Paid" + - option "Refunded" + - option "Partially refunded" + - combobox [ref=e131]: + - option "All fulfillment" [selected] + - option "Unfulfilled" + - option "Partial" + - option "Fulfilled" + - table [ref=e135]: + - rowgroup [ref=e136]: + - row "Order Customer Total Payment Fulfillment Date" [ref=e137]: + - columnheader "Order" [ref=e138]: + - generic [ref=e139]: Order + - columnheader "Customer" [ref=e140]: + - generic [ref=e141]: Customer + - columnheader "Total" [ref=e142]: + - generic [ref=e143]: Total + - columnheader "Payment" [ref=e144]: + - generic [ref=e145]: Payment + - columnheader "Fulfillment" [ref=e146]: + - generic [ref=e147]: Fulfillment + - columnheader "Date" [ref=e148]: + - generic [ref=e149]: Date + - rowgroup [ref=e150]: + - row "#1 review@shop.test 25.98 EUR paid unfulfilled Apr 12, 2026" [ref=e151]: + - cell "#1" [ref=e152]: + - link "#1" [ref=e153] [cursor=pointer]: + - /url: http://shop.test/admin/orders/4 + - cell "review@shop.test" [ref=e154] + - cell "25.98 EUR" [ref=e155] + - cell "paid" [ref=e156]: + - generic [ref=e157]: paid + - cell "unfulfilled" [ref=e158]: + - generic [ref=e159]: unfulfilled + - cell "Apr 12, 2026" [ref=e160] + - row "D-1001 alice@shop.test 30.98 EUR pending unfulfilled Apr 11, 2026" [ref=e161]: + - cell "D-1001" [ref=e162]: + - link "D-1001" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "alice@shop.test" [ref=e164] + - cell "30.98 EUR" [ref=e165] + - cell "pending" [ref=e166]: + - generic [ref=e167]: pending + - cell "unfulfilled" [ref=e168]: + - generic [ref=e169]: unfulfilled + - cell "Apr 11, 2026" [ref=e170] + - row "D-1002 bob@shop.test 20.98 EUR paid fulfilled Apr 10, 2026" [ref=e171]: + - cell "D-1002" [ref=e172]: + - link "D-1002" [ref=e173] [cursor=pointer]: + - /url: http://shop.test/admin/orders/2 + - cell "bob@shop.test" [ref=e174] + - cell "20.98 EUR" [ref=e175] + - cell "paid" [ref=e176]: + - generic [ref=e177]: paid + - cell "fulfilled" [ref=e178]: + - generic [ref=e179]: fulfilled + - cell "Apr 10, 2026" [ref=e180] + - row "D-1003 carol@shop.test 25.98 EUR refunded fulfilled Apr 09, 2026" [ref=e181]: + - cell "D-1003" [ref=e182]: + - link "D-1003" [ref=e183] [cursor=pointer]: + - /url: http://shop.test/admin/orders/3 + - cell "carol@shop.test" [ref=e184] + - cell "25.98 EUR" [ref=e185] + - cell "refunded" [ref=e186]: + - generic [ref=e187]: refunded + - cell "fulfilled" [ref=e188]: + - generic [ref=e189]: fulfilled + - cell "Apr 09, 2026" [ref=e190] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-23-34-236Z.yml b/.playwright-mcp/page-2026-04-12T20-23-34-236Z.yml new file mode 100644 index 00000000..4f27a8dd --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-23-34-236Z.yml @@ -0,0 +1,123 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Shipping + - generic [ref=e127]: + - link "Back" [ref=e128] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - button "New zone" [ref=e129]: + - img [ref=e131] + - img [ref=e134] + - generic [ref=e136]: New zone + - generic [ref=e138]: + - generic [ref=e139]: + - generic [ref=e140]: + - generic [ref=e141]: Europe + - generic [ref=e142]: + - generic [ref=e143]: DE + - generic [ref=e144]: AT + - generic [ref=e145]: CH + - generic [ref=e146]: + - button "Add rate" [ref=e147]: + - img [ref=e149] + - img [ref=e152] + - generic [ref=e154]: Add rate + - button "Delete" [ref=e155]: + - img [ref=e157] + - generic [ref=e160]: Delete + - table [ref=e164]: + - rowgroup [ref=e165]: + - row "Name Type Amount" [ref=e166]: + - columnheader "Name" [ref=e167]: + - generic [ref=e168]: Name + - columnheader "Type" [ref=e169]: + - generic [ref=e170]: Type + - columnheader "Amount" [ref=e171]: + - generic [ref=e172]: Amount + - columnheader [ref=e173] + - rowgroup [ref=e174]: + - row "Standard flat 5.99 Remove" [ref=e175]: + - cell "Standard" [ref=e176] + - cell "flat" [ref=e177] + - cell "5.99" [ref=e178] + - cell "Remove" [ref=e179]: + - button "Remove" [ref=e180]: + - img [ref=e182] + - generic [ref=e185]: Remove \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-23-49-336Z.yml b/.playwright-mcp/page-2026-04-12T20-23-49-336Z.yml new file mode 100644 index 00000000..75998fca --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-23-49-336Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: Developers + - generic [ref=e126]: + - generic [ref=e127]: API tokens + - paragraph [ref=e128]: Personal access tokens for the Admin API. + - generic [ref=e129]: + - generic [ref=e130]: + - generic [ref=e131]: Token name + - textbox "Token name" [ref=e133]: + - /placeholder: My integration + - button "Create token" [ref=e134]: + - img [ref=e136] + - img [ref=e139] + - generic [ref=e141]: Create token + - paragraph [ref=e143]: No tokens yet. + - generic [ref=e144]: + - generic [ref=e145]: Webhook subscriptions + - paragraph [ref=e146]: HTTP endpoints notified when events occur. + - generic [ref=e147]: + - generic [ref=e148]: + - generic [ref=e149]: Event type + - textbox "Event type" [ref=e151]: + - /placeholder: order.placed + - generic [ref=e152]: + - generic [ref=e153]: URL + - textbox "URL" [ref=e155]: + - /placeholder: https://example.com/webhook + - button "Add webhook" [ref=e157]: + - img [ref=e159] + - img [ref=e162] + - generic [ref=e164]: Add webhook + - paragraph [ref=e166]: No webhooks yet. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-23-59-972Z.yml b/.playwright-mcp/page-2026-04-12T20-23-59-972Z.yml new file mode 100644 index 00000000..ab4891c6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-23-59-972Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Pages + - link "New page" [ref=e127] [cursor=pointer]: + - /url: http://shop.test/admin/pages/create + - img [ref=e128] + - generic [ref=e130]: New page + - generic [ref=e131]: + - generic: + - img + - textbox "Search pages..." [ref=e132] + - table [ref=e137]: + - rowgroup [ref=e138]: + - row "Title Handle Status Updated" [ref=e139]: + - columnheader "Title" [ref=e140]: + - generic [ref=e141]: Title + - columnheader "Handle" [ref=e142]: + - generic [ref=e143]: Handle + - columnheader "Status" [ref=e144]: + - generic [ref=e145]: Status + - columnheader "Updated" [ref=e146]: + - generic [ref=e147]: Updated + - columnheader [ref=e148] + - rowgroup [ref=e149]: + - row "About Us about-us published 4 minutes ago Edit Delete" [ref=e150]: + - cell "About Us" [ref=e151]: + - link "About Us" [ref=e152] [cursor=pointer]: + - /url: http://shop.test/admin/pages/1/edit + - cell "about-us" [ref=e153] + - cell "published" [ref=e154]: + - generic [ref=e155]: published + - cell "4 minutes ago" [ref=e156] + - cell "Edit Delete" [ref=e157]: + - generic [ref=e158]: + - link "Edit" [ref=e159] [cursor=pointer]: + - /url: http://shop.test/admin/pages/1/edit + - button "Delete" [ref=e160]: + - img [ref=e162] + - generic [ref=e165]: Delete + - row "Contact contact published 4 minutes ago Edit Delete" [ref=e166]: + - cell "Contact" [ref=e167]: + - link "Contact" [ref=e168] [cursor=pointer]: + - /url: http://shop.test/admin/pages/2/edit + - cell "contact" [ref=e169] + - cell "published" [ref=e170]: + - generic [ref=e171]: published + - cell "4 minutes ago" [ref=e172] + - cell "Edit Delete" [ref=e173]: + - generic [ref=e174]: + - link "Edit" [ref=e175] [cursor=pointer]: + - /url: http://shop.test/admin/pages/2/edit + - button "Delete" [ref=e176]: + - img [ref=e178] + - generic [ref=e181]: Delete + - row "FAQ faq published 4 minutes ago Edit Delete" [ref=e182]: + - cell "FAQ" [ref=e183]: + - link "FAQ" [ref=e184] [cursor=pointer]: + - /url: http://shop.test/admin/pages/3/edit + - cell "faq" [ref=e185] + - cell "published" [ref=e186]: + - generic [ref=e187]: published + - cell "4 minutes ago" [ref=e188] + - cell "Edit Delete" [ref=e189]: + - generic [ref=e190]: + - link "Edit" [ref=e191] [cursor=pointer]: + - /url: http://shop.test/admin/pages/3/edit + - button "Delete" [ref=e192]: + - img [ref=e194] + - generic [ref=e197]: Delete \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-24-08-408Z.yml b/.playwright-mcp/page-2026-04-12T20-24-08-408Z.yml new file mode 100644 index 00000000..71fba4a2 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-24-08-408Z.yml @@ -0,0 +1,104 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Analytics + - generic [ref=e127]: + - generic [ref=e128]: + - generic [ref=e129]: Start date + - textbox "Start date" [ref=e131]: 2026-03-14 + - generic [ref=e133]: + - generic [ref=e134]: End date + - textbox "End date" [ref=e136]: 2026-04-12 + - generic [ref=e138]: + - generic [ref=e139]: + - paragraph [ref=e140]: Revenue + - paragraph [ref=e141]: 0.00 EUR + - generic [ref=e142]: + - paragraph [ref=e143]: Orders + - paragraph [ref=e144]: "0" + - generic [ref=e145]: + - paragraph [ref=e146]: AOV + - paragraph [ref=e147]: 0.00 EUR + - generic [ref=e148]: + - paragraph [ref=e149]: Visits + - paragraph [ref=e150]: "0" + - generic [ref=e151]: + - generic [ref=e153]: Daily breakdown + - generic [ref=e154]: No data for this range. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-24-13-117Z.yml b/.playwright-mcp/page-2026-04-12T20-24-13-117Z.yml new file mode 100644 index 00000000..ea6c60ed --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-24-13-117Z.yml @@ -0,0 +1,58 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - button "Search" [ref=e6]: + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: "#" + - img [ref=e10] + - button "Cart" [ref=e12]: + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - navigation "Breadcrumb" [ref=e18]: + - link "Collections" [ref=e19] [cursor=pointer]: + - /url: http://shop.test/collections + - text: / Featured + - heading "Featured" [level=1] [ref=e20] + - generic [ref=e21]: + - paragraph [ref=e22]: 4 products + - generic [ref=e23]: + - generic [ref=e24]: Sort + - combobox "Sort" [ref=e25]: + - option "Newest" [selected] + - option "Title, A to Z" + - option "Featured" + - generic [ref=e26]: + - link "Tote Bag EUR 14.99" [ref=e27] [cursor=pointer]: + - /url: /products/tote-bag + - generic [ref=e29]: + - heading "Tote Bag" [level=3] [ref=e30] + - paragraph [ref=e31]: EUR 14.99 + - link "Cap EUR 24.99" [ref=e32] [cursor=pointer]: + - /url: /products/cap + - generic [ref=e34]: + - heading "Cap" [level=3] [ref=e35] + - paragraph [ref=e36]: EUR 24.99 + - link "Hoodie EUR 49.99" [ref=e37] [cursor=pointer]: + - /url: /products/hoodie + - generic [ref=e39]: + - heading "Hoodie" [level=3] [ref=e40] + - paragraph [ref=e41]: EUR 49.99 + - link "Classic Tee EUR 19.99" [ref=e42] [cursor=pointer]: + - /url: /products/classic-tee + - generic [ref=e44]: + - heading "Classic Tee" [level=3] [ref=e45] + - paragraph [ref=e46]: EUR 19.99 + - contentinfo [ref=e47]: + - generic [ref=e49]: + - navigation "Footer navigation" + - paragraph [ref=e50]: (c) Shop + - button "Open cart" [ref=e53]: + - img [ref=e54] + - generic [ref=e56]: Cart + - generic [ref=e57]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-28-09-973Z.yml b/.playwright-mcp/page-2026-04-12T20-28-09-973Z.yml new file mode 100644 index 00000000..08d41c6b --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-28-09-973Z.yml @@ -0,0 +1,147 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Dashboard + - combobox [ref=e127]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=e128]: + - generic [ref=e129]: + - generic [ref=e130]: Total sales + - generic [ref=e131]: "103.92" + - generic [ref=e132]: + - generic [ref=e133]: Orders + - generic [ref=e134]: "4" + - generic [ref=e135]: + - generic [ref=e136]: Average order value + - generic [ref=e137]: "25.98" + - generic [ref=e138]: + - generic [ref=e139]: Conversion rate + - generic [ref=e140]: N/A + - generic [ref=e141]: + - generic [ref=e142]: Sales over time + - generic [ref=e143]: Charts coming soon + - generic [ref=e144]: + - generic [ref=e145]: Recent orders + - table [ref=e149]: + - rowgroup [ref=e150]: + - row "Order Customer Total Status" [ref=e151]: + - columnheader "Order" [ref=e152]: + - generic [ref=e153]: Order + - columnheader "Customer" [ref=e154]: + - generic [ref=e155]: Customer + - columnheader "Total" [ref=e156]: + - generic [ref=e157]: Total + - columnheader "Status" [ref=e158]: + - generic [ref=e159]: Status + - rowgroup [ref=e160]: + - row "#1 review@shop.test 25.98 EUR paid" [ref=e161]: + - cell "#1" [ref=e162]: + - link "#1" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/orders/4 + - cell "review@shop.test" [ref=e164] + - cell "25.98 EUR" [ref=e165] + - cell "paid" [ref=e166]: + - generic [ref=e167]: paid + - row "D-1001 alice@shop.test 30.98 EUR pending" [ref=e168]: + - cell "D-1001" [ref=e169]: + - link "D-1001" [ref=e170] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "alice@shop.test" [ref=e171] + - cell "30.98 EUR" [ref=e172] + - cell "pending" [ref=e173]: + - generic [ref=e174]: pending + - row "D-1002 bob@shop.test 20.98 EUR paid" [ref=e175]: + - cell "D-1002" [ref=e176]: + - link "D-1002" [ref=e177] [cursor=pointer]: + - /url: http://shop.test/admin/orders/2 + - cell "bob@shop.test" [ref=e178] + - cell "20.98 EUR" [ref=e179] + - cell "paid" [ref=e180]: + - generic [ref=e181]: paid + - row "D-1003 carol@shop.test 25.98 EUR refunded" [ref=e182]: + - cell "D-1003" [ref=e183]: + - link "D-1003" [ref=e184] [cursor=pointer]: + - /url: http://shop.test/admin/orders/3 + - cell "carol@shop.test" [ref=e185] + - cell "25.98 EUR" [ref=e186] + - cell "refunded" [ref=e187]: + - generic [ref=e188]: refunded \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-28-14-370Z.yml b/.playwright-mcp/page-2026-04-12T20-28-14-370Z.yml new file mode 100644 index 00000000..db0e408e --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-28-14-370Z.yml @@ -0,0 +1,84 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - paragraph [ref=e18]: New season + - heading "Thoughtfully made, honestly priced." [level=1] [ref=e19] + - paragraph [ref=e20]: A curated collection of timeless goods designed to last. Explore our latest arrivals and find something you will love. + - generic [ref=e21]: + - link "Shop the collection" [ref=e22] [cursor=pointer]: + - /url: "#featured" + - link "What is new" [ref=e23] [cursor=pointer]: + - /url: "#recent" + - generic [ref=e24]: + - generic [ref=e26]: + - heading "Featured collections" [level=2] [ref=e27] + - paragraph [ref=e28]: Hand-picked edits for every occasion. + - generic [ref=e29]: + - link "Featured Shop now" [ref=e30] [cursor=pointer]: + - /url: /collections/featured + - generic [ref=e31]: + - heading "Featured" [level=3] [ref=e32] + - generic [ref=e33]: Shop now + - link "Sale Shop now" [ref=e34] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e35]: + - heading "Sale" [level=3] [ref=e36] + - generic [ref=e37]: Shop now + - generic [ref=e38]: + - generic [ref=e40]: + - heading "New arrivals" [level=2] [ref=e41] + - paragraph [ref=e42]: Fresh goods, just in. + - generic [ref=e43]: + - link "Cap EUR 24.99" [ref=e44] [cursor=pointer]: + - /url: /products/cap + - generic [ref=e46]: + - heading "Cap" [level=3] [ref=e47] + - paragraph [ref=e48]: EUR 24.99 + - link "Tote Bag EUR 14.99" [ref=e49] [cursor=pointer]: + - /url: /products/tote-bag + - generic [ref=e51]: + - heading "Tote Bag" [level=3] [ref=e52] + - paragraph [ref=e53]: EUR 14.99 + - link "Classic Tee EUR 19.99" [ref=e54] [cursor=pointer]: + - /url: /products/classic-tee + - generic [ref=e56]: + - heading "Classic Tee" [level=3] [ref=e57] + - paragraph [ref=e58]: EUR 19.99 + - link "Hoodie EUR 49.99" [ref=e59] [cursor=pointer]: + - /url: /products/hoodie + - generic [ref=e61]: + - heading "Hoodie" [level=3] [ref=e62] + - paragraph [ref=e63]: EUR 49.99 + - link "Sneakers EUR 79.99" [ref=e64] [cursor=pointer]: + - /url: /products/sneakers + - generic [ref=e66]: + - heading "Sneakers" [level=3] [ref=e67] + - paragraph [ref=e68]: EUR 79.99 + - link "Mug EUR 9.99" [ref=e69] [cursor=pointer]: + - /url: /products/mug + - generic [ref=e71]: + - heading "Mug" [level=3] [ref=e72] + - paragraph [ref=e73]: EUR 9.99 + - contentinfo [ref=e74]: + - generic [ref=e76]: + - navigation "Footer navigation" + - paragraph [ref=e77]: (c) Shop + - button "Open cart" [ref=e80]: + - img [ref=e81] + - generic [ref=e83]: Cart + - generic [ref=e84]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-28-27-083Z.yml b/.playwright-mcp/page-2026-04-12T20-28-27-083Z.yml new file mode 100644 index 00000000..8bc2ff42 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-28-27-083Z.yml @@ -0,0 +1,43 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Hoodie" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 49.99 + - group "Variant" [ref=e25]: + - generic [ref=e26]: Variant + - generic [ref=e27]: + - button "HOOD-L" [ref=e28] + - button "HOOD-M" [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: Quantity + - generic [ref=e32]: + - button "Decrease quantity" [ref=e33]: "-" + - generic [ref=e34]: "1" + - button "Increase quantity" [ref=e35]: + + - button "Add to cart" [ref=e36]: + - generic [ref=e37]: Add to cart + - paragraph [ref=e39]: Hoodie description. + - contentinfo [ref=e40]: + - generic [ref=e42]: + - navigation "Footer navigation" + - paragraph [ref=e43]: (c) Shop + - button "Open cart" [ref=e46]: + - img [ref=e47] + - generic [ref=e49]: Cart + - generic [ref=e50]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-28-32-540Z.yml b/.playwright-mcp/page-2026-04-12T20-28-32-540Z.yml new file mode 100644 index 00000000..85d46518 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-28-32-540Z.yml @@ -0,0 +1,43 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account/login + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - heading "Sign in" [level=1] [ref=e18] + - paragraph [ref=e19]: Access your orders and saved addresses. + - generic [ref=e20]: + - generic [ref=e21]: + - generic [ref=e22]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - textbox "Password" [ref=e26] + - generic [ref=e27]: + - checkbox "Remember me" [ref=e28] + - generic [ref=e29]: Remember me + - button "Sign in" [ref=e30] + - paragraph [ref=e31]: + - text: New customer? + - link "Create an account" [ref=e32] [cursor=pointer]: + - /url: http://shop.test/account/register + - contentinfo [ref=e33]: + - generic [ref=e35]: + - navigation "Footer navigation" + - paragraph [ref=e36]: (c) Shop + - button "Open cart" [ref=e39]: + - img [ref=e40] + - generic [ref=e42]: Cart + - generic [ref=e43]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-28-59-050Z.yml b/.playwright-mcp/page-2026-04-12T20-28-59-050Z.yml new file mode 100644 index 00000000..60ff04c6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-28-59-050Z.yml @@ -0,0 +1,55 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - generic [ref=e18]: + - paragraph [ref=e19]: Account + - heading "Hi, Alice Example" [level=1] [ref=e20] + - paragraph [ref=e21]: alice@shop.test + - button "Sign out" [ref=e23] + - navigation "Account sections" [ref=e24]: + - link "Orders Review your order history." [ref=e25] [cursor=pointer]: + - /url: http://shop.test/account/orders + - heading "Orders" [level=2] [ref=e26] + - paragraph [ref=e27]: Review your order history. + - link "Addresses Manage saved shipping addresses." [ref=e28] [cursor=pointer]: + - /url: http://shop.test/account/addresses + - heading "Addresses" [level=2] [ref=e29] + - paragraph [ref=e30]: Manage saved shipping addresses. + - link "Continue shopping Browse the latest arrivals." [ref=e31] [cursor=pointer]: + - /url: http://shop.test/storefront + - heading "Continue shopping" [level=2] [ref=e32] + - paragraph [ref=e33]: Browse the latest arrivals. + - generic [ref=e34]: + - heading "Recent orders" [level=2] [ref=e35] + - list [ref=e36]: + - listitem [ref=e37]: + - generic [ref=e38]: + - paragraph [ref=e39]: D-1001 + - paragraph [ref=e40]: Apr 11, 2026 + - generic [ref=e41]: + - generic [ref=e42]: EUR 30.98 + - link "View" [ref=e43] [cursor=pointer]: + - /url: http://shop.test/account/orders/D-1001 + - contentinfo [ref=e44]: + - generic [ref=e46]: + - navigation "Footer navigation" + - paragraph [ref=e47]: (c) Shop + - button "Open cart" [ref=e50]: + - img [ref=e51] + - generic [ref=e53]: Cart + - generic [ref=e54]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-29-07-015Z.yml b/.playwright-mcp/page-2026-04-12T20-29-07-015Z.yml new file mode 100644 index 00000000..f507eb3b --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-29-07-015Z.yml @@ -0,0 +1,46 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - link "Back to account" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/account + - heading "Orders" [level=1] [ref=e19] + - table [ref=e21]: + - rowgroup [ref=e22]: + - row "Order Date Total Status" [ref=e23]: + - columnheader "Order" [ref=e24] + - columnheader "Date" [ref=e25] + - columnheader "Total" [ref=e26] + - columnheader "Status" [ref=e27] + - columnheader [ref=e28] + - rowgroup [ref=e29]: + - row "D-1001 Apr 11, 2026 EUR 30.98 pending View" [ref=e30]: + - cell "D-1001" [ref=e31] + - cell "Apr 11, 2026" [ref=e32] + - cell "EUR 30.98" [ref=e33] + - cell "pending" [ref=e34] + - cell "View" [ref=e35]: + - link "View" [ref=e36] [cursor=pointer]: + - /url: http://shop.test/account/orders/D-1001 + - contentinfo [ref=e37]: + - generic [ref=e39]: + - navigation "Footer navigation" + - paragraph [ref=e40]: (c) Shop + - button "Open cart" [ref=e43]: + - img [ref=e44] + - generic [ref=e46]: Cart + - generic [ref=e47]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-29-18-970Z.yml b/.playwright-mcp/page-2026-04-12T20-29-18-970Z.yml new file mode 100644 index 00000000..2b825ce1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-29-18-970Z.yml @@ -0,0 +1,156 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: Orders + - generic [ref=e126]: + - generic [ref=e127]: + - generic: + - img + - textbox "Search orders..." [ref=e128] + - combobox [ref=e130]: + - option "All payments" [selected] + - option "Pending" + - option "Paid" + - option "Refunded" + - option "Partially refunded" + - combobox [ref=e131]: + - option "All fulfillment" [selected] + - option "Unfulfilled" + - option "Partial" + - option "Fulfilled" + - table [ref=e135]: + - rowgroup [ref=e136]: + - row "Order Customer Total Payment Fulfillment Date" [ref=e137]: + - columnheader "Order" [ref=e138]: + - generic [ref=e139]: Order + - columnheader "Customer" [ref=e140]: + - generic [ref=e141]: Customer + - columnheader "Total" [ref=e142]: + - generic [ref=e143]: Total + - columnheader "Payment" [ref=e144]: + - generic [ref=e145]: Payment + - columnheader "Fulfillment" [ref=e146]: + - generic [ref=e147]: Fulfillment + - columnheader "Date" [ref=e148]: + - generic [ref=e149]: Date + - rowgroup [ref=e150]: + - row "#1 review@shop.test 25.98 EUR paid unfulfilled Apr 12, 2026" [ref=e151]: + - cell "#1" [ref=e152]: + - link "#1" [ref=e153] [cursor=pointer]: + - /url: http://shop.test/admin/orders/4 + - cell "review@shop.test" [ref=e154] + - cell "25.98 EUR" [ref=e155] + - cell "paid" [ref=e156]: + - generic [ref=e157]: paid + - cell "unfulfilled" [ref=e158]: + - generic [ref=e159]: unfulfilled + - cell "Apr 12, 2026" [ref=e160] + - row "D-1001 alice@shop.test 30.98 EUR pending unfulfilled Apr 11, 2026" [ref=e161]: + - cell "D-1001" [ref=e162]: + - link "D-1001" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "alice@shop.test" [ref=e164] + - cell "30.98 EUR" [ref=e165] + - cell "pending" [ref=e166]: + - generic [ref=e167]: pending + - cell "unfulfilled" [ref=e168]: + - generic [ref=e169]: unfulfilled + - cell "Apr 11, 2026" [ref=e170] + - row "D-1002 bob@shop.test 20.98 EUR paid fulfilled Apr 10, 2026" [ref=e171]: + - cell "D-1002" [ref=e172]: + - link "D-1002" [ref=e173] [cursor=pointer]: + - /url: http://shop.test/admin/orders/2 + - cell "bob@shop.test" [ref=e174] + - cell "20.98 EUR" [ref=e175] + - cell "paid" [ref=e176]: + - generic [ref=e177]: paid + - cell "fulfilled" [ref=e178]: + - generic [ref=e179]: fulfilled + - cell "Apr 10, 2026" [ref=e180] + - row "D-1003 carol@shop.test 25.98 EUR refunded fulfilled Apr 09, 2026" [ref=e181]: + - cell "D-1003" [ref=e182]: + - link "D-1003" [ref=e183] [cursor=pointer]: + - /url: http://shop.test/admin/orders/3 + - cell "carol@shop.test" [ref=e184] + - cell "25.98 EUR" [ref=e185] + - cell "refunded" [ref=e186]: + - generic [ref=e187]: refunded + - cell "fulfilled" [ref=e188]: + - generic [ref=e189]: fulfilled + - cell "Apr 09, 2026" [ref=e190] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-29-29-752Z.yml b/.playwright-mcp/page-2026-04-12T20-29-29-752Z.yml new file mode 100644 index 00000000..7269713b --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-29-29-752Z.yml @@ -0,0 +1,133 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e126]: + - generic [ref=e127]: Order D-1001 + - generic [ref=e128]: + - generic [ref=e129]: pending + - generic [ref=e130]: unfulfilled + - generic [ref=e131]: Apr 11, 2026 20:19 + - generic [ref=e132]: + - generic [ref=e133]: + - generic [ref=e134]: + - generic [ref=e135]: Items + - table [ref=e138]: + - rowgroup [ref=e139]: + - row "Product SKU Qty Total" [ref=e140]: + - columnheader "Product" [ref=e141]: + - generic [ref=e142]: Product + - columnheader "SKU" [ref=e143]: + - generic [ref=e144]: SKU + - columnheader "Qty" [ref=e145]: + - generic [ref=e146]: Qty + - columnheader "Total" [ref=e147]: + - generic [ref=e148]: Total + - rowgroup [ref=e149]: + - row "Cap CAP-001 1 24.99 EUR" [ref=e150]: + - cell "Cap" [ref=e151] + - cell "CAP-001" [ref=e152] + - cell "1" [ref=e153] + - cell "24.99 EUR" [ref=e154] + - generic [ref=e155]: + - generic [ref=e156]: + - generic [ref=e157]: Subtotal + - generic [ref=e158]: "24.99" + - generic [ref=e159]: + - generic [ref=e160]: Shipping + - generic [ref=e161]: "5.99" + - generic [ref=e162]: + - generic [ref=e163]: Tax + - generic [ref=e164]: "0.00" + - generic [ref=e165]: + - generic [ref=e166]: Total + - generic [ref=e167]: "30.98" + - generic [ref=e168]: + - generic [ref=e169]: Fulfillments + - paragraph [ref=e170]: No fulfillments yet. + - generic [ref=e171]: + - generic [ref=e172]: + - generic [ref=e173]: Customer + - generic [ref=e174]: + - generic [ref=e175]: Alice Example + - generic [ref=e176]: alice@shop.test + - generic [ref=e177]: + - generic [ref=e178]: Payment + - generic [ref=e179]: + - generic [ref=e180]: "Method: credit_card" + - generic [ref=e181]: "Status: pending" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-32-45-824Z.yml b/.playwright-mcp/page-2026-04-12T20-32-45-824Z.yml new file mode 100644 index 00000000..4cb5ed6e --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-32-45-824Z.yml @@ -0,0 +1,84 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - paragraph [ref=e18]: New season + - heading "Thoughtfully made, honestly priced." [level=1] [ref=e19] + - paragraph [ref=e20]: A curated collection of timeless goods designed to last. Explore our latest arrivals and find something you will love. + - generic [ref=e21]: + - link "Shop the collection" [ref=e22] [cursor=pointer]: + - /url: "#featured" + - link "What is new" [ref=e23] [cursor=pointer]: + - /url: "#recent" + - generic [ref=e24]: + - generic [ref=e26]: + - heading "Featured collections" [level=2] [ref=e27] + - paragraph [ref=e28]: Hand-picked edits for every occasion. + - generic [ref=e29]: + - link "Featured Shop now" [ref=e30] [cursor=pointer]: + - /url: /collections/featured + - generic [ref=e31]: + - heading "Featured" [level=3] [ref=e32] + - generic [ref=e33]: Shop now + - link "Sale Shop now" [ref=e34] [cursor=pointer]: + - /url: /collections/sale + - generic [ref=e35]: + - heading "Sale" [level=3] [ref=e36] + - generic [ref=e37]: Shop now + - generic [ref=e38]: + - generic [ref=e40]: + - heading "New arrivals" [level=2] [ref=e41] + - paragraph [ref=e42]: Fresh goods, just in. + - generic [ref=e43]: + - link "Cap EUR 24.99" [ref=e44] [cursor=pointer]: + - /url: /products/cap + - generic [ref=e46]: + - heading "Cap" [level=3] [ref=e47] + - paragraph [ref=e48]: EUR 24.99 + - link "Tote Bag EUR 14.99" [ref=e49] [cursor=pointer]: + - /url: /products/tote-bag + - generic [ref=e51]: + - heading "Tote Bag" [level=3] [ref=e52] + - paragraph [ref=e53]: EUR 14.99 + - link "Classic Tee EUR 19.99" [ref=e54] [cursor=pointer]: + - /url: /products/classic-tee + - generic [ref=e56]: + - heading "Classic Tee" [level=3] [ref=e57] + - paragraph [ref=e58]: EUR 19.99 + - link "Hoodie EUR 49.99" [ref=e59] [cursor=pointer]: + - /url: /products/hoodie + - generic [ref=e61]: + - heading "Hoodie" [level=3] [ref=e62] + - paragraph [ref=e63]: EUR 49.99 + - link "Sneakers EUR 79.99" [ref=e64] [cursor=pointer]: + - /url: /products/sneakers + - generic [ref=e66]: + - heading "Sneakers" [level=3] [ref=e67] + - paragraph [ref=e68]: EUR 79.99 + - link "Mug EUR 9.99" [ref=e69] [cursor=pointer]: + - /url: /products/mug + - generic [ref=e71]: + - heading "Mug" [level=3] [ref=e72] + - paragraph [ref=e73]: EUR 9.99 + - contentinfo [ref=e74]: + - generic [ref=e76]: + - navigation "Footer navigation" + - paragraph [ref=e77]: (c) Shop + - button "Open cart" [ref=e80]: + - img [ref=e81] + - generic [ref=e83]: Cart + - generic [ref=e84]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-32-58-626Z.yml b/.playwright-mcp/page-2026-04-12T20-32-58-626Z.yml new file mode 100644 index 00000000..0228d8a7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-32-58-626Z.yml @@ -0,0 +1,44 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Sneakers" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 79.99 + - group "Variant" [ref=e25]: + - generic [ref=e26]: Variant + - generic [ref=e27]: + - button "SNK-43" [ref=e28] + - button "SNK-44" [ref=e29] + - button "SNK-42" [ref=e30] + - generic [ref=e31]: + - generic [ref=e32]: Quantity + - generic [ref=e33]: + - button "Decrease quantity" [ref=e34]: "-" + - generic [ref=e35]: "1" + - button "Increase quantity" [ref=e36]: + + - button "Add to cart" [ref=e37]: + - generic [ref=e38]: Add to cart + - paragraph [ref=e40]: Sneakers description. + - contentinfo [ref=e41]: + - generic [ref=e43]: + - navigation "Footer navigation" + - paragraph [ref=e44]: (c) Shop + - button "Open cart" [ref=e47]: + - img [ref=e48] + - generic [ref=e50]: Cart + - generic [ref=e51]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-33-11-977Z.yml b/.playwright-mcp/page-2026-04-12T20-33-11-977Z.yml new file mode 100644 index 00000000..4abac94d --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-33-11-977Z.yml @@ -0,0 +1,139 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Dashboard + - combobox [ref=e127]: + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Last 90 days" + - generic [ref=e128]: + - generic [ref=e129]: + - generic [ref=e130]: Total sales + - generic [ref=e131]: "77.94" + - generic [ref=e132]: + - generic [ref=e133]: Orders + - generic [ref=e134]: "3" + - generic [ref=e135]: + - generic [ref=e136]: Average order value + - generic [ref=e137]: "25.98" + - generic [ref=e138]: + - generic [ref=e139]: Conversion rate + - generic [ref=e140]: N/A + - generic [ref=e141]: + - generic [ref=e142]: Sales over time + - generic [ref=e143]: Charts coming soon + - generic [ref=e144]: + - generic [ref=e145]: Recent orders + - table [ref=e149]: + - rowgroup [ref=e150]: + - row "Order Customer Total Status" [ref=e151]: + - columnheader "Order" [ref=e152]: + - generic [ref=e153]: Order + - columnheader "Customer" [ref=e154]: + - generic [ref=e155]: Customer + - columnheader "Total" [ref=e156]: + - generic [ref=e157]: Total + - columnheader "Status" [ref=e158]: + - generic [ref=e159]: Status + - rowgroup [ref=e160]: + - row "D-1001 alice@shop.test 30.98 EUR pending" [ref=e161]: + - cell "D-1001" [ref=e162]: + - link "D-1001" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "alice@shop.test" [ref=e164] + - cell "30.98 EUR" [ref=e165] + - cell "pending" [ref=e166]: + - generic [ref=e167]: pending + - row "D-1002 bob@shop.test 20.98 EUR paid" [ref=e168]: + - cell "D-1002" [ref=e169]: + - link "D-1002" [ref=e170] [cursor=pointer]: + - /url: http://shop.test/admin/orders/2 + - cell "bob@shop.test" [ref=e171] + - cell "20.98 EUR" [ref=e172] + - cell "paid" [ref=e173]: + - generic [ref=e174]: paid + - row "D-1003 carol@shop.test 25.98 EUR refunded" [ref=e175]: + - cell "D-1003" [ref=e176]: + - link "D-1003" [ref=e177] [cursor=pointer]: + - /url: http://shop.test/admin/orders/3 + - cell "carol@shop.test" [ref=e178] + - cell "25.98 EUR" [ref=e179] + - cell "refunded" [ref=e180]: + - generic [ref=e181]: refunded \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-33-43-842Z.yml b/.playwright-mcp/page-2026-04-12T20-33-43-842Z.yml new file mode 100644 index 00000000..c9c07aee --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-33-43-842Z.yml @@ -0,0 +1,174 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Products + - link "New product" [ref=e127] [cursor=pointer]: + - /url: http://shop.test/admin/products/create + - img [ref=e128] + - generic [ref=e130]: New product + - generic [ref=e131]: + - generic [ref=e132]: + - generic: + - img + - textbox "Search products..." [ref=e133] + - combobox [ref=e135]: + - option "All statuses" [selected] + - option "Draft" + - option "Active" + - option "Archived" + - table [ref=e139]: + - rowgroup [ref=e140]: + - row "Title Status Vendor Variants" [ref=e141]: + - columnheader "Title" [ref=e142]: + - generic [ref=e143]: Title + - columnheader "Status" [ref=e144]: + - generic [ref=e145]: Status + - columnheader "Vendor" [ref=e146]: + - generic [ref=e147]: Vendor + - columnheader "Variants" [ref=e148]: + - generic [ref=e149]: Variants + - columnheader [ref=e150] + - rowgroup [ref=e151]: + - row "Cap active Demo Brand 1 Edit" [ref=e152]: + - cell "Cap" [ref=e153]: + - link "Cap" [ref=e154] [cursor=pointer]: + - /url: http://shop.test/admin/products/3/edit + - cell "active" [ref=e155]: + - generic [ref=e156]: active + - cell "Demo Brand" [ref=e157] + - cell "1" [ref=e158] + - cell "Edit" [ref=e159]: + - link "Edit" [ref=e160] [cursor=pointer]: + - /url: http://shop.test/admin/products/3/edit + - row "Tote Bag active Demo Brand 1 Edit" [ref=e161]: + - cell "Tote Bag" [ref=e162]: + - link "Tote Bag" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/products/4/edit + - cell "active" [ref=e164]: + - generic [ref=e165]: active + - cell "Demo Brand" [ref=e166] + - cell "1" [ref=e167] + - cell "Edit" [ref=e168]: + - link "Edit" [ref=e169] [cursor=pointer]: + - /url: http://shop.test/admin/products/4/edit + - row "Classic Tee active Demo Brand 3 Edit" [ref=e170]: + - cell "Classic Tee" [ref=e171]: + - link "Classic Tee" [ref=e172] [cursor=pointer]: + - /url: http://shop.test/admin/products/1/edit + - cell "active" [ref=e173]: + - generic [ref=e174]: active + - cell "Demo Brand" [ref=e175] + - cell "3" [ref=e176] + - cell "Edit" [ref=e177]: + - link "Edit" [ref=e178] [cursor=pointer]: + - /url: http://shop.test/admin/products/1/edit + - row "Hoodie active Demo Brand 2 Edit" [ref=e179]: + - cell "Hoodie" [ref=e180]: + - link "Hoodie" [ref=e181] [cursor=pointer]: + - /url: http://shop.test/admin/products/2/edit + - cell "active" [ref=e182]: + - generic [ref=e183]: active + - cell "Demo Brand" [ref=e184] + - cell "2" [ref=e185] + - cell "Edit" [ref=e186]: + - link "Edit" [ref=e187] [cursor=pointer]: + - /url: http://shop.test/admin/products/2/edit + - row "Sneakers active Demo Brand 3 Edit" [ref=e188]: + - cell "Sneakers" [ref=e189]: + - link "Sneakers" [ref=e190] [cursor=pointer]: + - /url: http://shop.test/admin/products/5/edit + - cell "active" [ref=e191]: + - generic [ref=e192]: active + - cell "Demo Brand" [ref=e193] + - cell "3" [ref=e194] + - cell "Edit" [ref=e195]: + - link "Edit" [ref=e196] [cursor=pointer]: + - /url: http://shop.test/admin/products/5/edit + - row "Mug active Demo Brand 1 Edit" [ref=e197]: + - cell "Mug" [ref=e198]: + - link "Mug" [ref=e199] [cursor=pointer]: + - /url: http://shop.test/admin/products/6/edit + - cell "active" [ref=e200]: + - generic [ref=e201]: active + - cell "Demo Brand" [ref=e202] + - cell "1" [ref=e203] + - cell "Edit" [ref=e204]: + - link "Edit" [ref=e205] [cursor=pointer]: + - /url: http://shop.test/admin/products/6/edit \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-33-53-778Z.yml b/.playwright-mcp/page-2026-04-12T20-33-53-778Z.yml new file mode 100644 index 00000000..32b520b6 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-33-53-778Z.yml @@ -0,0 +1,137 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: + - generic [ref=e127]: Order D-1002 + - generic [ref=e128]: + - generic [ref=e129]: paid + - generic [ref=e130]: fulfilled + - generic [ref=e131]: Apr 10, 2026 20:32 + - button "Refund" [ref=e133]: + - img [ref=e135] + - generic [ref=e138]: Refund + - generic [ref=e139]: + - generic [ref=e140]: + - generic [ref=e141]: + - generic [ref=e142]: Items + - table [ref=e145]: + - rowgroup [ref=e146]: + - row "Product SKU Qty Total" [ref=e147]: + - columnheader "Product" [ref=e148]: + - generic [ref=e149]: Product + - columnheader "SKU" [ref=e150]: + - generic [ref=e151]: SKU + - columnheader "Qty" [ref=e152]: + - generic [ref=e153]: Qty + - columnheader "Total" [ref=e154]: + - generic [ref=e155]: Total + - rowgroup [ref=e156]: + - row "Tote Bag TOTE-001 1 14.99 EUR" [ref=e157]: + - cell "Tote Bag" [ref=e158] + - cell "TOTE-001" [ref=e159] + - cell "1" [ref=e160] + - cell "14.99 EUR" [ref=e161] + - generic [ref=e162]: + - generic [ref=e163]: + - generic [ref=e164]: Subtotal + - generic [ref=e165]: "14.99" + - generic [ref=e166]: + - generic [ref=e167]: Shipping + - generic [ref=e168]: "5.99" + - generic [ref=e169]: + - generic [ref=e170]: Tax + - generic [ref=e171]: "0.00" + - generic [ref=e172]: + - generic [ref=e173]: Total + - generic [ref=e174]: "20.98" + - generic [ref=e175]: + - generic [ref=e176]: Fulfillments + - paragraph [ref=e177]: No fulfillments yet. + - generic [ref=e178]: + - generic [ref=e179]: + - generic [ref=e180]: Customer + - generic [ref=e181]: + - generic [ref=e182]: Bob Example + - generic [ref=e183]: bob@shop.test + - generic [ref=e184]: + - generic [ref=e185]: Payment + - generic [ref=e186]: + - generic [ref=e187]: "Method: credit_card" + - generic [ref=e188]: "Status: paid" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-34-07-387Z.yml b/.playwright-mcp/page-2026-04-12T20-34-07-387Z.yml new file mode 100644 index 00000000..4f27a8dd --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-34-07-387Z.yml @@ -0,0 +1,123 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: Shipping + - generic [ref=e127]: + - link "Back" [ref=e128] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - button "New zone" [ref=e129]: + - img [ref=e131] + - img [ref=e134] + - generic [ref=e136]: New zone + - generic [ref=e138]: + - generic [ref=e139]: + - generic [ref=e140]: + - generic [ref=e141]: Europe + - generic [ref=e142]: + - generic [ref=e143]: DE + - generic [ref=e144]: AT + - generic [ref=e145]: CH + - generic [ref=e146]: + - button "Add rate" [ref=e147]: + - img [ref=e149] + - img [ref=e152] + - generic [ref=e154]: Add rate + - button "Delete" [ref=e155]: + - img [ref=e157] + - generic [ref=e160]: Delete + - table [ref=e164]: + - rowgroup [ref=e165]: + - row "Name Type Amount" [ref=e166]: + - columnheader "Name" [ref=e167]: + - generic [ref=e168]: Name + - columnheader "Type" [ref=e169]: + - generic [ref=e170]: Type + - columnheader "Amount" [ref=e171]: + - generic [ref=e172]: Amount + - columnheader [ref=e173] + - rowgroup [ref=e174]: + - row "Standard flat 5.99 Remove" [ref=e175]: + - cell "Standard" [ref=e176] + - cell "flat" [ref=e177] + - cell "5.99" [ref=e178] + - cell "Remove" [ref=e179]: + - button "Remove" [ref=e180]: + - img [ref=e182] + - generic [ref=e185]: Remove \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-34-19-216Z.yml b/.playwright-mcp/page-2026-04-12T20-34-19-216Z.yml new file mode 100644 index 00000000..cfde9d4d --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-34-19-216Z.yml @@ -0,0 +1,38 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Mug" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 9.99 + - generic [ref=e25]: + - generic [ref=e26]: Quantity + - generic [ref=e27]: + - button "Decrease quantity" [ref=e28]: "-" + - generic [ref=e29]: "1" + - button "Increase quantity" [ref=e30]: + + - button "Add to cart" [ref=e31]: + - generic [ref=e32]: Add to cart + - paragraph [ref=e34]: Mug description. + - contentinfo [ref=e35]: + - generic [ref=e37]: + - navigation "Footer navigation" + - paragraph [ref=e38]: (c) Shop + - button "Open cart" [ref=e41]: + - img [ref=e42] + - generic [ref=e44]: Cart + - generic [ref=e45]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-34-31-378Z.yml b/.playwright-mcp/page-2026-04-12T20-34-31-378Z.yml new file mode 100644 index 00000000..cfde9d4d --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-34-31-378Z.yml @@ -0,0 +1,38 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Mug" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 9.99 + - generic [ref=e25]: + - generic [ref=e26]: Quantity + - generic [ref=e27]: + - button "Decrease quantity" [ref=e28]: "-" + - generic [ref=e29]: "1" + - button "Increase quantity" [ref=e30]: + + - button "Add to cart" [ref=e31]: + - generic [ref=e32]: Add to cart + - paragraph [ref=e34]: Mug description. + - contentinfo [ref=e35]: + - generic [ref=e37]: + - navigation "Footer navigation" + - paragraph [ref=e38]: (c) Shop + - button "Open cart" [ref=e41]: + - img [ref=e42] + - generic [ref=e44]: Cart + - generic [ref=e45]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-34-35-711Z.yml b/.playwright-mcp/page-2026-04-12T20-34-35-711Z.yml new file mode 100644 index 00000000..db5fb83e --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-34-35-711Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - heading "Your cart" [level=1] [ref=e18] + - paragraph [ref=e19]: Review the items you are about to purchase. + - generic [ref=e20]: + - paragraph [ref=e21]: Your cart is empty. + - link "Browse collections" [ref=e22] [cursor=pointer]: + - /url: http://shop.test/collections + - contentinfo [ref=e23]: + - generic [ref=e25]: + - navigation "Footer navigation" + - paragraph [ref=e26]: (c) Shop + - button "Open cart" [ref=e29]: + - img [ref=e30] + - generic [ref=e32]: Cart + - generic [ref=e33]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-34-55-308Z.yml b/.playwright-mcp/page-2026-04-12T20-34-55-308Z.yml new file mode 100644 index 00000000..cfde9d4d --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-34-55-308Z.yml @@ -0,0 +1,38 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Mug" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 9.99 + - generic [ref=e25]: + - generic [ref=e26]: Quantity + - generic [ref=e27]: + - button "Decrease quantity" [ref=e28]: "-" + - generic [ref=e29]: "1" + - button "Increase quantity" [ref=e30]: + + - button "Add to cart" [ref=e31]: + - generic [ref=e32]: Add to cart + - paragraph [ref=e34]: Mug description. + - contentinfo [ref=e35]: + - generic [ref=e37]: + - navigation "Footer navigation" + - paragraph [ref=e38]: (c) Shop + - button "Open cart" [ref=e41]: + - img [ref=e42] + - generic [ref=e44]: Cart + - generic [ref=e45]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-35-09-693Z.yml b/.playwright-mcp/page-2026-04-12T20-35-09-693Z.yml new file mode 100644 index 00000000..cfde9d4d --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-35-09-693Z.yml @@ -0,0 +1,38 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e20]: + - generic [ref=e21]: + - paragraph [ref=e22]: Demo Brand + - heading "Mug" [level=1] [ref=e23] + - paragraph [ref=e24]: EUR 9.99 + - generic [ref=e25]: + - generic [ref=e26]: Quantity + - generic [ref=e27]: + - button "Decrease quantity" [ref=e28]: "-" + - generic [ref=e29]: "1" + - button "Increase quantity" [ref=e30]: + + - button "Add to cart" [ref=e31]: + - generic [ref=e32]: Add to cart + - paragraph [ref=e34]: Mug description. + - contentinfo [ref=e35]: + - generic [ref=e37]: + - navigation "Footer navigation" + - paragraph [ref=e38]: (c) Shop + - button "Open cart" [ref=e41]: + - img [ref=e42] + - generic [ref=e44]: Cart + - generic [ref=e45]: "0" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-36-14-207Z.yml b/.playwright-mcp/page-2026-04-12T20-36-14-207Z.yml new file mode 100644 index 00000000..d50c8679 --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-36-14-207Z.yml @@ -0,0 +1,95 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/storefront + - generic [ref=e5]: + - link "Search" [ref=e6] [cursor=pointer]: + - /url: http://shop.test/search + - img [ref=e7] + - link "Account" [ref=e9] [cursor=pointer]: + - /url: http://shop.test/account + - img [ref=e10] + - link "Cart" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/cart + - img [ref=e13] + - main [ref=e15]: + - generic [ref=e16]: + - heading "Checkout" [level=1] [ref=e18] + - list [ref=e19]: + - listitem [ref=e20]: + - generic [ref=e21]: "1" + - generic [ref=e22]: Address + - listitem [ref=e24]: + - generic [ref=e25]: "2" + - generic [ref=e26]: Shipping + - listitem [ref=e28]: + - generic [ref=e29]: "3" + - generic [ref=e30]: Payment + - generic [ref=e31]: + - generic [ref=e33]: + - heading "Contact and shipping address" [level=2] [ref=e34] + - generic [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: Email + - textbox "Email" [ref=e38]: alice@shop.test + - generic [ref=e39]: + - generic [ref=e40]: + - generic [ref=e41]: First name + - textbox "First name" [ref=e42] + - generic [ref=e43]: + - generic [ref=e44]: Last name + - textbox "Last name" [ref=e45] + - generic [ref=e46]: + - generic [ref=e47]: Address + - textbox "Address" [ref=e48] + - generic [ref=e49]: + - generic [ref=e50]: Apartment, suite (optional) + - textbox "Apartment, suite (optional)" [ref=e51] + - generic [ref=e52]: + - generic [ref=e53]: + - generic [ref=e54]: City + - textbox "City" [ref=e55] + - generic [ref=e56]: + - generic [ref=e57]: Postal code + - textbox "Postal code" [ref=e58] + - generic [ref=e59]: + - generic [ref=e60]: Country + - combobox "Country" [ref=e61]: + - option "Germany" [selected] + - option "Austria" + - option "Switzerland" + - option "France" + - option "Netherlands" + - option "United States" + - option "United Kingdom" + - generic [ref=e62]: + - checkbox "Billing address is the same as shipping" [checked] [ref=e63] + - generic [ref=e64]: Billing address is the same as shipping + - button "Continue to shipping" [ref=e65] + - complementary [ref=e66]: + - heading "Order summary" [level=2] [ref=e67] + - list [ref=e68]: + - listitem [ref=e69]: + - generic [ref=e71]: + - paragraph [ref=e72]: Mug + - paragraph [ref=e73]: Qty 1 + - paragraph [ref=e74]: EUR 9.99 + - generic [ref=e75]: + - generic [ref=e76]: + - generic [ref=e77]: Subtotal + - generic [ref=e78]: EUR 9.99 + - generic [ref=e79]: + - generic [ref=e80]: Shipping + - generic [ref=e81]: EUR 0.00 + - generic [ref=e82]: + - generic [ref=e83]: Total + - generic [ref=e84]: EUR 9.99 + - contentinfo [ref=e85]: + - generic [ref=e87]: + - navigation "Footer navigation" + - paragraph [ref=e88]: (c) Shop + - button "Open cart" [ref=e91]: + - img [ref=e92] + - generic [ref=e94]: Cart + - generic [ref=e95]: "1" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-12T20-37-25-632Z.yml b/.playwright-mcp/page-2026-04-12T20-37-25-632Z.yml new file mode 100644 index 00000000..a614240f --- /dev/null +++ b/.playwright-mcp/page-2026-04-12T20-37-25-632Z.yml @@ -0,0 +1,156 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - link "Shop Admin Demo Store" [ref=e4] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e6] + - generic [ref=e8]: + - generic [ref=e9]: Shop Admin + - generic [ref=e10]: Demo Store + - navigation [ref=e11]: + - generic [ref=e12]: + - generic [ref=e14]: Catalog + - generic [ref=e15]: + - link "Dashboard" [ref=e17] [cursor=pointer]: + - /url: http://shop.test/admin + - img [ref=e19] + - generic [ref=e21]: Dashboard + - link "Products" [ref=e23] [cursor=pointer]: + - /url: http://shop.test/admin/products + - img [ref=e25] + - generic [ref=e27]: Products + - link "Collections" [ref=e29] [cursor=pointer]: + - /url: http://shop.test/admin/collections + - img [ref=e31] + - generic [ref=e33]: Collections + - link "Customers" [ref=e35] [cursor=pointer]: + - /url: http://shop.test/admin/customers + - img [ref=e37] + - generic [ref=e39]: Customers + - link "Discounts" [ref=e41] [cursor=pointer]: + - /url: http://shop.test/admin/discounts + - img [ref=e43] + - generic [ref=e46]: Discounts + - generic [ref=e47]: + - generic [ref=e49]: Orders + - link "Orders" [ref=e52] [cursor=pointer]: + - /url: http://shop.test/admin/orders + - img [ref=e54] + - generic [ref=e56]: Orders + - generic [ref=e57]: + - generic [ref=e59]: Content + - generic [ref=e60]: + - link "Pages" [ref=e62] [cursor=pointer]: + - /url: http://shop.test/admin/pages + - img [ref=e64] + - generic [ref=e66]: Pages + - link "Navigation" [ref=e68] [cursor=pointer]: + - /url: http://shop.test/admin/navigation + - img [ref=e70] + - generic [ref=e72]: Navigation + - link "Themes" [ref=e74] [cursor=pointer]: + - /url: http://shop.test/admin/themes + - img [ref=e76] + - generic [ref=e78]: Themes + - generic [ref=e79]: + - generic [ref=e81]: Marketing + - link "Analytics" [ref=e84] [cursor=pointer]: + - /url: http://shop.test/admin/analytics + - img [ref=e86] + - generic [ref=e88]: Analytics + - generic [ref=e89]: + - generic [ref=e91]: Configuration + - generic [ref=e92]: + - link "Settings" [ref=e94] [cursor=pointer]: + - /url: http://shop.test/admin/settings + - img [ref=e96] + - generic [ref=e99]: Settings + - link "Apps" [ref=e101] [cursor=pointer]: + - /url: http://shop.test/admin/apps + - img [ref=e103] + - generic [ref=e105]: Apps + - link "Developers" [ref=e107] [cursor=pointer]: + - /url: http://shop.test/admin/developers + - img [ref=e109] + - generic [ref=e111]: Developers + - button "SA Shop Admin" [ref=e114]: + - generic [ref=e117]: SA + - generic [ref=e118]: Shop Admin + - img [ref=e120] + - generic [ref=e124]: + - generic [ref=e125]: Orders + - generic [ref=e126]: + - generic [ref=e127]: + - generic: + - img + - textbox "Search orders..." [ref=e128] + - combobox [ref=e130]: + - option "All payments" [selected] + - option "Pending" + - option "Paid" + - option "Refunded" + - option "Partially refunded" + - combobox [ref=e131]: + - option "All fulfillment" [selected] + - option "Unfulfilled" + - option "Partial" + - option "Fulfilled" + - table [ref=e135]: + - rowgroup [ref=e136]: + - row "Order Customer Total Payment Fulfillment Date" [ref=e137]: + - columnheader "Order" [ref=e138]: + - generic [ref=e139]: Order + - columnheader "Customer" [ref=e140]: + - generic [ref=e141]: Customer + - columnheader "Total" [ref=e142]: + - generic [ref=e143]: Total + - columnheader "Payment" [ref=e144]: + - generic [ref=e145]: Payment + - columnheader "Fulfillment" [ref=e146]: + - generic [ref=e147]: Fulfillment + - columnheader "Date" [ref=e148]: + - generic [ref=e149]: Date + - rowgroup [ref=e150]: + - row "#1001 final@shop.test 15.98 EUR paid unfulfilled Apr 12, 2026" [ref=e151]: + - cell "#1001" [ref=e152]: + - link "#1001" [ref=e153] [cursor=pointer]: + - /url: http://shop.test/admin/orders/4 + - cell "final@shop.test" [ref=e154] + - cell "15.98 EUR" [ref=e155] + - cell "paid" [ref=e156]: + - generic [ref=e157]: paid + - cell "unfulfilled" [ref=e158]: + - generic [ref=e159]: unfulfilled + - cell "Apr 12, 2026" [ref=e160] + - row "D-1001 alice@shop.test 30.98 EUR pending unfulfilled Apr 11, 2026" [ref=e161]: + - cell "D-1001" [ref=e162]: + - link "D-1001" [ref=e163] [cursor=pointer]: + - /url: http://shop.test/admin/orders/1 + - cell "alice@shop.test" [ref=e164] + - cell "30.98 EUR" [ref=e165] + - cell "pending" [ref=e166]: + - generic [ref=e167]: pending + - cell "unfulfilled" [ref=e168]: + - generic [ref=e169]: unfulfilled + - cell "Apr 11, 2026" [ref=e170] + - row "D-1002 bob@shop.test 20.98 EUR paid fulfilled Apr 10, 2026" [ref=e171]: + - cell "D-1002" [ref=e172]: + - link "D-1002" [ref=e173] [cursor=pointer]: + - /url: http://shop.test/admin/orders/2 + - cell "bob@shop.test" [ref=e174] + - cell "20.98 EUR" [ref=e175] + - cell "paid" [ref=e176]: + - generic [ref=e177]: paid + - cell "fulfilled" [ref=e178]: + - generic [ref=e179]: fulfilled + - cell "Apr 10, 2026" [ref=e180] + - row "D-1003 carol@shop.test 25.98 EUR refunded fulfilled Apr 09, 2026" [ref=e181]: + - cell "D-1003" [ref=e182]: + - link "D-1003" [ref=e183] [cursor=pointer]: + - /url: http://shop.test/admin/orders/3 + - cell "carol@shop.test" [ref=e184] + - cell "25.98 EUR" [ref=e185] + - cell "refunded" [ref=e186]: + - generic [ref=e187]: refunded + - cell "fulfilled" [ref=e188]: + - generic [ref=e189]: fulfilled + - cell "Apr 09, 2026" [ref=e190] \ No newline at end of file diff --git a/composer.json b/composer.json index 5150f1e1..034435d3 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.3", - "pestphp/pest-plugin-laravel": "^4.0" + "pestphp/pest-plugin-laravel": "^4.0", + "phpmetrics/phpmetrics": "^2.9" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 92524aaa..bbad7d4f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0d57e8f92f66c4a9fab1ad2cc5623cd8", + "content-hash": "d43dc6a9f550c4bc0ac2a7035a050de8", "packages": [ { "name": "bacon/bacon-qr-code", @@ -8345,6 +8345,76 @@ }, "time": "2025-11-21T15:09:14+00:00" }, + { + "name": "phpmetrics/phpmetrics", + "version": "v2.9.1", + "source": { + "type": "git", + "url": "https://github.com/phpmetrics/PhpMetrics.git", + "reference": "e2e68ddd1543bc3f44402c383f7bccb62de1ece3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpmetrics/PhpMetrics/zipball/e2e68ddd1543bc3f44402c383f7bccb62de1ece3", + "reference": "e2e68ddd1543bc3f44402c383f7bccb62de1ece3", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^3|^4|^5" + }, + "replace": { + "halleck45/php-metrics": "*", + "halleck45/phpmetrics": "*" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "bin": [ + "bin/phpmetrics" + ], + "type": "library", + "autoload": { + "files": [ + "./src/functions.php" + ], + "psr-0": { + "Hal\\": "./src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jean-François Lépine", + "email": "lepinejeanfrancois@yahoo.fr", + "homepage": "http://www.lepine.pro", + "role": "Copyright Holder" + } + ], + "description": "Static analyzer tool for PHP : Coupling, Cyclomatic complexity, Maintainability Index, Halstead's metrics... and more !", + "homepage": "http://www.phpmetrics.org", + "keywords": [ + "analysis", + "qa", + "quality", + "testing" + ], + "support": { + "issues": "https://github.com/PhpMetrics/PhpMetrics/issues", + "source": "https://github.com/phpmetrics/PhpMetrics/tree/v2.9.1" + }, + "funding": [ + { + "url": "https://github.com/Halleck45", + "type": "github" + } + ], + "time": "2025-09-25T05:21:02+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.2", diff --git a/report/all.html b/report/all.html new file mode 100644 index 00000000..ffe7d3e6 --- /dev/null +++ b/report/all.html @@ -0,0 +1,7386 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nameabstractfinalmethodsnbMethodsIncludingGettersSettersnbMethodsnbMethodsPrivatenbMethodsPublicnbMethodsGetternbMethodsSetterswmcccnccnMethodMaxexternalsparentsimplementslcomlengthvocabularyvolumedifficultyeffortlevelbugstimeintelligentContentnumber_operatorsnumber_operandsnumber_operators_uniquenumber_operands_uniquecloclocllocmimIwoCcommentWeightkanDefectrelativeStructuralComplexityrelativeDataComplexityrelativeSystemComplexitytotalStructuralComplexitytotalDataComplexitytotalSystemComplexitypackagepageRankafferentCouplingefferentCouplinginstabilityviolations
App\Auth\CustomerUserProvider1010103700292081101315833797.0119.8815840.660.050.2788040.152106924161099362.0234.0527.971.014840.63484.6348406.264846.26App\Auth\0170.88
App\Providers\AppServiceProvider66642007221310226161041.71178.290.580.031060.6722421413534085.3750.6634.710.155290.04529.0431740.253174.25App\Providers\00101
App\Providers\FortifyServiceProvider5553200511171022818116.762.3268.540.430.041550.7652331516543888.2750.9337.340.154840.09484.0924200.432420.43App\Providers\0051
App\Models\OrderLine55514005115103241388.810.7970.311.260.034112.1851911216432795.525540.520.1541.675.67208.3328.33App\Models\0130.75
App\Models\WebhookSubscription22202002113102131144.970.5524.731.820.01181.7721111072215100.9862.6438.340.1540.674.6781.339.33App\Models\0.02330.5
App\Models\ThemeFile22211002112102108300.5717.141.750.01152.5281772215102.2163.8738.340.15112224App\Models\0021
App\Models\ProductOption222020021131027516.250.6310.161.60.01126.01251472215104.0765.7338.340.1540.674.6781.339.33App\Models\0031
App\Models\NavigationItem3331200422210330161202.65318.460.380.041845.227233137312488.6455.0633.570.1511.52.534.57.5App\Models\0.01220.5
App\Models\Refund33312003113102128360.6423.141.560.011563917102919100.5461.0739.470.1511.52.534.57.5App\Models\0.01220.5
App\Models\InventoryItem3331200311210316950.721.7186.950.580.02529.59412277261996.0360.03360.1511.52.534.57.5App\Models\0.01420.33
App\Models\NavigationMenu111010011121017719.650.59.8320.01139.3161641410105.836936.830.1540.334.3340.334.33App\Models\0.01220.5
App\Models\App2221100211210211934.870.5619.611.780.01161.99291872215101.7563.4138.340.15112224App\Models\0120.67
App\Models\CartLine2220200211310112938.040.6323.771.60.01160.862101872215101.4963.1538.340.15112224App\Models\0120.67
App\Models\AppInstallation33312003113103161155.350.6535.981.540.02285.1631311010312197.3558.8238.530.1541512315App\Models\0.01130.75
App\Models\Cart44413004113103181264.532.17139.810.460.02829.78513391032229657.9138.090.1511.52.54610App\Models\0.04620.25
App\Models\Discount222110098811025022222.975.811296.020.170.077238.3619316164312777.6751.2626.410.434268412App\Models\0.01310.25
App\Models\Product55514005115103251597.670.7169.771.40.034136.7452011416422695.9255.0740.850.1591.2510.25456.2551.25App\Models\0.041030.23
App\Models\Order9991800135561036531322.025.761854.850.170.1110355.91174862519695081.0244.7136.320.52361.5737.5732414.14338.14App\Models\0.12030.13
App\Models\Store55514005115105191165.730.746.011.430.02393.951411016422697.1256.2740.850.15360.7136.711803.57183.57App\Models\0.061750.23
App\Models\StoreDomain22211002112102118330.6421.211.560.01151.33291772215101.9263.5838.340.15112224App\Models\0120.67
App\Models\Theme33312003113103138390.7127.861.40.01254.631017102818101.361.3439.960.1541512315App\Models\0.01230.6
App\Models\ProductMedia22211002112102161359.210.5834.541.710.022101.52141127231698.9161.1937.720.15112224App\Models\0.01120.67
App\Models\User444130074461035529267.193.36897.750.30.095079.52134242524563292.0949.6442.450.291690.45169.456761.79677.79App\Models\0.02450.56
App\Models\WebhookDelivery22211002112102161257.360.6436.51.570.02290.14214111723169961.2937.720.15112224App\Models\0.01120.67
App\Models\Fulfillment33312003113103171056.470.7843.921.290.02272.613141910291999.1759.739.470.1541512315App\Models\0.02230.6
App\Models\ThemeSettings2221100211210210728.070.6718.721.50.01142.1128167261997.8361.83360.15112224App\Models\0021
App\Models\Checkout222110021121022717110.360.7886.221.280.045141.262251167211499.5560.5638.990.15112224App\Models\0.02720.22
App\Models\Payment33312003113103171158.810.741.171.430.02284.0131411010291999.0559.5839.470.1541512315App\Models\0.01430.43
App\Models\AnalyticsDaily333300031111022913107.311.08116.260.920.04699.0632611215382397.2855.9441.340.15160.7316.73482.250.2App\Models\0.01110.5
App\Models\Customer77615106114104251289.620.8273.331.220.034109.5471811116513590.6652.5138.140.1513.54.5724.531.5App\Models\0.01420.33
App\Models\ProductVariant444130041141042717110.360.7279.321.390.044153.5542311613352296.7856.2840.50.15911036440App\Models\0140.8
App\Models\ProductOptionValue111010011121016513.930.638.711.60022.29151441511105.0169.1435.870.1510.51.510.51.5App\Models\0021
App\Models\TaxSettings22211002112102161155.350.738.751.430.02279.072141107261995.7759.77360.15112224App\Models\0.01320.4
App\Models\ShippingZone22211002112102128360.7125.711.40.01150.42101772114102.9663.9738.990.15112224App\Models\0.01220.5
App\Models\Collection22211002112102131043.190.6126.391.640.01170.672111972114102.4163.4138.990.1540.674.6781.339.33App\Models\0.01520.29
App\Models\Page11110001111101108300.6419.291.560.01146.67191741410104.5467.7136.830.15011011App\Models\0.01310.25
App\Models\ShippingRate2221100211210214944.380.7533.281.330.01259.172121872114102.3263.3338.990.15112224App\Models\0.01420.33
App\Models\StoreSettings222110021121029623.260.716.291.430.01133.2427157251899.4662.9136.550.15112224App\Models\0021
App\Models\CustomerAddress22211002112102118330.6421.211.560.01151.33291772215101.9263.5838.340.15112224App\Models\0.01120.67
App\Models\StoreUser2222000322210210625.85251.70.50.01312.922824323208861.4626.540.2290.259.25180.518.5App\Models\0021
App\Models\FulfillmentLine222020021131017516.250.6310.161.60.01126.01251472215104.0765.7338.340.15112224App\Models\0.01120.67
App\Models\AnalyticsEvent11110001111101131144.970.626.981.670.01174.951121104161299.7264.7534.970.15011011App\Models\0.01210.33
App\Models\Scopes\StoreScope1110100222301113941.213.6148.350.280.01811.4549451131285.7164.8820.830.2290.759.7590.759.75App\Models\Scopes\0.01130.75
App\Models\Organization111010011121014480.5420016131341410108.5671.7336.830.1510.51.510.51.5App\Models\0021
App\Models\Concerns\BelongsToStore12220200433400214944.385221.890.20.01128.8868544221891.3660.6830.680.22160.216.2320.432.4App\Models\Concerns\0031
App\Exceptions\InsufficientInventoryException00000000101100000000000000004417117100.15000000App\Exceptions\0110.5
App\Exceptions\FulfillmentGuardException00000000101100000000000000004417117100.15000000App\Exceptions\0110.5
App\Exceptions\InvalidDiscountException777070082211072617106.271.27134.610.790.04783.97192150323252.7152.7100.1513.144.1472229App\Exceptions\0.01110.5
App\Exceptions\PaymentFailedException00000000101100000000000000004417117100.15000000App\Exceptions\0110.5
App\Policies\StorePolicy3330300311600320756.159.33524.040.110.02296.02614430181860.2360.2300.1512.53.537.510.5App\Policies\0021
App\Policies\Concerns\ChecksStoreRole122220004323001221176.115.83443.960.170.032513.05814561181777.4459.5817.850.2291.1310.13182.2520.25App\Policies\Concerns\0021
App\Livewire\Settings\TwoFactor99918001793101019531470.656.923258.340.140.1618167.982372526321168474.4438.136.340.571440.34144.3412963.081299.08App\Livewire\Settings\0061
App\Livewire\Settings\DeleteUserForm111010011131018518.58001.250.01023.2208053151299.3767.4431.940.15250.1725.17250.1725.17App\Livewire\Settings\0031
App\Livewire\Settings\TwoFactor\RecoveryCodes3331200644210119960.235.2313.190.190.021711.586134510362692.5756.1336.440.22160.0716.07480.248.2App\Livewire\Settings\TwoFactor\0021
App\Livewire\Settings\Password11101002222101261086.373259.110.330.031428.79224283232084.3357.7926.540.154904949049App\Livewire\Settings\0021
App\Livewire\Settings\Profile55505001063111053914148.496.941031.160.140.055721.3814255911524181.4948.8132.690.291440.23144.237201.15721.15App\Livewire\Settings\0031
App\Livewire\Settings\Appearance000000001011000000000000000154202.9417131.940.15000000App\Livewire\Settings\0011
App\Livewire\Storefront\Products\Show665050195341018431416.1510.784487.210.090.1424938.5922628231535254.1143.5510.560.292560.2256.215361.181537.18App\Livewire\Storefront\Products\0041
App\Livewire\Storefront\Home44422008537102251495.182.8266.510.360.031533.9911144107383183.7952.9430.850.431440.38144.385761.54577.54App\Livewire\Storefront\0061
App\Livewire\Storefront\Checkout\Show1111114700352599101300871932.8818.435565.050.050.641976105.057023012751413512152.1228.1923.921.019000.27900.2799002.949902.94App\Livewire\Storefront\Checkout\0061Probably bugged,
App\Livewire\Storefront\Checkout\Confirmation22202002113101141046.512.36109.620.420.02619.73311371161581.4262.5318.880.15250.2525.25500.550.5App\Livewire\Storefront\Checkout\0031
App\Livewire\Storefront\Search\Index33303004223102221278.873236.610.330.031326.29418392222080.5858.0722.510.15360.1436.141080.43108.43App\Livewire\Storefront\Search\0031
App\Livewire\Storefront\CartDrawer333030053331023415132.836.5863.420.150.044820.448265102292773.2953.5119.790.4540.334.3312113App\Livewire\Storefront\0031
App\Livewire\Storefront\Cart\Show5550500106351026323284.9810.2829300.10.0916327.7216477161525155.4144.7610.660.59490.4549.452452.25247.25App\Livewire\Storefront\Cart\0031
App\Livewire\Storefront\Account\Dashboard22202002114102171056.471.7598.830.570.02532.27314282171587.2861.9425.340.15640.1164.111280.22128.22App\Livewire\Storefront\Account\0041
App\Livewire\Storefront\Account\Auth\Login333030064361024019169.925.71970.950.180.065429.748325141333264.3351.0113.320.291690.14169.145070.43507.43App\Livewire\Storefront\Account\Auth\0051
App\Livewire\Storefront\Account\Auth\Register333030042261024521197.653.42675.320.290.073857.854413182323070.3251.4318.880.221210.08121.083630.25363.25App\Livewire\Storefront\Account\Auth\0051
App\Livewire\Storefront\Account\Addresses\Index55505006221210510135518.064.312234.120.230.17124120.139923325524767.3644.2523.110.221690.1169.18450.5845.5App\Livewire\Storefront\Account\Addresses\0041
App\Livewire\Storefront\Account\Orders\Index22202002114102151049.831.574.740.670.02433.22312282171587.6662.3225.340.15490.1349.13980.2598.25App\Livewire\Storefront\Account\Orders\0041
App\Livewire\Storefront\Account\Orders\Show22202002114101191370.312.25158.190.440.02931.254153102181685.3560.6724.690.15640.1764.171280.33128.33App\Livewire\Storefront\Account\Orders\0041
App\Livewire\Storefront\Collections\Index2220200211310211934.871.2944.830.780.01227.1229271151483.5364.0619.470.15360.1436.14720.2972.29App\Livewire\Storefront\Collections\0031
App\Livewire\Storefront\Collections\Show333030053331024120177.24.13730.950.240.064142.968334162323070.5251.6318.880.221210.11121.113630.33363.33App\Livewire\Storefront\Collections\0031
App\Livewire\Storefront\Pages\Show2220200211310116950.721.8694.190.540.02527.31313271171680.0161.6618.350.15250.2525.25500.550.5App\Livewire\Storefront\Pages\0031
App\Livewire\Storefront\Concerns\EnsuresStore11111000333300112733.694.67157.210.210.0197.2257432151391.464.626.80.22250.1725.17250.1725.17App\Livewire\Storefront\Concerns\0011
App\Livewire\Admin\Customers\Index222020021131023619152.933.87591.310.260.053339.557294152222078.756.1922.510.151000.09100.092000.18200.18App\Livewire\Admin\Customers\0031
App\Livewire\Admin\Customers\Show222020032231013519148.683.87574.890.260.053238.456294151161577.7558.8718.880.15250.2525.25500.550.5App\Livewire\Admin\Customers\0031
App\Livewire\Admin\Settings\Taxes333030075541026128293.252.88844.560.350.147101.8213483256352980.0750.1529.920.15250.1725.17750.575.5App\Livewire\Admin\Settings\0031
App\Livewire\Admin\Settings\Index333030031121024522200.671.9381.280.530.0721105.627382207342784.8452.5232.320.1590.259.25270.7527.75App\Livewire\Admin\Settings\0021
App\Livewire\Admin\Settings\Shipping7770700822710410344562.324.352446.10.230.19136129.2716874407686165.3741.5323.840.221960.16196.1613721.131373.13App\Livewire\Admin\Settings\0041
App\Livewire\Admin\Dashboard444130052261035028240.373.25781.20.310.084373.9611394249393084.7150.8433.870.151210.33121.334841.33485.33App\Livewire\Admin\0051
App\Livewire\Admin\Products\Index555050073251025620242.0392178.250.110.0812126.8914426144423871.4548.44230.291690.21169.218451.07846.07App\Livewire\Admin\Products\0031
App\Livewire\Admin\Products\Form33303002220114102181471005.3811.911964.030.080.3466584.494513674012746266.437.1929.210.51440.21144.214320.62432.62App\Livewire\Admin\Products\0041Too complex method code,
App\Livewire\Admin\Auth\Login3331200644121026030294.415.631656.080.180.19252.3415456243403768.5547.9720.580.362560.18256.187680.53768.53App\Livewire\Admin\Auth\0061
App\Livewire\Admin\Navigation\Index888080014761110515152860.779.648301.610.10.2946189.25271247457787160.5138.1322.380.297290.13729.1358321.045833.04App\Livewire\Admin\Navigation\0051Blob / God object,
App\Livewire\Admin\Discounts\Index33303003113102271296.793.83371.040.260.032125.25423393242183.1657.1226.040.15490.1349.131470.38147.38App\Livewire\Admin\Discounts\0031
App\Livewire\Admin\Discounts\Form333030014128310213246729.1110.217444.60.10.2441471.41359783811675669.5740.2129.360.43360.4836.481081.43109.43App\Livewire\Admin\Discounts\0031
App\Livewire\Admin\Orders\Index555050051131026221272.326.121665.980.160.099344.5110524175403575.1749.1326.040.151000.09100.095000.45500.45App\Livewire\Admin\Orders\0031
App\Livewire\Admin\Orders\Show1010100100022136910215940846.1913.8911756.860.070.2865360.92813173321019945.0334.2210.810.522890.22289.2228902.172892.17App\Livewire\Admin\Orders\0061
App\Livewire\Admin\Collections\Index22202002113102251597.673.64355.170.280.032026.865204112181684.3559.6724.690.15490.1349.13980.2598.25App\Livewire\Admin\Collections\0031
App\Livewire\Admin\Collections\Form555050018148810117745972.0614.9214502.050.070.3280665.16391388378706263.138.125.010.593240.14324.1416200.681620.68App\Livewire\Admin\Collections\0051
App\Livewire\Admin\Pages\Index333030031141033219135.933.47471.240.290.052639.216264152242277.2755.6421.620.15810.1381.132430.4243.4App\Livewire\Admin\Pages\0031
App\Livewire\Admin\Pages\Form3330300151310610212634641.0212.317894.050.080.2143952.0531957277524569.4542.5326.910.36360.3336.331081109App\Livewire\Admin\Pages\0041
App\Livewire\Admin\Apps\Index333030031161037124325.535.91920.640.170.1110755.1712594204332976.0550.3725.680.152560.16256.167680.47768.47App\Livewire\Admin\Apps\0041
App\Livewire\Admin\Themes\Index444040052291046124279.686.711876.820.150.0910441.681051519338357048.9221.080.221690.2169.26760.79676.79App\Livewire\Admin\Themes\0041
App\Livewire\Admin\Analytics\Index222020043331016229301.195.421631.470.180.19155.6110525242211977.3554.34230.15250.2525.25500.550.5App\Livewire\Admin\Analytics\0031
App\Livewire\Admin\Developers\Index555050051191047835400.082.03812.290.490.1345197.0611672338524474.3445.828.550.152890.08289.0814450.391445.39App\Livewire\Admin\Developers\0051
App\Livewire\Actions\Logout11101001113001334.750.52.382009.51121231411105.2772.4132.860.15160.216.2160.216.2App\Livewire\Actions\0.01120.67
App\Support\HandleGenerator222110054320025523248.88.752176.960.110.0812128.4315407160262651.8251.8200.52490.7549.75981.599.5App\Support\0.01320.4
App\Support\CartSession333030097450034913181.3215.172750.040.070.0615311.962326760343449.8449.8400.43490.6749.671472149App\Support\0.02430.43
App\Http\Middleware\ResolveStore33321001085120018326390.146.192415.130.160.1313463.0231525210525243.3543.3500.643240.56324.569721.68973.68App\Http\Middleware\0091
App\Http\Controllers\Controller1000000001000000000000000000154202.9417131.940.15000000App\Http\Controllers\0000
App\Actions\Fortify\ResetUserPassword111010011130119418000.890.01016090451510108.2569.2638.990.15250.3325.33250.3325.33App\Actions\Fortify\0031
App\Actions\Fortify\CreateNewUser1110100111401115638.771.454.280.710.01327.71141551510105.9266.9338.990.15250.3325.33250.3325.33App\Actions\Fortify\0031
App\Jobs\ExpireAbandonedCheckouts111010011130119520.9001.110.01023.2209050111167.967.900.15640.1164.11640.1164.11App\Jobs\0031
App\Jobs\CleanupAbandonedCarts111010011120115410001.60016050409972.0572.0500.153603636036App\Jobs\0021
App\Jobs\AggregateAnalytics2220200433701210040532.194.672483.570.210.18138114.0416844360272749.2849.2800.156760.02676.0213520.041352.04App\Jobs\0061
App\Jobs\CancelUnpaidBankTransferOrders1110100111301111833001.450.01048011080111166.5266.5200.15640.1164.11640.1164.11App\Jobs\0031
App\Jobs\ProcessMediaUpload222020021130129623.260.818.611.250.01129.0818150141465.2965.2900.1540.174.1780.338.33App\Jobs\0031
App\Jobs\DeliverWebhook33312001086801210853618.6211.397047.80.090.2139254.3218711427514470.6843.5327.150.363610.18361.1810830.551083.55App\Jobs\0.01170.88
App\Events\OrderRefunded11101001112001222002004020208878.0678.0600.15022022App\Events\0120.67
App\Events\OrderCancelled11101001111001110002000010108817117100.15011011App\Events\0110.5
App\Events\OrderCreated11101001111001110002000010108817117100.15011011App\Events\0.01210.33
App\Events\OrderPaid11101001111001110002000010108817117100.15011011App\Events\0.01310.25
App\Events\FulfillmentDelivered11101001111001110002000010108817117100.15011011App\Events\0110.5
App\Events\OrderFulfilled11101001111001110002000010108817117100.15011011App\Events\0.01210.33
App\Observers\ProductObserver4440400411400210315.85000.60.0109.51010030191963.5763.5700.1540.334.33161.3317.33App\Observers\0021
App\Listeners\DispatchOrderWebhooks55514005115002261293.21000.920.03086.040260120232356.3756.3700.1510.61.6538App\Listeners\0051
App\Services\WebhookService333030042230023217130.83392.40.330.042243.64283143221984.0857.0227.070.38360.6736.671082110App\Services\0.01230.6
App\Services\OrderService444040028251223003237751476.2314.3121121.450.070.491173103.185118610652817945.133.0512.051.410890.131089.1343560.54356.5App\Services\0.013130.81Too complex method code,Probably bugged,
App\Services\Payments\MockPaymentProvider4442200854150126929335.24.41474.880.230.118276.1814554256413575.8947.9627.930.1591.510.536642App\Services\Payments\0071
App\Services\CheckoutService12121239004130837003312591835.3815.2828042.560.070.611558120.13852277521017016043.3825.0318.352.1212250.351225.35147004.2514704.25App\Services\0.011160.94Blob / God object,Probably bugged,
App\Services\FulfillmentService4441300171471300311737609.5112.557650.350.080.242548.5626918298564869.5741.9427.630.661960.32196.327841.27785.27App\Services\0.01180.89
App\Services\TaxCalculator33303001210620028830431.8114.256153.240.070.1434230.3315710204343073.3247.9825.340.3614.175.17312.515.5App\Services\0120.67
App\Services\ThemeSettingsService333030053350023521153.734.06624.530.250.053537.849265169322391.1954.5836.610.22810.4781.472431.4244.4App\Services\0031
App\Services\InventoryService555050084411005781631239.2112234.860.030.16807.961761970454545.9345.9300.22250.525.51252.5127.5App\Services\0.01340.57
App\Services\NavigationService33303003115002271399.911.04104.070.960.03695.922251125221792.6859.0233.660.15640.3364.331921193App\Services\0031
App\Services\RefundService2220200111010100029237479.2714.66995.490.070.1638932.84236911261403957.345.1812.130.731690.39169.393380.79338.79App\Services\0190.9
App\Services\ProductService666150038331621004308571796.5316.830181.710.060.61677106.94682407501012911948.427.520.929000.18900.1854001.15401.1App\Services\0.01170.88Too complex method code,Probably bugged,
App\Services\ShippingCalculator44422003936127002213531220.0522.9127954.330.040.41155353.2572141134012867460.1232.7727.351.492890.72289.7211562.891158.89App\Services\0150.83Too complex method code,Probably bugged,
App\Services\AnalyticsService22202003226002311612422480.50.04146232821461812100.5261.5338.990.15360.8636.86721.7173.71App\Services\0.01140.8
App\Services\CartService888170027201026003295451620.136.9159796.30.030.54332243.89922031233013013028.7228.7201.297290.37729.3758322.935834.93App\Services\0071Probably bugged,
App\Services\PricingEngine222020017161611002191491072.4116.54177360.060.3698564.8462129103905959383800.641960.27196.273920.53392.53App\Services\0190.9Too complex method code,Probably bugged,
App\Services\DiscountService33303002321151900316542889.7332.6229018.960.030.3161227.285910616263807750.1535.3714.781.224840.32484.3214520.961452.96App\Services\0160.86Too complex method code,
App\Services\SearchService5551400191571300213945763.3712.59542.090.080.2553061.07391009367595265.840.3625.430.573610.55361.5518052.751807.75App\Services\0.01150.83
App\Services\VariantMatrixService3332100151310200112239644.8210.066488.490.10.2136064.0830927327706362.8639.3323.531.812890.22289.228670.67867.67App\Services\0120.67
App\Concerns\ProfileValidationRules13333000422200120963.42.29144.910.440.02827.7441627153116104.8960.8544.040.15160.7316.73482.250.2App\Concerns\0011
App\Concerns\PasswordValidationRules1222200021110028518.580.7513.931.330.01124.772614102212110.6767.4443.230.15112224App\Concerns\0011
App\ValueObjects\PaymentResult144404004111002148421.33560.750.01331.568260191960.660.600.1504.254.2501717App\ValueObjects\0.01210.33
App\ValueObjects\DiscountResult111101001110001334.75002009.5103033107114.2176.6937.520.15033033App\ValueObjects\0.01100
App\ValueObjects\PricingResult1222020021100022714102.81102.810.036102.8126113152611109.2363.0646.170.1512.53.5257App\ValueObjects\0.01100
App\ValueObjects\TaxLine12220200211000210523.221.1326.120.890.01120.64191431411100.4567.5832.860.1502.52.5055App\ValueObjects\0.01200
App\ValueObjects\RefundResult1222020021110027719.65119.6510.01119.6525250111168.0968.0900.15033066App\ValueObjects\0.01210.33
+
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + diff --git a/report/classes.js b/report/classes.js new file mode 100644 index 00000000..676ccbdc --- /dev/null +++ b/report/classes.js @@ -0,0 +1,11646 @@ +var classes = [ + { + "name": "App\\Auth\\CustomerUserProvider", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "retrieveById", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "retrieveByToken", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updateRememberToken", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "retrieveByCredentials", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "validateCredentials", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "rehashPasswordIfRequired", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "createModel", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "newModelQuery", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "constrainToCurrentStore", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 10, + "nbMethods": 10, + "nbMethodsPrivate": 3, + "nbMethodsPublic": 7, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 29, + "ccn": 20, + "ccnMethodMax": 8, + "externals": [ + "Illuminate\\Contracts\\Auth\\UserProvider", + "Illuminate\\Contracts\\Hashing\\Hasher", + "Illuminate\\Contracts\\Auth\\Authenticatable", + "Illuminate\\Support\\Str", + "Illuminate\\Support\\Str", + "Illuminate\\Contracts\\Auth\\Authenticatable", + "Illuminate\\Contracts\\Auth\\Authenticatable", + "Illuminate\\Database\\Eloquent\\Model", + "class", + "Illuminate\\Database\\Eloquent\\Builder", + "Illuminate\\Database\\Eloquent\\Builder" + ], + "parents": [], + "implements": [ + "Illuminate\\Contracts\\Auth\\UserProvider" + ], + "lcom": 3, + "length": 158, + "vocabulary": 33, + "volume": 797.01, + "difficulty": 19.88, + "effort": 15840.66, + "level": 0.05, + "bugs": 0.27, + "time": 880, + "intelligentContent": 40.1, + "number_operators": 52, + "number_operands": 106, + "number_operators_unique": 9, + "number_operands_unique": 24, + "cloc": 16, + "loc": 109, + "lloc": 93, + "mi": 62.02, + "mIwoC": 34.05, + "commentWeight": 27.97, + "kanDefect": 1.01, + "relativeStructuralComplexity": 484, + "relativeDataComplexity": 0.63, + "relativeSystemComplexity": 484.63, + "totalStructuralComplexity": 4840, + "totalDataComplexity": 6.26, + "totalSystemComplexity": 4846.26, + "package": "App\\Auth\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 7, + "instability": 0.88, + "violations": {} + }, + { + "name": "App\\Providers\\AppServiceProvider", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "register", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "boot", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "configureWebhookListeners", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "configureRateLimiters", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "configureCustomerAuthProvider", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "configureDefaults", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 6, + "nbMethods": 6, + "nbMethodsPrivate": 4, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 7, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Illuminate\\Support\\ServiceProvider", + "App\\Models\\Product", + "Illuminate\\Support\\Facades\\Event", + "Illuminate\\Support\\Facades\\Event", + "Illuminate\\Support\\Facades\\Event", + "Illuminate\\Cache\\RateLimiting\\Limit", + "Illuminate\\Support\\Facades\\RateLimiter", + "App\\Auth\\CustomerUserProvider", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Date", + "Illuminate\\Support\\Facades\\DB", + "Illuminate\\Validation\\Rules\\Password", + "Illuminate\\Validation\\Rules\\Password" + ], + "parents": [ + "Illuminate\\Support\\ServiceProvider" + ], + "implements": [], + "lcom": 2, + "length": 26, + "vocabulary": 16, + "volume": 104, + "difficulty": 1.71, + "effort": 178.29, + "level": 0.58, + "bugs": 0.03, + "time": 10, + "intelligentContent": 60.67, + "number_operators": 2, + "number_operands": 24, + "number_operators_unique": 2, + "number_operands_unique": 14, + "cloc": 13, + "loc": 53, + "lloc": 40, + "mi": 85.37, + "mIwoC": 50.66, + "commentWeight": 34.71, + "kanDefect": 0.15, + "relativeStructuralComplexity": 529, + "relativeDataComplexity": 0.04, + "relativeSystemComplexity": 529.04, + "totalStructuralComplexity": 3174, + "totalDataComplexity": 0.25, + "totalSystemComplexity": 3174.25, + "package": "App\\Providers\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 10, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Providers\\FortifyServiceProvider", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "register", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "boot", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "configureActions", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "configureViews", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "configureRateLimiting", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 3, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Support\\ServiceProvider", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Fortify", + "Illuminate\\Cache\\RateLimiting\\Limit", + "Illuminate\\Support\\Facades\\RateLimiter", + "Laravel\\Fortify\\Fortify", + "Illuminate\\Support\\Str", + "Illuminate\\Support\\Str", + "Illuminate\\Cache\\RateLimiting\\Limit", + "Illuminate\\Support\\Facades\\RateLimiter" + ], + "parents": [ + "Illuminate\\Support\\ServiceProvider" + ], + "implements": [], + "lcom": 2, + "length": 28, + "vocabulary": 18, + "volume": 116.76, + "difficulty": 2.3, + "effort": 268.54, + "level": 0.43, + "bugs": 0.04, + "time": 15, + "intelligentContent": 50.76, + "number_operators": 5, + "number_operands": 23, + "number_operators_unique": 3, + "number_operands_unique": 15, + "cloc": 16, + "loc": 54, + "lloc": 38, + "mi": 88.27, + "mIwoC": 50.93, + "commentWeight": 37.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 484, + "relativeDataComplexity": 0.09, + "relativeSystemComplexity": 484.09, + "totalStructuralComplexity": 2420, + "totalDataComplexity": 0.43, + "totalSystemComplexity": 2420.43, + "package": "App\\Providers\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\OrderLine", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "order", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "product", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "variant", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "fulfillmentLines", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 24, + "vocabulary": 13, + "volume": 88.81, + "difficulty": 0.79, + "effort": 70.31, + "level": 1.26, + "bugs": 0.03, + "time": 4, + "intelligentContent": 112.18, + "number_operators": 5, + "number_operands": 19, + "number_operators_unique": 1, + "number_operands_unique": 12, + "cloc": 16, + "loc": 43, + "lloc": 27, + "mi": 95.52, + "mIwoC": 55, + "commentWeight": 40.52, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 1.67, + "relativeSystemComplexity": 5.67, + "totalStructuralComplexity": 20, + "totalDataComplexity": 8.33, + "totalSystemComplexity": 28.33, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 3, + "instability": 0.75, + "violations": {} + }, + { + "name": "App\\Models\\WebhookSubscription", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "appInstallation", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deliveries", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 13, + "vocabulary": 11, + "volume": 44.97, + "difficulty": 0.55, + "effort": 24.73, + "level": 1.82, + "bugs": 0.01, + "time": 1, + "intelligentContent": 81.77, + "number_operators": 2, + "number_operands": 11, + "number_operators_unique": 1, + "number_operands_unique": 10, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 100.98, + "mIwoC": 62.64, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 0.67, + "relativeSystemComplexity": 4.67, + "totalStructuralComplexity": 8, + "totalDataComplexity": 1.33, + "totalSystemComplexity": 9.33, + "package": "App\\Models\\", + "pageRank": 0.02, + "afferentCoupling": 3, + "efferentCoupling": 3, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Models\\ThemeFile", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "theme", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 10, + "vocabulary": 8, + "volume": 30, + "difficulty": 0.57, + "effort": 17.14, + "level": 1.75, + "bugs": 0.01, + "time": 1, + "intelligentContent": 52.5, + "number_operators": 2, + "number_operands": 8, + "number_operators_unique": 1, + "number_operands_unique": 7, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 102.21, + "mIwoC": 63.87, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\ProductOption", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "product", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "values", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 7, + "vocabulary": 5, + "volume": 16.25, + "difficulty": 0.63, + "effort": 10.16, + "level": 1.6, + "bugs": 0.01, + "time": 1, + "intelligentContent": 26.01, + "number_operators": 2, + "number_operands": 5, + "number_operators_unique": 1, + "number_operands_unique": 4, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 104.07, + "mIwoC": 65.73, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 0.67, + "relativeSystemComplexity": 4.67, + "totalStructuralComplexity": 8, + "totalDataComplexity": 1.33, + "totalSystemComplexity": 9.33, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\NavigationItem", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "menu", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "resolveUrl", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 30, + "vocabulary": 16, + "volume": 120, + "difficulty": 2.65, + "effort": 318.46, + "level": 0.38, + "bugs": 0.04, + "time": 18, + "intelligentContent": 45.22, + "number_operators": 7, + "number_operands": 23, + "number_operators_unique": 3, + "number_operands_unique": 13, + "cloc": 7, + "loc": 31, + "lloc": 24, + "mi": 88.64, + "mIwoC": 55.06, + "commentWeight": 33.57, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1.5, + "relativeSystemComplexity": 2.5, + "totalStructuralComplexity": 3, + "totalDataComplexity": 4.5, + "totalSystemComplexity": 7.5, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 2, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Models\\Refund", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "order", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "payment", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 12, + "vocabulary": 8, + "volume": 36, + "difficulty": 0.64, + "effort": 23.14, + "level": 1.56, + "bugs": 0.01, + "time": 1, + "intelligentContent": 56, + "number_operators": 3, + "number_operands": 9, + "number_operators_unique": 1, + "number_operands_unique": 7, + "cloc": 10, + "loc": 29, + "lloc": 19, + "mi": 100.54, + "mIwoC": 61.07, + "commentWeight": 39.47, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1.5, + "relativeSystemComplexity": 2.5, + "totalStructuralComplexity": 3, + "totalDataComplexity": 4.5, + "totalSystemComplexity": 7.5, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 2, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Models\\InventoryItem", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "variant", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "quantityAvailable", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 16, + "vocabulary": 9, + "volume": 50.72, + "difficulty": 1.71, + "effort": 86.95, + "level": 0.58, + "bugs": 0.02, + "time": 5, + "intelligentContent": 29.59, + "number_operators": 4, + "number_operands": 12, + "number_operators_unique": 2, + "number_operands_unique": 7, + "cloc": 7, + "loc": 26, + "lloc": 19, + "mi": 96.03, + "mIwoC": 60.03, + "commentWeight": 36, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1.5, + "relativeSystemComplexity": 2.5, + "totalStructuralComplexity": 3, + "totalDataComplexity": 4.5, + "totalSystemComplexity": 7.5, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 4, + "efferentCoupling": 2, + "instability": 0.33, + "violations": {} + }, + { + "name": "App\\Models\\NavigationMenu", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "items", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 1, + "length": 7, + "vocabulary": 7, + "volume": 19.65, + "difficulty": 0.5, + "effort": 9.83, + "level": 2, + "bugs": 0.01, + "time": 1, + "intelligentContent": 39.3, + "number_operators": 1, + "number_operands": 6, + "number_operators_unique": 1, + "number_operands_unique": 6, + "cloc": 4, + "loc": 14, + "lloc": 10, + "mi": 105.83, + "mIwoC": 69, + "commentWeight": 36.83, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 4.33, + "totalStructuralComplexity": 4, + "totalDataComplexity": 0.33, + "totalSystemComplexity": 4.33, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 2, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Models\\App", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "installations", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 11, + "vocabulary": 9, + "volume": 34.87, + "difficulty": 0.56, + "effort": 19.61, + "level": 1.78, + "bugs": 0.01, + "time": 1, + "intelligentContent": 61.99, + "number_operators": 2, + "number_operands": 9, + "number_operators_unique": 1, + "number_operands_unique": 8, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 101.75, + "mIwoC": 63.41, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Models\\CartLine", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "cart", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "variant", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 1, + "length": 12, + "vocabulary": 9, + "volume": 38.04, + "difficulty": 0.63, + "effort": 23.77, + "level": 1.6, + "bugs": 0.01, + "time": 1, + "intelligentContent": 60.86, + "number_operators": 2, + "number_operands": 10, + "number_operators_unique": 1, + "number_operands_unique": 8, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 101.49, + "mIwoC": 63.15, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Models\\AppInstallation", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "app", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "webhookSubscriptions", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 16, + "vocabulary": 11, + "volume": 55.35, + "difficulty": 0.65, + "effort": 35.98, + "level": 1.54, + "bugs": 0.02, + "time": 2, + "intelligentContent": 85.16, + "number_operators": 3, + "number_operands": 13, + "number_operators_unique": 1, + "number_operands_unique": 10, + "cloc": 10, + "loc": 31, + "lloc": 21, + "mi": 97.35, + "mIwoC": 58.82, + "commentWeight": 38.53, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 5, + "totalStructuralComplexity": 12, + "totalDataComplexity": 3, + "totalSystemComplexity": 15, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 3, + "instability": 0.75, + "violations": {} + }, + { + "name": "App\\Models\\Cart", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "lines", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "checkouts", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "incrementVersion", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 18, + "vocabulary": 12, + "volume": 64.53, + "difficulty": 2.17, + "effort": 139.81, + "level": 0.46, + "bugs": 0.02, + "time": 8, + "intelligentContent": 29.78, + "number_operators": 5, + "number_operands": 13, + "number_operators_unique": 3, + "number_operands_unique": 9, + "cloc": 10, + "loc": 32, + "lloc": 22, + "mi": 96, + "mIwoC": 57.91, + "commentWeight": 38.09, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1.5, + "relativeSystemComplexity": 2.5, + "totalStructuralComplexity": 4, + "totalDataComplexity": 6, + "totalSystemComplexity": 10, + "package": "App\\Models\\", + "pageRank": 0.04, + "afferentCoupling": 6, + "efferentCoupling": 2, + "instability": 0.25, + "violations": {} + }, + { + "name": "App\\Models\\Discount", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "isCurrentlyActive", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 9, + "ccn": 8, + "ccnMethodMax": 8, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 50, + "vocabulary": 22, + "volume": 222.97, + "difficulty": 5.81, + "effort": 1296.02, + "level": 0.17, + "bugs": 0.07, + "time": 72, + "intelligentContent": 38.36, + "number_operators": 19, + "number_operands": 31, + "number_operators_unique": 6, + "number_operands_unique": 16, + "cloc": 4, + "loc": 31, + "lloc": 27, + "mi": 77.67, + "mIwoC": 51.26, + "commentWeight": 26.41, + "kanDefect": 0.43, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 2, + "relativeSystemComplexity": 6, + "totalStructuralComplexity": 8, + "totalDataComplexity": 4, + "totalSystemComplexity": 12, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 3, + "efferentCoupling": 1, + "instability": 0.25, + "violations": {} + }, + { + "name": "App\\Models\\Product", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "variants", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "options", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "media", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "collections", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 25, + "vocabulary": 15, + "volume": 97.67, + "difficulty": 0.71, + "effort": 69.77, + "level": 1.4, + "bugs": 0.03, + "time": 4, + "intelligentContent": 136.74, + "number_operators": 5, + "number_operands": 20, + "number_operators_unique": 1, + "number_operands_unique": 14, + "cloc": 16, + "loc": 42, + "lloc": 26, + "mi": 95.92, + "mIwoC": 55.07, + "commentWeight": 40.85, + "kanDefect": 0.15, + "relativeStructuralComplexity": 9, + "relativeDataComplexity": 1.25, + "relativeSystemComplexity": 10.25, + "totalStructuralComplexity": 45, + "totalDataComplexity": 6.25, + "totalSystemComplexity": 51.25, + "package": "App\\Models\\", + "pageRank": 0.04, + "afferentCoupling": 10, + "efferentCoupling": 3, + "instability": 0.23, + "violations": {} + }, + { + "name": "App\\Models\\Order", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "customer", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "lines", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "payments", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "refunds", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "fulfillments", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "requiresShipping", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "refundedTotal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "refundableAmount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 9, + "nbMethods": 9, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 8, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 13, + "ccn": 5, + "ccnMethodMax": 5, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 65, + "vocabulary": 31, + "volume": 322.02, + "difficulty": 5.76, + "effort": 1854.85, + "level": 0.17, + "bugs": 0.11, + "time": 103, + "intelligentContent": 55.91, + "number_operators": 17, + "number_operands": 48, + "number_operators_unique": 6, + "number_operands_unique": 25, + "cloc": 19, + "loc": 69, + "lloc": 50, + "mi": 81.02, + "mIwoC": 44.71, + "commentWeight": 36.32, + "kanDefect": 0.52, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 1.57, + "relativeSystemComplexity": 37.57, + "totalStructuralComplexity": 324, + "totalDataComplexity": 14.14, + "totalSystemComplexity": 338.14, + "package": "App\\Models\\", + "pageRank": 0.1, + "afferentCoupling": 20, + "efferentCoupling": 3, + "instability": 0.13, + "violations": {} + }, + { + "name": "App\\Models\\Store", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "organization", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "domains", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "users", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "settings", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasOne" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 5, + "length": 19, + "vocabulary": 11, + "volume": 65.73, + "difficulty": 0.7, + "effort": 46.01, + "level": 1.43, + "bugs": 0.02, + "time": 3, + "intelligentContent": 93.9, + "number_operators": 5, + "number_operands": 14, + "number_operators_unique": 1, + "number_operands_unique": 10, + "cloc": 16, + "loc": 42, + "lloc": 26, + "mi": 97.12, + "mIwoC": 56.27, + "commentWeight": 40.85, + "kanDefect": 0.15, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0.71, + "relativeSystemComplexity": 36.71, + "totalStructuralComplexity": 180, + "totalDataComplexity": 3.57, + "totalSystemComplexity": 183.57, + "package": "App\\Models\\", + "pageRank": 0.06, + "afferentCoupling": 17, + "efferentCoupling": 5, + "instability": 0.23, + "violations": {} + }, + { + "name": "App\\Models\\StoreDomain", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "store", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 11, + "vocabulary": 8, + "volume": 33, + "difficulty": 0.64, + "effort": 21.21, + "level": 1.56, + "bugs": 0.01, + "time": 1, + "intelligentContent": 51.33, + "number_operators": 2, + "number_operands": 9, + "number_operators_unique": 1, + "number_operands_unique": 7, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 101.92, + "mIwoC": 63.58, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Models\\Theme", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "files", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "settings", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasOne" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 13, + "vocabulary": 8, + "volume": 39, + "difficulty": 0.71, + "effort": 27.86, + "level": 1.4, + "bugs": 0.01, + "time": 2, + "intelligentContent": 54.6, + "number_operators": 3, + "number_operands": 10, + "number_operators_unique": 1, + "number_operands_unique": 7, + "cloc": 10, + "loc": 28, + "lloc": 18, + "mi": 101.3, + "mIwoC": 61.34, + "commentWeight": 39.96, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 5, + "totalStructuralComplexity": 12, + "totalDataComplexity": 3, + "totalSystemComplexity": 15, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 3, + "instability": 0.6, + "violations": {} + }, + { + "name": "App\\Models\\ProductMedia", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "product", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 16, + "vocabulary": 13, + "volume": 59.21, + "difficulty": 0.58, + "effort": 34.54, + "level": 1.71, + "bugs": 0.02, + "time": 2, + "intelligentContent": 101.5, + "number_operators": 2, + "number_operands": 14, + "number_operators_unique": 1, + "number_operands_unique": 12, + "cloc": 7, + "loc": 23, + "lloc": 16, + "mi": 98.91, + "mIwoC": 61.19, + "commentWeight": 37.72, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Models\\User", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "initials", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "stores", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "roleForStore", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 7, + "ccn": 4, + "ccnMethodMax": 4, + "externals": [ + "Illuminate\\Foundation\\Auth\\User", + "Illuminate\\Support\\Str", + "Illuminate\\Support\\Str", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany", + "App\\Models\\Store", + "App\\Enums\\StoreUserRole" + ], + "parents": [ + "Illuminate\\Foundation\\Auth\\User" + ], + "implements": [], + "lcom": 3, + "length": 55, + "vocabulary": 29, + "volume": 267.19, + "difficulty": 3.36, + "effort": 897.75, + "level": 0.3, + "bugs": 0.09, + "time": 50, + "intelligentContent": 79.52, + "number_operators": 13, + "number_operands": 42, + "number_operators_unique": 4, + "number_operands_unique": 25, + "cloc": 24, + "loc": 56, + "lloc": 32, + "mi": 92.09, + "mIwoC": 49.64, + "commentWeight": 42.45, + "kanDefect": 0.29, + "relativeStructuralComplexity": 169, + "relativeDataComplexity": 0.45, + "relativeSystemComplexity": 169.45, + "totalStructuralComplexity": 676, + "totalDataComplexity": 1.79, + "totalSystemComplexity": 677.79, + "package": "App\\Models\\", + "pageRank": 0.02, + "afferentCoupling": 4, + "efferentCoupling": 5, + "instability": 0.56, + "violations": {} + }, + { + "name": "App\\Models\\WebhookDelivery", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "subscription", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 16, + "vocabulary": 12, + "volume": 57.36, + "difficulty": 0.64, + "effort": 36.5, + "level": 1.57, + "bugs": 0.02, + "time": 2, + "intelligentContent": 90.14, + "number_operators": 2, + "number_operands": 14, + "number_operators_unique": 1, + "number_operands_unique": 11, + "cloc": 7, + "loc": 23, + "lloc": 16, + "mi": 99, + "mIwoC": 61.29, + "commentWeight": 37.72, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Models\\Fulfillment", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "order", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "lines", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 17, + "vocabulary": 10, + "volume": 56.47, + "difficulty": 0.78, + "effort": 43.92, + "level": 1.29, + "bugs": 0.02, + "time": 2, + "intelligentContent": 72.61, + "number_operators": 3, + "number_operands": 14, + "number_operators_unique": 1, + "number_operands_unique": 9, + "cloc": 10, + "loc": 29, + "lloc": 19, + "mi": 99.17, + "mIwoC": 59.7, + "commentWeight": 39.47, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 5, + "totalStructuralComplexity": 12, + "totalDataComplexity": 3, + "totalSystemComplexity": 15, + "package": "App\\Models\\", + "pageRank": 0.02, + "afferentCoupling": 2, + "efferentCoupling": 3, + "instability": 0.6, + "violations": {} + }, + { + "name": "App\\Models\\ThemeSettings", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "theme", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 10, + "vocabulary": 7, + "volume": 28.07, + "difficulty": 0.67, + "effort": 18.72, + "level": 1.5, + "bugs": 0.01, + "time": 1, + "intelligentContent": 42.11, + "number_operators": 2, + "number_operands": 8, + "number_operators_unique": 1, + "number_operands_unique": 6, + "cloc": 7, + "loc": 26, + "lloc": 19, + "mi": 97.83, + "mIwoC": 61.83, + "commentWeight": 36, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\Checkout", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "cart", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 27, + "vocabulary": 17, + "volume": 110.36, + "difficulty": 0.78, + "effort": 86.22, + "level": 1.28, + "bugs": 0.04, + "time": 5, + "intelligentContent": 141.26, + "number_operators": 2, + "number_operands": 25, + "number_operators_unique": 1, + "number_operands_unique": 16, + "cloc": 7, + "loc": 21, + "lloc": 14, + "mi": 99.55, + "mIwoC": 60.56, + "commentWeight": 38.99, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.02, + "afferentCoupling": 7, + "efferentCoupling": 2, + "instability": 0.22, + "violations": {} + }, + { + "name": "App\\Models\\Payment", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "order", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "refunds", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 3, + "length": 17, + "vocabulary": 11, + "volume": 58.81, + "difficulty": 0.7, + "effort": 41.17, + "level": 1.43, + "bugs": 0.02, + "time": 2, + "intelligentContent": 84.01, + "number_operators": 3, + "number_operands": 14, + "number_operators_unique": 1, + "number_operands_unique": 10, + "cloc": 10, + "loc": 29, + "lloc": 19, + "mi": 99.05, + "mIwoC": 59.58, + "commentWeight": 39.47, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 5, + "totalStructuralComplexity": 12, + "totalDataComplexity": 3, + "totalSystemComplexity": 15, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 4, + "efferentCoupling": 3, + "instability": 0.43, + "violations": {} + }, + { + "name": "App\\Models\\AnalyticsDaily", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "setKeysForSaveQuery", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "setKeysForSelectQuery", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 3, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 29, + "vocabulary": 13, + "volume": 107.31, + "difficulty": 1.08, + "effort": 116.26, + "level": 0.92, + "bugs": 0.04, + "time": 6, + "intelligentContent": 99.06, + "number_operators": 3, + "number_operands": 26, + "number_operators_unique": 1, + "number_operands_unique": 12, + "cloc": 15, + "loc": 38, + "lloc": 23, + "mi": 97.28, + "mIwoC": 55.94, + "commentWeight": 41.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 16, + "relativeDataComplexity": 0.73, + "relativeSystemComplexity": 16.73, + "totalStructuralComplexity": 48, + "totalDataComplexity": 2.2, + "totalSystemComplexity": 50.2, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 1, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Models\\Customer", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "getAuthPassword", + "role": "getter", + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "getAuthPasswordName", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "getAuthIdentifierName", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "addresses", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "orders", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "carts", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 7, + "nbMethods": 6, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 5, + "nbMethodsGetter": 1, + "nbMethodsSetters": 0, + "wmc": 6, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Foundation\\Auth\\User", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Foundation\\Auth\\User" + ], + "implements": [], + "lcom": 4, + "length": 25, + "vocabulary": 12, + "volume": 89.62, + "difficulty": 0.82, + "effort": 73.33, + "level": 1.22, + "bugs": 0.03, + "time": 4, + "intelligentContent": 109.54, + "number_operators": 7, + "number_operands": 18, + "number_operators_unique": 1, + "number_operands_unique": 11, + "cloc": 16, + "loc": 51, + "lloc": 35, + "mi": 90.66, + "mIwoC": 52.51, + "commentWeight": 38.14, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 3.5, + "relativeSystemComplexity": 4.5, + "totalStructuralComplexity": 7, + "totalDataComplexity": 24.5, + "totalSystemComplexity": 31.5, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 4, + "efferentCoupling": 2, + "instability": 0.33, + "violations": {} + }, + { + "name": "App\\Models\\ProductVariant", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "product", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "inventoryItem", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "optionValues", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\HasOne", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 4, + "length": 27, + "vocabulary": 17, + "volume": 110.36, + "difficulty": 0.72, + "effort": 79.32, + "level": 1.39, + "bugs": 0.04, + "time": 4, + "intelligentContent": 153.55, + "number_operators": 4, + "number_operands": 23, + "number_operators_unique": 1, + "number_operands_unique": 16, + "cloc": 13, + "loc": 35, + "lloc": 22, + "mi": 96.78, + "mIwoC": 56.28, + "commentWeight": 40.5, + "kanDefect": 0.15, + "relativeStructuralComplexity": 9, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 10, + "totalStructuralComplexity": 36, + "totalDataComplexity": 4, + "totalSystemComplexity": 40, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 4, + "instability": 0.8, + "violations": {} + }, + { + "name": "App\\Models\\ProductOptionValue", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "option", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 1, + "length": 6, + "vocabulary": 5, + "volume": 13.93, + "difficulty": 0.63, + "effort": 8.71, + "level": 1.6, + "bugs": 0, + "time": 0, + "intelligentContent": 22.29, + "number_operators": 1, + "number_operands": 5, + "number_operators_unique": 1, + "number_operands_unique": 4, + "cloc": 4, + "loc": 15, + "lloc": 11, + "mi": 105.01, + "mIwoC": 69.14, + "commentWeight": 35.87, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 0.5, + "relativeSystemComplexity": 1.5, + "totalStructuralComplexity": 1, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 1.5, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\TaxSettings", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "store", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 16, + "vocabulary": 11, + "volume": 55.35, + "difficulty": 0.7, + "effort": 38.75, + "level": 1.43, + "bugs": 0.02, + "time": 2, + "intelligentContent": 79.07, + "number_operators": 2, + "number_operands": 14, + "number_operators_unique": 1, + "number_operands_unique": 10, + "cloc": 7, + "loc": 26, + "lloc": 19, + "mi": 95.77, + "mIwoC": 59.77, + "commentWeight": 36, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 3, + "efferentCoupling": 2, + "instability": 0.4, + "violations": {} + }, + { + "name": "App\\Models\\ShippingZone", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "rates", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 12, + "vocabulary": 8, + "volume": 36, + "difficulty": 0.71, + "effort": 25.71, + "level": 1.4, + "bugs": 0.01, + "time": 1, + "intelligentContent": 50.4, + "number_operators": 2, + "number_operands": 10, + "number_operators_unique": 1, + "number_operands_unique": 7, + "cloc": 7, + "loc": 21, + "lloc": 14, + "mi": 102.96, + "mIwoC": 63.97, + "commentWeight": 38.99, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 2, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Models\\Collection", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "products", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 13, + "vocabulary": 10, + "volume": 43.19, + "difficulty": 0.61, + "effort": 26.39, + "level": 1.64, + "bugs": 0.01, + "time": 1, + "intelligentContent": 70.67, + "number_operators": 2, + "number_operands": 11, + "number_operators_unique": 1, + "number_operands_unique": 9, + "cloc": 7, + "loc": 21, + "lloc": 14, + "mi": 102.41, + "mIwoC": 63.41, + "commentWeight": 38.99, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 0.67, + "relativeSystemComplexity": 4.67, + "totalStructuralComplexity": 8, + "totalDataComplexity": 1.33, + "totalSystemComplexity": 9.33, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 5, + "efferentCoupling": 2, + "instability": 0.29, + "violations": {} + }, + { + "name": "App\\Models\\Page", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 1, + "length": 10, + "vocabulary": 8, + "volume": 30, + "difficulty": 0.64, + "effort": 19.29, + "level": 1.56, + "bugs": 0.01, + "time": 1, + "intelligentContent": 46.67, + "number_operators": 1, + "number_operands": 9, + "number_operators_unique": 1, + "number_operands_unique": 7, + "cloc": 4, + "loc": 14, + "lloc": 10, + "mi": 104.54, + "mIwoC": 67.71, + "commentWeight": 36.83, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 1, + "totalStructuralComplexity": 0, + "totalDataComplexity": 1, + "totalSystemComplexity": 1, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 3, + "efferentCoupling": 1, + "instability": 0.25, + "violations": {} + }, + { + "name": "App\\Models\\ShippingRate", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "zone", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 14, + "vocabulary": 9, + "volume": 44.38, + "difficulty": 0.75, + "effort": 33.28, + "level": 1.33, + "bugs": 0.01, + "time": 2, + "intelligentContent": 59.17, + "number_operators": 2, + "number_operands": 12, + "number_operators_unique": 1, + "number_operands_unique": 8, + "cloc": 7, + "loc": 21, + "lloc": 14, + "mi": 102.32, + "mIwoC": 63.33, + "commentWeight": 38.99, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 4, + "efferentCoupling": 2, + "instability": 0.33, + "violations": {} + }, + { + "name": "App\\Models\\StoreSettings", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "store", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 9, + "vocabulary": 6, + "volume": 23.26, + "difficulty": 0.7, + "effort": 16.29, + "level": 1.43, + "bugs": 0.01, + "time": 1, + "intelligentContent": 33.24, + "number_operators": 2, + "number_operands": 7, + "number_operators_unique": 1, + "number_operands_unique": 5, + "cloc": 7, + "loc": 25, + "lloc": 18, + "mi": 99.46, + "mIwoC": 62.91, + "commentWeight": 36.55, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\CustomerAddress", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "customer", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 2, + "length": 11, + "vocabulary": 8, + "volume": 33, + "difficulty": 0.64, + "effort": 21.21, + "level": 1.56, + "bugs": 0.01, + "time": 1, + "intelligentContent": 51.33, + "number_operators": 2, + "number_operands": 9, + "number_operators_unique": 1, + "number_operands_unique": 7, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 101.92, + "mIwoC": 63.58, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Models\\StoreUser", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "booted", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Illuminate\\Database\\Eloquent\\Relations\\Pivot", + "static" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Relations\\Pivot" + ], + "implements": [], + "lcom": 2, + "length": 10, + "vocabulary": 6, + "volume": 25.85, + "difficulty": 2, + "effort": 51.7, + "level": 0.5, + "bugs": 0.01, + "time": 3, + "intelligentContent": 12.92, + "number_operators": 2, + "number_operands": 8, + "number_operators_unique": 2, + "number_operands_unique": 4, + "cloc": 3, + "loc": 23, + "lloc": 20, + "mi": 88, + "mIwoC": 61.46, + "commentWeight": 26.54, + "kanDefect": 0.22, + "relativeStructuralComplexity": 9, + "relativeDataComplexity": 0.25, + "relativeSystemComplexity": 9.25, + "totalStructuralComplexity": 18, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 18.5, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\FulfillmentLine", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "fulfillment", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "orderLine", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 1, + "length": 7, + "vocabulary": 5, + "volume": 16.25, + "difficulty": 0.63, + "effort": 10.16, + "level": 1.6, + "bugs": 0.01, + "time": 1, + "intelligentContent": 26.01, + "number_operators": 2, + "number_operands": 5, + "number_operators_unique": 1, + "number_operands_unique": 4, + "cloc": 7, + "loc": 22, + "lloc": 15, + "mi": 104.07, + "mIwoC": 65.73, + "commentWeight": 38.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Models\\AnalyticsEvent", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "casts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 1, + "length": 13, + "vocabulary": 11, + "volume": 44.97, + "difficulty": 0.6, + "effort": 26.98, + "level": 1.67, + "bugs": 0.01, + "time": 1, + "intelligentContent": 74.95, + "number_operators": 1, + "number_operands": 12, + "number_operators_unique": 1, + "number_operands_unique": 10, + "cloc": 4, + "loc": 16, + "lloc": 12, + "mi": 99.72, + "mIwoC": 64.75, + "commentWeight": 34.97, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 1, + "totalStructuralComplexity": 0, + "totalDataComplexity": 1, + "totalSystemComplexity": 1, + "package": "App\\Models\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 1, + "instability": 0.33, + "violations": {} + }, + { + "name": "App\\Models\\Scopes\\StoreScope", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "apply", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Illuminate\\Database\\Eloquent\\Scope", + "Illuminate\\Database\\Eloquent\\Builder", + "Illuminate\\Database\\Eloquent\\Model" + ], + "parents": [], + "implements": [ + "Illuminate\\Database\\Eloquent\\Scope" + ], + "lcom": 1, + "length": 13, + "vocabulary": 9, + "volume": 41.21, + "difficulty": 3.6, + "effort": 148.35, + "level": 0.28, + "bugs": 0.01, + "time": 8, + "intelligentContent": 11.45, + "number_operators": 4, + "number_operands": 9, + "number_operators_unique": 4, + "number_operands_unique": 5, + "cloc": 1, + "loc": 13, + "lloc": 12, + "mi": 85.71, + "mIwoC": 64.88, + "commentWeight": 20.83, + "kanDefect": 0.22, + "relativeStructuralComplexity": 9, + "relativeDataComplexity": 0.75, + "relativeSystemComplexity": 9.75, + "totalStructuralComplexity": 9, + "totalDataComplexity": 0.75, + "totalSystemComplexity": 9.75, + "package": "App\\Models\\Scopes\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 3, + "instability": 0.75, + "violations": {} + }, + { + "name": "App\\Models\\Organization", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "stores", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Database\\Eloquent\\Model", + "Illuminate\\Database\\Eloquent\\Relations\\HasMany" + ], + "parents": [ + "Illuminate\\Database\\Eloquent\\Model" + ], + "implements": [], + "lcom": 1, + "length": 4, + "vocabulary": 4, + "volume": 8, + "difficulty": 0.5, + "effort": 4, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 16, + "number_operators": 1, + "number_operands": 3, + "number_operators_unique": 1, + "number_operands_unique": 3, + "cloc": 4, + "loc": 14, + "lloc": 10, + "mi": 108.56, + "mIwoC": 71.73, + "commentWeight": 36.83, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 0.5, + "relativeSystemComplexity": 1.5, + "totalStructuralComplexity": 1, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 1.5, + "package": "App\\Models\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Models\\Concerns\\BelongsToStore", + "interface": false, + "abstract": true, + "final": false, + "methods": [ + { + "name": "bootBelongsToStore", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "store", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 3, + "ccnMethodMax": 3, + "externals": [ + "App\\Models\\Scopes\\StoreScope", + "static", + "static", + "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 14, + "vocabulary": 9, + "volume": 44.38, + "difficulty": 5, + "effort": 221.89, + "level": 0.2, + "bugs": 0.01, + "time": 12, + "intelligentContent": 8.88, + "number_operators": 6, + "number_operands": 8, + "number_operators_unique": 5, + "number_operands_unique": 4, + "cloc": 4, + "loc": 22, + "lloc": 18, + "mi": 91.36, + "mIwoC": 60.68, + "commentWeight": 30.68, + "kanDefect": 0.22, + "relativeStructuralComplexity": 16, + "relativeDataComplexity": 0.2, + "relativeSystemComplexity": 16.2, + "totalStructuralComplexity": 32, + "totalDataComplexity": 0.4, + "totalSystemComplexity": 32.4, + "package": "App\\Models\\Concerns\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Exceptions\\InsufficientInventoryException", + "interface": false, + "abstract": false, + "final": false, + "methods": [], + "nbMethodsIncludingGettersSetters": 0, + "nbMethods": 0, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 0, + "ccn": 1, + "ccnMethodMax": 0, + "externals": [ + "RuntimeException" + ], + "parents": [ + "RuntimeException" + ], + "implements": [], + "lcom": 0, + "length": 0, + "vocabulary": 0, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 0, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 0, + "number_operators_unique": 0, + "number_operands_unique": 0, + "cloc": 0, + "loc": 4, + "lloc": 4, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 0, + "relativeSystemComplexity": 0, + "totalStructuralComplexity": 0, + "totalDataComplexity": 0, + "totalSystemComplexity": 0, + "package": "App\\Exceptions\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 1, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Exceptions\\FulfillmentGuardException", + "interface": false, + "abstract": false, + "final": false, + "methods": [], + "nbMethodsIncludingGettersSetters": 0, + "nbMethods": 0, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 0, + "ccn": 1, + "ccnMethodMax": 0, + "externals": [ + "RuntimeException" + ], + "parents": [ + "RuntimeException" + ], + "implements": [], + "lcom": 0, + "length": 0, + "vocabulary": 0, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 0, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 0, + "number_operators_unique": 0, + "number_operands_unique": 0, + "cloc": 0, + "loc": 4, + "lloc": 4, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 0, + "relativeSystemComplexity": 0, + "totalStructuralComplexity": 0, + "totalDataComplexity": 0, + "totalSystemComplexity": 0, + "package": "App\\Exceptions\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 1, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Exceptions\\InvalidDiscountException", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "notFound", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "expired", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "notYetActive", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "usageLimitReached", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "minimumNotMet", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "disabled", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 7, + "nbMethods": 7, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 7, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 8, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "RuntimeException" + ], + "parents": [ + "RuntimeException" + ], + "implements": [], + "lcom": 7, + "length": 26, + "vocabulary": 17, + "volume": 106.27, + "difficulty": 1.27, + "effort": 134.61, + "level": 0.79, + "bugs": 0.04, + "time": 7, + "intelligentContent": 83.9, + "number_operators": 7, + "number_operands": 19, + "number_operators_unique": 2, + "number_operands_unique": 15, + "cloc": 0, + "loc": 32, + "lloc": 32, + "mi": 52.71, + "mIwoC": 52.71, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 3.14, + "relativeSystemComplexity": 4.14, + "totalStructuralComplexity": 7, + "totalDataComplexity": 22, + "totalSystemComplexity": 29, + "package": "App\\Exceptions\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 1, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Exceptions\\PaymentFailedException", + "interface": false, + "abstract": false, + "final": false, + "methods": [], + "nbMethodsIncludingGettersSetters": 0, + "nbMethods": 0, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 0, + "ccn": 1, + "ccnMethodMax": 0, + "externals": [ + "RuntimeException" + ], + "parents": [ + "RuntimeException" + ], + "implements": [], + "lcom": 0, + "length": 0, + "vocabulary": 0, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 0, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 0, + "number_operators_unique": 0, + "number_operands_unique": 0, + "cloc": 0, + "loc": 4, + "lloc": 4, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 0, + "relativeSystemComplexity": 0, + "totalStructuralComplexity": 0, + "totalDataComplexity": 0, + "totalSystemComplexity": 0, + "package": "App\\Exceptions\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 1, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Policies\\StorePolicy", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "view", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "update", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "delete", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\User", + "App\\Models\\Store", + "App\\Models\\User", + "App\\Models\\Store", + "App\\Models\\User", + "App\\Models\\Store" + ], + "parents": [], + "implements": [], + "lcom": 3, + "length": 20, + "vocabulary": 7, + "volume": 56.15, + "difficulty": 9.33, + "effort": 524.04, + "level": 0.11, + "bugs": 0.02, + "time": 29, + "intelligentContent": 6.02, + "number_operators": 6, + "number_operands": 14, + "number_operators_unique": 4, + "number_operands_unique": 3, + "cloc": 0, + "loc": 18, + "lloc": 18, + "mi": 60.23, + "mIwoC": 60.23, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 2.5, + "relativeSystemComplexity": 3.5, + "totalStructuralComplexity": 3, + "totalDataComplexity": 7.5, + "totalSystemComplexity": 10.5, + "package": "App\\Policies\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Policies\\Concerns\\ChecksStoreRole", + "interface": false, + "abstract": true, + "final": false, + "methods": [ + { + "name": "getUserRole", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "hasMinRole", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 3, + "ccnMethodMax": 2, + "externals": [ + "App\\Models\\User", + "App\\Models\\User", + "App\\Enums\\StoreUserRole" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 22, + "vocabulary": 11, + "volume": 76.11, + "difficulty": 5.83, + "effort": 443.96, + "level": 0.17, + "bugs": 0.03, + "time": 25, + "intelligentContent": 13.05, + "number_operators": 8, + "number_operands": 14, + "number_operators_unique": 5, + "number_operands_unique": 6, + "cloc": 1, + "loc": 18, + "lloc": 17, + "mi": 77.44, + "mIwoC": 59.58, + "commentWeight": 17.85, + "kanDefect": 0.22, + "relativeStructuralComplexity": 9, + "relativeDataComplexity": 1.13, + "relativeSystemComplexity": 10.13, + "totalStructuralComplexity": 18, + "totalDataComplexity": 2.25, + "totalSystemComplexity": 20.25, + "package": "App\\Policies\\Concerns\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Settings\\TwoFactor", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "enable", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "loadSetupData", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "showVerificationIfNecessary", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "confirmTwoFactor", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "resetVerification", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "disable", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "closeModal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "getModalConfigProperty", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 9, + "nbMethods": 9, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 8, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 17, + "ccn": 9, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "Laravel\\Fortify\\Actions\\DisableTwoFactorAuthentication", + "Laravel\\Fortify\\Features", + "Laravel\\Fortify\\Features", + "Laravel\\Fortify\\Fortify", + "Laravel\\Fortify\\Features", + "Laravel\\Fortify\\Features", + "Laravel\\Fortify\\Actions\\EnableTwoFactorAuthentication", + "Laravel\\Fortify\\Actions\\ConfirmTwoFactorAuthentication", + "Laravel\\Fortify\\Actions\\DisableTwoFactorAuthentication" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 95, + "vocabulary": 31, + "volume": 470.65, + "difficulty": 6.92, + "effort": 3258.34, + "level": 0.14, + "bugs": 0.16, + "time": 181, + "intelligentContent": 67.98, + "number_operators": 23, + "number_operands": 72, + "number_operators_unique": 5, + "number_operands_unique": 26, + "cloc": 32, + "loc": 116, + "lloc": 84, + "mi": 74.44, + "mIwoC": 38.1, + "commentWeight": 36.34, + "kanDefect": 0.57, + "relativeStructuralComplexity": 144, + "relativeDataComplexity": 0.34, + "relativeSystemComplexity": 144.34, + "totalStructuralComplexity": 1296, + "totalDataComplexity": 3.08, + "totalSystemComplexity": 1299.08, + "package": "App\\Livewire\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 6, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Settings\\DeleteUserForm", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "deleteUser", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "App\\Livewire\\Actions\\Logout", + "Illuminate\\Support\\Facades\\Auth" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 8, + "vocabulary": 5, + "volume": 18.58, + "difficulty": 0, + "effort": 0, + "level": 1.25, + "bugs": 0.01, + "time": 0, + "intelligentContent": 23.22, + "number_operators": 0, + "number_operands": 8, + "number_operators_unique": 0, + "number_operands_unique": 5, + "cloc": 3, + "loc": 15, + "lloc": 12, + "mi": 99.37, + "mIwoC": 67.44, + "commentWeight": 31.94, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.17, + "relativeSystemComplexity": 25.17, + "totalStructuralComplexity": 25, + "totalDataComplexity": 0.17, + "totalSystemComplexity": 25.17, + "package": "App\\Livewire\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Settings\\TwoFactor\\RecoveryCodes", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "regenerateRecoveryCodes", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "loadRecoveryCodes", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 6, + "ccn": 4, + "ccnMethodMax": 4, + "externals": [ + "Livewire\\Component", + "Laravel\\Fortify\\Actions\\GenerateNewRecoveryCodes" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 19, + "vocabulary": 9, + "volume": 60.23, + "difficulty": 5.2, + "effort": 313.19, + "level": 0.19, + "bugs": 0.02, + "time": 17, + "intelligentContent": 11.58, + "number_operators": 6, + "number_operands": 13, + "number_operators_unique": 4, + "number_operands_unique": 5, + "cloc": 10, + "loc": 36, + "lloc": 26, + "mi": 92.57, + "mIwoC": 56.13, + "commentWeight": 36.44, + "kanDefect": 0.22, + "relativeStructuralComplexity": 16, + "relativeDataComplexity": 0.07, + "relativeSystemComplexity": 16.07, + "totalStructuralComplexity": 48, + "totalDataComplexity": 0.2, + "totalSystemComplexity": 48.2, + "package": "App\\Livewire\\Settings\\TwoFactor\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Settings\\Password", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "updatePassword", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\Auth" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 26, + "vocabulary": 10, + "volume": 86.37, + "difficulty": 3, + "effort": 259.11, + "level": 0.33, + "bugs": 0.03, + "time": 14, + "intelligentContent": 28.79, + "number_operators": 2, + "number_operands": 24, + "number_operators_unique": 2, + "number_operands_unique": 8, + "cloc": 3, + "loc": 23, + "lloc": 20, + "mi": 84.33, + "mIwoC": 57.79, + "commentWeight": 26.54, + "kanDefect": 0.15, + "relativeStructuralComplexity": 49, + "relativeDataComplexity": 0, + "relativeSystemComplexity": 49, + "totalStructuralComplexity": 49, + "totalDataComplexity": 0, + "totalSystemComplexity": 49, + "package": "App\\Livewire\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Settings\\Profile", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updateProfileInformation", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "resendVerificationNotification", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "hasUnverifiedEmail", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "showDeleteUser", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 10, + "ccn": 6, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Session", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 5, + "length": 39, + "vocabulary": 14, + "volume": 148.49, + "difficulty": 6.94, + "effort": 1031.16, + "level": 0.14, + "bugs": 0.05, + "time": 57, + "intelligentContent": 21.38, + "number_operators": 14, + "number_operands": 25, + "number_operators_unique": 5, + "number_operands_unique": 9, + "cloc": 11, + "loc": 52, + "lloc": 41, + "mi": 81.49, + "mIwoC": 48.81, + "commentWeight": 32.69, + "kanDefect": 0.29, + "relativeStructuralComplexity": 144, + "relativeDataComplexity": 0.23, + "relativeSystemComplexity": 144.23, + "totalStructuralComplexity": 720, + "totalDataComplexity": 1.15, + "totalSystemComplexity": 721.15, + "package": "App\\Livewire\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Settings\\Appearance", + "interface": false, + "abstract": false, + "final": false, + "methods": [], + "nbMethodsIncludingGettersSetters": 0, + "nbMethods": 0, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 0, + "ccn": 1, + "ccnMethodMax": 0, + "externals": [ + "Livewire\\Component" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 0, + "length": 0, + "vocabulary": 0, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 0, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 0, + "number_operators_unique": 0, + "number_operands_unique": 0, + "cloc": 1, + "loc": 5, + "lloc": 4, + "mi": 202.94, + "mIwoC": 171, + "commentWeight": 31.94, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 0, + "relativeSystemComplexity": 0, + "totalStructuralComplexity": 0, + "totalDataComplexity": 0, + "totalSystemComplexity": 0, + "package": "App\\Livewire\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 1, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Products\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "incrementQuantity", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "decrementQuantity", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "selectVariant", + "role": "setter", + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "addToCart", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 6, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 1, + "wmc": 9, + "ccn": 5, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "App\\Support\\CartSession", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Product" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 84, + "vocabulary": 31, + "volume": 416.15, + "difficulty": 10.78, + "effort": 4487.21, + "level": 0.09, + "bugs": 0.14, + "time": 249, + "intelligentContent": 38.59, + "number_operators": 22, + "number_operands": 62, + "number_operators_unique": 8, + "number_operands_unique": 23, + "cloc": 1, + "loc": 53, + "lloc": 52, + "mi": 54.11, + "mIwoC": 43.55, + "commentWeight": 10.56, + "kanDefect": 0.29, + "relativeStructuralComplexity": 256, + "relativeDataComplexity": 0.2, + "relativeSystemComplexity": 256.2, + "totalStructuralComplexity": 1536, + "totalDataComplexity": 1.18, + "totalSystemComplexity": 1537.18, + "package": "App\\Livewire\\Storefront\\Products\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Home", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "featuredCollections", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "recentProducts", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 8, + "ccn": 5, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "App\\Models\\Store", + "Illuminate\\Contracts\\View\\View", + "Illuminate\\Support\\Collection", + "App\\Models\\Collection", + "Illuminate\\Support\\Collection", + "App\\Models\\Product" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 25, + "vocabulary": 14, + "volume": 95.18, + "difficulty": 2.8, + "effort": 266.51, + "level": 0.36, + "bugs": 0.03, + "time": 15, + "intelligentContent": 33.99, + "number_operators": 11, + "number_operands": 14, + "number_operators_unique": 4, + "number_operands_unique": 10, + "cloc": 7, + "loc": 38, + "lloc": 31, + "mi": 83.79, + "mIwoC": 52.94, + "commentWeight": 30.85, + "kanDefect": 0.43, + "relativeStructuralComplexity": 144, + "relativeDataComplexity": 0.38, + "relativeSystemComplexity": 144.38, + "totalStructuralComplexity": 576, + "totalDataComplexity": 1.54, + "totalSystemComplexity": 577.54, + "package": "App\\Livewire\\Storefront\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 6, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Checkout\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "continueToShipping", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "continueToPayment", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "backToAddress", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "backToShipping", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "placeOrder", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "getOrCreateCheckout", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "shippingAddressPayload", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "addressToPayload", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "computeTotals", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 11, + "nbMethods": 11, + "nbMethodsPrivate": 4, + "nbMethodsPublic": 7, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 35, + "ccn": 25, + "ccnMethodMax": 9, + "externals": [ + "Livewire\\Component", + "App\\Support\\CartSession", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\Order", + "App\\Support\\CartSession", + "Illuminate\\Contracts\\View\\View", + "App\\Support\\CartSession", + "App\\Models\\Checkout", + "App\\Support\\CartSession" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 300, + "vocabulary": 87, + "volume": 1932.88, + "difficulty": 18.4, + "effort": 35565.05, + "level": 0.05, + "bugs": 0.64, + "time": 1976, + "intelligentContent": 105.05, + "number_operators": 70, + "number_operands": 230, + "number_operators_unique": 12, + "number_operands_unique": 75, + "cloc": 14, + "loc": 135, + "lloc": 121, + "mi": 52.12, + "mIwoC": 28.19, + "commentWeight": 23.92, + "kanDefect": 1.01, + "relativeStructuralComplexity": 900, + "relativeDataComplexity": 0.27, + "relativeSystemComplexity": 900.27, + "totalStructuralComplexity": 9900, + "totalDataComplexity": 2.94, + "totalSystemComplexity": 9902.94, + "package": "App\\Livewire\\Storefront\\Checkout\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 6, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Checkout\\Confirmation", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "App\\Models\\Order", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 14, + "vocabulary": 10, + "volume": 46.51, + "difficulty": 2.36, + "effort": 109.62, + "level": 0.42, + "bugs": 0.02, + "time": 6, + "intelligentContent": 19.73, + "number_operators": 3, + "number_operands": 11, + "number_operators_unique": 3, + "number_operands_unique": 7, + "cloc": 1, + "loc": 16, + "lloc": 15, + "mi": 81.42, + "mIwoC": 62.53, + "commentWeight": 18.88, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.25, + "relativeSystemComplexity": 25.25, + "totalStructuralComplexity": 50, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 50.5, + "package": "App\\Livewire\\Storefront\\Checkout\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Search\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updatedQ", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Product" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 22, + "vocabulary": 12, + "volume": 78.87, + "difficulty": 3, + "effort": 236.61, + "level": 0.33, + "bugs": 0.03, + "time": 13, + "intelligentContent": 26.29, + "number_operators": 4, + "number_operands": 18, + "number_operators_unique": 3, + "number_operands_unique": 9, + "cloc": 2, + "loc": 22, + "lloc": 20, + "mi": 80.58, + "mIwoC": 58.07, + "commentWeight": 22.51, + "kanDefect": 0.15, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0.14, + "relativeSystemComplexity": 36.14, + "totalStructuralComplexity": 108, + "totalDataComplexity": 0.43, + "totalSystemComplexity": 108.43, + "package": "App\\Livewire\\Storefront\\Search\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\CartDrawer", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "refreshCart", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 3, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Support\\CartSession" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 34, + "vocabulary": 15, + "volume": 132.83, + "difficulty": 6.5, + "effort": 863.42, + "level": 0.15, + "bugs": 0.04, + "time": 48, + "intelligentContent": 20.44, + "number_operators": 8, + "number_operands": 26, + "number_operators_unique": 5, + "number_operands_unique": 10, + "cloc": 2, + "loc": 29, + "lloc": 27, + "mi": 73.29, + "mIwoC": 53.51, + "commentWeight": 19.79, + "kanDefect": 0.45, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 4.33, + "totalStructuralComplexity": 12, + "totalDataComplexity": 1, + "totalSystemComplexity": 13, + "package": "App\\Livewire\\Storefront\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Cart\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updateQty", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "removeLine", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "applyDiscount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 10, + "ccn": 6, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "App\\Support\\CartSession", + "App\\Support\\CartSession", + "Illuminate\\Contracts\\View\\View", + "App\\Support\\CartSession" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 63, + "vocabulary": 23, + "volume": 284.98, + "difficulty": 10.28, + "effort": 2930, + "level": 0.1, + "bugs": 0.09, + "time": 163, + "intelligentContent": 27.72, + "number_operators": 16, + "number_operands": 47, + "number_operators_unique": 7, + "number_operands_unique": 16, + "cloc": 1, + "loc": 52, + "lloc": 51, + "mi": 55.41, + "mIwoC": 44.76, + "commentWeight": 10.66, + "kanDefect": 0.59, + "relativeStructuralComplexity": 49, + "relativeDataComplexity": 0.45, + "relativeSystemComplexity": 49.45, + "totalStructuralComplexity": 245, + "totalDataComplexity": 2.25, + "totalSystemComplexity": 247.25, + "package": "App\\Livewire\\Storefront\\Cart\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Account\\Dashboard", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\Order" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 17, + "vocabulary": 10, + "volume": 56.47, + "difficulty": 1.75, + "effort": 98.83, + "level": 0.57, + "bugs": 0.02, + "time": 5, + "intelligentContent": 32.27, + "number_operators": 3, + "number_operands": 14, + "number_operators_unique": 2, + "number_operands_unique": 8, + "cloc": 2, + "loc": 17, + "lloc": 15, + "mi": 87.28, + "mIwoC": 61.94, + "commentWeight": 25.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 64, + "relativeDataComplexity": 0.11, + "relativeSystemComplexity": 64.11, + "totalStructuralComplexity": 128, + "totalDataComplexity": 0.22, + "totalSystemComplexity": 128.22, + "package": "App\\Livewire\\Storefront\\Account\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Account\\Auth\\Login", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "login", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 6, + "ccn": 4, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\Customer", + "Illuminate\\Support\\Facades\\Hash", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 40, + "vocabulary": 19, + "volume": 169.92, + "difficulty": 5.71, + "effort": 970.95, + "level": 0.18, + "bugs": 0.06, + "time": 54, + "intelligentContent": 29.74, + "number_operators": 8, + "number_operands": 32, + "number_operators_unique": 5, + "number_operands_unique": 14, + "cloc": 1, + "loc": 33, + "lloc": 32, + "mi": 64.33, + "mIwoC": 51.01, + "commentWeight": 13.32, + "kanDefect": 0.29, + "relativeStructuralComplexity": 169, + "relativeDataComplexity": 0.14, + "relativeSystemComplexity": 169.14, + "totalStructuralComplexity": 507, + "totalDataComplexity": 0.43, + "totalSystemComplexity": 507.43, + "package": "App\\Livewire\\Storefront\\Account\\Auth\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Account\\Auth\\Register", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "register", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Validation\\Rule", + "App\\Models\\Customer", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 45, + "vocabulary": 21, + "volume": 197.65, + "difficulty": 3.42, + "effort": 675.32, + "level": 0.29, + "bugs": 0.07, + "time": 38, + "intelligentContent": 57.85, + "number_operators": 4, + "number_operands": 41, + "number_operators_unique": 3, + "number_operands_unique": 18, + "cloc": 2, + "loc": 32, + "lloc": 30, + "mi": 70.32, + "mIwoC": 51.43, + "commentWeight": 18.88, + "kanDefect": 0.22, + "relativeStructuralComplexity": 121, + "relativeDataComplexity": 0.08, + "relativeSystemComplexity": 121.08, + "totalStructuralComplexity": 363, + "totalDataComplexity": 0.25, + "totalSystemComplexity": 363.25, + "package": "App\\Livewire\\Storefront\\Account\\Auth\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Account\\Addresses\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "addAddress", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "makeDefault", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deleteAddress", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 6, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\CustomerAddress", + "App\\Models\\CustomerAddress", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\CustomerAddress", + "App\\Models\\CustomerAddress", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\CustomerAddress", + "Illuminate\\Contracts\\View\\View", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\CustomerAddress" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 5, + "length": 101, + "vocabulary": 35, + "volume": 518.06, + "difficulty": 4.31, + "effort": 2234.12, + "level": 0.23, + "bugs": 0.17, + "time": 124, + "intelligentContent": 120.13, + "number_operators": 9, + "number_operands": 92, + "number_operators_unique": 3, + "number_operands_unique": 32, + "cloc": 5, + "loc": 52, + "lloc": 47, + "mi": 67.36, + "mIwoC": 44.25, + "commentWeight": 23.11, + "kanDefect": 0.22, + "relativeStructuralComplexity": 169, + "relativeDataComplexity": 0.1, + "relativeSystemComplexity": 169.1, + "totalStructuralComplexity": 845, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 845.5, + "package": "App\\Livewire\\Storefront\\Account\\Addresses\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Account\\Orders\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\Order" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 15, + "vocabulary": 10, + "volume": 49.83, + "difficulty": 1.5, + "effort": 74.74, + "level": 0.67, + "bugs": 0.02, + "time": 4, + "intelligentContent": 33.22, + "number_operators": 3, + "number_operands": 12, + "number_operators_unique": 2, + "number_operands_unique": 8, + "cloc": 2, + "loc": 17, + "lloc": 15, + "mi": 87.66, + "mIwoC": 62.32, + "commentWeight": 25.34, + "kanDefect": 0.15, + "relativeStructuralComplexity": 49, + "relativeDataComplexity": 0.13, + "relativeSystemComplexity": 49.13, + "totalStructuralComplexity": 98, + "totalDataComplexity": 0.25, + "totalSystemComplexity": 98.25, + "package": "App\\Livewire\\Storefront\\Account\\Orders\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Account\\Orders\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\Order", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 19, + "vocabulary": 13, + "volume": 70.31, + "difficulty": 2.25, + "effort": 158.19, + "level": 0.44, + "bugs": 0.02, + "time": 9, + "intelligentContent": 31.25, + "number_operators": 4, + "number_operands": 15, + "number_operators_unique": 3, + "number_operands_unique": 10, + "cloc": 2, + "loc": 18, + "lloc": 16, + "mi": 85.35, + "mIwoC": 60.67, + "commentWeight": 24.69, + "kanDefect": 0.15, + "relativeStructuralComplexity": 64, + "relativeDataComplexity": 0.17, + "relativeSystemComplexity": 64.17, + "totalStructuralComplexity": 128, + "totalDataComplexity": 0.33, + "totalSystemComplexity": 128.33, + "package": "App\\Livewire\\Storefront\\Account\\Orders\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Collections\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Collection" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 11, + "vocabulary": 9, + "volume": 34.87, + "difficulty": 1.29, + "effort": 44.83, + "level": 0.78, + "bugs": 0.01, + "time": 2, + "intelligentContent": 27.12, + "number_operators": 2, + "number_operands": 9, + "number_operators_unique": 2, + "number_operands_unique": 7, + "cloc": 1, + "loc": 15, + "lloc": 14, + "mi": 83.53, + "mIwoC": 64.06, + "commentWeight": 19.47, + "kanDefect": 0.15, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0.14, + "relativeSystemComplexity": 36.14, + "totalStructuralComplexity": 72, + "totalDataComplexity": 0.29, + "totalSystemComplexity": 72.29, + "package": "App\\Livewire\\Storefront\\Collections\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Collections\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updatedSort", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 3, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Collection" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 41, + "vocabulary": 20, + "volume": 177.2, + "difficulty": 4.13, + "effort": 730.95, + "level": 0.24, + "bugs": 0.06, + "time": 41, + "intelligentContent": 42.96, + "number_operators": 8, + "number_operands": 33, + "number_operators_unique": 4, + "number_operands_unique": 16, + "cloc": 2, + "loc": 32, + "lloc": 30, + "mi": 70.52, + "mIwoC": 51.63, + "commentWeight": 18.88, + "kanDefect": 0.22, + "relativeStructuralComplexity": 121, + "relativeDataComplexity": 0.11, + "relativeSystemComplexity": 121.11, + "totalStructuralComplexity": 363, + "totalDataComplexity": 0.33, + "totalSystemComplexity": 363.33, + "package": "App\\Livewire\\Storefront\\Collections\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Pages\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Page" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 16, + "vocabulary": 9, + "volume": 50.72, + "difficulty": 1.86, + "effort": 94.19, + "level": 0.54, + "bugs": 0.02, + "time": 5, + "intelligentContent": 27.31, + "number_operators": 3, + "number_operands": 13, + "number_operators_unique": 2, + "number_operands_unique": 7, + "cloc": 1, + "loc": 17, + "lloc": 16, + "mi": 80.01, + "mIwoC": 61.66, + "commentWeight": 18.35, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.25, + "relativeSystemComplexity": 25.25, + "totalStructuralComplexity": 50, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 50.5, + "package": "App\\Livewire\\Storefront\\Pages\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Storefront\\Concerns\\EnsuresStore", + "interface": false, + "abstract": true, + "final": false, + "methods": [ + { + "name": "ensureCurrentStore", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 3, + "ccnMethodMax": 3, + "externals": [ + "App\\Models\\Store", + "App\\Models\\Store", + "App\\Models\\Store" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 12, + "vocabulary": 7, + "volume": 33.69, + "difficulty": 4.67, + "effort": 157.21, + "level": 0.21, + "bugs": 0.01, + "time": 9, + "intelligentContent": 7.22, + "number_operators": 5, + "number_operands": 7, + "number_operators_unique": 4, + "number_operands_unique": 3, + "cloc": 2, + "loc": 15, + "lloc": 13, + "mi": 91.4, + "mIwoC": 64.6, + "commentWeight": 26.8, + "kanDefect": 0.22, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.17, + "relativeSystemComplexity": 25.17, + "totalStructuralComplexity": 25, + "totalDataComplexity": 0.17, + "totalSystemComplexity": 25.17, + "package": "App\\Livewire\\Storefront\\Concerns\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 1, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Customers\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "updatingSearch", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Customer" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 36, + "vocabulary": 19, + "volume": 152.93, + "difficulty": 3.87, + "effort": 591.31, + "level": 0.26, + "bugs": 0.05, + "time": 33, + "intelligentContent": 39.55, + "number_operators": 7, + "number_operands": 29, + "number_operators_unique": 4, + "number_operands_unique": 15, + "cloc": 2, + "loc": 22, + "lloc": 20, + "mi": 78.7, + "mIwoC": 56.19, + "commentWeight": 22.51, + "kanDefect": 0.15, + "relativeStructuralComplexity": 100, + "relativeDataComplexity": 0.09, + "relativeSystemComplexity": 100.09, + "totalStructuralComplexity": 200, + "totalDataComplexity": 0.18, + "totalSystemComplexity": 200.18, + "package": "App\\Livewire\\Admin\\Customers\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Customers\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "App\\Models\\Customer", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 35, + "vocabulary": 19, + "volume": 148.68, + "difficulty": 3.87, + "effort": 574.89, + "level": 0.26, + "bugs": 0.05, + "time": 32, + "intelligentContent": 38.45, + "number_operators": 6, + "number_operands": 29, + "number_operators_unique": 4, + "number_operands_unique": 15, + "cloc": 1, + "loc": 16, + "lloc": 15, + "mi": 77.75, + "mIwoC": 58.87, + "commentWeight": 18.88, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.25, + "relativeSystemComplexity": 25.25, + "totalStructuralComplexity": 50, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 50.5, + "package": "App\\Livewire\\Admin\\Customers\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Settings\\Taxes", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "save", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 7, + "ccn": 5, + "ccnMethodMax": 5, + "externals": [ + "Livewire\\Component", + "App\\Models\\TaxSettings", + "App\\Models\\TaxSettings", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 61, + "vocabulary": 28, + "volume": 293.25, + "difficulty": 2.88, + "effort": 844.56, + "level": 0.35, + "bugs": 0.1, + "time": 47, + "intelligentContent": 101.82, + "number_operators": 13, + "number_operands": 48, + "number_operators_unique": 3, + "number_operands_unique": 25, + "cloc": 6, + "loc": 35, + "lloc": 29, + "mi": 80.07, + "mIwoC": 50.15, + "commentWeight": 29.92, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.17, + "relativeSystemComplexity": 25.17, + "totalStructuralComplexity": 75, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 75.5, + "package": "App\\Livewire\\Admin\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Settings\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "save", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 45, + "vocabulary": 22, + "volume": 200.67, + "difficulty": 1.9, + "effort": 381.28, + "level": 0.53, + "bugs": 0.07, + "time": 21, + "intelligentContent": 105.62, + "number_operators": 7, + "number_operands": 38, + "number_operators_unique": 2, + "number_operands_unique": 20, + "cloc": 7, + "loc": 34, + "lloc": 27, + "mi": 84.84, + "mIwoC": 52.52, + "commentWeight": 32.32, + "kanDefect": 0.15, + "relativeStructuralComplexity": 9, + "relativeDataComplexity": 0.25, + "relativeSystemComplexity": 9.25, + "totalStructuralComplexity": 27, + "totalDataComplexity": 0.75, + "totalSystemComplexity": 27.75, + "package": "App\\Livewire\\Admin\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Settings\\Shipping", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "openZoneModal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "createZone", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deleteZone", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "openRateModal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "createRate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deleteRate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 7, + "nbMethods": 7, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 7, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 8, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "App\\Models\\ShippingZone", + "App\\Models\\ShippingZone", + "App\\Models\\ShippingRate", + "App\\Models\\ShippingRate", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\ShippingZone" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 4, + "length": 103, + "vocabulary": 44, + "volume": 562.32, + "difficulty": 4.35, + "effort": 2446.1, + "level": 0.23, + "bugs": 0.19, + "time": 136, + "intelligentContent": 129.27, + "number_operators": 16, + "number_operands": 87, + "number_operators_unique": 4, + "number_operands_unique": 40, + "cloc": 7, + "loc": 68, + "lloc": 61, + "mi": 65.37, + "mIwoC": 41.53, + "commentWeight": 23.84, + "kanDefect": 0.22, + "relativeStructuralComplexity": 196, + "relativeDataComplexity": 0.16, + "relativeSystemComplexity": 196.16, + "totalStructuralComplexity": 1372, + "totalDataComplexity": 1.13, + "totalSystemComplexity": 1373.13, + "package": "App\\Livewire\\Admin\\Settings\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Dashboard", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "kpis", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "recentOrders", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "periodStart", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "App\\Models\\Order", + "Illuminate\\Support\\Collection", + "App\\Models\\Order", + "DateTimeInterface", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 3, + "length": 50, + "vocabulary": 28, + "volume": 240.37, + "difficulty": 3.25, + "effort": 781.2, + "level": 0.31, + "bugs": 0.08, + "time": 43, + "intelligentContent": 73.96, + "number_operators": 11, + "number_operands": 39, + "number_operators_unique": 4, + "number_operands_unique": 24, + "cloc": 9, + "loc": 39, + "lloc": 30, + "mi": 84.71, + "mIwoC": 50.84, + "commentWeight": 33.87, + "kanDefect": 0.15, + "relativeStructuralComplexity": 121, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 121.33, + "totalStructuralComplexity": 484, + "totalDataComplexity": 1.33, + "totalSystemComplexity": 485.33, + "package": "App\\Livewire\\Admin\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Products\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "updatingSearch", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updatingStatusFilter", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "bulkArchive", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "bulkDelete", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 7, + "ccn": 3, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "App\\Models\\Product", + "App\\Models\\Product", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Product" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 56, + "vocabulary": 20, + "volume": 242.03, + "difficulty": 9, + "effort": 2178.25, + "level": 0.11, + "bugs": 0.08, + "time": 121, + "intelligentContent": 26.89, + "number_operators": 14, + "number_operands": 42, + "number_operators_unique": 6, + "number_operands_unique": 14, + "cloc": 4, + "loc": 42, + "lloc": 38, + "mi": 71.45, + "mIwoC": 48.44, + "commentWeight": 23, + "kanDefect": 0.29, + "relativeStructuralComplexity": 169, + "relativeDataComplexity": 0.21, + "relativeSystemComplexity": 169.21, + "totalStructuralComplexity": 845, + "totalDataComplexity": 1.07, + "totalSystemComplexity": 846.07, + "package": "App\\Livewire\\Admin\\Products\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Products\\Form", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "save", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 22, + "ccn": 20, + "ccnMethodMax": 11, + "externals": [ + "Livewire\\Component", + "App\\Services\\ProductService", + "Illuminate\\Contracts\\View\\View", + "App\\Enums\\ProductStatus" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 181, + "vocabulary": 47, + "volume": 1005.38, + "difficulty": 11.9, + "effort": 11964.03, + "level": 0.08, + "bugs": 0.34, + "time": 665, + "intelligentContent": 84.49, + "number_operators": 45, + "number_operands": 136, + "number_operators_unique": 7, + "number_operands_unique": 40, + "cloc": 12, + "loc": 74, + "lloc": 62, + "mi": 66.4, + "mIwoC": 37.19, + "commentWeight": 29.21, + "kanDefect": 0.5, + "relativeStructuralComplexity": 144, + "relativeDataComplexity": 0.21, + "relativeSystemComplexity": 144.21, + "totalStructuralComplexity": 432, + "totalDataComplexity": 0.62, + "totalSystemComplexity": 432.62, + "package": "App\\Livewire\\Admin\\Products\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Auth\\Login", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "login", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "throttleKey", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 6, + "ccn": 4, + "ccnMethodMax": 4, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\RateLimiter", + "Illuminate\\Support\\Facades\\RateLimiter", + "Illuminate\\Validation\\ValidationException", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\RateLimiter", + "Illuminate\\Validation\\ValidationException", + "Illuminate\\Support\\Facades\\RateLimiter", + "Illuminate\\Support\\Facades\\Session", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Session", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 60, + "vocabulary": 30, + "volume": 294.41, + "difficulty": 5.63, + "effort": 1656.08, + "level": 0.18, + "bugs": 0.1, + "time": 92, + "intelligentContent": 52.34, + "number_operators": 15, + "number_operands": 45, + "number_operators_unique": 6, + "number_operands_unique": 24, + "cloc": 3, + "loc": 40, + "lloc": 37, + "mi": 68.55, + "mIwoC": 47.97, + "commentWeight": 20.58, + "kanDefect": 0.36, + "relativeStructuralComplexity": 256, + "relativeDataComplexity": 0.18, + "relativeSystemComplexity": 256.18, + "totalStructuralComplexity": 768, + "totalDataComplexity": 0.53, + "totalSystemComplexity": 768.53, + "package": "App\\Livewire\\Admin\\Auth\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 6, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Navigation\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "createMenu", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deleteMenu", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "openItemModal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "closeItemModal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "addItem", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deleteItem", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "moveItem", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 8, + "nbMethods": 8, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 8, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 14, + "ccn": 7, + "ccnMethodMax": 6, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Str", + "App\\Models\\NavigationMenu", + "App\\Models\\NavigationMenu", + "App\\Models\\NavigationMenu", + "App\\Models\\NavigationItem", + "App\\Models\\NavigationItem", + "App\\Models\\NavigationItem", + "App\\Models\\NavigationItem", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\NavigationMenu" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 5, + "length": 151, + "vocabulary": 52, + "volume": 860.77, + "difficulty": 9.64, + "effort": 8301.61, + "level": 0.1, + "bugs": 0.29, + "time": 461, + "intelligentContent": 89.25, + "number_operators": 27, + "number_operands": 124, + "number_operators_unique": 7, + "number_operands_unique": 45, + "cloc": 7, + "loc": 78, + "lloc": 71, + "mi": 60.51, + "mIwoC": 38.13, + "commentWeight": 22.38, + "kanDefect": 0.29, + "relativeStructuralComplexity": 729, + "relativeDataComplexity": 0.13, + "relativeSystemComplexity": 729.13, + "totalStructuralComplexity": 5832, + "totalDataComplexity": 1.04, + "totalSystemComplexity": 5833.04, + "package": "App\\Livewire\\Admin\\Navigation\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Discounts\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "updatingStatusFilter", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updatingTypeFilter", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Discount" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 27, + "vocabulary": 12, + "volume": 96.79, + "difficulty": 3.83, + "effort": 371.04, + "level": 0.26, + "bugs": 0.03, + "time": 21, + "intelligentContent": 25.25, + "number_operators": 4, + "number_operands": 23, + "number_operators_unique": 3, + "number_operands_unique": 9, + "cloc": 3, + "loc": 24, + "lloc": 21, + "mi": 83.16, + "mIwoC": 57.12, + "commentWeight": 26.04, + "kanDefect": 0.15, + "relativeStructuralComplexity": 49, + "relativeDataComplexity": 0.13, + "relativeSystemComplexity": 49.13, + "totalStructuralComplexity": 147, + "totalDataComplexity": 0.38, + "totalSystemComplexity": 147.38, + "package": "App\\Livewire\\Admin\\Discounts\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Discounts\\Form", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "save", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 14, + "ccn": 12, + "ccnMethodMax": 8, + "externals": [ + "Livewire\\Component", + "App\\Models\\Discount", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 132, + "vocabulary": 46, + "volume": 729.11, + "difficulty": 10.21, + "effort": 7444.6, + "level": 0.1, + "bugs": 0.24, + "time": 414, + "intelligentContent": 71.41, + "number_operators": 35, + "number_operands": 97, + "number_operators_unique": 8, + "number_operands_unique": 38, + "cloc": 11, + "loc": 67, + "lloc": 56, + "mi": 69.57, + "mIwoC": 40.21, + "commentWeight": 29.36, + "kanDefect": 0.43, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0.48, + "relativeSystemComplexity": 36.48, + "totalStructuralComplexity": 108, + "totalDataComplexity": 1.43, + "totalSystemComplexity": 109.43, + "package": "App\\Livewire\\Admin\\Discounts\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Orders\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "updatingSearch", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updatingStatusFilter", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updatingFinancialFilter", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updatingFulfillmentFilter", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Order" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 62, + "vocabulary": 21, + "volume": 272.32, + "difficulty": 6.12, + "effort": 1665.98, + "level": 0.16, + "bugs": 0.09, + "time": 93, + "intelligentContent": 44.51, + "number_operators": 10, + "number_operands": 52, + "number_operators_unique": 4, + "number_operands_unique": 17, + "cloc": 5, + "loc": 40, + "lloc": 35, + "mi": 75.17, + "mIwoC": 49.13, + "commentWeight": 26.04, + "kanDefect": 0.15, + "relativeStructuralComplexity": 100, + "relativeDataComplexity": 0.09, + "relativeSystemComplexity": 100.09, + "totalStructuralComplexity": 500, + "totalDataComplexity": 0.45, + "totalSystemComplexity": 500.45, + "package": "App\\Livewire\\Admin\\Orders\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Orders\\Show", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "openFulfillModal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "openRefundModal", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "createFulfillment", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "markShipped", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "markDelivered", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "createRefund", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "confirmBankTransfer", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "cancelOrder", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 10, + "nbMethods": 10, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 10, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 22, + "ccn": 13, + "ccnMethodMax": 6, + "externals": [ + "Livewire\\Component", + "App\\Models\\Order", + "App\\Services\\FulfillmentService", + "App\\Services\\FulfillmentService", + "App\\Services\\FulfillmentService", + "App\\Services\\RefundService", + "App\\Services\\OrderService", + "App\\Services\\OrderService", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 159, + "vocabulary": 40, + "volume": 846.19, + "difficulty": 13.89, + "effort": 11756.86, + "level": 0.07, + "bugs": 0.28, + "time": 653, + "intelligentContent": 60.9, + "number_operators": 28, + "number_operands": 131, + "number_operators_unique": 7, + "number_operands_unique": 33, + "cloc": 2, + "loc": 101, + "lloc": 99, + "mi": 45.03, + "mIwoC": 34.22, + "commentWeight": 10.81, + "kanDefect": 0.52, + "relativeStructuralComplexity": 289, + "relativeDataComplexity": 0.22, + "relativeSystemComplexity": 289.22, + "totalStructuralComplexity": 2890, + "totalDataComplexity": 2.17, + "totalSystemComplexity": 2892.17, + "package": "App\\Livewire\\Admin\\Orders\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 6, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Collections\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "updatingSearch", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Collection" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 25, + "vocabulary": 15, + "volume": 97.67, + "difficulty": 3.64, + "effort": 355.17, + "level": 0.28, + "bugs": 0.03, + "time": 20, + "intelligentContent": 26.86, + "number_operators": 5, + "number_operands": 20, + "number_operators_unique": 4, + "number_operands_unique": 11, + "cloc": 2, + "loc": 18, + "lloc": 16, + "mi": 84.35, + "mIwoC": 59.67, + "commentWeight": 24.69, + "kanDefect": 0.15, + "relativeStructuralComplexity": 49, + "relativeDataComplexity": 0.13, + "relativeSystemComplexity": 49.13, + "totalStructuralComplexity": 98, + "totalDataComplexity": 0.25, + "totalSystemComplexity": 98.25, + "package": "App\\Livewire\\Admin\\Collections\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Collections\\Form", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "addProduct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "removeProduct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "save", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 18, + "ccn": 14, + "ccnMethodMax": 8, + "externals": [ + "Livewire\\Component", + "App\\Support\\HandleGenerator", + "App\\Support\\HandleGenerator", + "App\\Models\\Collection", + "App\\Support\\HandleGenerator", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Product", + "App\\Models\\Product" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 177, + "vocabulary": 45, + "volume": 972.06, + "difficulty": 14.92, + "effort": 14502.05, + "level": 0.07, + "bugs": 0.32, + "time": 806, + "intelligentContent": 65.16, + "number_operators": 39, + "number_operands": 138, + "number_operators_unique": 8, + "number_operands_unique": 37, + "cloc": 8, + "loc": 70, + "lloc": 62, + "mi": 63.1, + "mIwoC": 38.1, + "commentWeight": 25.01, + "kanDefect": 0.59, + "relativeStructuralComplexity": 324, + "relativeDataComplexity": 0.14, + "relativeSystemComplexity": 324.14, + "totalStructuralComplexity": 1620, + "totalDataComplexity": 0.68, + "totalSystemComplexity": 1620.68, + "package": "App\\Livewire\\Admin\\Collections\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Pages\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "updatingSearch", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "delete", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "App\\Models\\Page", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Page" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 3, + "length": 32, + "vocabulary": 19, + "volume": 135.93, + "difficulty": 3.47, + "effort": 471.24, + "level": 0.29, + "bugs": 0.05, + "time": 26, + "intelligentContent": 39.21, + "number_operators": 6, + "number_operands": 26, + "number_operators_unique": 4, + "number_operands_unique": 15, + "cloc": 2, + "loc": 24, + "lloc": 22, + "mi": 77.27, + "mIwoC": 55.64, + "commentWeight": 21.62, + "kanDefect": 0.15, + "relativeStructuralComplexity": 81, + "relativeDataComplexity": 0.13, + "relativeSystemComplexity": 81.13, + "totalStructuralComplexity": 243, + "totalDataComplexity": 0.4, + "totalSystemComplexity": 243.4, + "package": "App\\Livewire\\Admin\\Pages\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Pages\\Form", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "save", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 15, + "ccn": 13, + "ccnMethodMax": 10, + "externals": [ + "Livewire\\Component", + "App\\Support\\HandleGenerator", + "App\\Support\\HandleGenerator", + "App\\Models\\Page", + "App\\Support\\HandleGenerator", + "Illuminate\\Contracts\\View\\View" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 2, + "length": 126, + "vocabulary": 34, + "volume": 641.02, + "difficulty": 12.31, + "effort": 7894.05, + "level": 0.08, + "bugs": 0.21, + "time": 439, + "intelligentContent": 52.05, + "number_operators": 31, + "number_operands": 95, + "number_operators_unique": 7, + "number_operands_unique": 27, + "cloc": 7, + "loc": 52, + "lloc": 45, + "mi": 69.45, + "mIwoC": 42.53, + "commentWeight": 26.91, + "kanDefect": 0.36, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 36.33, + "totalStructuralComplexity": 108, + "totalDataComplexity": 1, + "totalSystemComplexity": 109, + "package": "App\\Livewire\\Admin\\Pages\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Apps\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "install", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "uninstall", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "App\\Models\\AppInstallation", + "App\\Models\\AppInstallation", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\AppInstallation", + "App\\Models\\App" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 3, + "length": 71, + "vocabulary": 24, + "volume": 325.53, + "difficulty": 5.9, + "effort": 1920.64, + "level": 0.17, + "bugs": 0.11, + "time": 107, + "intelligentContent": 55.17, + "number_operators": 12, + "number_operands": 59, + "number_operators_unique": 4, + "number_operands_unique": 20, + "cloc": 4, + "loc": 33, + "lloc": 29, + "mi": 76.05, + "mIwoC": 50.37, + "commentWeight": 25.68, + "kanDefect": 0.15, + "relativeStructuralComplexity": 256, + "relativeDataComplexity": 0.16, + "relativeSystemComplexity": 256.16, + "totalStructuralComplexity": 768, + "totalDataComplexity": 0.47, + "totalSystemComplexity": 768.47, + "package": "App\\Livewire\\Admin\\Apps\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Themes\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "publish", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "duplicate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "delete", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Livewire\\Component", + "App\\Models\\Theme", + "App\\Models\\Theme", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Theme", + "App\\Models\\Theme", + "App\\Models\\Theme", + "Illuminate\\Contracts\\View\\View", + "App\\Models\\Theme" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 4, + "length": 61, + "vocabulary": 24, + "volume": 279.68, + "difficulty": 6.71, + "effort": 1876.82, + "level": 0.15, + "bugs": 0.09, + "time": 104, + "intelligentContent": 41.68, + "number_operators": 10, + "number_operands": 51, + "number_operators_unique": 5, + "number_operands_unique": 19, + "cloc": 3, + "loc": 38, + "lloc": 35, + "mi": 70, + "mIwoC": 48.92, + "commentWeight": 21.08, + "kanDefect": 0.22, + "relativeStructuralComplexity": 169, + "relativeDataComplexity": 0.2, + "relativeSystemComplexity": 169.2, + "totalStructuralComplexity": 676, + "totalDataComplexity": 0.79, + "totalSystemComplexity": 676.79, + "package": "App\\Livewire\\Admin\\Themes\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 4, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Analytics\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "mount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 3, + "ccnMethodMax": 3, + "externals": [ + "Livewire\\Component", + "Illuminate\\Contracts\\View\\View", + "App\\Services\\AnalyticsService" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 1, + "length": 62, + "vocabulary": 29, + "volume": 301.19, + "difficulty": 5.42, + "effort": 1631.47, + "level": 0.18, + "bugs": 0.1, + "time": 91, + "intelligentContent": 55.61, + "number_operators": 10, + "number_operands": 52, + "number_operators_unique": 5, + "number_operands_unique": 24, + "cloc": 2, + "loc": 21, + "lloc": 19, + "mi": 77.35, + "mIwoC": 54.34, + "commentWeight": 23, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.25, + "relativeSystemComplexity": 25.25, + "totalStructuralComplexity": 50, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 50.5, + "package": "App\\Livewire\\Admin\\Analytics\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Admin\\Developers\\Index", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "createToken", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "revokeToken", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "createWebhook", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deleteWebhook", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "render", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Livewire\\Component", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Str", + "App\\Models\\WebhookSubscription", + "App\\Models\\WebhookSubscription", + "Illuminate\\Contracts\\View\\View", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\WebhookSubscription" + ], + "parents": [ + "Livewire\\Component" + ], + "implements": [], + "lcom": 4, + "length": 78, + "vocabulary": 35, + "volume": 400.08, + "difficulty": 2.03, + "effort": 812.29, + "level": 0.49, + "bugs": 0.13, + "time": 45, + "intelligentContent": 197.06, + "number_operators": 11, + "number_operands": 67, + "number_operators_unique": 2, + "number_operands_unique": 33, + "cloc": 8, + "loc": 52, + "lloc": 44, + "mi": 74.34, + "mIwoC": 45.8, + "commentWeight": 28.55, + "kanDefect": 0.15, + "relativeStructuralComplexity": 289, + "relativeDataComplexity": 0.08, + "relativeSystemComplexity": 289.08, + "totalStructuralComplexity": 1445, + "totalDataComplexity": 0.39, + "totalSystemComplexity": 1445.39, + "package": "App\\Livewire\\Admin\\Developers\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Livewire\\Actions\\Logout", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__invoke", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Support\\Facades\\Auth", + "Illuminate\\Support\\Facades\\Session", + "Illuminate\\Support\\Facades\\Session" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 3, + "vocabulary": 3, + "volume": 4.75, + "difficulty": 0.5, + "effort": 2.38, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 9.51, + "number_operators": 1, + "number_operands": 2, + "number_operators_unique": 1, + "number_operands_unique": 2, + "cloc": 3, + "loc": 14, + "lloc": 11, + "mi": 105.27, + "mIwoC": 72.41, + "commentWeight": 32.86, + "kanDefect": 0.15, + "relativeStructuralComplexity": 16, + "relativeDataComplexity": 0.2, + "relativeSystemComplexity": 16.2, + "totalStructuralComplexity": 16, + "totalDataComplexity": 0.2, + "totalSystemComplexity": 16.2, + "package": "App\\Livewire\\Actions\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Support\\HandleGenerator", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "generate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "exists", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 4, + "ccnMethodMax": 3, + "externals": [ + "Illuminate\\Support\\Str", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 55, + "vocabulary": 23, + "volume": 248.8, + "difficulty": 8.75, + "effort": 2176.96, + "level": 0.11, + "bugs": 0.08, + "time": 121, + "intelligentContent": 28.43, + "number_operators": 15, + "number_operands": 40, + "number_operators_unique": 7, + "number_operands_unique": 16, + "cloc": 0, + "loc": 26, + "lloc": 26, + "mi": 51.82, + "mIwoC": 51.82, + "commentWeight": 0, + "kanDefect": 0.52, + "relativeStructuralComplexity": 49, + "relativeDataComplexity": 0.75, + "relativeSystemComplexity": 49.75, + "totalStructuralComplexity": 98, + "totalDataComplexity": 1.5, + "totalSystemComplexity": 99.5, + "package": "App\\Support\\", + "pageRank": 0.01, + "afferentCoupling": 3, + "efferentCoupling": 2, + "instability": 0.4, + "violations": {} + }, + { + "name": "App\\Support\\CartSession", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "getOrCreate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "current", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "clear", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 9, + "ccn": 7, + "ccnMethodMax": 4, + "externals": [ + "App\\Models\\Cart", + "App\\Models\\Store", + "App\\Models\\Cart", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\Cart" + ], + "parents": [], + "implements": [], + "lcom": 3, + "length": 49, + "vocabulary": 13, + "volume": 181.32, + "difficulty": 15.17, + "effort": 2750.04, + "level": 0.07, + "bugs": 0.06, + "time": 153, + "intelligentContent": 11.96, + "number_operators": 23, + "number_operands": 26, + "number_operators_unique": 7, + "number_operands_unique": 6, + "cloc": 0, + "loc": 34, + "lloc": 34, + "mi": 49.84, + "mIwoC": 49.84, + "commentWeight": 0, + "kanDefect": 0.43, + "relativeStructuralComplexity": 49, + "relativeDataComplexity": 0.67, + "relativeSystemComplexity": 49.67, + "totalStructuralComplexity": 147, + "totalDataComplexity": 2, + "totalSystemComplexity": 149, + "package": "App\\Support\\", + "pageRank": 0.02, + "afferentCoupling": 4, + "efferentCoupling": 3, + "instability": 0.43, + "violations": {} + }, + { + "name": "App\\Http\\Middleware\\ResolveStore", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "handle", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "resolveFromHost", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "resolveFromSession", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 10, + "ccn": 8, + "ccnMethodMax": 5, + "externals": [ + "Symfony\\Component\\HttpFoundation\\Response", + "Illuminate\\Http\\Request", + "Closure", + "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException", + "Symfony\\Component\\HttpKernel\\Exception\\HttpException", + "Illuminate\\Http\\Request", + "App\\Models\\StoreDomain", + "Illuminate\\Support\\Facades\\Cache", + "App\\Models\\Store", + "Illuminate\\Http\\Request", + "Illuminate\\Support\\Facades\\Auth", + "App\\Models\\Store" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 83, + "vocabulary": 26, + "volume": 390.14, + "difficulty": 6.19, + "effort": 2415.13, + "level": 0.16, + "bugs": 0.13, + "time": 134, + "intelligentContent": 63.02, + "number_operators": 31, + "number_operands": 52, + "number_operators_unique": 5, + "number_operands_unique": 21, + "cloc": 0, + "loc": 52, + "lloc": 52, + "mi": 43.35, + "mIwoC": 43.35, + "commentWeight": 0, + "kanDefect": 0.64, + "relativeStructuralComplexity": 324, + "relativeDataComplexity": 0.56, + "relativeSystemComplexity": 324.56, + "totalStructuralComplexity": 972, + "totalDataComplexity": 1.68, + "totalSystemComplexity": 973.68, + "package": "App\\Http\\Middleware\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 9, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Http\\Controllers\\Controller", + "interface": false, + "abstract": true, + "final": false, + "methods": [], + "nbMethodsIncludingGettersSetters": 0, + "nbMethods": 0, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 0, + "ccn": 1, + "ccnMethodMax": 0, + "externals": [], + "parents": [], + "implements": [], + "lcom": 0, + "length": 0, + "vocabulary": 0, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 0, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 0, + "number_operators_unique": 0, + "number_operands_unique": 0, + "cloc": 1, + "loc": 5, + "lloc": 4, + "mi": 202.94, + "mIwoC": 171, + "commentWeight": 31.94, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 0, + "relativeSystemComplexity": 0, + "totalStructuralComplexity": 0, + "totalDataComplexity": 0, + "totalSystemComplexity": 0, + "package": "App\\Http\\Controllers\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 0, + "instability": 0, + "violations": {} + }, + { + "name": "App\\Actions\\Fortify\\ResetUserPassword", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "reset", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Laravel\\Fortify\\Contracts\\ResetsUserPasswords", + "App\\Models\\User", + "Illuminate\\Support\\Facades\\Validator" + ], + "parents": [], + "implements": [ + "Laravel\\Fortify\\Contracts\\ResetsUserPasswords" + ], + "lcom": 1, + "length": 9, + "vocabulary": 4, + "volume": 18, + "difficulty": 0, + "effort": 0, + "level": 0.89, + "bugs": 0.01, + "time": 0, + "intelligentContent": 16, + "number_operators": 0, + "number_operands": 9, + "number_operators_unique": 0, + "number_operands_unique": 4, + "cloc": 5, + "loc": 15, + "lloc": 10, + "mi": 108.25, + "mIwoC": 69.26, + "commentWeight": 38.99, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 25.33, + "totalStructuralComplexity": 25, + "totalDataComplexity": 0.33, + "totalSystemComplexity": 25.33, + "package": "App\\Actions\\Fortify\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Actions\\Fortify\\CreateNewUser", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "create", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Laravel\\Fortify\\Contracts\\CreatesNewUsers", + "App\\Models\\User", + "Illuminate\\Support\\Facades\\Validator", + "App\\Models\\User" + ], + "parents": [], + "implements": [ + "Laravel\\Fortify\\Contracts\\CreatesNewUsers" + ], + "lcom": 1, + "length": 15, + "vocabulary": 6, + "volume": 38.77, + "difficulty": 1.4, + "effort": 54.28, + "level": 0.71, + "bugs": 0.01, + "time": 3, + "intelligentContent": 27.7, + "number_operators": 1, + "number_operands": 14, + "number_operators_unique": 1, + "number_operands_unique": 5, + "cloc": 5, + "loc": 15, + "lloc": 10, + "mi": 105.92, + "mIwoC": 66.93, + "commentWeight": 38.99, + "kanDefect": 0.15, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 25.33, + "totalStructuralComplexity": 25, + "totalDataComplexity": 0.33, + "totalSystemComplexity": 25.33, + "package": "App\\Actions\\Fortify\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Jobs\\ExpireAbandonedCheckouts", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "handle", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue", + "App\\Services\\CheckoutService", + "App\\Models\\Checkout" + ], + "parents": [], + "implements": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue" + ], + "lcom": 1, + "length": 9, + "vocabulary": 5, + "volume": 20.9, + "difficulty": 0, + "effort": 0, + "level": 1.11, + "bugs": 0.01, + "time": 0, + "intelligentContent": 23.22, + "number_operators": 0, + "number_operands": 9, + "number_operators_unique": 0, + "number_operands_unique": 5, + "cloc": 0, + "loc": 11, + "lloc": 11, + "mi": 67.9, + "mIwoC": 67.9, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 64, + "relativeDataComplexity": 0.11, + "relativeSystemComplexity": 64.11, + "totalStructuralComplexity": 64, + "totalDataComplexity": 0.11, + "totalSystemComplexity": 64.11, + "package": "App\\Jobs\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Jobs\\CleanupAbandonedCarts", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "handle", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue", + "App\\Models\\Cart" + ], + "parents": [], + "implements": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue" + ], + "lcom": 1, + "length": 5, + "vocabulary": 4, + "volume": 10, + "difficulty": 0, + "effort": 0, + "level": 1.6, + "bugs": 0, + "time": 0, + "intelligentContent": 16, + "number_operators": 0, + "number_operands": 5, + "number_operators_unique": 0, + "number_operands_unique": 4, + "cloc": 0, + "loc": 9, + "lloc": 9, + "mi": 72.05, + "mIwoC": 72.05, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0, + "relativeSystemComplexity": 36, + "totalStructuralComplexity": 36, + "totalDataComplexity": 0, + "totalSystemComplexity": 36, + "package": "App\\Jobs\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Jobs\\AggregateAnalytics", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "handle", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 3, + "ccnMethodMax": 3, + "externals": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue", + "Carbon\\CarbonImmutable", + "Carbon\\CarbonImmutable", + "App\\Models\\Store", + "App\\Models\\Order", + "App\\Models\\AnalyticsEvent", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue" + ], + "lcom": 2, + "length": 100, + "vocabulary": 40, + "volume": 532.19, + "difficulty": 4.67, + "effort": 2483.57, + "level": 0.21, + "bugs": 0.18, + "time": 138, + "intelligentContent": 114.04, + "number_operators": 16, + "number_operands": 84, + "number_operators_unique": 4, + "number_operands_unique": 36, + "cloc": 0, + "loc": 27, + "lloc": 27, + "mi": 49.28, + "mIwoC": 49.28, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 676, + "relativeDataComplexity": 0.02, + "relativeSystemComplexity": 676.02, + "totalStructuralComplexity": 1352, + "totalDataComplexity": 0.04, + "totalSystemComplexity": 1352.04, + "package": "App\\Jobs\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 6, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Jobs\\CancelUnpaidBankTransferOrders", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "handle", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue", + "App\\Services\\OrderService", + "App\\Models\\Order" + ], + "parents": [], + "implements": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue" + ], + "lcom": 1, + "length": 11, + "vocabulary": 8, + "volume": 33, + "difficulty": 0, + "effort": 0, + "level": 1.45, + "bugs": 0.01, + "time": 0, + "intelligentContent": 48, + "number_operators": 0, + "number_operands": 11, + "number_operators_unique": 0, + "number_operands_unique": 8, + "cloc": 0, + "loc": 11, + "lloc": 11, + "mi": 66.52, + "mIwoC": 66.52, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 64, + "relativeDataComplexity": 0.11, + "relativeSystemComplexity": 64.11, + "totalStructuralComplexity": 64, + "totalDataComplexity": 0.11, + "totalSystemComplexity": 64.11, + "package": "App\\Jobs\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Jobs\\ProcessMediaUpload", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "handle", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue", + "App\\Models\\ProductMedia", + "Illuminate\\Support\\Facades\\Log" + ], + "parents": [], + "implements": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue" + ], + "lcom": 2, + "length": 9, + "vocabulary": 6, + "volume": 23.26, + "difficulty": 0.8, + "effort": 18.61, + "level": 1.25, + "bugs": 0.01, + "time": 1, + "intelligentContent": 29.08, + "number_operators": 1, + "number_operands": 8, + "number_operators_unique": 1, + "number_operands_unique": 5, + "cloc": 0, + "loc": 14, + "lloc": 14, + "mi": 65.29, + "mIwoC": 65.29, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 0.17, + "relativeSystemComplexity": 4.17, + "totalStructuralComplexity": 8, + "totalDataComplexity": 0.33, + "totalSystemComplexity": 8.33, + "package": "App\\Jobs\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Jobs\\DeliverWebhook", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "handle", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "recordFailure", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 10, + "ccn": 8, + "ccnMethodMax": 6, + "externals": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue", + "App\\Models\\WebhookSubscription", + "App\\Services\\WebhookService", + "Illuminate\\Support\\Str", + "App\\Models\\WebhookDelivery", + "Illuminate\\Support\\Facades\\Http", + "RuntimeException", + "App\\Models\\WebhookSubscription" + ], + "parents": [], + "implements": [ + "Illuminate\\Contracts\\Queue\\ShouldQueue" + ], + "lcom": 2, + "length": 108, + "vocabulary": 53, + "volume": 618.62, + "difficulty": 11.39, + "effort": 7047.8, + "level": 0.09, + "bugs": 0.21, + "time": 392, + "intelligentContent": 54.3, + "number_operators": 21, + "number_operands": 87, + "number_operators_unique": 11, + "number_operands_unique": 42, + "cloc": 7, + "loc": 51, + "lloc": 44, + "mi": 70.68, + "mIwoC": 43.53, + "commentWeight": 27.15, + "kanDefect": 0.36, + "relativeStructuralComplexity": 361, + "relativeDataComplexity": 0.18, + "relativeSystemComplexity": 361.18, + "totalStructuralComplexity": 1083, + "totalDataComplexity": 0.55, + "totalSystemComplexity": 1083.55, + "package": "App\\Jobs\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 7, + "instability": 0.88, + "violations": {} + }, + { + "name": "App\\Events\\OrderRefunded", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\Order", + "App\\Models\\Refund" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 2, + "vocabulary": 2, + "volume": 2, + "difficulty": 0, + "effort": 0, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 4, + "number_operators": 0, + "number_operands": 2, + "number_operators_unique": 0, + "number_operands_unique": 2, + "cloc": 0, + "loc": 8, + "lloc": 8, + "mi": 78.06, + "mIwoC": 78.06, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 2, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 0, + "totalDataComplexity": 2, + "totalSystemComplexity": 2, + "package": "App\\Events\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Events\\OrderCancelled", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\Order" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 1, + "vocabulary": 1, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 1, + "number_operators_unique": 0, + "number_operands_unique": 1, + "cloc": 0, + "loc": 8, + "lloc": 8, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 1, + "totalStructuralComplexity": 0, + "totalDataComplexity": 1, + "totalSystemComplexity": 1, + "package": "App\\Events\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 1, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Events\\OrderCreated", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\Order" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 1, + "vocabulary": 1, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 1, + "number_operators_unique": 0, + "number_operands_unique": 1, + "cloc": 0, + "loc": 8, + "lloc": 8, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 1, + "totalStructuralComplexity": 0, + "totalDataComplexity": 1, + "totalSystemComplexity": 1, + "package": "App\\Events\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 1, + "instability": 0.33, + "violations": {} + }, + { + "name": "App\\Events\\OrderPaid", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\Order" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 1, + "vocabulary": 1, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 1, + "number_operators_unique": 0, + "number_operands_unique": 1, + "cloc": 0, + "loc": 8, + "lloc": 8, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 1, + "totalStructuralComplexity": 0, + "totalDataComplexity": 1, + "totalSystemComplexity": 1, + "package": "App\\Events\\", + "pageRank": 0.01, + "afferentCoupling": 3, + "efferentCoupling": 1, + "instability": 0.25, + "violations": {} + }, + { + "name": "App\\Events\\FulfillmentDelivered", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\Fulfillment" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 1, + "vocabulary": 1, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 1, + "number_operators_unique": 0, + "number_operands_unique": 1, + "cloc": 0, + "loc": 8, + "lloc": 8, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 1, + "totalStructuralComplexity": 0, + "totalDataComplexity": 1, + "totalSystemComplexity": 1, + "package": "App\\Events\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 1, + "instability": 0.5, + "violations": {} + }, + { + "name": "App\\Events\\OrderFulfilled", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\Order" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 1, + "vocabulary": 1, + "volume": 0, + "difficulty": 0, + "effort": 0, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 0, + "number_operators": 0, + "number_operands": 1, + "number_operators_unique": 0, + "number_operands_unique": 1, + "cloc": 0, + "loc": 8, + "lloc": 8, + "mi": 171, + "mIwoC": 171, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 1, + "totalStructuralComplexity": 0, + "totalDataComplexity": 1, + "totalSystemComplexity": 1, + "package": "App\\Events\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 1, + "instability": 0.33, + "violations": {} + }, + { + "name": "App\\Observers\\ProductObserver", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "created", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updated", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "deleted", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Services\\SearchService", + "App\\Models\\Product", + "App\\Models\\Product", + "App\\Models\\Product" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 10, + "vocabulary": 3, + "volume": 15.85, + "difficulty": 0, + "effort": 0, + "level": 0.6, + "bugs": 0.01, + "time": 0, + "intelligentContent": 9.51, + "number_operators": 0, + "number_operands": 10, + "number_operators_unique": 0, + "number_operands_unique": 3, + "cloc": 0, + "loc": 19, + "lloc": 19, + "mi": 63.57, + "mIwoC": 63.57, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 4, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 4.33, + "totalStructuralComplexity": 16, + "totalDataComplexity": 1.33, + "totalSystemComplexity": 17.33, + "package": "App\\Observers\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 2, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Listeners\\DispatchOrderWebhooks", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "handleCreated", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "handlePaid", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "handleFulfilled", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "dispatch", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Services\\WebhookService", + "App\\Events\\OrderCreated", + "App\\Events\\OrderPaid", + "App\\Events\\OrderFulfilled", + "App\\Models\\Order" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 26, + "vocabulary": 12, + "volume": 93.21, + "difficulty": 0, + "effort": 0, + "level": 0.92, + "bugs": 0.03, + "time": 0, + "intelligentContent": 86.04, + "number_operators": 0, + "number_operands": 26, + "number_operators_unique": 0, + "number_operands_unique": 12, + "cloc": 0, + "loc": 23, + "lloc": 23, + "mi": 56.37, + "mIwoC": 56.37, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 0.6, + "relativeSystemComplexity": 1.6, + "totalStructuralComplexity": 5, + "totalDataComplexity": 3, + "totalSystemComplexity": 8, + "package": "App\\Listeners\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 5, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Services\\WebhookService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "dispatch", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "sign", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "verify", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "App\\Models\\Store", + "App\\Models\\WebhookSubscription", + "App\\Jobs\\DeliverWebhook" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 32, + "vocabulary": 17, + "volume": 130.8, + "difficulty": 3, + "effort": 392.4, + "level": 0.33, + "bugs": 0.04, + "time": 22, + "intelligentContent": 43.6, + "number_operators": 4, + "number_operands": 28, + "number_operators_unique": 3, + "number_operands_unique": 14, + "cloc": 3, + "loc": 22, + "lloc": 19, + "mi": 84.08, + "mIwoC": 57.02, + "commentWeight": 27.07, + "kanDefect": 0.38, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0.67, + "relativeSystemComplexity": 36.67, + "totalStructuralComplexity": 108, + "totalDataComplexity": 2, + "totalSystemComplexity": 110, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 3, + "instability": 0.6, + "violations": {} + }, + { + "name": "App\\Services\\OrderService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "createFromCheckout", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "generateOrderNumber", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "cancel", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "confirmBankTransferPayment", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 28, + "ccn": 25, + "ccnMethodMax": 12, + "externals": [ + "App\\Models\\Order", + "App\\Models\\Checkout", + "App\\Models\\Store", + "App\\Models\\Order", + "App\\Models\\OrderLine", + "App\\Events\\OrderCreated", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Store", + "App\\Models\\Order", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Order", + "App\\Enums\\FulfillmentStatus", + "DomainException", + "App\\Models\\InventoryItem", + "App\\Events\\OrderCancelled", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Order", + "App\\Enums\\PaymentMethod", + "App\\Enums\\FinancialStatus", + "DomainException", + "App\\Models\\InventoryItem", + "App\\Events\\OrderPaid", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [], + "lcom": 3, + "length": 237, + "vocabulary": 75, + "volume": 1476.23, + "difficulty": 14.31, + "effort": 21121.45, + "level": 0.07, + "bugs": 0.49, + "time": 1173, + "intelligentContent": 103.18, + "number_operators": 51, + "number_operands": 186, + "number_operators_unique": 10, + "number_operands_unique": 65, + "cloc": 2, + "loc": 81, + "lloc": 79, + "mi": 45.1, + "mIwoC": 33.05, + "commentWeight": 12.05, + "kanDefect": 1.4, + "relativeStructuralComplexity": 1089, + "relativeDataComplexity": 0.13, + "relativeSystemComplexity": 1089.13, + "totalStructuralComplexity": 4356, + "totalDataComplexity": 0.5, + "totalSystemComplexity": 4356.5, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 3, + "efferentCoupling": 13, + "instability": 0.81, + "violations": {} + }, + { + "name": "App\\Services\\Payments\\MockPaymentProvider", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "charge", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "refund", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "chargeCreditCard", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "generateId", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 8, + "ccn": 5, + "ccnMethodMax": 4, + "externals": [ + "App\\Contracts\\PaymentProvider", + "App\\ValueObjects\\PaymentResult", + "App\\Models\\Checkout", + "App\\Enums\\PaymentMethod", + "App\\ValueObjects\\PaymentResult", + "App\\ValueObjects\\PaymentResult", + "App\\ValueObjects\\RefundResult", + "App\\Models\\Payment", + "Illuminate\\Support\\Str", + "App\\ValueObjects\\RefundResult", + "App\\ValueObjects\\PaymentResult", + "App\\ValueObjects\\PaymentResult", + "App\\ValueObjects\\PaymentResult", + "App\\ValueObjects\\PaymentResult", + "Illuminate\\Support\\Str" + ], + "parents": [], + "implements": [ + "App\\Contracts\\PaymentProvider" + ], + "lcom": 2, + "length": 69, + "vocabulary": 29, + "volume": 335.2, + "difficulty": 4.4, + "effort": 1474.88, + "level": 0.23, + "bugs": 0.11, + "time": 82, + "intelligentContent": 76.18, + "number_operators": 14, + "number_operands": 55, + "number_operators_unique": 4, + "number_operands_unique": 25, + "cloc": 6, + "loc": 41, + "lloc": 35, + "mi": 75.89, + "mIwoC": 47.96, + "commentWeight": 27.93, + "kanDefect": 0.15, + "relativeStructuralComplexity": 9, + "relativeDataComplexity": 1.5, + "relativeSystemComplexity": 10.5, + "totalStructuralComplexity": 36, + "totalDataComplexity": 6, + "totalSystemComplexity": 42, + "package": "App\\Services\\Payments\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 7, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Services\\CheckoutService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "start", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "setAddress", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "setShippingMethod", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "selectPaymentMethod", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "applyDiscount", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "recalculate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "expire", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "complete", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "releaseReservedInventory", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "commitReservedInventory", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "assertTransitionAllowed", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 12, + "nbMethods": 12, + "nbMethodsPrivate": 3, + "nbMethodsPublic": 9, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 41, + "ccn": 30, + "ccnMethodMax": 8, + "externals": [ + "App\\Services\\PricingEngine", + "App\\Services\\InventoryService", + "App\\Services\\OrderService", + "App\\Contracts\\PaymentProvider", + "App\\Models\\Checkout", + "App\\Models\\Cart", + "App\\Models\\Checkout", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "App\\Models\\ShippingRate", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "DomainException", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Checkout", + "App\\Models\\Checkout", + "DomainException", + "App\\Enums\\PaymentMethod", + "App\\Exceptions\\PaymentFailedException", + "App\\Models\\Payment", + "App\\Events\\OrderPaid", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Checkout", + "App\\Models\\Order", + "App\\Models\\InventoryItem", + "App\\Models\\Checkout", + "App\\Enums\\CheckoutStatus", + "DomainException" + ], + "parents": [], + "implements": [], + "lcom": 3, + "length": 312, + "vocabulary": 59, + "volume": 1835.38, + "difficulty": 15.28, + "effort": 28042.56, + "level": 0.07, + "bugs": 0.61, + "time": 1558, + "intelligentContent": 120.13, + "number_operators": 85, + "number_operands": 227, + "number_operators_unique": 7, + "number_operands_unique": 52, + "cloc": 10, + "loc": 170, + "lloc": 160, + "mi": 43.38, + "mIwoC": 25.03, + "commentWeight": 18.35, + "kanDefect": 2.12, + "relativeStructuralComplexity": 1225, + "relativeDataComplexity": 0.35, + "relativeSystemComplexity": 1225.35, + "totalStructuralComplexity": 14700, + "totalDataComplexity": 4.25, + "totalSystemComplexity": 14704.25, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 16, + "instability": 0.94, + "violations": {} + }, + { + "name": "App\\Services\\FulfillmentService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "create", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "markAsShipped", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "markAsDelivered", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updateOrderFulfillmentStatus", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 17, + "ccn": 14, + "ccnMethodMax": 7, + "externals": [ + "App\\Models\\Fulfillment", + "App\\Models\\Order", + "App\\Enums\\FinancialStatus", + "App\\Exceptions\\FulfillmentGuardException", + "App\\Models\\Fulfillment", + "App\\Models\\FulfillmentLine", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Fulfillment", + "App\\Models\\Fulfillment", + "App\\Events\\FulfillmentDelivered", + "App\\Models\\Order", + "App\\Models\\FulfillmentLine", + "App\\Events\\OrderFulfilled" + ], + "parents": [], + "implements": [], + "lcom": 3, + "length": 117, + "vocabulary": 37, + "volume": 609.51, + "difficulty": 12.55, + "effort": 7650.35, + "level": 0.08, + "bugs": 0.2, + "time": 425, + "intelligentContent": 48.56, + "number_operators": 26, + "number_operands": 91, + "number_operators_unique": 8, + "number_operands_unique": 29, + "cloc": 8, + "loc": 56, + "lloc": 48, + "mi": 69.57, + "mIwoC": 41.94, + "commentWeight": 27.63, + "kanDefect": 0.66, + "relativeStructuralComplexity": 196, + "relativeDataComplexity": 0.32, + "relativeSystemComplexity": 196.32, + "totalStructuralComplexity": 784, + "totalDataComplexity": 1.27, + "totalSystemComplexity": 785.27, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 8, + "instability": 0.89, + "violations": {} + }, + { + "name": "App\\Services\\TaxCalculator", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "addExclusive", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "extractInclusive", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "calculate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 12, + "ccn": 10, + "ccnMethodMax": 6, + "externals": [ + "App\\Models\\TaxSettings", + "App\\ValueObjects\\TaxLine" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 88, + "vocabulary": 30, + "volume": 431.81, + "difficulty": 14.25, + "effort": 6153.24, + "level": 0.07, + "bugs": 0.14, + "time": 342, + "intelligentContent": 30.3, + "number_operators": 31, + "number_operands": 57, + "number_operators_unique": 10, + "number_operands_unique": 20, + "cloc": 4, + "loc": 34, + "lloc": 30, + "mi": 73.32, + "mIwoC": 47.98, + "commentWeight": 25.34, + "kanDefect": 0.36, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 4.17, + "relativeSystemComplexity": 5.17, + "totalStructuralComplexity": 3, + "totalDataComplexity": 12.5, + "totalSystemComplexity": 15.5, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Services\\ThemeSettingsService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "forStore", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "defaultSettings", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "forgetStore", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 5, + "ccn": 3, + "ccnMethodMax": 3, + "externals": [ + "App\\Models\\Store", + "App\\Models\\Theme", + "Illuminate\\Support\\Facades\\Cache", + "App\\Models\\Store", + "Illuminate\\Support\\Facades\\Cache" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 35, + "vocabulary": 21, + "volume": 153.73, + "difficulty": 4.06, + "effort": 624.53, + "level": 0.25, + "bugs": 0.05, + "time": 35, + "intelligentContent": 37.84, + "number_operators": 9, + "number_operands": 26, + "number_operators_unique": 5, + "number_operands_unique": 16, + "cloc": 9, + "loc": 32, + "lloc": 23, + "mi": 91.19, + "mIwoC": 54.58, + "commentWeight": 36.61, + "kanDefect": 0.22, + "relativeStructuralComplexity": 81, + "relativeDataComplexity": 0.47, + "relativeSystemComplexity": 81.47, + "totalStructuralComplexity": 243, + "totalDataComplexity": 1.4, + "totalSystemComplexity": 244.4, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Services\\InventoryService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "checkAvailability", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "reserve", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "release", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "commit", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "restock", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 8, + "ccn": 4, + "ccnMethodMax": 4, + "externals": [ + "App\\Models\\InventoryItem", + "App\\Models\\InventoryItem", + "App\\Enums\\InventoryPolicy", + "App\\Exceptions\\InsufficientInventoryException", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\InventoryItem", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\InventoryItem", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\InventoryItem", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [], + "lcom": 5, + "length": 78, + "vocabulary": 16, + "volume": 312, + "difficulty": 39.21, + "effort": 12234.86, + "level": 0.03, + "bugs": 0.1, + "time": 680, + "intelligentContent": 7.96, + "number_operators": 17, + "number_operands": 61, + "number_operators_unique": 9, + "number_operands_unique": 7, + "cloc": 0, + "loc": 45, + "lloc": 45, + "mi": 45.93, + "mIwoC": 45.93, + "commentWeight": 0, + "kanDefect": 0.22, + "relativeStructuralComplexity": 25, + "relativeDataComplexity": 0.5, + "relativeSystemComplexity": 25.5, + "totalStructuralComplexity": 125, + "totalDataComplexity": 2.5, + "totalSystemComplexity": 127.5, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 3, + "efferentCoupling": 4, + "instability": 0.57, + "violations": {} + }, + { + "name": "App\\Services\\NavigationService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "buildTree", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "resolveUrl", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "forgetMenu", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Models\\NavigationMenu", + "Illuminate\\Support\\Facades\\Cache", + "App\\Models\\NavigationItem", + "App\\Models\\NavigationMenu", + "Illuminate\\Support\\Facades\\Cache" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 27, + "vocabulary": 13, + "volume": 99.91, + "difficulty": 1.04, + "effort": 104.07, + "level": 0.96, + "bugs": 0.03, + "time": 6, + "intelligentContent": 95.92, + "number_operators": 2, + "number_operands": 25, + "number_operators_unique": 1, + "number_operands_unique": 12, + "cloc": 5, + "loc": 22, + "lloc": 17, + "mi": 92.68, + "mIwoC": 59.02, + "commentWeight": 33.66, + "kanDefect": 0.15, + "relativeStructuralComplexity": 64, + "relativeDataComplexity": 0.33, + "relativeSystemComplexity": 64.33, + "totalStructuralComplexity": 192, + "totalDataComplexity": 1, + "totalSystemComplexity": 193, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 3, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Services\\RefundService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "create", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 11, + "ccn": 10, + "ccnMethodMax": 10, + "externals": [ + "App\\Contracts\\PaymentProvider", + "App\\Services\\InventoryService", + "App\\Models\\Refund", + "App\\Models\\Order", + "App\\Models\\Payment", + "InvalidArgumentException", + "App\\Models\\Refund", + "App\\Models\\InventoryItem", + "App\\Events\\OrderRefunded", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 92, + "vocabulary": 37, + "volume": 479.27, + "difficulty": 14.6, + "effort": 6995.49, + "level": 0.07, + "bugs": 0.16, + "time": 389, + "intelligentContent": 32.84, + "number_operators": 23, + "number_operands": 69, + "number_operators_unique": 11, + "number_operands_unique": 26, + "cloc": 1, + "loc": 40, + "lloc": 39, + "mi": 57.3, + "mIwoC": 45.18, + "commentWeight": 12.13, + "kanDefect": 0.73, + "relativeStructuralComplexity": 169, + "relativeDataComplexity": 0.39, + "relativeSystemComplexity": 169.39, + "totalStructuralComplexity": 338, + "totalDataComplexity": 0.79, + "totalSystemComplexity": 338.79, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 9, + "instability": 0.9, + "violations": {} + }, + { + "name": "App\\Services\\ProductService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "create", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "update", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "transitionStatus", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "delete", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "syncOptions", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 6, + "nbMethods": 6, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 5, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 38, + "ccn": 33, + "ccnMethodMax": 16, + "externals": [ + "App\\Services\\VariantMatrixService", + "App\\Models\\Product", + "App\\Models\\Store", + "App\\Support\\HandleGenerator", + "App\\Support\\HandleGenerator", + "App\\Models\\Product", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Product", + "App\\Models\\Product", + "App\\Support\\HandleGenerator", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Product", + "App\\Enums\\ProductStatus", + "App\\Enums\\ProductStatus", + "InvalidArgumentException", + "App\\Models\\Product", + "App\\Enums\\ProductStatus", + "InvalidArgumentException", + "App\\Models\\Product", + "Illuminate\\Support\\Facades\\DB", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [], + "lcom": 4, + "length": 308, + "vocabulary": 57, + "volume": 1796.53, + "difficulty": 16.8, + "effort": 30181.71, + "level": 0.06, + "bugs": 0.6, + "time": 1677, + "intelligentContent": 106.94, + "number_operators": 68, + "number_operands": 240, + "number_operators_unique": 7, + "number_operands_unique": 50, + "cloc": 10, + "loc": 129, + "lloc": 119, + "mi": 48.4, + "mIwoC": 27.5, + "commentWeight": 20.9, + "kanDefect": 2, + "relativeStructuralComplexity": 900, + "relativeDataComplexity": 0.18, + "relativeSystemComplexity": 900.18, + "totalStructuralComplexity": 5400, + "totalDataComplexity": 1.1, + "totalSystemComplexity": 5401.1, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 7, + "instability": 0.88, + "violations": {} + }, + { + "name": "App\\Services\\ShippingCalculator", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "getAvailableRates", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "calculate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "weightRate", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "priceRate", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 39, + "ccn": 36, + "ccnMethodMax": 12, + "externals": [ + "Illuminate\\Database\\Eloquent\\Collection", + "App\\Models\\Store", + "App\\Models\\ShippingZone", + "Illuminate\\Database\\Eloquent\\Collection", + "App\\Models\\ShippingRate", + "App\\Models\\ShippingRate", + "App\\Models\\Cart" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 213, + "vocabulary": 53, + "volume": 1220.05, + "difficulty": 22.91, + "effort": 27954.33, + "level": 0.04, + "bugs": 0.41, + "time": 1553, + "intelligentContent": 53.25, + "number_operators": 72, + "number_operands": 141, + "number_operators_unique": 13, + "number_operands_unique": 40, + "cloc": 12, + "loc": 86, + "lloc": 74, + "mi": 60.12, + "mIwoC": 32.77, + "commentWeight": 27.35, + "kanDefect": 1.49, + "relativeStructuralComplexity": 289, + "relativeDataComplexity": 0.72, + "relativeSystemComplexity": 289.72, + "totalStructuralComplexity": 1156, + "totalDataComplexity": 2.89, + "totalSystemComplexity": 1158.89, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 5, + "instability": 0.83, + "violations": {} + }, + { + "name": "App\\Services\\AnalyticsService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "track", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "getDailyMetrics", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 3, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "App\\Models\\AnalyticsEvent", + "App\\Models\\Store", + "App\\Models\\AnalyticsEvent", + "Illuminate\\Support\\Collection", + "App\\Models\\Store", + "App\\Models\\AnalyticsDaily" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 31, + "vocabulary": 16, + "volume": 124, + "difficulty": 2, + "effort": 248, + "level": 0.5, + "bugs": 0.04, + "time": 14, + "intelligentContent": 62, + "number_operators": 3, + "number_operands": 28, + "number_operators_unique": 2, + "number_operands_unique": 14, + "cloc": 6, + "loc": 18, + "lloc": 12, + "mi": 100.52, + "mIwoC": 61.53, + "commentWeight": 38.99, + "kanDefect": 0.15, + "relativeStructuralComplexity": 36, + "relativeDataComplexity": 0.86, + "relativeSystemComplexity": 36.86, + "totalStructuralComplexity": 72, + "totalDataComplexity": 1.71, + "totalSystemComplexity": 73.71, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 4, + "instability": 0.8, + "violations": {} + }, + { + "name": "App\\Services\\CartService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "create", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "getOrCreateForSession", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "addLine", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "updateLineQuantity", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "removeLine", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "mergeOnLogin", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "touchVersion", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 8, + "nbMethods": 8, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 7, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 27, + "ccn": 20, + "ccnMethodMax": 10, + "externals": [ + "App\\Services\\InventoryService", + "App\\Models\\Cart", + "App\\Models\\Store", + "App\\Models\\Cart", + "App\\Models\\Cart", + "App\\Models\\Store", + "App\\Models\\Cart", + "App\\Models\\CartLine", + "App\\Models\\Cart", + "InvalidArgumentException", + "App\\Models\\ProductVariant", + "RuntimeException", + "RuntimeException", + "RuntimeException", + "RuntimeException", + "App\\Models\\CartLine", + "App\\Models\\CartLine", + "App\\Models\\Cart", + "InvalidArgumentException", + "App\\Models\\ProductVariant", + "RuntimeException", + "App\\Models\\Cart", + "App\\Models\\Cart", + "App\\Models\\Cart", + "App\\Models\\Cart", + "App\\Models\\Cart" + ], + "parents": [], + "implements": [], + "lcom": 3, + "length": 295, + "vocabulary": 45, + "volume": 1620.1, + "difficulty": 36.91, + "effort": 59796.3, + "level": 0.03, + "bugs": 0.54, + "time": 3322, + "intelligentContent": 43.89, + "number_operators": 92, + "number_operands": 203, + "number_operators_unique": 12, + "number_operands_unique": 33, + "cloc": 0, + "loc": 130, + "lloc": 130, + "mi": 28.72, + "mIwoC": 28.72, + "commentWeight": 0, + "kanDefect": 1.29, + "relativeStructuralComplexity": 729, + "relativeDataComplexity": 0.37, + "relativeSystemComplexity": 729.37, + "totalStructuralComplexity": 5832, + "totalDataComplexity": 2.93, + "totalSystemComplexity": 5834.93, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 7, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Services\\PricingEngine", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "calculate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 17, + "ccn": 16, + "ccnMethodMax": 16, + "externals": [ + "App\\Services\\DiscountService", + "App\\Services\\ShippingCalculator", + "App\\Services\\TaxCalculator", + "App\\ValueObjects\\PricingResult", + "App\\Models\\Checkout", + "App\\ValueObjects\\PricingResult", + "App\\Models\\Store", + "App\\Models\\ShippingRate", + "App\\Models\\TaxSettings", + "App\\ValueObjects\\TaxLine", + "App\\ValueObjects\\PricingResult" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 191, + "vocabulary": 49, + "volume": 1072.41, + "difficulty": 16.54, + "effort": 17736, + "level": 0.06, + "bugs": 0.36, + "time": 985, + "intelligentContent": 64.84, + "number_operators": 62, + "number_operands": 129, + "number_operators_unique": 10, + "number_operands_unique": 39, + "cloc": 0, + "loc": 59, + "lloc": 59, + "mi": 38, + "mIwoC": 38, + "commentWeight": 0, + "kanDefect": 0.64, + "relativeStructuralComplexity": 196, + "relativeDataComplexity": 0.27, + "relativeSystemComplexity": 196.27, + "totalStructuralComplexity": 392, + "totalDataComplexity": 0.53, + "totalSystemComplexity": 392.53, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 9, + "instability": 0.9, + "violations": {} + }, + { + "name": "App\\Services\\DiscountService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "validate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "calculate", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "recordUsage", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 3, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 23, + "ccn": 21, + "ccnMethodMax": 15, + "externals": [ + "App\\Models\\Discount", + "App\\Models\\Store", + "App\\Models\\Cart", + "App\\Models\\Discount", + "App\\Exceptions\\InvalidDiscountException", + "App\\Exceptions\\InvalidDiscountException", + "App\\Exceptions\\InvalidDiscountException", + "App\\Exceptions\\InvalidDiscountException", + "App\\Exceptions\\InvalidDiscountException", + "App\\Exceptions\\InvalidDiscountException", + "App\\Exceptions\\InvalidDiscountException", + "App\\ValueObjects\\DiscountResult", + "App\\Models\\Discount", + "App\\ValueObjects\\DiscountResult", + "App\\ValueObjects\\DiscountResult", + "App\\ValueObjects\\DiscountResult", + "App\\ValueObjects\\DiscountResult", + "App\\Models\\Discount", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [], + "lcom": 3, + "length": 165, + "vocabulary": 42, + "volume": 889.73, + "difficulty": 32.62, + "effort": 29018.96, + "level": 0.03, + "bugs": 0.3, + "time": 1612, + "intelligentContent": 27.28, + "number_operators": 59, + "number_operands": 106, + "number_operators_unique": 16, + "number_operands_unique": 26, + "cloc": 3, + "loc": 80, + "lloc": 77, + "mi": 50.15, + "mIwoC": 35.37, + "commentWeight": 14.78, + "kanDefect": 1.22, + "relativeStructuralComplexity": 484, + "relativeDataComplexity": 0.32, + "relativeSystemComplexity": 484.32, + "totalStructuralComplexity": 1452, + "totalDataComplexity": 0.96, + "totalSystemComplexity": 1452.96, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 6, + "instability": 0.86, + "violations": {} + }, + { + "name": "App\\Services\\SearchService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "syncProduct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "removeProduct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "search", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "autocomplete", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "buildFtsQuery", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 5, + "nbMethods": 5, + "nbMethodsPrivate": 1, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 19, + "ccn": 15, + "ccnMethodMax": 7, + "externals": [ + "App\\Models\\Product", + "Illuminate\\Support\\Facades\\DB", + "Illuminate\\Support\\Facades\\DB", + "Illuminate\\Contracts\\Pagination\\LengthAwarePaginator", + "App\\Models\\Store", + "App\\Models\\Product", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Product", + "App\\Models\\Product", + "Illuminate\\Support\\Collection", + "App\\Models\\Store", + "Illuminate\\Support\\Facades\\DB", + "App\\Models\\Product" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 139, + "vocabulary": 45, + "volume": 763.37, + "difficulty": 12.5, + "effort": 9542.09, + "level": 0.08, + "bugs": 0.25, + "time": 530, + "intelligentContent": 61.07, + "number_operators": 39, + "number_operands": 100, + "number_operators_unique": 9, + "number_operands_unique": 36, + "cloc": 7, + "loc": 59, + "lloc": 52, + "mi": 65.8, + "mIwoC": 40.36, + "commentWeight": 25.43, + "kanDefect": 0.57, + "relativeStructuralComplexity": 361, + "relativeDataComplexity": 0.55, + "relativeSystemComplexity": 361.55, + "totalStructuralComplexity": 1805, + "totalDataComplexity": 2.75, + "totalSystemComplexity": 1807.75, + "package": "App\\Services\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 5, + "instability": 0.83, + "violations": {} + }, + { + "name": "App\\Services\\VariantMatrixService", + "interface": false, + "abstract": false, + "final": false, + "methods": [ + { + "name": "rebuildMatrix", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "cartesian", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "keyFor", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 15, + "ccn": 13, + "ccnMethodMax": 10, + "externals": [ + "App\\Models\\Product", + "Illuminate\\Support\\Facades\\DB" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 122, + "vocabulary": 39, + "volume": 644.82, + "difficulty": 10.06, + "effort": 6488.49, + "level": 0.1, + "bugs": 0.21, + "time": 360, + "intelligentContent": 64.08, + "number_operators": 30, + "number_operands": 92, + "number_operators_unique": 7, + "number_operands_unique": 32, + "cloc": 7, + "loc": 70, + "lloc": 63, + "mi": 62.86, + "mIwoC": 39.33, + "commentWeight": 23.53, + "kanDefect": 1.81, + "relativeStructuralComplexity": 289, + "relativeDataComplexity": 0.22, + "relativeSystemComplexity": 289.22, + "totalStructuralComplexity": 867, + "totalDataComplexity": 0.67, + "totalSystemComplexity": 867.67, + "package": "App\\Services\\", + "pageRank": 0, + "afferentCoupling": 1, + "efferentCoupling": 2, + "instability": 0.67, + "violations": {} + }, + { + "name": "App\\Concerns\\ProfileValidationRules", + "interface": false, + "abstract": true, + "final": false, + "methods": [ + { + "name": "profileRules", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "nameRules", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "emailRules", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 3, + "nbMethods": 3, + "nbMethodsPrivate": 3, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 2, + "ccnMethodMax": 2, + "externals": [ + "Illuminate\\Validation\\Rule", + "Illuminate\\Validation\\Rule" + ], + "parents": [], + "implements": [], + "lcom": 1, + "length": 20, + "vocabulary": 9, + "volume": 63.4, + "difficulty": 2.29, + "effort": 144.91, + "level": 0.44, + "bugs": 0.02, + "time": 8, + "intelligentContent": 27.74, + "number_operators": 4, + "number_operands": 16, + "number_operators_unique": 2, + "number_operands_unique": 7, + "cloc": 15, + "loc": 31, + "lloc": 16, + "mi": 104.89, + "mIwoC": 60.85, + "commentWeight": 44.04, + "kanDefect": 0.15, + "relativeStructuralComplexity": 16, + "relativeDataComplexity": 0.73, + "relativeSystemComplexity": 16.73, + "totalStructuralComplexity": 48, + "totalDataComplexity": 2.2, + "totalSystemComplexity": 50.2, + "package": "App\\Concerns\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 1, + "instability": 1, + "violations": {} + }, + { + "name": "App\\Concerns\\PasswordValidationRules", + "interface": false, + "abstract": true, + "final": false, + "methods": [ + { + "name": "passwordRules", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "currentPasswordRules", + "role": null, + "public": false, + "private": true, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 2, + "nbMethodsPublic": 0, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "Illuminate\\Validation\\Rules\\Password" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 8, + "vocabulary": 5, + "volume": 18.58, + "difficulty": 0.75, + "effort": 13.93, + "level": 1.33, + "bugs": 0.01, + "time": 1, + "intelligentContent": 24.77, + "number_operators": 2, + "number_operands": 6, + "number_operators_unique": 1, + "number_operands_unique": 4, + "cloc": 10, + "loc": 22, + "lloc": 12, + "mi": 110.67, + "mIwoC": 67.44, + "commentWeight": 43.23, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 1, + "relativeSystemComplexity": 2, + "totalStructuralComplexity": 2, + "totalDataComplexity": 2, + "totalSystemComplexity": 4, + "package": "App\\Concerns\\", + "pageRank": 0, + "afferentCoupling": 0, + "efferentCoupling": 1, + "instability": 1, + "violations": {} + }, + { + "name": "App\\ValueObjects\\PaymentResult", + "interface": false, + "abstract": false, + "final": true, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "successful", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "pending", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "failed", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 4, + "nbMethods": 4, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 4, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 4, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Enums\\PaymentStatus" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 14, + "vocabulary": 8, + "volume": 42, + "difficulty": 1.33, + "effort": 56, + "level": 0.75, + "bugs": 0.01, + "time": 3, + "intelligentContent": 31.5, + "number_operators": 6, + "number_operands": 8, + "number_operators_unique": 2, + "number_operands_unique": 6, + "cloc": 0, + "loc": 19, + "lloc": 19, + "mi": 60.6, + "mIwoC": 60.6, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 4.25, + "relativeSystemComplexity": 4.25, + "totalStructuralComplexity": 0, + "totalDataComplexity": 17, + "totalSystemComplexity": 17, + "package": "App\\ValueObjects\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 1, + "instability": 0.33, + "violations": {} + }, + { + "name": "App\\ValueObjects\\DiscountResult", + "interface": false, + "abstract": false, + "final": true, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 1, + "nbMethods": 1, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 1, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 1, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [], + "parents": [], + "implements": [], + "lcom": 1, + "length": 3, + "vocabulary": 3, + "volume": 4.75, + "difficulty": 0, + "effort": 0, + "level": 2, + "bugs": 0, + "time": 0, + "intelligentContent": 9.51, + "number_operators": 0, + "number_operands": 3, + "number_operators_unique": 0, + "number_operands_unique": 3, + "cloc": 3, + "loc": 10, + "lloc": 7, + "mi": 114.21, + "mIwoC": 76.69, + "commentWeight": 37.52, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 3, + "relativeSystemComplexity": 3, + "totalStructuralComplexity": 0, + "totalDataComplexity": 3, + "totalSystemComplexity": 3, + "package": "App\\ValueObjects\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 0, + "instability": 0, + "violations": {} + }, + { + "name": "App\\ValueObjects\\PricingResult", + "interface": false, + "abstract": false, + "final": true, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "toArray", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [], + "parents": [], + "implements": [], + "lcom": 2, + "length": 27, + "vocabulary": 14, + "volume": 102.8, + "difficulty": 1, + "effort": 102.8, + "level": 1, + "bugs": 0.03, + "time": 6, + "intelligentContent": 102.8, + "number_operators": 1, + "number_operands": 26, + "number_operators_unique": 1, + "number_operands_unique": 13, + "cloc": 15, + "loc": 26, + "lloc": 11, + "mi": 109.23, + "mIwoC": 63.06, + "commentWeight": 46.17, + "kanDefect": 0.15, + "relativeStructuralComplexity": 1, + "relativeDataComplexity": 2.5, + "relativeSystemComplexity": 3.5, + "totalStructuralComplexity": 2, + "totalDataComplexity": 5, + "totalSystemComplexity": 7, + "package": "App\\ValueObjects\\", + "pageRank": 0.01, + "afferentCoupling": 1, + "efferentCoupling": 0, + "instability": 0, + "violations": {} + }, + { + "name": "App\\ValueObjects\\TaxLine", + "interface": false, + "abstract": false, + "final": true, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "toArray", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [], + "parents": [], + "implements": [], + "lcom": 2, + "length": 10, + "vocabulary": 5, + "volume": 23.22, + "difficulty": 1.13, + "effort": 26.12, + "level": 0.89, + "bugs": 0.01, + "time": 1, + "intelligentContent": 20.64, + "number_operators": 1, + "number_operands": 9, + "number_operators_unique": 1, + "number_operands_unique": 4, + "cloc": 3, + "loc": 14, + "lloc": 11, + "mi": 100.45, + "mIwoC": 67.58, + "commentWeight": 32.86, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 2.5, + "relativeSystemComplexity": 2.5, + "totalStructuralComplexity": 0, + "totalDataComplexity": 5, + "totalSystemComplexity": 5, + "package": "App\\ValueObjects\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 0, + "instability": 0, + "violations": {} + }, + { + "name": "App\\ValueObjects\\RefundResult", + "interface": false, + "abstract": false, + "final": true, + "methods": [ + { + "name": "__construct", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + }, + { + "name": "successful", + "role": null, + "public": true, + "private": false, + "_type": "Hal\\Metric\\FunctionMetric" + } + ], + "nbMethodsIncludingGettersSetters": 2, + "nbMethods": 2, + "nbMethodsPrivate": 0, + "nbMethodsPublic": 2, + "nbMethodsGetter": 0, + "nbMethodsSetters": 0, + "wmc": 2, + "ccn": 1, + "ccnMethodMax": 1, + "externals": [ + "App\\Enums\\RefundStatus" + ], + "parents": [], + "implements": [], + "lcom": 2, + "length": 7, + "vocabulary": 7, + "volume": 19.65, + "difficulty": 1, + "effort": 19.65, + "level": 1, + "bugs": 0.01, + "time": 1, + "intelligentContent": 19.65, + "number_operators": 2, + "number_operands": 5, + "number_operators_unique": 2, + "number_operands_unique": 5, + "cloc": 0, + "loc": 11, + "lloc": 11, + "mi": 68.09, + "mIwoC": 68.09, + "commentWeight": 0, + "kanDefect": 0.15, + "relativeStructuralComplexity": 0, + "relativeDataComplexity": 3, + "relativeSystemComplexity": 3, + "totalStructuralComplexity": 0, + "totalDataComplexity": 6, + "totalSystemComplexity": 6, + "package": "App\\ValueObjects\\", + "pageRank": 0.01, + "afferentCoupling": 2, + "efferentCoupling": 1, + "instability": 0.33, + "violations": {} + } +] \ No newline at end of file diff --git a/report/complexity.html b/report/complexity.html new file mode 100644 index 00000000..b546acb5 --- /dev/null +++ b/report/complexity.html @@ -0,0 +1,5262 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + +
+
+
+
Average weighted method count by class (CC)
+
+ 6.35
+
+
+
+
+
Average cyclomatic complexity by class
+
+ 4.27
+
+
+
+
+
Average relative System complexity
+
+ 112.57
+
+
+
+
+
Average bugs by class(Halstead)
+
+ 0.08
+
+
+
+
+
average defects by class (Kan)
+
+ 0.3
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassWMCClass cycl.Max method cycl.Relative system complexityRelative data complexityRelative structural complexityBugsDefects
App\Auth\CustomerUserProvider + + 29 + + + 20 + + + 8 + + + 484.63 + + + 0.63 + + + 484 + + + 0.27 + + + 1.01 +
App\Providers\AppServiceProvider + + 7 + + + 2 + + + 2 + + + 529.04 + + + 0.04 + + + 529 + + + 0.03 + + + 0.15 +
App\Providers\FortifyServiceProvider + + 5 + + + 1 + + + 1 + + + 484.09 + + + 0.09 + + + 484 + + + 0.04 + + + 0.15 +
App\Models\OrderLine + + 5 + + + 1 + + + 1 + + + 5.67 + + + 1.67 + + + 4 + + + 0.03 + + + 0.15 +
App\Models\WebhookSubscription + + 2 + + + 1 + + + 1 + + + 4.67 + + + 0.67 + + + 4 + + + 0.01 + + + 0.15 +
App\Models\ThemeFile + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\ProductOption + + 2 + + + 1 + + + 1 + + + 4.67 + + + 0.67 + + + 4 + + + 0.01 + + + 0.15 +
App\Models\NavigationItem + + 4 + + + 2 + + + 2 + + + 2.5 + + + 1.5 + + + 1 + + + 0.04 + + + 0.15 +
App\Models\Refund + + 3 + + + 1 + + + 1 + + + 2.5 + + + 1.5 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\InventoryItem + + 3 + + + 1 + + + 1 + + + 2.5 + + + 1.5 + + + 1 + + + 0.02 + + + 0.15 +
App\Models\NavigationMenu + + 1 + + + 1 + + + 1 + + + 4.33 + + + 0.33 + + + 4 + + + 0.01 + + + 0.15 +
App\Models\App + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\CartLine + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\AppInstallation + + 3 + + + 1 + + + 1 + + + 5 + + + 1 + + + 4 + + + 0.02 + + + 0.15 +
App\Models\Cart + + 4 + + + 1 + + + 1 + + + 2.5 + + + 1.5 + + + 1 + + + 0.02 + + + 0.15 +
App\Models\Discount + + 9 + + + 8 + + + 8 + + + 6 + + + 2 + + + 4 + + + 0.07 + + + 0.43 +
App\Models\Product + + 5 + + + 1 + + + 1 + + + 10.25 + + + 1.25 + + + 9 + + + 0.03 + + + 0.15 +
App\Models\Order + + 13 + + + 5 + + + 5 + + + 37.57 + + + 1.57 + + + 36 + + + 0.11 + + + 0.52 +
App\Models\Store + + 5 + + + 1 + + + 1 + + + 36.71 + + + 0.71 + + + 36 + + + 0.02 + + + 0.15 +
App\Models\StoreDomain + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\Theme + + 3 + + + 1 + + + 1 + + + 5 + + + 1 + + + 4 + + + 0.01 + + + 0.15 +
App\Models\ProductMedia + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.02 + + + 0.15 +
App\Models\User + + 7 + + + 4 + + + 4 + + + 169.45 + + + 0.45 + + + 169 + + + 0.09 + + + 0.29 +
App\Models\WebhookDelivery + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.02 + + + 0.15 +
App\Models\Fulfillment + + 3 + + + 1 + + + 1 + + + 5 + + + 1 + + + 4 + + + 0.02 + + + 0.15 +
App\Models\ThemeSettings + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\Checkout + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.04 + + + 0.15 +
App\Models\Payment + + 3 + + + 1 + + + 1 + + + 5 + + + 1 + + + 4 + + + 0.02 + + + 0.15 +
App\Models\AnalyticsDaily + + 3 + + + 1 + + + 1 + + + 16.73 + + + 0.73 + + + 16 + + + 0.04 + + + 0.15 +
App\Models\Customer + + 6 + + + 1 + + + 1 + + + 4.5 + + + 3.5 + + + 1 + + + 0.03 + + + 0.15 +
App\Models\ProductVariant + + 4 + + + 1 + + + 1 + + + 10 + + + 1 + + + 9 + + + 0.04 + + + 0.15 +
App\Models\ProductOptionValue + + 1 + + + 1 + + + 1 + + + 1.5 + + + 0.5 + + + 1 + + + 0 + + + 0.15 +
App\Models\TaxSettings + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.02 + + + 0.15 +
App\Models\ShippingZone + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\Collection + + 2 + + + 1 + + + 1 + + + 4.67 + + + 0.67 + + + 4 + + + 0.01 + + + 0.15 +
App\Models\Page + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0.01 + + + 0.15 +
App\Models\ShippingRate + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\StoreSettings + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\CustomerAddress + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\StoreUser + + 3 + + + 2 + + + 2 + + + 9.25 + + + 0.25 + + + 9 + + + 0.01 + + + 0.22 +
App\Models\FulfillmentLine + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\Models\AnalyticsEvent + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0.01 + + + 0.15 +
App\Models\Scopes\StoreScope + + 2 + + + 2 + + + 2 + + + 9.75 + + + 0.75 + + + 9 + + + 0.01 + + + 0.22 +
App\Models\Organization + + 1 + + + 1 + + + 1 + + + 1.5 + + + 0.5 + + + 1 + + + 0 + + + 0.15 +
App\Models\Concerns\BelongsToStore + + 4 + + + 3 + + + 3 + + + 16.2 + + + 0.2 + + + 16 + + + 0.01 + + + 0.22 +
App\Exceptions\InsufficientInventoryException + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0.15 +
App\Exceptions\FulfillmentGuardException + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0.15 +
App\Exceptions\InvalidDiscountException + + 8 + + + 2 + + + 2 + + + 4.14 + + + 3.14 + + + 1 + + + 0.04 + + + 0.15 +
App\Exceptions\PaymentFailedException + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0.15 +
App\Policies\StorePolicy + + 3 + + + 1 + + + 1 + + + 3.5 + + + 2.5 + + + 1 + + + 0.02 + + + 0.15 +
App\Policies\Concerns\ChecksStoreRole + + 4 + + + 3 + + + 2 + + + 10.13 + + + 1.13 + + + 9 + + + 0.03 + + + 0.22 +
App\Livewire\Settings\TwoFactor + + 17 + + + 9 + + + 3 + + + 144.34 + + + 0.34 + + + 144 + + + 0.16 + + + 0.57 +
App\Livewire\Settings\DeleteUserForm + + 1 + + + 1 + + + 1 + + + 25.17 + + + 0.17 + + + 25 + + + 0.01 + + + 0.15 +
App\Livewire\Settings\TwoFactor\RecoveryCodes + + 6 + + + 4 + + + 4 + + + 16.07 + + + 0.07 + + + 16 + + + 0.02 + + + 0.22 +
App\Livewire\Settings\Password + + 2 + + + 2 + + + 2 + + + 49 + + + 0 + + + 49 + + + 0.03 + + + 0.15 +
App\Livewire\Settings\Profile + + 10 + + + 6 + + + 3 + + + 144.23 + + + 0.23 + + + 144 + + + 0.05 + + + 0.29 +
App\Livewire\Settings\Appearance + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0.15 +
App\Livewire\Storefront\Products\Show + + 9 + + + 5 + + + 3 + + + 256.2 + + + 0.2 + + + 256 + + + 0.14 + + + 0.29 +
App\Livewire\Storefront\Home + + 8 + + + 5 + + + 3 + + + 144.38 + + + 0.38 + + + 144 + + + 0.03 + + + 0.43 +
App\Livewire\Storefront\Checkout\Show + + 35 + + + 25 + + + 9 + + + 900.27 + + + 0.27 + + + 900 + + + 0.64 + + + 1.01 +
App\Livewire\Storefront\Checkout\Confirmation + + 2 + + + 1 + + + 1 + + + 25.25 + + + 0.25 + + + 25 + + + 0.02 + + + 0.15 +
App\Livewire\Storefront\Search\Index + + 4 + + + 2 + + + 2 + + + 36.14 + + + 0.14 + + + 36 + + + 0.03 + + + 0.15 +
App\Livewire\Storefront\CartDrawer + + 5 + + + 3 + + + 3 + + + 4.33 + + + 0.33 + + + 4 + + + 0.04 + + + 0.45 +
App\Livewire\Storefront\Cart\Show + + 10 + + + 6 + + + 3 + + + 49.45 + + + 0.45 + + + 49 + + + 0.09 + + + 0.59 +
App\Livewire\Storefront\Account\Dashboard + + 2 + + + 1 + + + 1 + + + 64.11 + + + 0.11 + + + 64 + + + 0.02 + + + 0.15 +
App\Livewire\Storefront\Account\Auth\Login + + 6 + + + 4 + + + 3 + + + 169.14 + + + 0.14 + + + 169 + + + 0.06 + + + 0.29 +
App\Livewire\Storefront\Account\Auth\Register + + 4 + + + 2 + + + 2 + + + 121.08 + + + 0.08 + + + 121 + + + 0.07 + + + 0.22 +
App\Livewire\Storefront\Account\Addresses\Index + + 6 + + + 2 + + + 2 + + + 169.1 + + + 0.1 + + + 169 + + + 0.17 + + + 0.22 +
App\Livewire\Storefront\Account\Orders\Index + + 2 + + + 1 + + + 1 + + + 49.13 + + + 0.13 + + + 49 + + + 0.02 + + + 0.15 +
App\Livewire\Storefront\Account\Orders\Show + + 2 + + + 1 + + + 1 + + + 64.17 + + + 0.17 + + + 64 + + + 0.02 + + + 0.15 +
App\Livewire\Storefront\Collections\Index + + 2 + + + 1 + + + 1 + + + 36.14 + + + 0.14 + + + 36 + + + 0.01 + + + 0.15 +
App\Livewire\Storefront\Collections\Show + + 5 + + + 3 + + + 3 + + + 121.11 + + + 0.11 + + + 121 + + + 0.06 + + + 0.22 +
App\Livewire\Storefront\Pages\Show + + 2 + + + 1 + + + 1 + + + 25.25 + + + 0.25 + + + 25 + + + 0.02 + + + 0.15 +
App\Livewire\Storefront\Concerns\EnsuresStore + + 3 + + + 3 + + + 3 + + + 25.17 + + + 0.17 + + + 25 + + + 0.01 + + + 0.22 +
App\Livewire\Admin\Customers\Index + + 2 + + + 1 + + + 1 + + + 100.09 + + + 0.09 + + + 100 + + + 0.05 + + + 0.15 +
App\Livewire\Admin\Customers\Show + + 3 + + + 2 + + + 2 + + + 25.25 + + + 0.25 + + + 25 + + + 0.05 + + + 0.15 +
App\Livewire\Admin\Settings\Taxes + + 7 + + + 5 + + + 5 + + + 25.17 + + + 0.17 + + + 25 + + + 0.1 + + + 0.15 +
App\Livewire\Admin\Settings\Index + + 3 + + + 1 + + + 1 + + + 9.25 + + + 0.25 + + + 9 + + + 0.07 + + + 0.15 +
App\Livewire\Admin\Settings\Shipping + + 8 + + + 2 + + + 2 + + + 196.16 + + + 0.16 + + + 196 + + + 0.19 + + + 0.22 +
App\Livewire\Admin\Dashboard + + 5 + + + 2 + + + 2 + + + 121.33 + + + 0.33 + + + 121 + + + 0.08 + + + 0.15 +
App\Livewire\Admin\Products\Index + + 7 + + + 3 + + + 2 + + + 169.21 + + + 0.21 + + + 169 + + + 0.08 + + + 0.29 +
App\Livewire\Admin\Products\Form + + 22 + + + 20 + + + 11 + + + 144.21 + + + 0.21 + + + 144 + + + 0.34 + + + 0.5 +
App\Livewire\Admin\Auth\Login + + 6 + + + 4 + + + 4 + + + 256.18 + + + 0.18 + + + 256 + + + 0.1 + + + 0.36 +
App\Livewire\Admin\Navigation\Index + + 14 + + + 7 + + + 6 + + + 729.13 + + + 0.13 + + + 729 + + + 0.29 + + + 0.29 +
App\Livewire\Admin\Discounts\Index + + 3 + + + 1 + + + 1 + + + 49.13 + + + 0.13 + + + 49 + + + 0.03 + + + 0.15 +
App\Livewire\Admin\Discounts\Form + + 14 + + + 12 + + + 8 + + + 36.48 + + + 0.48 + + + 36 + + + 0.24 + + + 0.43 +
App\Livewire\Admin\Orders\Index + + 5 + + + 1 + + + 1 + + + 100.09 + + + 0.09 + + + 100 + + + 0.09 + + + 0.15 +
App\Livewire\Admin\Orders\Show + + 22 + + + 13 + + + 6 + + + 289.22 + + + 0.22 + + + 289 + + + 0.28 + + + 0.52 +
App\Livewire\Admin\Collections\Index + + 2 + + + 1 + + + 1 + + + 49.13 + + + 0.13 + + + 49 + + + 0.03 + + + 0.15 +
App\Livewire\Admin\Collections\Form + + 18 + + + 14 + + + 8 + + + 324.14 + + + 0.14 + + + 324 + + + 0.32 + + + 0.59 +
App\Livewire\Admin\Pages\Index + + 3 + + + 1 + + + 1 + + + 81.13 + + + 0.13 + + + 81 + + + 0.05 + + + 0.15 +
App\Livewire\Admin\Pages\Form + + 15 + + + 13 + + + 10 + + + 36.33 + + + 0.33 + + + 36 + + + 0.21 + + + 0.36 +
App\Livewire\Admin\Apps\Index + + 3 + + + 1 + + + 1 + + + 256.16 + + + 0.16 + + + 256 + + + 0.11 + + + 0.15 +
App\Livewire\Admin\Themes\Index + + 5 + + + 2 + + + 2 + + + 169.2 + + + 0.2 + + + 169 + + + 0.09 + + + 0.22 +
App\Livewire\Admin\Analytics\Index + + 4 + + + 3 + + + 3 + + + 25.25 + + + 0.25 + + + 25 + + + 0.1 + + + 0.15 +
App\Livewire\Admin\Developers\Index + + 5 + + + 1 + + + 1 + + + 289.08 + + + 0.08 + + + 289 + + + 0.13 + + + 0.15 +
App\Livewire\Actions\Logout + + 1 + + + 1 + + + 1 + + + 16.2 + + + 0.2 + + + 16 + + + 0 + + + 0.15 +
App\Support\HandleGenerator + + 5 + + + 4 + + + 3 + + + 49.75 + + + 0.75 + + + 49 + + + 0.08 + + + 0.52 +
App\Support\CartSession + + 9 + + + 7 + + + 4 + + + 49.67 + + + 0.67 + + + 49 + + + 0.06 + + + 0.43 +
App\Http\Middleware\ResolveStore + + 10 + + + 8 + + + 5 + + + 324.56 + + + 0.56 + + + 324 + + + 0.13 + + + 0.64 +
App\Http\Controllers\Controller + + 0 + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0.15 +
App\Actions\Fortify\ResetUserPassword + + 1 + + + 1 + + + 1 + + + 25.33 + + + 0.33 + + + 25 + + + 0.01 + + + 0.15 +
App\Actions\Fortify\CreateNewUser + + 1 + + + 1 + + + 1 + + + 25.33 + + + 0.33 + + + 25 + + + 0.01 + + + 0.15 +
App\Jobs\ExpireAbandonedCheckouts + + 1 + + + 1 + + + 1 + + + 64.11 + + + 0.11 + + + 64 + + + 0.01 + + + 0.15 +
App\Jobs\CleanupAbandonedCarts + + 1 + + + 1 + + + 1 + + + 36 + + + 0 + + + 36 + + + 0 + + + 0.15 +
App\Jobs\AggregateAnalytics + + 4 + + + 3 + + + 3 + + + 676.02 + + + 0.02 + + + 676 + + + 0.18 + + + 0.15 +
App\Jobs\CancelUnpaidBankTransferOrders + + 1 + + + 1 + + + 1 + + + 64.11 + + + 0.11 + + + 64 + + + 0.01 + + + 0.15 +
App\Jobs\ProcessMediaUpload + + 2 + + + 1 + + + 1 + + + 4.17 + + + 0.17 + + + 4 + + + 0.01 + + + 0.15 +
App\Jobs\DeliverWebhook + + 10 + + + 8 + + + 6 + + + 361.18 + + + 0.18 + + + 361 + + + 0.21 + + + 0.36 +
App\Events\OrderRefunded + + 1 + + + 1 + + + 1 + + + 2 + + + 2 + + + 0 + + + 0 + + + 0.15 +
App\Events\OrderCancelled + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0.15 +
App\Events\OrderCreated + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0.15 +
App\Events\OrderPaid + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0.15 +
App\Events\FulfillmentDelivered + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0.15 +
App\Events\OrderFulfilled + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 0 + + + 0 + + + 0.15 +
App\Observers\ProductObserver + + 4 + + + 1 + + + 1 + + + 4.33 + + + 0.33 + + + 4 + + + 0.01 + + + 0.15 +
App\Listeners\DispatchOrderWebhooks + + 5 + + + 1 + + + 1 + + + 1.6 + + + 0.6 + + + 1 + + + 0.03 + + + 0.15 +
App\Services\WebhookService + + 4 + + + 2 + + + 2 + + + 36.67 + + + 0.67 + + + 36 + + + 0.04 + + + 0.38 +
App\Services\OrderService + + 28 + + + 25 + + + 12 + + + 1089.13 + + + 0.13 + + + 1089 + + + 0.49 + + + 1.4 +
App\Services\Payments\MockPaymentProvider + + 8 + + + 5 + + + 4 + + + 10.5 + + + 1.5 + + + 9 + + + 0.11 + + + 0.15 +
App\Services\CheckoutService + + 41 + + + 30 + + + 8 + + + 1225.35 + + + 0.35 + + + 1225 + + + 0.61 + + + 2.12 +
App\Services\FulfillmentService + + 17 + + + 14 + + + 7 + + + 196.32 + + + 0.32 + + + 196 + + + 0.2 + + + 0.66 +
App\Services\TaxCalculator + + 12 + + + 10 + + + 6 + + + 5.17 + + + 4.17 + + + 1 + + + 0.14 + + + 0.36 +
App\Services\ThemeSettingsService + + 5 + + + 3 + + + 3 + + + 81.47 + + + 0.47 + + + 81 + + + 0.05 + + + 0.22 +
App\Services\InventoryService + + 8 + + + 4 + + + 4 + + + 25.5 + + + 0.5 + + + 25 + + + 0.1 + + + 0.22 +
App\Services\NavigationService + + 3 + + + 1 + + + 1 + + + 64.33 + + + 0.33 + + + 64 + + + 0.03 + + + 0.15 +
App\Services\RefundService + + 11 + + + 10 + + + 10 + + + 169.39 + + + 0.39 + + + 169 + + + 0.16 + + + 0.73 +
App\Services\ProductService + + 38 + + + 33 + + + 16 + + + 900.18 + + + 0.18 + + + 900 + + + 0.6 + + + 2 +
App\Services\ShippingCalculator + + 39 + + + 36 + + + 12 + + + 289.72 + + + 0.72 + + + 289 + + + 0.41 + + + 1.49 +
App\Services\AnalyticsService + + 3 + + + 2 + + + 2 + + + 36.86 + + + 0.86 + + + 36 + + + 0.04 + + + 0.15 +
App\Services\CartService + + 27 + + + 20 + + + 10 + + + 729.37 + + + 0.37 + + + 729 + + + 0.54 + + + 1.29 +
App\Services\PricingEngine + + 17 + + + 16 + + + 16 + + + 196.27 + + + 0.27 + + + 196 + + + 0.36 + + + 0.64 +
App\Services\DiscountService + + 23 + + + 21 + + + 15 + + + 484.32 + + + 0.32 + + + 484 + + + 0.3 + + + 1.22 +
App\Services\SearchService + + 19 + + + 15 + + + 7 + + + 361.55 + + + 0.55 + + + 361 + + + 0.25 + + + 0.57 +
App\Services\VariantMatrixService + + 15 + + + 13 + + + 10 + + + 289.22 + + + 0.22 + + + 289 + + + 0.21 + + + 1.81 +
App\Concerns\ProfileValidationRules + + 4 + + + 2 + + + 2 + + + 16.73 + + + 0.73 + + + 16 + + + 0.02 + + + 0.15 +
App\Concerns\PasswordValidationRules + + 2 + + + 1 + + + 1 + + + 2 + + + 1 + + + 1 + + + 0.01 + + + 0.15 +
App\ValueObjects\PaymentResult + + 4 + + + 1 + + + 1 + + + 4.25 + + + 4.25 + + + 0 + + + 0.01 + + + 0.15 +
App\ValueObjects\DiscountResult + + 1 + + + 1 + + + 1 + + + 3 + + + 3 + + + 0 + + + 0 + + + 0.15 +
App\ValueObjects\PricingResult + + 2 + + + 1 + + + 1 + + + 3.5 + + + 2.5 + + + 1 + + + 0.03 + + + 0.15 +
App\ValueObjects\TaxLine + + 2 + + + 1 + + + 1 + + + 2.5 + + + 2.5 + + + 0 + + + 0.01 + + + 0.15 +
App\ValueObjects\RefundResult + + 2 + + + 1 + + + 1 + + + 3 + + + 3 + + + 0 + + + 0.01 + + + 0.15 +
+
+
+
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + diff --git a/report/composer.html b/report/composer.html new file mode 100644 index 00000000..72487354 --- /dev/null +++ b/report/composer.html @@ -0,0 +1,230 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ +
No composer.json file found in this project
+ + + +
+ + + + + + + + + + + + + + + + + + diff --git a/report/coupling.html b/report/coupling.html new file mode 100644 index 00000000..2227a9a7 --- /dev/null +++ b/report/coupling.html @@ -0,0 +1,2963 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + +
+
+
+

Coupling

+ +
+ Afferent coupling (AC) is the number of classes affected by given class. +
Efferent coupling (EC) is the number of classes from which given class receives + effects. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassAfferent couplingEfferent couplingInstabilityClassRank
App\Auth\CustomerUserProvider + + 1 + + + 7 + + + 0.88 + + + 0 +
App\Providers\AppServiceProvider + + 0 + + + 10 + + + 1 + + + 0 +
App\Providers\FortifyServiceProvider + + 0 + + + 5 + + + 1 + + + 0 +
App\Models\OrderLine + + 1 + + + 3 + + + 0.75 + + + 0 +
App\Models\WebhookSubscription + + 3 + + + 3 + + + 0.5 + + + 0.02 +
App\Models\ThemeFile + + 0 + + + 2 + + + 1 + + + 0 +
App\Models\ProductOption + + 0 + + + 3 + + + 1 + + + 0 +
App\Models\NavigationItem + + 2 + + + 2 + + + 0.5 + + + 0.01 +
App\Models\Refund + + 2 + + + 2 + + + 0.5 + + + 0.01 +
App\Models\InventoryItem + + 4 + + + 2 + + + 0.33 + + + 0.01 +
App\Models\NavigationMenu + + 2 + + + 2 + + + 0.5 + + + 0.01 +
App\Models\App + + 1 + + + 2 + + + 0.67 + + + 0 +
App\Models\CartLine + + 1 + + + 2 + + + 0.67 + + + 0 +
App\Models\AppInstallation + + 1 + + + 3 + + + 0.75 + + + 0.01 +
App\Models\Cart + + 6 + + + 2 + + + 0.25 + + + 0.04 +
App\Models\Discount + + 3 + + + 1 + + + 0.25 + + + 0.01 +
App\Models\Product + + 10 + + + 3 + + + 0.23 + + + 0.04 +
App\Models\Order + + 20 + + + 3 + + + 0.13 + + + 0.1 +
App\Models\Store + + 17 + + + 5 + + + 0.23 + + + 0.06 +
App\Models\StoreDomain + + 1 + + + 2 + + + 0.67 + + + 0 +
App\Models\Theme + + 2 + + + 3 + + + 0.6 + + + 0.01 +
App\Models\ProductMedia + + 1 + + + 2 + + + 0.67 + + + 0.01 +
App\Models\User + + 4 + + + 5 + + + 0.56 + + + 0.02 +
App\Models\WebhookDelivery + + 1 + + + 2 + + + 0.67 + + + 0.01 +
App\Models\Fulfillment + + 2 + + + 3 + + + 0.6 + + + 0.02 +
App\Models\ThemeSettings + + 0 + + + 2 + + + 1 + + + 0 +
App\Models\Checkout + + 7 + + + 2 + + + 0.22 + + + 0.02 +
App\Models\Payment + + 4 + + + 3 + + + 0.43 + + + 0.01 +
App\Models\AnalyticsDaily + + 1 + + + 1 + + + 0.5 + + + 0.01 +
App\Models\Customer + + 4 + + + 2 + + + 0.33 + + + 0.01 +
App\Models\ProductVariant + + 1 + + + 4 + + + 0.8 + + + 0 +
App\Models\ProductOptionValue + + 0 + + + 2 + + + 1 + + + 0 +
App\Models\TaxSettings + + 3 + + + 2 + + + 0.4 + + + 0.01 +
App\Models\ShippingZone + + 2 + + + 2 + + + 0.5 + + + 0.01 +
App\Models\Collection + + 5 + + + 2 + + + 0.29 + + + 0.01 +
App\Models\Page + + 3 + + + 1 + + + 0.25 + + + 0.01 +
App\Models\ShippingRate + + 4 + + + 2 + + + 0.33 + + + 0.01 +
App\Models\StoreSettings + + 0 + + + 2 + + + 1 + + + 0 +
App\Models\CustomerAddress + + 1 + + + 2 + + + 0.67 + + + 0.01 +
App\Models\StoreUser + + 0 + + + 2 + + + 1 + + + 0 +
App\Models\FulfillmentLine + + 1 + + + 2 + + + 0.67 + + + 0.01 +
App\Models\AnalyticsEvent + + 2 + + + 1 + + + 0.33 + + + 0.01 +
App\Models\Scopes\StoreScope + + 1 + + + 3 + + + 0.75 + + + 0.01 +
App\Models\Organization + + 0 + + + 2 + + + 1 + + + 0 +
App\Models\Concerns\BelongsToStore + + 0 + + + 3 + + + 1 + + + 0 +
App\Exceptions\InsufficientInventoryException + + 1 + + + 1 + + + 0.5 + + + 0 +
App\Exceptions\FulfillmentGuardException + + 1 + + + 1 + + + 0.5 + + + 0 +
App\Exceptions\InvalidDiscountException + + 1 + + + 1 + + + 0.5 + + + 0.01 +
App\Exceptions\PaymentFailedException + + 1 + + + 1 + + + 0.5 + + + 0 +
App\Policies\StorePolicy + + 0 + + + 2 + + + 1 + + + 0 +
App\Policies\Concerns\ChecksStoreRole + + 0 + + + 2 + + + 1 + + + 0 +
App\Livewire\Settings\TwoFactor + + 0 + + + 6 + + + 1 + + + 0 +
App\Livewire\Settings\DeleteUserForm + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Settings\TwoFactor\RecoveryCodes + + 0 + + + 2 + + + 1 + + + 0 +
App\Livewire\Settings\Password + + 0 + + + 2 + + + 1 + + + 0 +
App\Livewire\Settings\Profile + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Settings\Appearance + + 0 + + + 1 + + + 1 + + + 0 +
App\Livewire\Storefront\Products\Show + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Storefront\Home + + 0 + + + 6 + + + 1 + + + 0 +
App\Livewire\Storefront\Checkout\Show + + 0 + + + 6 + + + 1 + + + 0 +
App\Livewire\Storefront\Checkout\Confirmation + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Storefront\Search\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Storefront\CartDrawer + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Storefront\Cart\Show + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Storefront\Account\Dashboard + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Storefront\Account\Auth\Login + + 0 + + + 5 + + + 1 + + + 0 +
App\Livewire\Storefront\Account\Auth\Register + + 0 + + + 5 + + + 1 + + + 0 +
App\Livewire\Storefront\Account\Addresses\Index + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Storefront\Account\Orders\Index + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Storefront\Account\Orders\Show + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Storefront\Collections\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Storefront\Collections\Show + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Storefront\Pages\Show + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Storefront\Concerns\EnsuresStore + + 0 + + + 1 + + + 1 + + + 0 +
App\Livewire\Admin\Customers\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Customers\Show + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Settings\Taxes + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Settings\Index + + 0 + + + 2 + + + 1 + + + 0 +
App\Livewire\Admin\Settings\Shipping + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Admin\Dashboard + + 0 + + + 5 + + + 1 + + + 0 +
App\Livewire\Admin\Products\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Products\Form + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Admin\Auth\Login + + 0 + + + 6 + + + 1 + + + 0 +
App\Livewire\Admin\Navigation\Index + + 0 + + + 5 + + + 1 + + + 0 +
App\Livewire\Admin\Discounts\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Discounts\Form + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Orders\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Orders\Show + + 0 + + + 6 + + + 1 + + + 0 +
App\Livewire\Admin\Collections\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Collections\Form + + 0 + + + 5 + + + 1 + + + 0 +
App\Livewire\Admin\Pages\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Pages\Form + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Admin\Apps\Index + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Admin\Themes\Index + + 0 + + + 4 + + + 1 + + + 0 +
App\Livewire\Admin\Analytics\Index + + 0 + + + 3 + + + 1 + + + 0 +
App\Livewire\Admin\Developers\Index + + 0 + + + 5 + + + 1 + + + 0 +
App\Livewire\Actions\Logout + + 1 + + + 2 + + + 0.67 + + + 0.01 +
App\Support\HandleGenerator + + 3 + + + 2 + + + 0.4 + + + 0.01 +
App\Support\CartSession + + 4 + + + 3 + + + 0.43 + + + 0.02 +
App\Http\Middleware\ResolveStore + + 0 + + + 9 + + + 1 + + + 0 +
App\Http\Controllers\Controller + + 0 + + + 0 + + + 0 + + + 0 +
App\Actions\Fortify\ResetUserPassword + + 0 + + + 3 + + + 1 + + + 0 +
App\Actions\Fortify\CreateNewUser + + 0 + + + 3 + + + 1 + + + 0 +
App\Jobs\ExpireAbandonedCheckouts + + 0 + + + 3 + + + 1 + + + 0 +
App\Jobs\CleanupAbandonedCarts + + 0 + + + 2 + + + 1 + + + 0 +
App\Jobs\AggregateAnalytics + + 0 + + + 6 + + + 1 + + + 0 +
App\Jobs\CancelUnpaidBankTransferOrders + + 0 + + + 3 + + + 1 + + + 0 +
App\Jobs\ProcessMediaUpload + + 0 + + + 3 + + + 1 + + + 0 +
App\Jobs\DeliverWebhook + + 1 + + + 7 + + + 0.88 + + + 0.01 +
App\Events\OrderRefunded + + 1 + + + 2 + + + 0.67 + + + 0 +
App\Events\OrderCancelled + + 1 + + + 1 + + + 0.5 + + + 0 +
App\Events\OrderCreated + + 2 + + + 1 + + + 0.33 + + + 0.01 +
App\Events\OrderPaid + + 3 + + + 1 + + + 0.25 + + + 0.01 +
App\Events\FulfillmentDelivered + + 1 + + + 1 + + + 0.5 + + + 0 +
App\Events\OrderFulfilled + + 2 + + + 1 + + + 0.33 + + + 0.01 +
App\Observers\ProductObserver + + 0 + + + 2 + + + 1 + + + 0 +
App\Listeners\DispatchOrderWebhooks + + 0 + + + 5 + + + 1 + + + 0 +
App\Services\WebhookService + + 2 + + + 3 + + + 0.6 + + + 0.01 +
App\Services\OrderService + + 3 + + + 13 + + + 0.81 + + + 0.01 +
App\Services\Payments\MockPaymentProvider + + 0 + + + 7 + + + 1 + + + 0 +
App\Services\CheckoutService + + 1 + + + 16 + + + 0.94 + + + 0.01 +
App\Services\FulfillmentService + + 1 + + + 8 + + + 0.89 + + + 0.01 +
App\Services\TaxCalculator + + 1 + + + 2 + + + 0.67 + + + 0 +
App\Services\ThemeSettingsService + + 0 + + + 3 + + + 1 + + + 0 +
App\Services\InventoryService + + 3 + + + 4 + + + 0.57 + + + 0.01 +
App\Services\NavigationService + + 0 + + + 3 + + + 1 + + + 0 +
App\Services\RefundService + + 1 + + + 9 + + + 0.9 + + + 0 +
App\Services\ProductService + + 1 + + + 7 + + + 0.88 + + + 0.01 +
App\Services\ShippingCalculator + + 1 + + + 5 + + + 0.83 + + + 0 +
App\Services\AnalyticsService + + 1 + + + 4 + + + 0.8 + + + 0.01 +
App\Services\CartService + + 0 + + + 7 + + + 1 + + + 0 +
App\Services\PricingEngine + + 1 + + + 9 + + + 0.9 + + + 0 +
App\Services\DiscountService + + 1 + + + 6 + + + 0.86 + + + 0 +
App\Services\SearchService + + 1 + + + 5 + + + 0.83 + + + 0.01 +
App\Services\VariantMatrixService + + 1 + + + 2 + + + 0.67 + + + 0 +
App\Concerns\ProfileValidationRules + + 0 + + + 1 + + + 1 + + + 0 +
App\Concerns\PasswordValidationRules + + 0 + + + 1 + + + 1 + + + 0 +
App\ValueObjects\PaymentResult + + 2 + + + 1 + + + 0.33 + + + 0.01 +
App\ValueObjects\DiscountResult + + 1 + + + 0 + + + 0 + + + 0.01 +
App\ValueObjects\PricingResult + + 1 + + + 0 + + + 0 + + + 0.01 +
App\ValueObjects\TaxLine + + 2 + + + 0 + + + 0 + + + 0.01 +
App\ValueObjects\RefundResult + + 2 + + + 1 + + + 0.33 + + + 0.01 +
+ +
+
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/report/css/clusterize.css b/report/css/clusterize.css new file mode 100644 index 00000000..5db98df4 --- /dev/null +++ b/report/css/clusterize.css @@ -0,0 +1,37 @@ +/* max-height - the only parameter in this file that needs to be edited. + * Change it to suit your needs. The rest is recommended to leave as is. + */ +.clusterize-scroll{ + max-height: 200px; + overflow: auto; +} + +/** + * Avoid vertical margins for extra tags + * Necessary for correct calculations when rows have nonzero vertical margins + */ +.clusterize-extra-row{ + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +/* By default extra tag .clusterize-keep-parity added to keep parity of rows. + * Useful when used :nth-child(even/odd) + */ +.clusterize-extra-row.clusterize-keep-parity{ + display: none; +} + +/* During initialization clusterize adds tabindex to force the browser to keep focus + * on the scrolling list, see issue #11 + * Outline removes default browser's borders for focused elements. + */ +.clusterize-content{ + outline: 0; +} + +/* Centering message that appears when no data provided + */ +.clusterize-no-data td{ + text-align: center; +} \ No newline at end of file diff --git a/report/css/material-icons.css b/report/css/material-icons.css new file mode 100644 index 00000000..bf3707e5 --- /dev/null +++ b/report/css/material-icons.css @@ -0,0 +1,20 @@ +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: url(fonts/material-icons.ttf) format('truetype'); +} + +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; +} diff --git a/report/css/milligram.min.css b/report/css/milligram.min.css new file mode 100644 index 00000000..c9d72065 --- /dev/null +++ b/report/css/milligram.min.css @@ -0,0 +1,12 @@ +/*! + * Milligram v1.1.0 + * http://milligram.github.io + * + * Copyright (c) 2016 CJ Patoilo + * Licensed under the MIT license +*/ + + +html{box-sizing:border-box;font-size:62.5%}body{color:#606c76;font-family:"Roboto","Helvetica Neue","Helvetica","Arial",sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}*,*:after,*:before{box-sizing:inherit}blockquote{border-left:.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#9b4dca;border:.1rem solid #9b4dca;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:hover,.button:focus,button:hover,button:focus,input[type='button']:hover,input[type='button']:focus,input[type='reset']:hover,input[type='reset']:focus,input[type='submit']:hover,input[type='submit']:focus{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button.button-disabled,.button[disabled],button.button-disabled,button[disabled],input[type='button'].button-disabled,input[type='button'][disabled],input[type='reset'].button-disabled,input[type='reset'][disabled],input[type='submit'].button-disabled,input[type='submit'][disabled]{opacity:.5;cursor:default}.button.button-disabled:hover,.button.button-disabled:focus,.button[disabled]:hover,.button[disabled]:focus,button.button-disabled:hover,button.button-disabled:focus,button[disabled]:hover,button[disabled]:focus,input[type='button'].button-disabled:hover,input[type='button'].button-disabled:focus,input[type='button'][disabled]:hover,input[type='button'][disabled]:focus,input[type='reset'].button-disabled:hover,input[type='reset'].button-disabled:focus,input[type='reset'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='submit'].button-disabled:hover,input[type='submit'].button-disabled:focus,input[type='submit'][disabled]:hover,input[type='submit'][disabled]:focus{background-color:#9b4dca;border-color:#9b4dca}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{color:#9b4dca;background-color:transparent}.button.button-outline:hover,.button.button-outline:focus,button.button-outline:hover,button.button-outline:focus,input[type='button'].button-outline:hover,input[type='button'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='submit'].button-outline:hover,input[type='submit'].button-outline:focus{color:#606c76;background-color:transparent;border-color:#606c76}.button.button-outline.button-disabled:hover,.button.button-outline.button-disabled:focus,.button.button-outline[disabled]:hover,.button.button-outline[disabled]:focus,button.button-outline.button-disabled:hover,button.button-outline.button-disabled:focus,button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,input[type='button'].button-outline.button-disabled:hover,input[type='button'].button-outline.button-disabled:focus,input[type='button'].button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='reset'].button-outline.button-disabled:hover,input[type='reset'].button-outline.button-disabled:focus,input[type='reset'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='submit'].button-outline.button-disabled:hover,input[type='submit'].button-outline.button-disabled:focus,input[type='submit'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus{color:#9b4dca;border-color:inherit}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{color:#9b4dca;background-color:transparent;border-color:transparent}.button.button-clear:hover,.button.button-clear:focus,button.button-clear:hover,button.button-clear:focus,input[type='button'].button-clear:hover,input[type='button'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='submit'].button-clear:hover,input[type='submit'].button-clear:focus{color:#606c76;background-color:transparent;border-color:transparent}.button.button-clear.button-disabled:hover,.button.button-clear.button-disabled:focus,.button.button-clear[disabled]:hover,.button.button-clear[disabled]:focus,button.button-clear.button-disabled:hover,button.button-clear.button-disabled:focus,button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,input[type='button'].button-clear.button-disabled:hover,input[type='button'].button-clear.button-disabled:focus,input[type='button'].button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='reset'].button-clear.button-disabled:hover,input[type='reset'].button-clear.button-disabled:focus,input[type='reset'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='submit'].button-clear.button-disabled:hover,input[type='submit'].button-clear.button-disabled:focus,input[type='submit'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus{color:#9b4dca}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;padding:.2rem .5rem;margin:0 .2rem;white-space:nowrap}pre{background:#f4f5f6;border-left:.3rem solid #9b4dca;font-family:"Menlo","Consolas","Bitstream Vera Sans Mono","DejaVu Sans Mono","Monaco",monospace}pre>code{background:transparent;border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:.1rem solid #f4f5f6;margin-bottom:3.5rem;margin-top:3rem}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;height:3.8rem;padding:.6rem 1rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border:.1rem solid #9b4dca;outline:0}select{padding:.6rem 3rem .6rem 1rem;background:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiICAgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjkgMTQiICAgaGVpZ2h0PSIxNHB4IiAgIGlkPSJMYXllcl8xIiAgIHZlcnNpb249IjEuMSIgICB2aWV3Qm94PSIwIDAgMjkgMTQiICAgd2lkdGg9IjI5cHgiICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgICBpbmtzY2FwZTp2ZXJzaW9uPSIwLjQ4LjQgcjk5MzkiICAgc29kaXBvZGk6ZG9jbmFtZT0iY2FyZXQtZ3JheS5zdmciPjxtZXRhZGF0YSAgICAgaWQ9Im1ldGFkYXRhMzAzOSI+PHJkZjpSREY+PGNjOldvcmsgICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUgICAgICAgICAgIHJkZjpyZXNvdXJjZT0iaHR0cDovL3B1cmwub3JnL2RjL2RjbWl0eXBlL1N0aWxsSW1hZ2UiIC8+PC9jYzpXb3JrPjwvcmRmOlJERj48L21ldGFkYXRhPjxkZWZzICAgICBpZD0iZGVmczMwMzciIC8+PHNvZGlwb2RpOm5hbWVkdmlldyAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiICAgICBib3JkZXJvcGFjaXR5PSIxIiAgICAgb2JqZWN0dG9sZXJhbmNlPSIxMCIgICAgIGdyaWR0b2xlcmFuY2U9IjEwIiAgICAgZ3VpZGV0b2xlcmFuY2U9IjEwIiAgICAgaW5rc2NhcGU6cGFnZW9wYWNpdHk9IjAiICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSI5MDMiICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSI1OTQiICAgICBpZD0ibmFtZWR2aWV3MzAzNSIgICAgIHNob3dncmlkPSJ0cnVlIiAgICAgaW5rc2NhcGU6em9vbT0iMTIuMTM3OTMxIiAgICAgaW5rc2NhcGU6Y3g9Ii00LjExOTMxODJlLTA4IiAgICAgaW5rc2NhcGU6Y3k9IjciICAgICBpbmtzY2FwZTp3aW5kb3cteD0iNTAyIiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjMwMiIgICAgIGlua3NjYXBlOndpbmRvdy1tYXhpbWl6ZWQ9IjAiICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJMYXllcl8xIj48aW5rc2NhcGU6Z3JpZCAgICAgICB0eXBlPSJ4eWdyaWQiICAgICAgIGlkPSJncmlkMzA0MSIgLz48L3NvZGlwb2RpOm5hbWVkdmlldz48cG9seWdvbiAgICAgcG9pbnRzPSIwLjE1LDAgMTQuNSwxNC4zNSAyOC44NSwwICIgICAgIGlkPSJwb2x5Z29uMzAzMyIgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuMzU0MTEzODcsMCwwLDAuNDgzMjkxMSw5LjMyNDE1NDUsMy42MjQ5OTkyKSIgICAgIHN0eWxlPSJmaWxsOiNkMWQxZDE7ZmlsbC1vcGFjaXR5OjEiIC8+PC9zdmc+) center right no-repeat}select:focus{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiICAgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjkgMTQiICAgaGVpZ2h0PSIxNHB4IiAgIGlkPSJMYXllcl8xIiAgIHZlcnNpb249IjEuMSIgICB2aWV3Qm94PSIwIDAgMjkgMTQiICAgd2lkdGg9IjI5cHgiICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgICBpbmtzY2FwZTp2ZXJzaW9uPSIwLjQ4LjQgcjk5MzkiICAgc29kaXBvZGk6ZG9jbmFtZT0iY2FyZXQuc3ZnIj48bWV0YWRhdGEgICAgIGlkPSJtZXRhZGF0YTMwMzkiPjxyZGY6UkRGPjxjYzpXb3JrICAgICAgICAgcmRmOmFib3V0PSIiPjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PjxkYzp0eXBlICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcyAgICAgaWQ9ImRlZnMzMDM3IiAvPjxzb2RpcG9kaTpuYW1lZHZpZXcgICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIgICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IiAgICAgYm9yZGVyb3BhY2l0eT0iMSIgICAgIG9iamVjdHRvbGVyYW5jZT0iMTAiICAgICBncmlkdG9sZXJhbmNlPSIxMCIgICAgIGd1aWRldG9sZXJhbmNlPSIxMCIgICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIgICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iOTAzIiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iNTk0IiAgICAgaWQ9Im5hbWVkdmlldzMwMzUiICAgICBzaG93Z3JpZD0idHJ1ZSIgICAgIGlua3NjYXBlOnpvb209IjEyLjEzNzkzMSIgICAgIGlua3NjYXBlOmN4PSItNC4xMTkzMTgyZS0wOCIgICAgIGlua3NjYXBlOmN5PSI3IiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjUwMiIgICAgIGlua3NjYXBlOndpbmRvdy15PSIzMDIiICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIwIiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0iTGF5ZXJfMSI+PGlua3NjYXBlOmdyaWQgICAgICAgdHlwZT0ieHlncmlkIiAgICAgICBpZD0iZ3JpZDMwNDEiIC8+PC9zb2RpcG9kaTpuYW1lZHZpZXc+PHBvbHlnb24gICAgIHBvaW50cz0iMjguODUsMCAwLjE1LDAgMTQuNSwxNC4zNSAiICAgICBpZD0icG9seWdvbjMwMzMiICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjM1NDExMzg3LDAsMCwwLjQ4MzI5MTEsOS4zMjQxNTUzLDMuNjI1KSIgICAgIHN0eWxlPSJmaWxsOiM5YjRkY2Y7ZmlsbC1vcGFjaXR5OjEiIC8+PC9zdmc+)}textarea{padding-bottom:.6rem;padding-top:.6rem;min-height:6.5rem}label,legend{font-size:1.6rem;font-weight:700;display:block;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{font-weight:normal;display:inline-block;margin-left:.5rem}.container{margin:0 auto;max-width:112rem;padding:0 2rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row .row-wrap{flex-wrap:wrap}.row .row-no-padding{padding:0}.row .row-no-padding>.column{padding:0}.row .row-top{align-items:flex-start}.row .row-bottom{align-items:flex-end}.row .row-center{align-items:center}.row .row-stretch{align-items:stretch}.row .row-baseline{align-items:baseline}.row .column{display:block;flex:1;margin-left:0;max-width:100%;width:100%}.row .column .col-top{align-self:flex-start}.row .column .col-bottom{align-self:flex-end}.row .column .col-center{align-self:center}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1rem}}a{color:#9b4dca;text-decoration:none}a:hover{color:#606c76}dl,ol,ul{margin-top:0;padding-left:0}dl ul,dl ol,ol ul,ol ol,ul ul,ul ol{font-size:90%;margin:1.5rem 0 1.5rem 3rem}dl{list-style:none}ul{list-style:circle inside}ol{list-style:decimal inside}dt,dd,li{margin-bottom:1rem}.button,button{margin-bottom:1rem}input,textarea,select,fieldset{margin-bottom:1.5rem}pre,blockquote,dl,figure,table,p,ul,ol,form{margin-bottom:2.5rem}table{width:100%}th,td{border-bottom:.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}th:first-child,td:first-child{padding-left:0}th:last-child,td:last-child{padding-right:0}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;margin-bottom:2rem;margin-top:0}h1{font-size:4rem;letter-spacing:-0.1rem;line-height:1.2}h2{font-size:3.6rem;letter-spacing:-0.1rem;line-height:1.25}h3{font-size:3rem;letter-spacing:-0.1rem;line-height:1.3}h4{font-size:2.4rem;letter-spacing:-0.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-0.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}@media (min-width: 40rem){h1{font-size:5rem}h2{font-size:4.2rem}h3{font-size:3.6rem}h4{font-size:3rem}h5{font-size:2.4rem}h6{font-size:1.5rem}}.float-right{float:right}.float-left{float:left}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{content:"";display:table}.clearfix:after{clear:both} + +/*# sourceMappingURL=milligram.min.css.map */ \ No newline at end of file diff --git a/report/css/milligram.min.css.map b/report/css/milligram.min.css.map new file mode 100644 index 00000000..4a28342a --- /dev/null +++ b/report/css/milligram.min.css.map @@ -0,0 +1,12 @@ +{ + "version": 3, + "sources": [ + "milligram.min.css" + ], + "names": [], + "mappings": "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", + "file": "milligram.min.css", + "sourcesContent": [ + "/*!\n * Milligram v1.1.0\n * http://milligram.github.io\n *\n * Copyright (c) 2016 CJ Patoilo\n * Licensed under the MIT license\n*/\n\n\nhtml{box-sizing:border-box;font-size:62.5%}body{color:#606c76;font-family:\"Roboto\",\"Helvetica Neue\",\"Helvetica\",\"Arial\",sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}*,*:after,*:before{box-sizing:inherit}blockquote{border-left:.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#9b4dca;border:.1rem solid #9b4dca;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:hover,.button:focus,button:hover,button:focus,input[type='button']:hover,input[type='button']:focus,input[type='reset']:hover,input[type='reset']:focus,input[type='submit']:hover,input[type='submit']:focus{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button.button-disabled,.button[disabled],button.button-disabled,button[disabled],input[type='button'].button-disabled,input[type='button'][disabled],input[type='reset'].button-disabled,input[type='reset'][disabled],input[type='submit'].button-disabled,input[type='submit'][disabled]{opacity:.5;cursor:default}.button.button-disabled:hover,.button.button-disabled:focus,.button[disabled]:hover,.button[disabled]:focus,button.button-disabled:hover,button.button-disabled:focus,button[disabled]:hover,button[disabled]:focus,input[type='button'].button-disabled:hover,input[type='button'].button-disabled:focus,input[type='button'][disabled]:hover,input[type='button'][disabled]:focus,input[type='reset'].button-disabled:hover,input[type='reset'].button-disabled:focus,input[type='reset'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='submit'].button-disabled:hover,input[type='submit'].button-disabled:focus,input[type='submit'][disabled]:hover,input[type='submit'][disabled]:focus{background-color:#9b4dca;border-color:#9b4dca}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{color:#9b4dca;background-color:transparent}.button.button-outline:hover,.button.button-outline:focus,button.button-outline:hover,button.button-outline:focus,input[type='button'].button-outline:hover,input[type='button'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='submit'].button-outline:hover,input[type='submit'].button-outline:focus{color:#606c76;background-color:transparent;border-color:#606c76}.button.button-outline.button-disabled:hover,.button.button-outline.button-disabled:focus,.button.button-outline[disabled]:hover,.button.button-outline[disabled]:focus,button.button-outline.button-disabled:hover,button.button-outline.button-disabled:focus,button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,input[type='button'].button-outline.button-disabled:hover,input[type='button'].button-outline.button-disabled:focus,input[type='button'].button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='reset'].button-outline.button-disabled:hover,input[type='reset'].button-outline.button-disabled:focus,input[type='reset'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='submit'].button-outline.button-disabled:hover,input[type='submit'].button-outline.button-disabled:focus,input[type='submit'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus{color:#9b4dca;border-color:inherit}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{color:#9b4dca;background-color:transparent;border-color:transparent}.button.button-clear:hover,.button.button-clear:focus,button.button-clear:hover,button.button-clear:focus,input[type='button'].button-clear:hover,input[type='button'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='submit'].button-clear:hover,input[type='submit'].button-clear:focus{color:#606c76;background-color:transparent;border-color:transparent}.button.button-clear.button-disabled:hover,.button.button-clear.button-disabled:focus,.button.button-clear[disabled]:hover,.button.button-clear[disabled]:focus,button.button-clear.button-disabled:hover,button.button-clear.button-disabled:focus,button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,input[type='button'].button-clear.button-disabled:hover,input[type='button'].button-clear.button-disabled:focus,input[type='button'].button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='reset'].button-clear.button-disabled:hover,input[type='reset'].button-clear.button-disabled:focus,input[type='reset'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='submit'].button-clear.button-disabled:hover,input[type='submit'].button-clear.button-disabled:focus,input[type='submit'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus{color:#9b4dca}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;padding:.2rem .5rem;margin:0 .2rem;white-space:nowrap}pre{background:#f4f5f6;border-left:.3rem solid #9b4dca;font-family:\"Menlo\",\"Consolas\",\"Bitstream Vera Sans Mono\",\"DejaVu Sans Mono\",\"Monaco\",monospace}pre>code{background:transparent;border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:.1rem solid #f4f5f6;margin-bottom:3.5rem;margin-top:3rem}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{appearance:none;background-color:transparent;border:.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;height:3.8rem;padding:.6rem 1rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border:.1rem solid #9b4dca;outline:0}select{padding:.6rem 3rem .6rem 1rem;background:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiICAgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjkgMTQiICAgaGVpZ2h0PSIxNHB4IiAgIGlkPSJMYXllcl8xIiAgIHZlcnNpb249IjEuMSIgICB2aWV3Qm94PSIwIDAgMjkgMTQiICAgd2lkdGg9IjI5cHgiICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgICBpbmtzY2FwZTp2ZXJzaW9uPSIwLjQ4LjQgcjk5MzkiICAgc29kaXBvZGk6ZG9jbmFtZT0iY2FyZXQtZ3JheS5zdmciPjxtZXRhZGF0YSAgICAgaWQ9Im1ldGFkYXRhMzAzOSI+PHJkZjpSREY+PGNjOldvcmsgICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUgICAgICAgICAgIHJkZjpyZXNvdXJjZT0iaHR0cDovL3B1cmwub3JnL2RjL2RjbWl0eXBlL1N0aWxsSW1hZ2UiIC8+PC9jYzpXb3JrPjwvcmRmOlJERj48L21ldGFkYXRhPjxkZWZzICAgICBpZD0iZGVmczMwMzciIC8+PHNvZGlwb2RpOm5hbWVkdmlldyAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiICAgICBib3JkZXJvcGFjaXR5PSIxIiAgICAgb2JqZWN0dG9sZXJhbmNlPSIxMCIgICAgIGdyaWR0b2xlcmFuY2U9IjEwIiAgICAgZ3VpZGV0b2xlcmFuY2U9IjEwIiAgICAgaW5rc2NhcGU6cGFnZW9wYWNpdHk9IjAiICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSI5MDMiICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSI1OTQiICAgICBpZD0ibmFtZWR2aWV3MzAzNSIgICAgIHNob3dncmlkPSJ0cnVlIiAgICAgaW5rc2NhcGU6em9vbT0iMTIuMTM3OTMxIiAgICAgaW5rc2NhcGU6Y3g9Ii00LjExOTMxODJlLTA4IiAgICAgaW5rc2NhcGU6Y3k9IjciICAgICBpbmtzY2FwZTp3aW5kb3cteD0iNTAyIiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjMwMiIgICAgIGlua3NjYXBlOndpbmRvdy1tYXhpbWl6ZWQ9IjAiICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJMYXllcl8xIj48aW5rc2NhcGU6Z3JpZCAgICAgICB0eXBlPSJ4eWdyaWQiICAgICAgIGlkPSJncmlkMzA0MSIgLz48L3NvZGlwb2RpOm5hbWVkdmlldz48cG9seWdvbiAgICAgcG9pbnRzPSIwLjE1LDAgMTQuNSwxNC4zNSAyOC44NSwwICIgICAgIGlkPSJwb2x5Z29uMzAzMyIgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuMzU0MTEzODcsMCwwLDAuNDgzMjkxMSw5LjMyNDE1NDUsMy42MjQ5OTkyKSIgICAgIHN0eWxlPSJmaWxsOiNkMWQxZDE7ZmlsbC1vcGFjaXR5OjEiIC8+PC9zdmc+) center right no-repeat}select:focus{background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiICAgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMjkgMTQiICAgaGVpZ2h0PSIxNHB4IiAgIGlkPSJMYXllcl8xIiAgIHZlcnNpb249IjEuMSIgICB2aWV3Qm94PSIwIDAgMjkgMTQiICAgd2lkdGg9IjI5cHgiICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgICBpbmtzY2FwZTp2ZXJzaW9uPSIwLjQ4LjQgcjk5MzkiICAgc29kaXBvZGk6ZG9jbmFtZT0iY2FyZXQuc3ZnIj48bWV0YWRhdGEgICAgIGlkPSJtZXRhZGF0YTMwMzkiPjxyZGY6UkRGPjxjYzpXb3JrICAgICAgICAgcmRmOmFib3V0PSIiPjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PjxkYzp0eXBlICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcyAgICAgaWQ9ImRlZnMzMDM3IiAvPjxzb2RpcG9kaTpuYW1lZHZpZXcgICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIgICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IiAgICAgYm9yZGVyb3BhY2l0eT0iMSIgICAgIG9iamVjdHRvbGVyYW5jZT0iMTAiICAgICBncmlkdG9sZXJhbmNlPSIxMCIgICAgIGd1aWRldG9sZXJhbmNlPSIxMCIgICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIgICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iOTAzIiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iNTk0IiAgICAgaWQ9Im5hbWVkdmlldzMwMzUiICAgICBzaG93Z3JpZD0idHJ1ZSIgICAgIGlua3NjYXBlOnpvb209IjEyLjEzNzkzMSIgICAgIGlua3NjYXBlOmN4PSItNC4xMTkzMTgyZS0wOCIgICAgIGlua3NjYXBlOmN5PSI3IiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjUwMiIgICAgIGlua3NjYXBlOndpbmRvdy15PSIzMDIiICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIwIiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0iTGF5ZXJfMSI+PGlua3NjYXBlOmdyaWQgICAgICAgdHlwZT0ieHlncmlkIiAgICAgICBpZD0iZ3JpZDMwNDEiIC8+PC9zb2RpcG9kaTpuYW1lZHZpZXc+PHBvbHlnb24gICAgIHBvaW50cz0iMjguODUsMCAwLjE1LDAgMTQuNSwxNC4zNSAiICAgICBpZD0icG9seWdvbjMwMzMiICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjM1NDExMzg3LDAsMCwwLjQ4MzI5MTEsOS4zMjQxNTUzLDMuNjI1KSIgICAgIHN0eWxlPSJmaWxsOiM5YjRkY2Y7ZmlsbC1vcGFjaXR5OjEiIC8+PC9zdmc+)}textarea{padding-bottom:.6rem;padding-top:.6rem;min-height:6.5rem}label,legend{font-size:1.6rem;font-weight:700;display:block;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{font-weight:normal;display:inline-block;margin-left:.5rem}.container{margin:0 auto;max-width:112rem;padding:0 2rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row .row-wrap{flex-wrap:wrap}.row .row-no-padding{padding:0}.row .row-no-padding>.column{padding:0}.row .row-top{align-items:flex-start}.row .row-bottom{align-items:flex-end}.row .row-center{align-items:center}.row .row-stretch{align-items:stretch}.row .row-baseline{align-items:baseline}.row .column{display:block;flex:1;margin-left:0;max-width:100%;width:100%}.row .column .col-top{align-self:flex-start}.row .column .col-bottom{align-self:flex-end}.row .column .col-center{align-self:center}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1rem}}a{color:#9b4dca;text-decoration:none}a:hover{color:#606c76}dl,ol,ul{margin-top:0;padding-left:0}dl ul,dl ol,ol ul,ol ol,ul ul,ul ol{font-size:90%;margin:1.5rem 0 1.5rem 3rem}dl{list-style:none}ul{list-style:circle inside}ol{list-style:decimal inside}dt,dd,li{margin-bottom:1rem}.button,button{margin-bottom:1rem}input,textarea,select,fieldset{margin-bottom:1.5rem}pre,blockquote,dl,figure,table,p,ul,ol,form{margin-bottom:2.5rem}table{width:100%}th,td{border-bottom:.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}th:first-child,td:first-child{padding-left:0}th:last-child,td:last-child{padding-right:0}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;margin-bottom:2rem;margin-top:0}h1{font-size:4rem;letter-spacing:-0.1rem;line-height:1.2}h2{font-size:3.6rem;letter-spacing:-0.1rem;line-height:1.25}h3{font-size:3rem;letter-spacing:-0.1rem;line-height:1.3}h4{font-size:2.4rem;letter-spacing:-0.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-0.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}@media (min-width: 40rem){h1{font-size:5rem}h2{font-size:4.2rem}h3{font-size:3.6rem}h4{font-size:3rem}h5{font-size:2.4rem}h6{font-size:1.5rem}}.float-right{float:right}.float-left{float:left}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{content:\"\";display:table}.clearfix:after{clear:both}\n" + ] +} \ No newline at end of file diff --git a/report/css/normalize.css b/report/css/normalize.css new file mode 100644 index 00000000..5e5e3c89 --- /dev/null +++ b/report/css/normalize.css @@ -0,0 +1,424 @@ +/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS and IE text size adjust after device orientation change, + * without disabling user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined for any HTML5 element in IE 8/9. + * Correct `block` display not defined for `details` or `summary` in IE 10/11 + * and Firefox. + * Correct `block` display not defined for `main` in IE 11. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} + +/** + * 1. Correct `inline-block` display not defined in IE 8/9. + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. + */ + +audio, +canvas, +progress, +video { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9/10. + * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22. + */ + +[hidden], +template { + display: none; +} + +/* Links + ========================================================================== */ + +/** + * Remove the gray background color from active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * Improve readability of focused elements when they are also in an + * active/hover state. + */ + +a:active, +a:hover { + outline: 0; +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9/10. + */ + +img { + border: 0; +} + +/** + * Correct overflow not hidden in IE 9/10/11. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari. + */ + +figure { + margin: 1em 40px; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + box-sizing: content-box; + height: 0; +} + +/** + * Contain overflow in all browsers. + */ + +pre { + overflow: auto; +} + +/** + * Address odd `em`-unit font size rendering in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +/* Forms + ========================================================================== */ + +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ + +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ + +button, +input, +optgroup, +select, +textarea { + color: inherit; /* 1 */ + font: inherit; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ + +button { + overflow: visible; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +input { + line-height: normal; +} + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Fix the cursor style for Chrome's increment/decrement buttons. For certain + * `font-size` values of the `input`, it causes the cursor style of the + * decrement button to change from `default` to `text`. + */ + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome. + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + box-sizing: content-box; /* 2 */ +} + +/** + * Remove inner padding and search cancel button in Safari and Chrome on OS X. + * Safari (but not Chrome) clips the cancel button when the search input has + * padding (and `textfield` appearance). + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ + +textarea { + overflow: auto; +} + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ + +optgroup { + font-weight: bold; +} + +/* Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} + +td, +th { + padding: 0; +} diff --git a/report/css/roboto.css b/report/css/roboto.css new file mode 100644 index 00000000..7ab7186d --- /dev/null +++ b/report/css/roboto.css @@ -0,0 +1,12 @@ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/roboto-light.ttf) format('truetype'); +} +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + src: local('Roboto Bold'), local('Roboto-Bold'), url(../fonts/roboto-bold.ttf) format('truetype'); +} diff --git a/report/css/style.css b/report/css/style.css new file mode 100644 index 00000000..442d553b --- /dev/null +++ b/report/css/style.css @@ -0,0 +1,705 @@ +/* ------------- layout -------- */ +body { + background: #EAEAEA; + padding-top: 80px; + font-family: "Roboto", "Helvetica Neue", "Helvetica", "Arial", sans-serif; +} + +.row { + margin-bottom: .5em; + align-items: stretch; +} + +.headerbar { + background-color: #fff; + border-bottom:3px solid #E4E4E4; + height:80px; + line-height:80px; + position: fixed; + top:0; + width: 100%; + z-index: 1; +} +.headerbarInner { + padding: 0 1em; +} +.headerbar img { + display:inline-block;; + vertical-align: middle; + height:60px; +} +.headerbar .title { + display:inline-block;; + font-size: 1.2em; + font-weight: bold; +} +.headerbar .subtitle { + display:inline-block;; + font-size: 1.2em; +} + + + +/* ----------- text ---------- */ +a { + color: #4CAF50; + cursor: pointer; +} + + +/* ------------- menu -------- */ +.navigation { + left: 0; + max-width: 100vw; + max-width: 100%; + right: 0; + top: 0; + z-index: 99; + margin-bottom: 1em; +} + +/* Re-overiding the width 100% declaration to match size of% based container */ +.navigation .container { + padding-bottom: 0; + padding-top: 0; +} + +.navigation { + background: #f4f5f6; + border-bottom: .1rem solid #d1d1d1; + display: block; + height: 5.2rem; + width: 100%; +} +.navigation-list { + list-style: none; + margin-bottom: 0; + padding-right: 1.5em; +} + +@media (min-width: 80.0rem) { + .navigation-list { + margin-right: 0; + } +} +@media (max-width: 600px) { + .navigation-list { + display:none + } +} + + +.navigation-item { + float: left; + margin-bottom: 0; + margin-left: 2.5rem; + position: relative; +} + +.navigation .img { + height: 2.0rem; + position: relative; + top: .3rem; +} + +.navigation .title, +.navigation-title a { + color: #606c76; + display: inline; + font-family: 'Gotham Rounded A', 'Gotham Rounded B', 'Helvetica Neue', Arial, sans-serif; + font-size: 1.6rem; + line-height: 5.2rem; + padding: 0; + position: relative; + text-decoration: none; +} + +.navigation-link { + display: inline; + font-size: 1.6rem; + line-height: 5.2rem; + padding: 0; + text-decoration: none; +} + +.navigation-link.active { + color: #606c76; +} + +/* Github */ +.github { + border: 0; + color: #f4f5f6; + fill: #4CAF50; + height: 5.2rem; + position: fixed; + right: 0; + top: 0; + width: 5.2rem; + z-index: 99; +} + +.github:hover .octo-arm { + -webkit-animation: octocat-wave 560ms infinite; + animation: octocat-wave 560ms infinite; +} + +@-webkit-keyframes octocat-wave { + 0%, 50% { + -webkit-transform: rotate(0); + transform: rotate(0); + } + 25%, 75% { + -webkit-transform: rotate(-25deg); + transform: rotate(-25deg); + } +} + +@keyframes octocat-wave { + 0%, 50% { + -webkit-transform: rotate(0); + transform: rotate(0); + } + 25%, 75% { + -webkit-transform: rotate(-25deg); + transform: rotate(-25deg); + } +} + +/* ---------- sidebar ------------- */ +.page { + margin-left: 300px; +} +.page .content { + padding: 2em 2em; + position:relative; +} +.content-first { + margin-top: 80px; +} +.content-full { + padding: 0; +} +.report-details { + position: absolute; + top:0; + right: 3em; + color: #666; + font-size: 0.8em; +} +.report-details a { + color: #666; + text-decoration: underline; +} +@media (max-width: 600px) { + .report-details { + display: none; + } +} + + +#sidebar { + position: fixed; + top: 80px; + left: 0; + background: #4CAF50; + width: 300px; + height: 92%; + overflow: auto; + color: #FFF; + text-align: left; +} +@media (max-width: 600px) { + #sidebar { + display: none; + } + .page { + margin-left: 0; + } +} + +#sidebar .content { + padding: 1em; +} + +#sidebar .logo { + text-align: center; + margin-bottom: 1em; +} + +#sidebar .logo img { + width: 150px; +} + +#sidebar .links ul { + list-style: none; +} + +#sidebar .links li { +} + +#sidebar .links a { + display: block; + color: #FFF; + line-height: 24px; + padding: 10px; +} +#sidebar .links .sep { + margin-top:1em; + padding-top: 1em; + border-top:1px solid #81C784; +} + +#sidebar .links svg, #sidebar .links img { + vertical-align: top; + margin-right: .5em; +} + +#sidebar .links a:hover { + background-color: #81C784; +} + +/* ------------- fullwidth -------- */ +.fullwidth #content { + margin-left: 0; +} +.fullwidth #content, .fullwidth .container, .fullwidth .row, .fullwidth .column { + width: auto; + max-width:none; + flex:auto; + float:none; + +} + + +/* ------------- text -------- */ +.badge { + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + background-color: #EEE; + color: #333; + display: inline-block; + padding: 1px 5px; + margin: 4px auto; + font-size: 0.8em; +} + +.badge-score { + background-color: #4CAF50; + color: #FFF; +} +.progress { + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + background-color: #EEE; + color: #333; + display: inline-block; + padding: 1px 5px; + font-size: 0.8em; + position: absolute; + right: 10px; + top: 10px; +} +.progress svg { + vertical-align:middle;; +} +.progress-good { + background-color: #4CAF50; + color: #FFF; +} +.progress-bad { + background-color: #F44336; + color: #FFF; +} +.path { + font-family: "Menlo", "Consolas", "Bitstream Vera Sans Mono", "DejaVu Sans Mono", "Monaco", monospace; + color: #2f855a; + background-color: #f0fff4; + display: inline-block; + padding: 1px 4px; +} +a[target="_blank"]::before { + content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==); + margin: 0 5px 0 3px; +} + + +/* ------------ Bloc number ------ */ +.bloc { + position: relative; + text-align: center; + background: #FFF; + padding: 15px; + border: 0; + box-shadow: 0 2px 7px 0 rgba(42, 51, 83, 0.12), 0 5px 15px rgba(0, 0, 0, 0.06); + transition: all .15s ease; + border-radius: .5rem; + border-top: 4px solid #48bb78 +} + +.bloc .number { + font-size: 2.1em; + font-weight: bold; + color: #333; + text-align: left; +} +.bloc .number, .bloc .number-alternate { + min-height: 55px; +} +.bloc .chart-in-number { + margin-top:1em; +} +.bloc .bloc-action { + background-color: #f3f7fa; + text-align: center; + padding:10px 0; + margin:0 -15px -15px -15px; + -webkit-border-bottom-right-radius: 3px; + -webkit-border-bottom-left-radius: 3px; + -moz-border-radius-bottomright: 3px; + -moz-border-radius-bottomleft: 3px; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; + font-size: 0.9em; + color: #95999c; +} +.bloc .bloc-action a { + color: #48566c; + text-decoration: none; +} +.bloc .bloc-action a:hover { + color: #000; +} +.bloc .label { + color: #333; + text-align: left; + margin-bottom:.5em; + font-weight: 700; +} + +.bloc-number { + min-height: 140px; +} +.bloc h4 { + text-align: left; +} +.column.with-help { + padding-right:0; + padding-bottom:0; +} +.column-help .column-help-inner { + background-color: #fff; + height:100%; +} +.help { + padding-left:0; + color: hsl(0, 0%, 55%); + text-align: left; + font-size: 0.9em; +} +.column.help { + align-items: stretch; + display: flex; +} +.column.help .help-inner { + border-left:2px solid #E4E4E4; + padding:1em; + margin-bottom: 0 !important; +} +.column-help { + margin-bottom: 0 !important; +} + +/* ----- list ----- */ +.list { + text-align: left; +} +.list-item { + padding:1em; + position: relative; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.list-item-title { + font-weight: bold; +} +.list-item:hover { + background-color: #EBF8FF; +} +.table-metrics { + margin: 0.5em; + text-align: center; +} +.table-metrics td { + text-align: center; +} +.table-metrics .card-number { + font-weight: bold; +} +.table-metrics .card-label { + color: #333; + font-size: 0.9em; +} + +/* -------- charts ---------------- */ +.tooltip { + position: absolute; + background: #333; + border-radius: 5px; + padding: 5px 15px; + box-shadow: 1px 1px 3px; + text-align: left; + color: #FFF; + z-index: 4; +} + +.bar { + fill: #4CAF50; +} + +.bar:hover { + fill: #81C784; +} + +.axis { + font: 10px sans-serif; +} + +.axis path, +.axis line, +.scattered-plot path { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} + +.x.axis path { + display: none; +} +.scattered-plot .x.axis path { + display: block; +} +.axis path, +.axis line { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} +.svg-container { + position:relative; +} +.btn-save-image { + position:absolute; + top: 0; + left: 0; + background:#333; + color: #FFF; + font-size: 0.8em; + line-height: 1em; + height: 1em; + cursor: pointer; + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)"; + filter: alpha(opacity=20); + -moz-opacity: 0.20; + -khtml-opacity: 0.20; + opacity: 0.20; + transition: opacity .2s ease-out; + -moz-transition: opacity .2s ease-out; + -webkit-transition: opacity .2s ease-out; + -o-transition: opacity .2s ease-out; +} +.btn-save-image:hover { + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)"; + filter: alpha(opacity=100); + -moz-opacity: 1; + -khtml-opacity: 1; + opacity: 1; +} + +/* -------- Table ------------ */ +table tr td { + border:none; + padding: 4px 0; +} +#table-length tbody { + font-size: 0.8em; +} + +#table-junit tbody { + font-size: 0.8em; +} + +#table-pagerank tbody { + font-size: 0.8em; +} + +#table-relations tbody { + font-size: 0.8em; +} +.table-small { + font-size:0.8em; +} + +#pagination a { + display: inline-block; + padding: 0 .5em; + cursor: pointer; +} + +.js-sort-table thead th { + cursor: pointer; +} + +/* ---- tabs ---- */ +.tabs { + list-style: none; + margin: 0; + padding: 0; +} +.tabs li { + list-style: none; + display: inline-block; + margin:0; +} +.tabs li a { + text-decoration: none; + padding: .5em 1em; + display: inline-block; + border-top: 4px solid #FFF; + border-bottom: 4px solid #FFF; +} +.tabs li a:hover, .tabs li.active a { + border-bottom: 4px solid #48bb78; +} +.tabs li.active a { + font-weight: bold; +} + +.group-tabs { + background-color: #fff; + line-height: 2em; + margin-bottom: 1em; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} + +/* ---- relations ---- */ +.node { + font: 300 11px "Helvetica Neue", Helvetica, Arial, sans-serif; + fill: #bbb; +} + +.node:hover { + fill: #000; +} + +.link { + stroke: steelblue; + stroke-opacity: .4; + fill: none; + pointer-events: none; +} + +.node:hover, +.node--source, +.node--target { + font-weight: 700; +} + +.node--source { + fill: #AE113D; +} + +.node--target { + fill: #4617B4; +} + +.link--source, +.link--target { + stroke-opacity: 1; + stroke-width: 2px; +} + +.link--source { + stroke: #AE113D; +} + +.link--target { + stroke: #4617B4; +} + +.relation-source { + background-color: #AE113D; +} + +.relation-target { + background-color: #4617B4; +} + +/* ---------- footer ---------- */ +.container { + padding-bottom: 40px; +} + +footer { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + background: #FFF; + border-top: 1px solid #CCC; + padding: 5px 0; + text-align: center; + font-size: .8em; +} + + +/* ---------- violations ---------- */ +.violation-list { + display: none; +} +.violation { + padding-left:50px; + margin-top: .5em; +} +.violation .name { + font-weight: bold; + margin-top:1em; +} +.progress-good { + background-color: #4CAF50; + color: #FFF; +} +.level-critical{ + background-color: #F44336; + color: #FFF; +} +.level-error{ + background-color: #F44336; + color: #FFF; +} +.level-warning{ + background-color: darkorange; + color: #FFF; +} + + +/* ------- overrides -------- */ +@media (min-width: 600px) { + .clusterize-scroll { + max-height: 400px !important; + } +} + + +/* ------ composer ----- */ +.help-warning { + background-color: #fbd38d; +} +.help-info { + background-color:#A7F9FC; +} diff --git a/report/favicon.ico b/report/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..36692aec8cd1ad6dbd5bcf4c9eb09829515d3015 GIT binary patch literal 15406 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%0Db_0Cr>L6UE5zd{Nj>otKt zsG0p6auP|x-H&khHty8mP7SC^0(Sb?kK+AL$as4N{sZd-GT+{YEU_OTPuO?joj_ou z6)_^$HH^Ygeuu5U3&5S5xD&){{{cOoX8(m0ARR|~7wJ8u6G&lrhNyKUM_D1j?hRn~ z2C#br*u4SF-r!}g#CaX^tw(wqX(Q5yxOWceBczX!YLGzp_Dv`e|2vZHfrxT$p`2SN z=N5Y87T&pqcW&XGTX^Rd-nj+1I|7nYaE1P84XZ(qUcH82k-+#hVEh^|ehnDE28>?= z4h{B2_#Zus$OfcMNSl#ZD{rBdx6sO4Xyq-m@)lZo3z+Eun2L~!kvbvuvQGj;n@C@K zB(3uhr|g{=bfPt9K5J6nyNrfo*oajsuf{|HqFT9_9c_&ryT{PUUnj{Um*TfF~+qbEUW z|N5~HV2|ua(W1|B{K7ugj`0IJG-8CEvR^}wF^!F&B%{3!?v2JXV&7-~9w`HFEVGw_ zdf5szgZ;4mgsX>NCc&x-F8-@M8Tom?YtLAD|NR4ML!aV!o7K)V;s5?Ydw=BlIW@Dk z|H~)HKK#*7?B6~5zT+;q2tNM}kKS)P>67Vb1Y_ShCOz^P_>*4R9Vb12ZtPt_ZM^qg z&(7I-IZz!d_*+HVlYc+EDOSq^j)D*9Fcind_8azW)j7)9ef28t?ncUDS2#nn1{LhZ zaT#VN=f}Rs^^0k<9v=tB?cZWVUIfQuA5B{G%vFip?;)K?G+XeB&S^^+WXj0LZ2FVGUUhDQg|s1W9W%zG;w{Y9Mc2 zgx#b!EDcvNm#|nB9lj=Bw~5ys&Ff}+P$b}aBEEG{P7=msGQJcLvHu#MhOZw=OUIiT z_(njA%f{0je8T{1V~m{weElF}6+(7xf^Q_&S2aZ)&F~FCYnp@B+Tp7s?eW#4WgQR$ z$=c?_+a_`8PlEi{3%Dr3SI^rS%G>FSb`Ain<@oxd#RJjntj&lP#_Vmv|Hj#X)iq2~ zGyw;j@S9;F8d!J{a2|l|Nr0UOo0UOwd=)Kvi?G@5IP6Y-kOfZy!c(v~>0oR55Kx^1 z?tFl|i|CKffI*RCkohzm7&LMW8aW2TP^J+WVSO0J@rb?E@fD!?rr^kMXX5x`Z|CAp zKE6R5YknMSz8q`pe^@oam|<88=2-LRSTk^}F?d5B_(pI% z>Np++jzNKA(3fM-pJPxD{M7&whC7ktPJ%t^GT>n}=mWZE8jK$_wypoF`!HJ)0oL@b z`n5BY=52bG;Ml%rD>KRK(ycR&U0e0Rv1hj~IF|J6gr^vdOjCkZ#LuY1sX2)iV@3@j zVf+}ykBKOoX<-gqAUm_f^eOhf4qA%DfBWFNB@*;P+zleT(VmXj)nq5GyLjZO>yc*< Q&(odkLZ0rp4;kwJ0Tq0wtN;K2 literal 0 HcmV?d00001 diff --git a/report/fonts/roboto-light.ttf b/report/fonts/roboto-light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ef52b760eec704cd26932d8e4f04089a064447eb GIT binary patch literal 33676 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 literal 0 HcmV?d00001 diff --git a/report/images/logo-git.png b/report/images/logo-git.png new file mode 100644 index 0000000000000000000000000000000000000000..adf6623a48eb6b476987692f5645ee6d8a5944a0 GIT binary patch literal 581 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 Sn;e$_0000EBE?wp2R&2l+iHO_B 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 Sn;e$_0000 + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ +
+
+
+
Violations (0 criticals, 8 errors) +
+
26
+ +
+
+
+
+ +
4,961
+ +
+
+
+
+ +
142
+ +
+
+
+ + +
+ +
+
+ +
+
+ No JUnit report found. Use the --junit=<junit.xml> option to analyse your unit tests. + See documentation of PHPUnit if needed +
+
+
+ No details +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ Maintainability / complexity + + (with comments) + +
+
+
+
+
+
+

Each file is symbolized by a circle. Size of the circle represents the Cyclomatic + complexity. + Color of the circle represents the Maintainability Index.

+

Large red circles will be probably hard to maintain.

+
+
+
+
+
+
+ +
+
+ +
+
+

+ Page Rank is a way to measure the importance of a class. There is no "good" or "bad" page rank. This metric reflects interactions in your code. +

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassRank
+ 0.1 + + App\Models\Order + 81.02 + 44.71 +
+ 0.06 + + App\Models\Store + 97.12 + 56.27 +
+ 0.04 + + App\Models\Cart + 96 + 57.91 +
+ 0.04 + + App\Models\Product + 95.92 + 55.07 +
+ 0.02 + + App\Models\WebhookSubscription + 100.98 + 62.64 +
+ 0.02 + + App\Models\User + 92.09 + 49.64 +
+ 0.02 + + App\Models\Fulfillment + 99.17 + 59.7 +
+ 0.02 + + App\Models\Checkout + 99.55 + 60.56 +
+ 0.02 + + App\Support\CartSession + 49.84 + 49.84 +
+ 0.01 + + App\Models\NavigationItem + 88.64 + 55.06 +
+ 0.01 + + App\Models\Refund + 100.54 + 61.07 +
+ 0.01 + + App\Models\InventoryItem + 96.03 + 60.03 +
+ 0.01 + + App\Models\NavigationMenu + 105.83 + 69 +
+ 0.01 + + App\Models\AppInstallation + 97.35 + 58.82 +
+ 0.01 + + App\Models\Discount + 77.67 + 51.26 +
+ 0.01 + + App\Models\Theme + 101.3 + 61.34 +
+ 0.01 + + App\Models\ProductMedia + 98.91 + 61.19 +
+ 0.01 + + App\Models\WebhookDelivery + 99 + 61.29 +
+ 0.01 + + App\Models\Payment + 99.05 + 59.58 +
+ 0.01 + + App\Models\AnalyticsDaily + 97.28 + 55.94 +
+ 0.01 + + App\Models\Customer + 90.66 + 52.51 +
+ 0.01 + + App\Models\TaxSettings + 95.77 + 59.77 +
+ 0.01 + + App\Models\ShippingZone + 102.96 + 63.97 +
+ 0.01 + + App\Models\Collection + 102.41 + 63.41 +
+ 0.01 + + App\Models\Page + 104.54 + 67.71 +
+ 0.01 + + App\Models\ShippingRate + 102.32 + 63.33 +
+ 0.01 + + App\Models\CustomerAddress + 101.92 + 63.58 +
+ 0.01 + + App\Models\FulfillmentLine + 104.07 + 65.73 +
+ 0.01 + + App\Models\AnalyticsEvent + 99.72 + 64.75 +
+ 0.01 + + App\Models\Scopes\StoreScope + 85.71 + 64.88 +
+ 0.01 + + App\Exceptions\InvalidDiscountException + 52.71 + 52.71 +
+ 0.01 + + App\Livewire\Actions\Logout + 105.27 + 72.41 +
+ 0.01 + + App\Support\HandleGenerator + 51.82 + 51.82 +
+ 0.01 + + App\Jobs\DeliverWebhook + 70.68 + 43.53 +
+ 0.01 + + App\Events\OrderCreated + 171 + 171 +
+ 0.01 + + App\Events\OrderPaid + 171 + 171 +
+ 0.01 + + App\Events\OrderFulfilled + 171 + 171 +
+ 0.01 + + App\Services\WebhookService + 84.08 + 57.02 +
+ 0.01 + + App\Services\OrderService + 45.1 + 33.05 +
+ 0.01 + + App\Services\CheckoutService + 43.38 + 25.03 +
+ 0.01 + + App\Services\FulfillmentService + 69.57 + 41.94 +
+ 0.01 + + App\Services\InventoryService + 45.93 + 45.93 +
+ 0.01 + + App\Services\ProductService + 48.4 + 27.5 +
+ 0.01 + + App\Services\AnalyticsService + 100.52 + 61.53 +
+ 0.01 + + App\Services\SearchService + 65.8 + 40.36 +
+ 0.01 + + App\ValueObjects\PaymentResult + 60.6 + 60.6 +
+ 0.01 + + App\ValueObjects\DiscountResult + 114.21 + 76.69 +
+ 0.01 + + App\ValueObjects\PricingResult + 109.23 + 63.06 +
+ 0.01 + + App\ValueObjects\TaxLine + 100.45 + 67.58 +
+ 0.01 + + App\ValueObjects\RefundResult + 68.09 + 68.09 +
+ 0 + + App\Auth\CustomerUserProvider + 62.02 + 34.05 +
+ 0 + + App\Providers\AppServiceProvider + 85.37 + 50.66 +
+ 0 + + App\Providers\FortifyServiceProvider + 88.27 + 50.93 +
+ 0 + + App\Models\OrderLine + 95.52 + 55 +
+ 0 + + App\Models\ThemeFile + 102.21 + 63.87 +
+ 0 + + App\Models\ProductOption + 104.07 + 65.73 +
+ 0 + + App\Models\App + 101.75 + 63.41 +
+ 0 + + App\Models\CartLine + 101.49 + 63.15 +
+ 0 + + App\Models\StoreDomain + 101.92 + 63.58 +
+ 0 + + App\Models\ThemeSettings + 97.83 + 61.83 +
+ 0 + + App\Models\ProductVariant + 96.78 + 56.28 +
+ 0 + + App\Models\ProductOptionValue + 105.01 + 69.14 +
+ 0 + + App\Models\StoreSettings + 99.46 + 62.91 +
+ 0 + + App\Models\StoreUser + 88 + 61.46 +
+ 0 + + App\Models\Organization + 108.56 + 71.73 +
+ 0 + + App\Models\Concerns\BelongsToStore + 91.36 + 60.68 +
+ 0 + + App\Exceptions\InsufficientInventoryException + 171 + 171 +
+ 0 + + App\Exceptions\FulfillmentGuardException + 171 + 171 +
+ 0 + + App\Exceptions\PaymentFailedException + 171 + 171 +
+ 0 + + App\Policies\StorePolicy + 60.23 + 60.23 +
+ 0 + + App\Policies\Concerns\ChecksStoreRole + 77.44 + 59.58 +
+ 0 + + App\Livewire\Settings\TwoFactor + 74.44 + 38.1 +
+ 0 + + App\Livewire\Settings\DeleteUserForm + 99.37 + 67.44 +
+ 0 + + App\Livewire\Settings\TwoFactor\RecoveryCodes + 92.57 + 56.13 +
+ 0 + + App\Livewire\Settings\Password + 84.33 + 57.79 +
+ 0 + + App\Livewire\Settings\Profile + 81.49 + 48.81 +
+ 0 + + App\Livewire\Settings\Appearance + 202.94 + 171 +
+ 0 + + App\Livewire\Storefront\Products\Show + 54.11 + 43.55 +
+ 0 + + App\Livewire\Storefront\Home + 83.79 + 52.94 +
+ 0 + + App\Livewire\Storefront\Checkout\Show + 52.12 + 28.19 +
+ 0 + + App\Livewire\Storefront\Checkout\Confirmation + 81.42 + 62.53 +
+ 0 + + App\Livewire\Storefront\Search\Index + 80.58 + 58.07 +
+ 0 + + App\Livewire\Storefront\CartDrawer + 73.29 + 53.51 +
+ 0 + + App\Livewire\Storefront\Cart\Show + 55.41 + 44.76 +
+ 0 + + App\Livewire\Storefront\Account\Dashboard + 87.28 + 61.94 +
+ 0 + + App\Livewire\Storefront\Account\Auth\Login + 64.33 + 51.01 +
+ 0 + + App\Livewire\Storefront\Account\Auth\Register + 70.32 + 51.43 +
+ 0 + + App\Livewire\Storefront\Account\Addresses\Index + 67.36 + 44.25 +
+ 0 + + App\Livewire\Storefront\Account\Orders\Index + 87.66 + 62.32 +
+ 0 + + App\Livewire\Storefront\Account\Orders\Show + 85.35 + 60.67 +
+ 0 + + App\Livewire\Storefront\Collections\Index + 83.53 + 64.06 +
+ 0 + + App\Livewire\Storefront\Collections\Show + 70.52 + 51.63 +
+ 0 + + App\Livewire\Storefront\Pages\Show + 80.01 + 61.66 +
+ 0 + + App\Livewire\Storefront\Concerns\EnsuresStore + 91.4 + 64.6 +
+ 0 + + App\Livewire\Admin\Customers\Index + 78.7 + 56.19 +
+ 0 + + App\Livewire\Admin\Customers\Show + 77.75 + 58.87 +
+ 0 + + App\Livewire\Admin\Settings\Taxes + 80.07 + 50.15 +
+ 0 + + App\Livewire\Admin\Settings\Index + 84.84 + 52.52 +
+ 0 + + App\Livewire\Admin\Settings\Shipping + 65.37 + 41.53 +
+ 0 + + App\Livewire\Admin\Dashboard + 84.71 + 50.84 +
+ 0 + + App\Livewire\Admin\Products\Index + 71.45 + 48.44 +
+ 0 + + App\Livewire\Admin\Products\Form + 66.4 + 37.19 +
+ 0 + + App\Livewire\Admin\Auth\Login + 68.55 + 47.97 +
+ 0 + + App\Livewire\Admin\Navigation\Index + 60.51 + 38.13 +
+ 0 + + App\Livewire\Admin\Discounts\Index + 83.16 + 57.12 +
+ 0 + + App\Livewire\Admin\Discounts\Form + 69.57 + 40.21 +
+ 0 + + App\Livewire\Admin\Orders\Index + 75.17 + 49.13 +
+ 0 + + App\Livewire\Admin\Orders\Show + 45.03 + 34.22 +
+ 0 + + App\Livewire\Admin\Collections\Index + 84.35 + 59.67 +
+ 0 + + App\Livewire\Admin\Collections\Form + 63.1 + 38.1 +
+ 0 + + App\Livewire\Admin\Pages\Index + 77.27 + 55.64 +
+ 0 + + App\Livewire\Admin\Pages\Form + 69.45 + 42.53 +
+ 0 + + App\Livewire\Admin\Apps\Index + 76.05 + 50.37 +
+ 0 + + App\Livewire\Admin\Themes\Index + 70 + 48.92 +
+ 0 + + App\Livewire\Admin\Analytics\Index + 77.35 + 54.34 +
+ 0 + + App\Livewire\Admin\Developers\Index + 74.34 + 45.8 +
+ 0 + + App\Http\Middleware\ResolveStore + 43.35 + 43.35 +
+ 0 + + App\Http\Controllers\Controller + 202.94 + 171 +
+ 0 + + App\Actions\Fortify\ResetUserPassword + 108.25 + 69.26 +
+ 0 + + App\Actions\Fortify\CreateNewUser + 105.92 + 66.93 +
+ 0 + + App\Jobs\ExpireAbandonedCheckouts + 67.9 + 67.9 +
+ 0 + + App\Jobs\CleanupAbandonedCarts + 72.05 + 72.05 +
+ 0 + + App\Jobs\AggregateAnalytics + 49.28 + 49.28 +
+ 0 + + App\Jobs\CancelUnpaidBankTransferOrders + 66.52 + 66.52 +
+ 0 + + App\Jobs\ProcessMediaUpload + 65.29 + 65.29 +
+ 0 + + App\Events\OrderRefunded + 78.06 + 78.06 +
+ 0 + + App\Events\OrderCancelled + 171 + 171 +
+ 0 + + App\Events\FulfillmentDelivered + 171 + 171 +
+ 0 + + App\Observers\ProductObserver + 63.57 + 63.57 +
+ 0 + + App\Listeners\DispatchOrderWebhooks + 56.37 + 56.37 +
+ 0 + + App\Services\Payments\MockPaymentProvider + 75.89 + 47.96 +
+ 0 + + App\Services\TaxCalculator + 73.32 + 47.98 +
+ 0 + + App\Services\ThemeSettingsService + 91.19 + 54.58 +
+ 0 + + App\Services\NavigationService + 92.68 + 59.02 +
+ 0 + + App\Services\RefundService + 57.3 + 45.18 +
+ 0 + + App\Services\ShippingCalculator + 60.12 + 32.77 +
+ 0 + + App\Services\CartService + 28.72 + 28.72 +
+ 0 + + App\Services\PricingEngine + 38 + 38 +
+ 0 + + App\Services\DiscountService + 50.15 + 35.37 +
+ 0 + + App\Services\VariantMatrixService + 62.86 + 39.33 +
+ 0 + + App\Concerns\ProfileValidationRules + 104.89 + 60.85 +
+ 0 + + App\Concerns\PasswordValidationRules + 110.67 + 67.44 +
+
+
+
+
+
+ +
+
+
+
+ Composer +
+
No composer.json file found
+ +
+ +
+
+
+
+
+ + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + diff --git a/report/js/FileSaver.min.js b/report/js/FileSaver.min.js new file mode 100644 index 00000000..183d42a1 --- /dev/null +++ b/report/js/FileSaver.min.js @@ -0,0 +1,3 @@ +(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(b,c,d){var e=new XMLHttpRequest;e.open("GET",b),e.responseType="blob",e.onload=function(){a(e.response,c,d)},e.onerror=function(){console.error("could not download file")},e.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(a,b,d,e){if(e=e||open("","_blank"),e&&(e.document.title=e.document.body.innerText="downloading..."),"string"==typeof a)return c(a,b,d);var g="application/octet-stream"===a.type,h=/constructor/i.test(f.HTMLElement)||f.safari,i=/CriOS\/[\d]+/.test(navigator.userAgent);if((i||g&&h)&&"undefined"!=typeof FileReader){var j=new FileReader;j.onloadend=function(){var a=j.result;a=i?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),e?e.location.href=a:location=a,e=null},j.readAsDataURL(a)}else{var k=f.URL||f.webkitURL,l=k.createObjectURL(a);e?e.location=l:location.href=l,e=null,setTimeout(function(){k.revokeObjectURL(l)},4E4)}});f.saveAs=a.saveAs=a,"undefined"!=typeof module&&(module.exports=a)}); + +//# sourceMappingURL=FileSaver.min.js.map \ No newline at end of file diff --git a/report/js/FileSaver.min.js.map b/report/js/FileSaver.min.js.map new file mode 100644 index 00000000..4fbcdd2e --- /dev/null +++ b/report/js/FileSaver.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/FileSaver.js"],"names":[],"mappings":"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","file":"FileSaver.min.js","sourcesContent":["/*\n* FileSaver.js\n* A saveAs() FileSaver implementation.\n*\n* By Eli Grey, http://eligrey.com\n*\n* License : https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md (MIT)\n* source : http://purl.eligrey.com/github/FileSaver.js\n*/\n\n// The one and only way of getting global scope in all environments\n// https://stackoverflow.com/q/3277182/1008999\nvar _global = typeof window === 'object' && window.window === window\n ? window : typeof self === 'object' && self.self === self\n ? self : typeof global === 'object' && global.global === global\n ? global\n : this\n\nfunction bom (blob, opts) {\n if (typeof opts === 'undefined') opts = { autoBom: false }\n else if (typeof opts !== 'object') {\n console.warn('Deprecated: Expected third argument to be a object')\n opts = { autoBom: !opts }\n }\n\n // prepend BOM for UTF-8 XML and text/* types (including HTML)\n // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF\n if (opts.autoBom && /^\\s*(?:text\\/\\S*|application\\/xml|\\S*\\/\\S*\\+xml)\\s*;.*charset\\s*=\\s*utf-8/i.test(blob.type)) {\n return new Blob([String.fromCharCode(0xFEFF), blob], { type: blob.type })\n }\n return blob\n}\n\nfunction download (url, name, opts) {\n var xhr = new XMLHttpRequest()\n xhr.open('GET', url)\n xhr.responseType = 'blob'\n xhr.onload = function () {\n saveAs(xhr.response, name, opts)\n }\n xhr.onerror = function () {\n console.error('could not download file')\n }\n xhr.send()\n}\n\nfunction corsEnabled (url) {\n var xhr = new XMLHttpRequest()\n // use sync to avoid popup blocker\n xhr.open('HEAD', url, false)\n try {\n xhr.send()\n } catch (e) {}\n return xhr.status >= 200 && xhr.status <= 299\n}\n\n// `a.click()` doesn't work for all browsers (#465)\nfunction click (node) {\n try {\n node.dispatchEvent(new MouseEvent('click'))\n } catch (e) {\n var evt = document.createEvent('MouseEvents')\n evt.initMouseEvent('click', true, true, window, 0, 0, 0, 80,\n 20, false, false, false, false, 0, null)\n node.dispatchEvent(evt)\n }\n}\n\nvar saveAs = _global.saveAs || (\n // probably in some web worker\n (typeof window !== 'object' || window !== _global)\n ? function saveAs () { /* noop */ }\n\n // Use download attribute first if possible (#193 Lumia mobile)\n : 'download' in HTMLAnchorElement.prototype\n ? function saveAs (blob, name, opts) {\n var URL = _global.URL || _global.webkitURL\n var a = document.createElement('a')\n name = name || blob.name || 'download'\n\n a.download = name\n a.rel = 'noopener' // tabnabbing\n\n // TODO: detect chrome extensions & packaged apps\n // a.target = '_blank'\n\n if (typeof blob === 'string') {\n // Support regular links\n a.href = blob\n if (a.origin !== location.origin) {\n corsEnabled(a.href)\n ? download(blob, name, opts)\n : click(a, a.target = '_blank')\n } else {\n click(a)\n }\n } else {\n // Support blobs\n a.href = URL.createObjectURL(blob)\n setTimeout(function () { URL.revokeObjectURL(a.href) }, 4E4) // 40s\n setTimeout(function () { click(a) }, 0)\n }\n }\n\n // Use msSaveOrOpenBlob as a second approach\n : 'msSaveOrOpenBlob' in navigator\n ? function saveAs (blob, name, opts) {\n name = name || blob.name || 'download'\n\n if (typeof blob === 'string') {\n if (corsEnabled(blob)) {\n download(blob, name, opts)\n } else {\n var a = document.createElement('a')\n a.href = blob\n a.target = '_blank'\n setTimeout(function () { click(a) })\n }\n } else {\n navigator.msSaveOrOpenBlob(bom(blob, opts), name)\n }\n }\n\n // Fallback to using FileReader and a popup\n : function saveAs (blob, name, opts, popup) {\n // Open a popup immediately do go around popup blocker\n // Mostly only available on user interaction and the fileReader is async so...\n popup = popup || open('', '_blank')\n if (popup) {\n popup.document.title =\n popup.document.body.innerText = 'downloading...'\n }\n\n if (typeof blob === 'string') return download(blob, name, opts)\n\n var force = blob.type === 'application/octet-stream'\n var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari\n var isChromeIOS = /CriOS\\/[\\d]+/.test(navigator.userAgent)\n\n if ((isChromeIOS || (force && isSafari)) && typeof FileReader !== 'undefined') {\n // Safari doesn't allow downloading of blob URLs\n var reader = new FileReader()\n reader.onloadend = function () {\n var url = reader.result\n url = isChromeIOS ? url : url.replace(/^data:[^;]*;/, 'data:attachment/file;')\n if (popup) popup.location.href = url\n else location = url\n popup = null // reverse-tabnabbing #460\n }\n reader.readAsDataURL(blob)\n } else {\n var URL = _global.URL || _global.webkitURL\n var url = URL.createObjectURL(blob)\n if (popup) popup.location = url\n else location.href = url\n popup = null // reverse-tabnabbing #460\n setTimeout(function () { URL.revokeObjectURL(url) }, 4E4) // 40s\n }\n }\n)\n\n_global.saveAs = saveAs.saveAs = saveAs\n\nif (typeof module !== 'undefined') {\n module.exports = saveAs;\n}\n"]} \ No newline at end of file diff --git a/report/js/clusterize.min.js b/report/js/clusterize.min.js new file mode 100644 index 00000000..b2d3c8e7 --- /dev/null +++ b/report/js/clusterize.min.js @@ -0,0 +1,16 @@ +/*! Clusterize.js - v0.17.6 - 2017-03-05 +* http://NeXTs.github.com/Clusterize.js/ +* Copyright (c) 2015 Denis Lukov; Licensed GPLv3 */ + +;(function(q,n){"undefined"!=typeof module?module.exports=n():"function"==typeof define&&"object"==typeof define.amd?define(n):this[q]=n()})("Clusterize",function(){function q(b,a,c){return a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)}function n(b,a,c){return a.removeEventListener?a.removeEventListener(b,c,!1):a.detachEvent("on"+b,c)}function r(b){return"[object Array]"===Object.prototype.toString.call(b)}function m(b,a){return window.getComputedStyle?window.getComputedStyle(a)[b]: +a.currentStyle[b]}var l=function(){for(var b=3,a=document.createElement("b"),c=a.all||[];a.innerHTML="\x3c!--[if gt IE "+ ++b+"]>=l&&!c.tag&&(c.tag=b[0].match(/<([^>\s/]*)/)[1].toLowerCase()),1>=this.content_elem.children.length&&(a.data=this.html(b[0]+b[0]+b[0])),c.tag||(c.tag=this.content_elem.children[0].tagName.toLowerCase()), +this.getRowsHeight(b))},getRowsHeight:function(b){var a=this.options,c=a.item_height;a.cluster_height=0;if(b.length){b=this.content_elem.children;var d=b[Math.floor(b.length/2)];a.item_height=d.offsetHeight;"tr"==a.tag&&"collapse"!=m("borderCollapse",this.content_elem)&&(a.item_height+=parseInt(m("borderSpacing",this.content_elem),10)||0);"tr"!=a.tag&&(b=parseInt(m("marginTop",d),10)||0,d=parseInt(m("marginBottom",d),10)||0,a.item_height+=Math.max(b,d));a.block_height=a.item_height*a.rows_in_block; +a.rows_in_cluster=a.blocks_in_cluster*a.rows_in_block;a.cluster_height=a.blocks_in_cluster*a.block_height;return c!=a.item_height}},getClusterNum:function(){this.options.scroll_top=this.scroll_elem.scrollTop;return Math.floor(this.options.scroll_top/(this.options.cluster_height-this.options.block_height))||0},generateEmptyRow:function(){var b=this.options;if(!b.tag||!b.show_no_data_row)return[];var a=document.createElement(b.tag),c=document.createTextNode(b.no_data_text),d;a.className=b.no_data_class; +"tr"==b.tag&&(d=document.createElement("td"),d.colSpan=100,d.appendChild(c));a.appendChild(d||c);return[a.outerHTML]},generate:function(b,a){var c=this.options,d=b.length;if(de&&g++;f=l&&"tr"==this.options.tag){var c=document.createElement("div");for(c.innerHTML=""+b+"
";b=a.lastChild;)a.removeChild(b);for(c=this.getChildNodes(c.firstChild.firstChild);c.length;)a.appendChild(c.shift())}else a.innerHTML=b},getChildNodes:function(b){b=b.children;for(var a=[],c=0,d=b.length;c 1) { + var px1 = px - pi, + pi2 = pi + (px < pi ? -1 : 1) / 2, + pj2 = pj + (py < pj ? -1 : 1), + px2 = px - pi2, + py2 = py - pj2; + if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) pi = pi2 + (pj & 1 ? 1 : -1) / 2, pj = pj2; + } + + var id = pi + "-" + pj, bin = binsById[id]; + if (bin) bin.push(point); else { + bin = binsById[id] = [point]; + bin.i = pi; + bin.j = pj; + bin.x = (pi + (pj & 1 ? 1 / 2 : 0)) * dx; + bin.y = pj * dy; + } + }); + + return d3.values(binsById); + } + + function hexagon(radius) { + var x0 = 0, y0 = 0; + return d3_hexbinAngles.map(function(angle) { + var x1 = Math.sin(angle) * radius, + y1 = -Math.cos(angle) * radius, + dx = x1 - x0, + dy = y1 - y0; + x0 = x1, y0 = y1; + return [dx, dy]; + }); + } + + hexbin.x = function(_) { + if (!arguments.length) return x; + x = _; + return hexbin; + }; + + hexbin.y = function(_) { + if (!arguments.length) return y; + y = _; + return hexbin; + }; + + hexbin.hexagon = function(radius) { + if (arguments.length < 1) radius = r; + return "m" + hexagon(radius).join("l") + "z"; + }; + + hexbin.centers = function() { + var centers = []; + for (var y = 0, odd = false, j = 0; y < height + r; y += dy, odd = !odd, ++j) { + for (var x = odd ? dx / 2 : 0, i = 0; x < width + dx / 2; x += dx, ++i) { + var center = [x, y]; + center.i = i; + center.j = j; + centers.push(center); + } + } + return centers; + }; + + hexbin.mesh = function() { + var fragment = hexagon(r).slice(0, 4).join("l"); + return hexbin.centers().map(function(p) { return "M" + p + "m" + fragment; }).join(""); + }; + + hexbin.size = function(_) { + if (!arguments.length) return [width, height]; + width = +_[0], height = +_[1]; + return hexbin; + }; + + hexbin.radius = function(_) { + if (!arguments.length) return r; + r = +_; + dx = r * 2 * Math.sin(Math.PI / 3); + dy = r * 1.5; + return hexbin; + }; + + return hexbin.radius(1); +}; + +var d3_hexbinAngles = d3.range(0, 2 * Math.PI, Math.PI / 3), + d3_hexbinX = function(d) { return d[0]; }, + d3_hexbinY = function(d) { return d[1]; }; + +})(); diff --git a/report/js/d3.v3.js b/report/js/d3.v3.js new file mode 100644 index 00000000..aded45c4 --- /dev/null +++ b/report/js/d3.v3.js @@ -0,0 +1,9554 @@ +!function() { + var d3 = { + version: "3.5.17" + }; + var d3_arraySlice = [].slice, d3_array = function(list) { + return d3_arraySlice.call(list); + }; + var d3_document = this.document; + function d3_documentElement(node) { + return node && (node.ownerDocument || node.document || node).documentElement; + } + function d3_window(node) { + return node && (node.ownerDocument && node.ownerDocument.defaultView || node.document && node || node.defaultView); + } + if (d3_document) { + try { + d3_array(d3_document.documentElement.childNodes)[0].nodeType; + } catch (e) { + d3_array = function(list) { + var i = list.length, array = new Array(i); + while (i--) array[i] = list[i]; + return array; + }; + } + } + if (!Date.now) Date.now = function() { + return +new Date(); + }; + if (d3_document) { + try { + d3_document.createElement("DIV").style.setProperty("opacity", 0, ""); + } catch (error) { + var d3_element_prototype = this.Element.prototype, d3_element_setAttribute = d3_element_prototype.setAttribute, d3_element_setAttributeNS = d3_element_prototype.setAttributeNS, d3_style_prototype = this.CSSStyleDeclaration.prototype, d3_style_setProperty = d3_style_prototype.setProperty; + d3_element_prototype.setAttribute = function(name, value) { + d3_element_setAttribute.call(this, name, value + ""); + }; + d3_element_prototype.setAttributeNS = function(space, local, value) { + d3_element_setAttributeNS.call(this, space, local, value + ""); + }; + d3_style_prototype.setProperty = function(name, value, priority) { + d3_style_setProperty.call(this, name, value + "", priority); + }; + } + } + d3.ascending = d3_ascending; + function d3_ascending(a, b) { + return a < b ? -1 : a > b ? 1 : a >= b ? 0 : NaN; + } + d3.descending = function(a, b) { + return b < a ? -1 : b > a ? 1 : b >= a ? 0 : NaN; + }; + d3.min = function(array, f) { + var i = -1, n = array.length, a, b; + if (arguments.length === 1) { + while (++i < n) if ((b = array[i]) != null && b >= b) { + a = b; + break; + } + while (++i < n) if ((b = array[i]) != null && a > b) a = b; + } else { + while (++i < n) if ((b = f.call(array, array[i], i)) != null && b >= b) { + a = b; + break; + } + while (++i < n) if ((b = f.call(array, array[i], i)) != null && a > b) a = b; + } + return a; + }; + d3.max = function(array, f) { + var i = -1, n = array.length, a, b; + if (arguments.length === 1) { + while (++i < n) if ((b = array[i]) != null && b >= b) { + a = b; + break; + } + while (++i < n) if ((b = array[i]) != null && b > a) a = b; + } else { + while (++i < n) if ((b = f.call(array, array[i], i)) != null && b >= b) { + a = b; + break; + } + while (++i < n) if ((b = f.call(array, array[i], i)) != null && b > a) a = b; + } + return a; + }; + d3.extent = function(array, f) { + var i = -1, n = array.length, a, b, c; + if (arguments.length === 1) { + while (++i < n) if ((b = array[i]) != null && b >= b) { + a = c = b; + break; + } + while (++i < n) if ((b = array[i]) != null) { + if (a > b) a = b; + if (c < b) c = b; + } + } else { + while (++i < n) if ((b = f.call(array, array[i], i)) != null && b >= b) { + a = c = b; + break; + } + while (++i < n) if ((b = f.call(array, array[i], i)) != null) { + if (a > b) a = b; + if (c < b) c = b; + } + } + return [ a, c ]; + }; + function d3_number(x) { + return x === null ? NaN : +x; + } + function d3_numeric(x) { + return !isNaN(x); + } + d3.sum = function(array, f) { + var s = 0, n = array.length, a, i = -1; + if (arguments.length === 1) { + while (++i < n) if (d3_numeric(a = +array[i])) s += a; + } else { + while (++i < n) if (d3_numeric(a = +f.call(array, array[i], i))) s += a; + } + return s; + }; + d3.mean = function(array, f) { + var s = 0, n = array.length, a, i = -1, j = n; + if (arguments.length === 1) { + while (++i < n) if (d3_numeric(a = d3_number(array[i]))) s += a; else --j; + } else { + while (++i < n) if (d3_numeric(a = d3_number(f.call(array, array[i], i)))) s += a; else --j; + } + if (j) return s / j; + }; + d3.quantile = function(values, p) { + var H = (values.length - 1) * p + 1, h = Math.floor(H), v = +values[h - 1], e = H - h; + return e ? v + e * (values[h] - v) : v; + }; + d3.median = function(array, f) { + var numbers = [], n = array.length, a, i = -1; + if (arguments.length === 1) { + while (++i < n) if (d3_numeric(a = d3_number(array[i]))) numbers.push(a); + } else { + while (++i < n) if (d3_numeric(a = d3_number(f.call(array, array[i], i)))) numbers.push(a); + } + if (numbers.length) return d3.quantile(numbers.sort(d3_ascending), .5); + }; + d3.variance = function(array, f) { + var n = array.length, m = 0, a, d, s = 0, i = -1, j = 0; + if (arguments.length === 1) { + while (++i < n) { + if (d3_numeric(a = d3_number(array[i]))) { + d = a - m; + m += d / ++j; + s += d * (a - m); + } + } + } else { + while (++i < n) { + if (d3_numeric(a = d3_number(f.call(array, array[i], i)))) { + d = a - m; + m += d / ++j; + s += d * (a - m); + } + } + } + if (j > 1) return s / (j - 1); + }; + d3.deviation = function() { + var v = d3.variance.apply(this, arguments); + return v ? Math.sqrt(v) : v; + }; + function d3_bisector(compare) { + return { + left: function(a, x, lo, hi) { + if (arguments.length < 3) lo = 0; + if (arguments.length < 4) hi = a.length; + while (lo < hi) { + var mid = lo + hi >>> 1; + if (compare(a[mid], x) < 0) lo = mid + 1; else hi = mid; + } + return lo; + }, + right: function(a, x, lo, hi) { + if (arguments.length < 3) lo = 0; + if (arguments.length < 4) hi = a.length; + while (lo < hi) { + var mid = lo + hi >>> 1; + if (compare(a[mid], x) > 0) hi = mid; else lo = mid + 1; + } + return lo; + } + }; + } + var d3_bisect = d3_bisector(d3_ascending); + d3.bisectLeft = d3_bisect.left; + d3.bisect = d3.bisectRight = d3_bisect.right; + d3.bisector = function(f) { + return d3_bisector(f.length === 1 ? function(d, x) { + return d3_ascending(f(d), x); + } : f); + }; + d3.shuffle = function(array, i0, i1) { + if ((m = arguments.length) < 3) { + i1 = array.length; + if (m < 2) i0 = 0; + } + var m = i1 - i0, t, i; + while (m) { + i = Math.random() * m-- | 0; + t = array[m + i0], array[m + i0] = array[i + i0], array[i + i0] = t; + } + return array; + }; + d3.permute = function(array, indexes) { + var i = indexes.length, permutes = new Array(i); + while (i--) permutes[i] = array[indexes[i]]; + return permutes; + }; + d3.pairs = function(array) { + var i = 0, n = array.length - 1, p0, p1 = array[0], pairs = new Array(n < 0 ? 0 : n); + while (i < n) pairs[i] = [ p0 = p1, p1 = array[++i] ]; + return pairs; + }; + d3.transpose = function(matrix) { + if (!(n = matrix.length)) return []; + for (var i = -1, m = d3.min(matrix, d3_transposeLength), transpose = new Array(m); ++i < m; ) { + for (var j = -1, n, row = transpose[i] = new Array(n); ++j < n; ) { + row[j] = matrix[j][i]; + } + } + return transpose; + }; + function d3_transposeLength(d) { + return d.length; + } + d3.zip = function() { + return d3.transpose(arguments); + }; + d3.keys = function(map) { + var keys = []; + for (var key in map) keys.push(key); + return keys; + }; + d3.values = function(map) { + var values = []; + for (var key in map) values.push(map[key]); + return values; + }; + d3.entries = function(map) { + var entries = []; + for (var key in map) entries.push({ + key: key, + value: map[key] + }); + return entries; + }; + d3.merge = function(arrays) { + var n = arrays.length, m, i = -1, j = 0, merged, array; + while (++i < n) j += arrays[i].length; + merged = new Array(j); + while (--n >= 0) { + array = arrays[n]; + m = array.length; + while (--m >= 0) { + merged[--j] = array[m]; + } + } + return merged; + }; + var abs = Math.abs; + d3.range = function(start, stop, step) { + if (arguments.length < 3) { + step = 1; + if (arguments.length < 2) { + stop = start; + start = 0; + } + } + if ((stop - start) / step === Infinity) throw new Error("infinite range"); + var range = [], k = d3_range_integerScale(abs(step)), i = -1, j; + start *= k, stop *= k, step *= k; + if (step < 0) while ((j = start + step * ++i) > stop) range.push(j / k); else while ((j = start + step * ++i) < stop) range.push(j / k); + return range; + }; + function d3_range_integerScale(x) { + var k = 1; + while (x * k % 1) k *= 10; + return k; + } + function d3_class(ctor, properties) { + for (var key in properties) { + Object.defineProperty(ctor.prototype, key, { + value: properties[key], + enumerable: false + }); + } + } + d3.map = function(object, f) { + var map = new d3_Map(); + if (object instanceof d3_Map) { + object.forEach(function(key, value) { + map.set(key, value); + }); + } else if (Array.isArray(object)) { + var i = -1, n = object.length, o; + if (arguments.length === 1) while (++i < n) map.set(i, object[i]); else while (++i < n) map.set(f.call(object, o = object[i], i), o); + } else { + for (var key in object) map.set(key, object[key]); + } + return map; + }; + function d3_Map() { + this._ = Object.create(null); + } + var d3_map_proto = "__proto__", d3_map_zero = "\x00"; + d3_class(d3_Map, { + has: d3_map_has, + get: function(key) { + return this._[d3_map_escape(key)]; + }, + set: function(key, value) { + return this._[d3_map_escape(key)] = value; + }, + remove: d3_map_remove, + keys: d3_map_keys, + values: function() { + var values = []; + for (var key in this._) values.push(this._[key]); + return values; + }, + entries: function() { + var entries = []; + for (var key in this._) entries.push({ + key: d3_map_unescape(key), + value: this._[key] + }); + return entries; + }, + size: d3_map_size, + empty: d3_map_empty, + forEach: function(f) { + for (var key in this._) f.call(this, d3_map_unescape(key), this._[key]); + } + }); + function d3_map_escape(key) { + return (key += "") === d3_map_proto || key[0] === d3_map_zero ? d3_map_zero + key : key; + } + function d3_map_unescape(key) { + return (key += "")[0] === d3_map_zero ? key.slice(1) : key; + } + function d3_map_has(key) { + return d3_map_escape(key) in this._; + } + function d3_map_remove(key) { + return (key = d3_map_escape(key)) in this._ && delete this._[key]; + } + function d3_map_keys() { + var keys = []; + for (var key in this._) keys.push(d3_map_unescape(key)); + return keys; + } + function d3_map_size() { + var size = 0; + for (var key in this._) ++size; + return size; + } + function d3_map_empty() { + for (var key in this._) return false; + return true; + } + d3.nest = function() { + var nest = {}, keys = [], sortKeys = [], sortValues, rollup; + function map(mapType, array, depth) { + if (depth >= keys.length) return rollup ? rollup.call(nest, array) : sortValues ? array.sort(sortValues) : array; + var i = -1, n = array.length, key = keys[depth++], keyValue, object, setter, valuesByKey = new d3_Map(), values; + while (++i < n) { + if (values = valuesByKey.get(keyValue = key(object = array[i]))) { + values.push(object); + } else { + valuesByKey.set(keyValue, [ object ]); + } + } + if (mapType) { + object = mapType(); + setter = function(keyValue, values) { + object.set(keyValue, map(mapType, values, depth)); + }; + } else { + object = {}; + setter = function(keyValue, values) { + object[keyValue] = map(mapType, values, depth); + }; + } + valuesByKey.forEach(setter); + return object; + } + function entries(map, depth) { + if (depth >= keys.length) return map; + var array = [], sortKey = sortKeys[depth++]; + map.forEach(function(key, keyMap) { + array.push({ + key: key, + values: entries(keyMap, depth) + }); + }); + return sortKey ? array.sort(function(a, b) { + return sortKey(a.key, b.key); + }) : array; + } + nest.map = function(array, mapType) { + return map(mapType, array, 0); + }; + nest.entries = function(array) { + return entries(map(d3.map, array, 0), 0); + }; + nest.key = function(d) { + keys.push(d); + return nest; + }; + nest.sortKeys = function(order) { + sortKeys[keys.length - 1] = order; + return nest; + }; + nest.sortValues = function(order) { + sortValues = order; + return nest; + }; + nest.rollup = function(f) { + rollup = f; + return nest; + }; + return nest; + }; + d3.set = function(array) { + var set = new d3_Set(); + if (array) for (var i = 0, n = array.length; i < n; ++i) set.add(array[i]); + return set; + }; + function d3_Set() { + this._ = Object.create(null); + } + d3_class(d3_Set, { + has: d3_map_has, + add: function(key) { + this._[d3_map_escape(key += "")] = true; + return key; + }, + remove: d3_map_remove, + values: d3_map_keys, + size: d3_map_size, + empty: d3_map_empty, + forEach: function(f) { + for (var key in this._) f.call(this, d3_map_unescape(key)); + } + }); + d3.behavior = {}; + function d3_identity(d) { + return d; + } + d3.rebind = function(target, source) { + var i = 1, n = arguments.length, method; + while (++i < n) target[method = arguments[i]] = d3_rebind(target, source, source[method]); + return target; + }; + function d3_rebind(target, source, method) { + return function() { + var value = method.apply(source, arguments); + return value === source ? target : value; + }; + } + function d3_vendorSymbol(object, name) { + if (name in object) return name; + name = name.charAt(0).toUpperCase() + name.slice(1); + for (var i = 0, n = d3_vendorPrefixes.length; i < n; ++i) { + var prefixName = d3_vendorPrefixes[i] + name; + if (prefixName in object) return prefixName; + } + } + var d3_vendorPrefixes = [ "webkit", "ms", "moz", "Moz", "o", "O" ]; + function d3_noop() {} + d3.dispatch = function() { + var dispatch = new d3_dispatch(), i = -1, n = arguments.length; + while (++i < n) dispatch[arguments[i]] = d3_dispatch_event(dispatch); + return dispatch; + }; + function d3_dispatch() {} + d3_dispatch.prototype.on = function(type, listener) { + var i = type.indexOf("."), name = ""; + if (i >= 0) { + name = type.slice(i + 1); + type = type.slice(0, i); + } + if (type) return arguments.length < 2 ? this[type].on(name) : this[type].on(name, listener); + if (arguments.length === 2) { + if (listener == null) for (type in this) { + if (this.hasOwnProperty(type)) this[type].on(name, null); + } + return this; + } + }; + function d3_dispatch_event(dispatch) { + var listeners = [], listenerByName = new d3_Map(); + function event() { + var z = listeners, i = -1, n = z.length, l; + while (++i < n) if (l = z[i].on) l.apply(this, arguments); + return dispatch; + } + event.on = function(name, listener) { + var l = listenerByName.get(name), i; + if (arguments.length < 2) return l && l.on; + if (l) { + l.on = null; + listeners = listeners.slice(0, i = listeners.indexOf(l)).concat(listeners.slice(i + 1)); + listenerByName.remove(name); + } + if (listener) listeners.push(listenerByName.set(name, { + on: listener + })); + return dispatch; + }; + return event; + } + d3.event = null; + function d3_eventPreventDefault() { + d3.event.preventDefault(); + } + function d3_eventSource() { + var e = d3.event, s; + while (s = e.sourceEvent) e = s; + return e; + } + function d3_eventDispatch(target) { + var dispatch = new d3_dispatch(), i = 0, n = arguments.length; + while (++i < n) dispatch[arguments[i]] = d3_dispatch_event(dispatch); + dispatch.of = function(thiz, argumentz) { + return function(e1) { + try { + var e0 = e1.sourceEvent = d3.event; + e1.target = target; + d3.event = e1; + dispatch[e1.type].apply(thiz, argumentz); + } finally { + d3.event = e0; + } + }; + }; + return dispatch; + } + d3.requote = function(s) { + return s.replace(d3_requote_re, "\\$&"); + }; + var d3_requote_re = /[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g; + var d3_subclass = {}.__proto__ ? function(object, prototype) { + object.__proto__ = prototype; + } : function(object, prototype) { + for (var property in prototype) object[property] = prototype[property]; + }; + function d3_selection(groups) { + d3_subclass(groups, d3_selectionPrototype); + return groups; + } + var d3_select = function(s, n) { + return n.querySelector(s); + }, d3_selectAll = function(s, n) { + return n.querySelectorAll(s); + }, d3_selectMatches = function(n, s) { + var d3_selectMatcher = n.matches || n[d3_vendorSymbol(n, "matchesSelector")]; + d3_selectMatches = function(n, s) { + return d3_selectMatcher.call(n, s); + }; + return d3_selectMatches(n, s); + }; + if (typeof Sizzle === "function") { + d3_select = function(s, n) { + return Sizzle(s, n)[0] || null; + }; + d3_selectAll = Sizzle; + d3_selectMatches = Sizzle.matchesSelector; + } + d3.selection = function() { + return d3.select(d3_document.documentElement); + }; + var d3_selectionPrototype = d3.selection.prototype = []; + d3_selectionPrototype.select = function(selector) { + var subgroups = [], subgroup, subnode, group, node; + selector = d3_selection_selector(selector); + for (var j = -1, m = this.length; ++j < m; ) { + subgroups.push(subgroup = []); + subgroup.parentNode = (group = this[j]).parentNode; + for (var i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + subgroup.push(subnode = selector.call(node, node.__data__, i, j)); + if (subnode && "__data__" in node) subnode.__data__ = node.__data__; + } else { + subgroup.push(null); + } + } + } + return d3_selection(subgroups); + }; + function d3_selection_selector(selector) { + return typeof selector === "function" ? selector : function() { + return d3_select(selector, this); + }; + } + d3_selectionPrototype.selectAll = function(selector) { + var subgroups = [], subgroup, node; + selector = d3_selection_selectorAll(selector); + for (var j = -1, m = this.length; ++j < m; ) { + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + subgroups.push(subgroup = d3_array(selector.call(node, node.__data__, i, j))); + subgroup.parentNode = node; + } + } + } + return d3_selection(subgroups); + }; + function d3_selection_selectorAll(selector) { + return typeof selector === "function" ? selector : function() { + return d3_selectAll(selector, this); + }; + } + var d3_nsXhtml = "http://www.w3.org/1999/xhtml"; + var d3_nsPrefix = { + svg: "http://www.w3.org/2000/svg", + xhtml: d3_nsXhtml, + xlink: "http://www.w3.org/1999/xlink", + xml: "http://www.w3.org/XML/1998/namespace", + xmlns: "http://www.w3.org/2000/xmlns/" + }; + d3.ns = { + prefix: d3_nsPrefix, + qualify: function(name) { + var i = name.indexOf(":"), prefix = name; + if (i >= 0 && (prefix = name.slice(0, i)) !== "xmlns") name = name.slice(i + 1); + return d3_nsPrefix.hasOwnProperty(prefix) ? { + space: d3_nsPrefix[prefix], + local: name + } : name; + } + }; + d3_selectionPrototype.attr = function(name, value) { + if (arguments.length < 2) { + if (typeof name === "string") { + var node = this.node(); + name = d3.ns.qualify(name); + return name.local ? node.getAttributeNS(name.space, name.local) : node.getAttribute(name); + } + for (value in name) this.each(d3_selection_attr(value, name[value])); + return this; + } + return this.each(d3_selection_attr(name, value)); + }; + function d3_selection_attr(name, value) { + name = d3.ns.qualify(name); + function attrNull() { + this.removeAttribute(name); + } + function attrNullNS() { + this.removeAttributeNS(name.space, name.local); + } + function attrConstant() { + this.setAttribute(name, value); + } + function attrConstantNS() { + this.setAttributeNS(name.space, name.local, value); + } + function attrFunction() { + var x = value.apply(this, arguments); + if (x == null) this.removeAttribute(name); else this.setAttribute(name, x); + } + function attrFunctionNS() { + var x = value.apply(this, arguments); + if (x == null) this.removeAttributeNS(name.space, name.local); else this.setAttributeNS(name.space, name.local, x); + } + return value == null ? name.local ? attrNullNS : attrNull : typeof value === "function" ? name.local ? attrFunctionNS : attrFunction : name.local ? attrConstantNS : attrConstant; + } + function d3_collapse(s) { + return s.trim().replace(/\s+/g, " "); + } + d3_selectionPrototype.classed = function(name, value) { + if (arguments.length < 2) { + if (typeof name === "string") { + var node = this.node(), n = (name = d3_selection_classes(name)).length, i = -1; + if (value = node.classList) { + while (++i < n) if (!value.contains(name[i])) return false; + } else { + value = node.getAttribute("class"); + while (++i < n) if (!d3_selection_classedRe(name[i]).test(value)) return false; + } + return true; + } + for (value in name) this.each(d3_selection_classed(value, name[value])); + return this; + } + return this.each(d3_selection_classed(name, value)); + }; + function d3_selection_classedRe(name) { + return new RegExp("(?:^|\\s+)" + d3.requote(name) + "(?:\\s+|$)", "g"); + } + function d3_selection_classes(name) { + return (name + "").trim().split(/^|\s+/); + } + function d3_selection_classed(name, value) { + name = d3_selection_classes(name).map(d3_selection_classedName); + var n = name.length; + function classedConstant() { + var i = -1; + while (++i < n) name[i](this, value); + } + function classedFunction() { + var i = -1, x = value.apply(this, arguments); + while (++i < n) name[i](this, x); + } + return typeof value === "function" ? classedFunction : classedConstant; + } + function d3_selection_classedName(name) { + var re = d3_selection_classedRe(name); + return function(node, value) { + if (c = node.classList) return value ? c.add(name) : c.remove(name); + var c = node.getAttribute("class") || ""; + if (value) { + re.lastIndex = 0; + if (!re.test(c)) node.setAttribute("class", d3_collapse(c + " " + name)); + } else { + node.setAttribute("class", d3_collapse(c.replace(re, " "))); + } + }; + } + d3_selectionPrototype.style = function(name, value, priority) { + var n = arguments.length; + if (n < 3) { + if (typeof name !== "string") { + if (n < 2) value = ""; + for (priority in name) this.each(d3_selection_style(priority, name[priority], value)); + return this; + } + if (n < 2) { + var node = this.node(); + return d3_window(node).getComputedStyle(node, null).getPropertyValue(name); + } + priority = ""; + } + return this.each(d3_selection_style(name, value, priority)); + }; + function d3_selection_style(name, value, priority) { + function styleNull() { + this.style.removeProperty(name); + } + function styleConstant() { + this.style.setProperty(name, value, priority); + } + function styleFunction() { + var x = value.apply(this, arguments); + if (x == null) this.style.removeProperty(name); else this.style.setProperty(name, x, priority); + } + return value == null ? styleNull : typeof value === "function" ? styleFunction : styleConstant; + } + d3_selectionPrototype.property = function(name, value) { + if (arguments.length < 2) { + if (typeof name === "string") return this.node()[name]; + for (value in name) this.each(d3_selection_property(value, name[value])); + return this; + } + return this.each(d3_selection_property(name, value)); + }; + function d3_selection_property(name, value) { + function propertyNull() { + delete this[name]; + } + function propertyConstant() { + this[name] = value; + } + function propertyFunction() { + var x = value.apply(this, arguments); + if (x == null) delete this[name]; else this[name] = x; + } + return value == null ? propertyNull : typeof value === "function" ? propertyFunction : propertyConstant; + } + d3_selectionPrototype.text = function(value) { + return arguments.length ? this.each(typeof value === "function" ? function() { + var v = value.apply(this, arguments); + this.textContent = v == null ? "" : v; + } : value == null ? function() { + this.textContent = ""; + } : function() { + this.textContent = value; + }) : this.node().textContent; + }; + d3_selectionPrototype.html = function(value) { + return arguments.length ? this.each(typeof value === "function" ? function() { + var v = value.apply(this, arguments); + this.innerHTML = v == null ? "" : v; + } : value == null ? function() { + this.innerHTML = ""; + } : function() { + this.innerHTML = value; + }) : this.node().innerHTML; + }; + d3_selectionPrototype.append = function(name) { + name = d3_selection_creator(name); + return this.select(function() { + return this.appendChild(name.apply(this, arguments)); + }); + }; + function d3_selection_creator(name) { + function create() { + var document = this.ownerDocument, namespace = this.namespaceURI; + return namespace === d3_nsXhtml && document.documentElement.namespaceURI === d3_nsXhtml ? document.createElement(name) : document.createElementNS(namespace, name); + } + function createNS() { + return this.ownerDocument.createElementNS(name.space, name.local); + } + return typeof name === "function" ? name : (name = d3.ns.qualify(name)).local ? createNS : create; + } + d3_selectionPrototype.insert = function(name, before) { + name = d3_selection_creator(name); + before = d3_selection_selector(before); + return this.select(function() { + return this.insertBefore(name.apply(this, arguments), before.apply(this, arguments) || null); + }); + }; + d3_selectionPrototype.remove = function() { + return this.each(d3_selectionRemove); + }; + function d3_selectionRemove() { + var parent = this.parentNode; + if (parent) parent.removeChild(this); + } + d3_selectionPrototype.data = function(value, key) { + var i = -1, n = this.length, group, node; + if (!arguments.length) { + value = new Array(n = (group = this[0]).length); + while (++i < n) { + if (node = group[i]) { + value[i] = node.__data__; + } + } + return value; + } + function bind(group, groupData) { + var i, n = group.length, m = groupData.length, n0 = Math.min(n, m), updateNodes = new Array(m), enterNodes = new Array(m), exitNodes = new Array(n), node, nodeData; + if (key) { + var nodeByKeyValue = new d3_Map(), keyValues = new Array(n), keyValue; + for (i = -1; ++i < n; ) { + if (node = group[i]) { + if (nodeByKeyValue.has(keyValue = key.call(node, node.__data__, i))) { + exitNodes[i] = node; + } else { + nodeByKeyValue.set(keyValue, node); + } + keyValues[i] = keyValue; + } + } + for (i = -1; ++i < m; ) { + if (!(node = nodeByKeyValue.get(keyValue = key.call(groupData, nodeData = groupData[i], i)))) { + enterNodes[i] = d3_selection_dataNode(nodeData); + } else if (node !== true) { + updateNodes[i] = node; + node.__data__ = nodeData; + } + nodeByKeyValue.set(keyValue, true); + } + for (i = -1; ++i < n; ) { + if (i in keyValues && nodeByKeyValue.get(keyValues[i]) !== true) { + exitNodes[i] = group[i]; + } + } + } else { + for (i = -1; ++i < n0; ) { + node = group[i]; + nodeData = groupData[i]; + if (node) { + node.__data__ = nodeData; + updateNodes[i] = node; + } else { + enterNodes[i] = d3_selection_dataNode(nodeData); + } + } + for (;i < m; ++i) { + enterNodes[i] = d3_selection_dataNode(groupData[i]); + } + for (;i < n; ++i) { + exitNodes[i] = group[i]; + } + } + enterNodes.update = updateNodes; + enterNodes.parentNode = updateNodes.parentNode = exitNodes.parentNode = group.parentNode; + enter.push(enterNodes); + update.push(updateNodes); + exit.push(exitNodes); + } + var enter = d3_selection_enter([]), update = d3_selection([]), exit = d3_selection([]); + if (typeof value === "function") { + while (++i < n) { + bind(group = this[i], value.call(group, group.parentNode.__data__, i)); + } + } else { + while (++i < n) { + bind(group = this[i], value); + } + } + update.enter = function() { + return enter; + }; + update.exit = function() { + return exit; + }; + return update; + }; + function d3_selection_dataNode(data) { + return { + __data__: data + }; + } + d3_selectionPrototype.datum = function(value) { + return arguments.length ? this.property("__data__", value) : this.property("__data__"); + }; + d3_selectionPrototype.filter = function(filter) { + var subgroups = [], subgroup, group, node; + if (typeof filter !== "function") filter = d3_selection_filter(filter); + for (var j = 0, m = this.length; j < m; j++) { + subgroups.push(subgroup = []); + subgroup.parentNode = (group = this[j]).parentNode; + for (var i = 0, n = group.length; i < n; i++) { + if ((node = group[i]) && filter.call(node, node.__data__, i, j)) { + subgroup.push(node); + } + } + } + return d3_selection(subgroups); + }; + function d3_selection_filter(selector) { + return function() { + return d3_selectMatches(this, selector); + }; + } + d3_selectionPrototype.order = function() { + for (var j = -1, m = this.length; ++j < m; ) { + for (var group = this[j], i = group.length - 1, next = group[i], node; --i >= 0; ) { + if (node = group[i]) { + if (next && next !== node.nextSibling) next.parentNode.insertBefore(node, next); + next = node; + } + } + } + return this; + }; + d3_selectionPrototype.sort = function(comparator) { + comparator = d3_selection_sortComparator.apply(this, arguments); + for (var j = -1, m = this.length; ++j < m; ) this[j].sort(comparator); + return this.order(); + }; + function d3_selection_sortComparator(comparator) { + if (!arguments.length) comparator = d3_ascending; + return function(a, b) { + return a && b ? comparator(a.__data__, b.__data__) : !a - !b; + }; + } + d3_selectionPrototype.each = function(callback) { + return d3_selection_each(this, function(node, i, j) { + callback.call(node, node.__data__, i, j); + }); + }; + function d3_selection_each(groups, callback) { + for (var j = 0, m = groups.length; j < m; j++) { + for (var group = groups[j], i = 0, n = group.length, node; i < n; i++) { + if (node = group[i]) callback(node, i, j); + } + } + return groups; + } + d3_selectionPrototype.call = function(callback) { + var args = d3_array(arguments); + callback.apply(args[0] = this, args); + return this; + }; + d3_selectionPrototype.empty = function() { + return !this.node(); + }; + d3_selectionPrototype.node = function() { + for (var j = 0, m = this.length; j < m; j++) { + for (var group = this[j], i = 0, n = group.length; i < n; i++) { + var node = group[i]; + if (node) return node; + } + } + return null; + }; + d3_selectionPrototype.size = function() { + var n = 0; + d3_selection_each(this, function() { + ++n; + }); + return n; + }; + function d3_selection_enter(selection) { + d3_subclass(selection, d3_selection_enterPrototype); + return selection; + } + var d3_selection_enterPrototype = []; + d3.selection.enter = d3_selection_enter; + d3.selection.enter.prototype = d3_selection_enterPrototype; + d3_selection_enterPrototype.append = d3_selectionPrototype.append; + d3_selection_enterPrototype.empty = d3_selectionPrototype.empty; + d3_selection_enterPrototype.node = d3_selectionPrototype.node; + d3_selection_enterPrototype.call = d3_selectionPrototype.call; + d3_selection_enterPrototype.size = d3_selectionPrototype.size; + d3_selection_enterPrototype.select = function(selector) { + var subgroups = [], subgroup, subnode, upgroup, group, node; + for (var j = -1, m = this.length; ++j < m; ) { + upgroup = (group = this[j]).update; + subgroups.push(subgroup = []); + subgroup.parentNode = group.parentNode; + for (var i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + subgroup.push(upgroup[i] = subnode = selector.call(group.parentNode, node.__data__, i, j)); + subnode.__data__ = node.__data__; + } else { + subgroup.push(null); + } + } + } + return d3_selection(subgroups); + }; + d3_selection_enterPrototype.insert = function(name, before) { + if (arguments.length < 2) before = d3_selection_enterInsertBefore(this); + return d3_selectionPrototype.insert.call(this, name, before); + }; + function d3_selection_enterInsertBefore(enter) { + var i0, j0; + return function(d, i, j) { + var group = enter[j].update, n = group.length, node; + if (j != j0) j0 = j, i0 = 0; + if (i >= i0) i0 = i + 1; + while (!(node = group[i0]) && ++i0 < n) ; + return node; + }; + } + d3.select = function(node) { + var group; + if (typeof node === "string") { + group = [ d3_select(node, d3_document) ]; + group.parentNode = d3_document.documentElement; + } else { + group = [ node ]; + group.parentNode = d3_documentElement(node); + } + return d3_selection([ group ]); + }; + d3.selectAll = function(nodes) { + var group; + if (typeof nodes === "string") { + group = d3_array(d3_selectAll(nodes, d3_document)); + group.parentNode = d3_document.documentElement; + } else { + group = d3_array(nodes); + group.parentNode = null; + } + return d3_selection([ group ]); + }; + d3_selectionPrototype.on = function(type, listener, capture) { + var n = arguments.length; + if (n < 3) { + if (typeof type !== "string") { + if (n < 2) listener = false; + for (capture in type) this.each(d3_selection_on(capture, type[capture], listener)); + return this; + } + if (n < 2) return (n = this.node()["__on" + type]) && n._; + capture = false; + } + return this.each(d3_selection_on(type, listener, capture)); + }; + function d3_selection_on(type, listener, capture) { + var name = "__on" + type, i = type.indexOf("."), wrap = d3_selection_onListener; + if (i > 0) type = type.slice(0, i); + var filter = d3_selection_onFilters.get(type); + if (filter) type = filter, wrap = d3_selection_onFilter; + function onRemove() { + var l = this[name]; + if (l) { + this.removeEventListener(type, l, l.$); + delete this[name]; + } + } + function onAdd() { + var l = wrap(listener, d3_array(arguments)); + onRemove.call(this); + this.addEventListener(type, this[name] = l, l.$ = capture); + l._ = listener; + } + function removeAll() { + var re = new RegExp("^__on([^.]+)" + d3.requote(type) + "$"), match; + for (var name in this) { + if (match = name.match(re)) { + var l = this[name]; + this.removeEventListener(match[1], l, l.$); + delete this[name]; + } + } + } + return i ? listener ? onAdd : onRemove : listener ? d3_noop : removeAll; + } + var d3_selection_onFilters = d3.map({ + mouseenter: "mouseover", + mouseleave: "mouseout" + }); + if (d3_document) { + d3_selection_onFilters.forEach(function(k) { + if ("on" + k in d3_document) d3_selection_onFilters.remove(k); + }); + } + function d3_selection_onListener(listener, argumentz) { + return function(e) { + var o = d3.event; + d3.event = e; + argumentz[0] = this.__data__; + try { + listener.apply(this, argumentz); + } finally { + d3.event = o; + } + }; + } + function d3_selection_onFilter(listener, argumentz) { + var l = d3_selection_onListener(listener, argumentz); + return function(e) { + var target = this, related = e.relatedTarget; + if (!related || related !== target && !(related.compareDocumentPosition(target) & 8)) { + l.call(target, e); + } + }; + } + var d3_event_dragSelect, d3_event_dragId = 0; + function d3_event_dragSuppress(node) { + var name = ".dragsuppress-" + ++d3_event_dragId, click = "click" + name, w = d3.select(d3_window(node)).on("touchmove" + name, d3_eventPreventDefault).on("dragstart" + name, d3_eventPreventDefault).on("selectstart" + name, d3_eventPreventDefault); + if (d3_event_dragSelect == null) { + d3_event_dragSelect = "onselectstart" in node ? false : d3_vendorSymbol(node.style, "userSelect"); + } + if (d3_event_dragSelect) { + var style = d3_documentElement(node).style, select = style[d3_event_dragSelect]; + style[d3_event_dragSelect] = "none"; + } + return function(suppressClick) { + w.on(name, null); + if (d3_event_dragSelect) style[d3_event_dragSelect] = select; + if (suppressClick) { + var off = function() { + w.on(click, null); + }; + w.on(click, function() { + d3_eventPreventDefault(); + off(); + }, true); + setTimeout(off, 0); + } + }; + } + d3.mouse = function(container) { + return d3_mousePoint(container, d3_eventSource()); + }; + var d3_mouse_bug44083 = this.navigator && /WebKit/.test(this.navigator.userAgent) ? -1 : 0; + function d3_mousePoint(container, e) { + if (e.changedTouches) e = e.changedTouches[0]; + var svg = container.ownerSVGElement || container; + if (svg.createSVGPoint) { + var point = svg.createSVGPoint(); + if (d3_mouse_bug44083 < 0) { + var window = d3_window(container); + if (window.scrollX || window.scrollY) { + svg = d3.select("body").append("svg").style({ + position: "absolute", + top: 0, + left: 0, + margin: 0, + padding: 0, + border: "none" + }, "important"); + var ctm = svg[0][0].getScreenCTM(); + d3_mouse_bug44083 = !(ctm.f || ctm.e); + svg.remove(); + } + } + if (d3_mouse_bug44083) point.x = e.pageX, point.y = e.pageY; else point.x = e.clientX, + point.y = e.clientY; + point = point.matrixTransform(container.getScreenCTM().inverse()); + return [ point.x, point.y ]; + } + var rect = container.getBoundingClientRect(); + return [ e.clientX - rect.left - container.clientLeft, e.clientY - rect.top - container.clientTop ]; + } + d3.touch = function(container, touches, identifier) { + if (arguments.length < 3) identifier = touches, touches = d3_eventSource().changedTouches; + if (touches) for (var i = 0, n = touches.length, touch; i < n; ++i) { + if ((touch = touches[i]).identifier === identifier) { + return d3_mousePoint(container, touch); + } + } + }; + d3.behavior.drag = function() { + var event = d3_eventDispatch(drag, "drag", "dragstart", "dragend"), origin = null, mousedown = dragstart(d3_noop, d3.mouse, d3_window, "mousemove", "mouseup"), touchstart = dragstart(d3_behavior_dragTouchId, d3.touch, d3_identity, "touchmove", "touchend"); + function drag() { + this.on("mousedown.drag", mousedown).on("touchstart.drag", touchstart); + } + function dragstart(id, position, subject, move, end) { + return function() { + var that = this, target = d3.event.target.correspondingElement || d3.event.target, parent = that.parentNode, dispatch = event.of(that, arguments), dragged = 0, dragId = id(), dragName = ".drag" + (dragId == null ? "" : "-" + dragId), dragOffset, dragSubject = d3.select(subject(target)).on(move + dragName, moved).on(end + dragName, ended), dragRestore = d3_event_dragSuppress(target), position0 = position(parent, dragId); + if (origin) { + dragOffset = origin.apply(that, arguments); + dragOffset = [ dragOffset.x - position0[0], dragOffset.y - position0[1] ]; + } else { + dragOffset = [ 0, 0 ]; + } + dispatch({ + type: "dragstart" + }); + function moved() { + var position1 = position(parent, dragId), dx, dy; + if (!position1) return; + dx = position1[0] - position0[0]; + dy = position1[1] - position0[1]; + dragged |= dx | dy; + position0 = position1; + dispatch({ + type: "drag", + x: position1[0] + dragOffset[0], + y: position1[1] + dragOffset[1], + dx: dx, + dy: dy + }); + } + function ended() { + if (!position(parent, dragId)) return; + dragSubject.on(move + dragName, null).on(end + dragName, null); + dragRestore(dragged); + dispatch({ + type: "dragend" + }); + } + }; + } + drag.origin = function(x) { + if (!arguments.length) return origin; + origin = x; + return drag; + }; + return d3.rebind(drag, event, "on"); + }; + function d3_behavior_dragTouchId() { + return d3.event.changedTouches[0].identifier; + } + d3.touches = function(container, touches) { + if (arguments.length < 2) touches = d3_eventSource().touches; + return touches ? d3_array(touches).map(function(touch) { + var point = d3_mousePoint(container, touch); + point.identifier = touch.identifier; + return point; + }) : []; + }; + var ε = 1e-6, ε2 = ε * ε, π = Math.PI, τ = 2 * π, τε = τ - ε, halfπ = π / 2, d3_radians = π / 180, d3_degrees = 180 / π; + function d3_sgn(x) { + return x > 0 ? 1 : x < 0 ? -1 : 0; + } + function d3_cross2d(a, b, c) { + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + } + function d3_acos(x) { + return x > 1 ? 0 : x < -1 ? π : Math.acos(x); + } + function d3_asin(x) { + return x > 1 ? halfπ : x < -1 ? -halfπ : Math.asin(x); + } + function d3_sinh(x) { + return ((x = Math.exp(x)) - 1 / x) / 2; + } + function d3_cosh(x) { + return ((x = Math.exp(x)) + 1 / x) / 2; + } + function d3_tanh(x) { + return ((x = Math.exp(2 * x)) - 1) / (x + 1); + } + function d3_haversin(x) { + return (x = Math.sin(x / 2)) * x; + } + var ρ = Math.SQRT2, ρ2 = 2, ρ4 = 4; + d3.interpolateZoom = function(p0, p1) { + var ux0 = p0[0], uy0 = p0[1], w0 = p0[2], ux1 = p1[0], uy1 = p1[1], w1 = p1[2], dx = ux1 - ux0, dy = uy1 - uy0, d2 = dx * dx + dy * dy, i, S; + if (d2 < ε2) { + S = Math.log(w1 / w0) / ρ; + i = function(t) { + return [ ux0 + t * dx, uy0 + t * dy, w0 * Math.exp(ρ * t * S) ]; + }; + } else { + var d1 = Math.sqrt(d2), b0 = (w1 * w1 - w0 * w0 + ρ4 * d2) / (2 * w0 * ρ2 * d1), b1 = (w1 * w1 - w0 * w0 - ρ4 * d2) / (2 * w1 * ρ2 * d1), r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0), r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1); + S = (r1 - r0) / ρ; + i = function(t) { + var s = t * S, coshr0 = d3_cosh(r0), u = w0 / (ρ2 * d1) * (coshr0 * d3_tanh(ρ * s + r0) - d3_sinh(r0)); + return [ ux0 + u * dx, uy0 + u * dy, w0 * coshr0 / d3_cosh(ρ * s + r0) ]; + }; + } + i.duration = S * 1e3; + return i; + }; + d3.behavior.zoom = function() { + var view = { + x: 0, + y: 0, + k: 1 + }, translate0, center0, center, size = [ 960, 500 ], scaleExtent = d3_behavior_zoomInfinity, duration = 250, zooming = 0, mousedown = "mousedown.zoom", mousemove = "mousemove.zoom", mouseup = "mouseup.zoom", mousewheelTimer, touchstart = "touchstart.zoom", touchtime, event = d3_eventDispatch(zoom, "zoomstart", "zoom", "zoomend"), x0, x1, y0, y1; + if (!d3_behavior_zoomWheel) { + d3_behavior_zoomWheel = "onwheel" in d3_document ? (d3_behavior_zoomDelta = function() { + return -d3.event.deltaY * (d3.event.deltaMode ? 120 : 1); + }, "wheel") : "onmousewheel" in d3_document ? (d3_behavior_zoomDelta = function() { + return d3.event.wheelDelta; + }, "mousewheel") : (d3_behavior_zoomDelta = function() { + return -d3.event.detail; + }, "MozMousePixelScroll"); + } + function zoom(g) { + g.on(mousedown, mousedowned).on(d3_behavior_zoomWheel + ".zoom", mousewheeled).on("dblclick.zoom", dblclicked).on(touchstart, touchstarted); + } + zoom.event = function(g) { + g.each(function() { + var dispatch = event.of(this, arguments), view1 = view; + if (d3_transitionInheritId) { + d3.select(this).transition().each("start.zoom", function() { + view = this.__chart__ || { + x: 0, + y: 0, + k: 1 + }; + zoomstarted(dispatch); + }).tween("zoom:zoom", function() { + var dx = size[0], dy = size[1], cx = center0 ? center0[0] : dx / 2, cy = center0 ? center0[1] : dy / 2, i = d3.interpolateZoom([ (cx - view.x) / view.k, (cy - view.y) / view.k, dx / view.k ], [ (cx - view1.x) / view1.k, (cy - view1.y) / view1.k, dx / view1.k ]); + return function(t) { + var l = i(t), k = dx / l[2]; + this.__chart__ = view = { + x: cx - l[0] * k, + y: cy - l[1] * k, + k: k + }; + zoomed(dispatch); + }; + }).each("interrupt.zoom", function() { + zoomended(dispatch); + }).each("end.zoom", function() { + zoomended(dispatch); + }); + } else { + this.__chart__ = view; + zoomstarted(dispatch); + zoomed(dispatch); + zoomended(dispatch); + } + }); + }; + zoom.translate = function(_) { + if (!arguments.length) return [ view.x, view.y ]; + view = { + x: +_[0], + y: +_[1], + k: view.k + }; + rescale(); + return zoom; + }; + zoom.scale = function(_) { + if (!arguments.length) return view.k; + view = { + x: view.x, + y: view.y, + k: null + }; + scaleTo(+_); + rescale(); + return zoom; + }; + zoom.scaleExtent = function(_) { + if (!arguments.length) return scaleExtent; + scaleExtent = _ == null ? d3_behavior_zoomInfinity : [ +_[0], +_[1] ]; + return zoom; + }; + zoom.center = function(_) { + if (!arguments.length) return center; + center = _ && [ +_[0], +_[1] ]; + return zoom; + }; + zoom.size = function(_) { + if (!arguments.length) return size; + size = _ && [ +_[0], +_[1] ]; + return zoom; + }; + zoom.duration = function(_) { + if (!arguments.length) return duration; + duration = +_; + return zoom; + }; + zoom.x = function(z) { + if (!arguments.length) return x1; + x1 = z; + x0 = z.copy(); + view = { + x: 0, + y: 0, + k: 1 + }; + return zoom; + }; + zoom.y = function(z) { + if (!arguments.length) return y1; + y1 = z; + y0 = z.copy(); + view = { + x: 0, + y: 0, + k: 1 + }; + return zoom; + }; + function location(p) { + return [ (p[0] - view.x) / view.k, (p[1] - view.y) / view.k ]; + } + function point(l) { + return [ l[0] * view.k + view.x, l[1] * view.k + view.y ]; + } + function scaleTo(s) { + view.k = Math.max(scaleExtent[0], Math.min(scaleExtent[1], s)); + } + function translateTo(p, l) { + l = point(l); + view.x += p[0] - l[0]; + view.y += p[1] - l[1]; + } + function zoomTo(that, p, l, k) { + that.__chart__ = { + x: view.x, + y: view.y, + k: view.k + }; + scaleTo(Math.pow(2, k)); + translateTo(center0 = p, l); + that = d3.select(that); + if (duration > 0) that = that.transition().duration(duration); + that.call(zoom.event); + } + function rescale() { + if (x1) x1.domain(x0.range().map(function(x) { + return (x - view.x) / view.k; + }).map(x0.invert)); + if (y1) y1.domain(y0.range().map(function(y) { + return (y - view.y) / view.k; + }).map(y0.invert)); + } + function zoomstarted(dispatch) { + if (!zooming++) dispatch({ + type: "zoomstart" + }); + } + function zoomed(dispatch) { + rescale(); + dispatch({ + type: "zoom", + scale: view.k, + translate: [ view.x, view.y ] + }); + } + function zoomended(dispatch) { + if (!--zooming) dispatch({ + type: "zoomend" + }), center0 = null; + } + function mousedowned() { + var that = this, dispatch = event.of(that, arguments), dragged = 0, subject = d3.select(d3_window(that)).on(mousemove, moved).on(mouseup, ended), location0 = location(d3.mouse(that)), dragRestore = d3_event_dragSuppress(that); + d3_selection_interrupt.call(that); + zoomstarted(dispatch); + function moved() { + dragged = 1; + translateTo(d3.mouse(that), location0); + zoomed(dispatch); + } + function ended() { + subject.on(mousemove, null).on(mouseup, null); + dragRestore(dragged); + zoomended(dispatch); + } + } + function touchstarted() { + var that = this, dispatch = event.of(that, arguments), locations0 = {}, distance0 = 0, scale0, zoomName = ".zoom-" + d3.event.changedTouches[0].identifier, touchmove = "touchmove" + zoomName, touchend = "touchend" + zoomName, targets = [], subject = d3.select(that), dragRestore = d3_event_dragSuppress(that); + started(); + zoomstarted(dispatch); + subject.on(mousedown, null).on(touchstart, started); + function relocate() { + var touches = d3.touches(that); + scale0 = view.k; + touches.forEach(function(t) { + if (t.identifier in locations0) locations0[t.identifier] = location(t); + }); + return touches; + } + function started() { + var target = d3.event.target; + d3.select(target).on(touchmove, moved).on(touchend, ended); + targets.push(target); + var changed = d3.event.changedTouches; + for (var i = 0, n = changed.length; i < n; ++i) { + locations0[changed[i].identifier] = null; + } + var touches = relocate(), now = Date.now(); + if (touches.length === 1) { + if (now - touchtime < 500) { + var p = touches[0]; + zoomTo(that, p, locations0[p.identifier], Math.floor(Math.log(view.k) / Math.LN2) + 1); + d3_eventPreventDefault(); + } + touchtime = now; + } else if (touches.length > 1) { + var p = touches[0], q = touches[1], dx = p[0] - q[0], dy = p[1] - q[1]; + distance0 = dx * dx + dy * dy; + } + } + function moved() { + var touches = d3.touches(that), p0, l0, p1, l1; + d3_selection_interrupt.call(that); + for (var i = 0, n = touches.length; i < n; ++i, l1 = null) { + p1 = touches[i]; + if (l1 = locations0[p1.identifier]) { + if (l0) break; + p0 = p1, l0 = l1; + } + } + if (l1) { + var distance1 = (distance1 = p1[0] - p0[0]) * distance1 + (distance1 = p1[1] - p0[1]) * distance1, scale1 = distance0 && Math.sqrt(distance1 / distance0); + p0 = [ (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2 ]; + l0 = [ (l0[0] + l1[0]) / 2, (l0[1] + l1[1]) / 2 ]; + scaleTo(scale1 * scale0); + } + touchtime = null; + translateTo(p0, l0); + zoomed(dispatch); + } + function ended() { + if (d3.event.touches.length) { + var changed = d3.event.changedTouches; + for (var i = 0, n = changed.length; i < n; ++i) { + delete locations0[changed[i].identifier]; + } + for (var identifier in locations0) { + return void relocate(); + } + } + d3.selectAll(targets).on(zoomName, null); + subject.on(mousedown, mousedowned).on(touchstart, touchstarted); + dragRestore(); + zoomended(dispatch); + } + } + function mousewheeled() { + var dispatch = event.of(this, arguments); + if (mousewheelTimer) clearTimeout(mousewheelTimer); else d3_selection_interrupt.call(this), + translate0 = location(center0 = center || d3.mouse(this)), zoomstarted(dispatch); + mousewheelTimer = setTimeout(function() { + mousewheelTimer = null; + zoomended(dispatch); + }, 50); + d3_eventPreventDefault(); + scaleTo(Math.pow(2, d3_behavior_zoomDelta() * .002) * view.k); + translateTo(center0, translate0); + zoomed(dispatch); + } + function dblclicked() { + var p = d3.mouse(this), k = Math.log(view.k) / Math.LN2; + zoomTo(this, p, location(p), d3.event.shiftKey ? Math.ceil(k) - 1 : Math.floor(k) + 1); + } + return d3.rebind(zoom, event, "on"); + }; + var d3_behavior_zoomInfinity = [ 0, Infinity ], d3_behavior_zoomDelta, d3_behavior_zoomWheel; + d3.color = d3_color; + function d3_color() {} + d3_color.prototype.toString = function() { + return this.rgb() + ""; + }; + d3.hsl = d3_hsl; + function d3_hsl(h, s, l) { + return this instanceof d3_hsl ? void (this.h = +h, this.s = +s, this.l = +l) : arguments.length < 2 ? h instanceof d3_hsl ? new d3_hsl(h.h, h.s, h.l) : d3_rgb_parse("" + h, d3_rgb_hsl, d3_hsl) : new d3_hsl(h, s, l); + } + var d3_hslPrototype = d3_hsl.prototype = new d3_color(); + d3_hslPrototype.brighter = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + return new d3_hsl(this.h, this.s, this.l / k); + }; + d3_hslPrototype.darker = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + return new d3_hsl(this.h, this.s, k * this.l); + }; + d3_hslPrototype.rgb = function() { + return d3_hsl_rgb(this.h, this.s, this.l); + }; + function d3_hsl_rgb(h, s, l) { + var m1, m2; + h = isNaN(h) ? 0 : (h %= 360) < 0 ? h + 360 : h; + s = isNaN(s) ? 0 : s < 0 ? 0 : s > 1 ? 1 : s; + l = l < 0 ? 0 : l > 1 ? 1 : l; + m2 = l <= .5 ? l * (1 + s) : l + s - l * s; + m1 = 2 * l - m2; + function v(h) { + if (h > 360) h -= 360; else if (h < 0) h += 360; + if (h < 60) return m1 + (m2 - m1) * h / 60; + if (h < 180) return m2; + if (h < 240) return m1 + (m2 - m1) * (240 - h) / 60; + return m1; + } + function vv(h) { + return Math.round(v(h) * 255); + } + return new d3_rgb(vv(h + 120), vv(h), vv(h - 120)); + } + d3.hcl = d3_hcl; + function d3_hcl(h, c, l) { + return this instanceof d3_hcl ? void (this.h = +h, this.c = +c, this.l = +l) : arguments.length < 2 ? h instanceof d3_hcl ? new d3_hcl(h.h, h.c, h.l) : h instanceof d3_lab ? d3_lab_hcl(h.l, h.a, h.b) : d3_lab_hcl((h = d3_rgb_lab((h = d3.rgb(h)).r, h.g, h.b)).l, h.a, h.b) : new d3_hcl(h, c, l); + } + var d3_hclPrototype = d3_hcl.prototype = new d3_color(); + d3_hclPrototype.brighter = function(k) { + return new d3_hcl(this.h, this.c, Math.min(100, this.l + d3_lab_K * (arguments.length ? k : 1))); + }; + d3_hclPrototype.darker = function(k) { + return new d3_hcl(this.h, this.c, Math.max(0, this.l - d3_lab_K * (arguments.length ? k : 1))); + }; + d3_hclPrototype.rgb = function() { + return d3_hcl_lab(this.h, this.c, this.l).rgb(); + }; + function d3_hcl_lab(h, c, l) { + if (isNaN(h)) h = 0; + if (isNaN(c)) c = 0; + return new d3_lab(l, Math.cos(h *= d3_radians) * c, Math.sin(h) * c); + } + d3.lab = d3_lab; + function d3_lab(l, a, b) { + return this instanceof d3_lab ? void (this.l = +l, this.a = +a, this.b = +b) : arguments.length < 2 ? l instanceof d3_lab ? new d3_lab(l.l, l.a, l.b) : l instanceof d3_hcl ? d3_hcl_lab(l.h, l.c, l.l) : d3_rgb_lab((l = d3_rgb(l)).r, l.g, l.b) : new d3_lab(l, a, b); + } + var d3_lab_K = 18; + var d3_lab_X = .95047, d3_lab_Y = 1, d3_lab_Z = 1.08883; + var d3_labPrototype = d3_lab.prototype = new d3_color(); + d3_labPrototype.brighter = function(k) { + return new d3_lab(Math.min(100, this.l + d3_lab_K * (arguments.length ? k : 1)), this.a, this.b); + }; + d3_labPrototype.darker = function(k) { + return new d3_lab(Math.max(0, this.l - d3_lab_K * (arguments.length ? k : 1)), this.a, this.b); + }; + d3_labPrototype.rgb = function() { + return d3_lab_rgb(this.l, this.a, this.b); + }; + function d3_lab_rgb(l, a, b) { + var y = (l + 16) / 116, x = y + a / 500, z = y - b / 200; + x = d3_lab_xyz(x) * d3_lab_X; + y = d3_lab_xyz(y) * d3_lab_Y; + z = d3_lab_xyz(z) * d3_lab_Z; + return new d3_rgb(d3_xyz_rgb(3.2404542 * x - 1.5371385 * y - .4985314 * z), d3_xyz_rgb(-.969266 * x + 1.8760108 * y + .041556 * z), d3_xyz_rgb(.0556434 * x - .2040259 * y + 1.0572252 * z)); + } + function d3_lab_hcl(l, a, b) { + return l > 0 ? new d3_hcl(Math.atan2(b, a) * d3_degrees, Math.sqrt(a * a + b * b), l) : new d3_hcl(NaN, NaN, l); + } + function d3_lab_xyz(x) { + return x > .206893034 ? x * x * x : (x - 4 / 29) / 7.787037; + } + function d3_xyz_lab(x) { + return x > .008856 ? Math.pow(x, 1 / 3) : 7.787037 * x + 4 / 29; + } + function d3_xyz_rgb(r) { + return Math.round(255 * (r <= .00304 ? 12.92 * r : 1.055 * Math.pow(r, 1 / 2.4) - .055)); + } + d3.rgb = d3_rgb; + function d3_rgb(r, g, b) { + return this instanceof d3_rgb ? void (this.r = ~~r, this.g = ~~g, this.b = ~~b) : arguments.length < 2 ? r instanceof d3_rgb ? new d3_rgb(r.r, r.g, r.b) : d3_rgb_parse("" + r, d3_rgb, d3_hsl_rgb) : new d3_rgb(r, g, b); + } + function d3_rgbNumber(value) { + return new d3_rgb(value >> 16, value >> 8 & 255, value & 255); + } + function d3_rgbString(value) { + return d3_rgbNumber(value) + ""; + } + var d3_rgbPrototype = d3_rgb.prototype = new d3_color(); + d3_rgbPrototype.brighter = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + var r = this.r, g = this.g, b = this.b, i = 30; + if (!r && !g && !b) return new d3_rgb(i, i, i); + if (r && r < i) r = i; + if (g && g < i) g = i; + if (b && b < i) b = i; + return new d3_rgb(Math.min(255, r / k), Math.min(255, g / k), Math.min(255, b / k)); + }; + d3_rgbPrototype.darker = function(k) { + k = Math.pow(.7, arguments.length ? k : 1); + return new d3_rgb(k * this.r, k * this.g, k * this.b); + }; + d3_rgbPrototype.hsl = function() { + return d3_rgb_hsl(this.r, this.g, this.b); + }; + d3_rgbPrototype.toString = function() { + return "#" + d3_rgb_hex(this.r) + d3_rgb_hex(this.g) + d3_rgb_hex(this.b); + }; + function d3_rgb_hex(v) { + return v < 16 ? "0" + Math.max(0, v).toString(16) : Math.min(255, v).toString(16); + } + function d3_rgb_parse(format, rgb, hsl) { + var r = 0, g = 0, b = 0, m1, m2, color; + m1 = /([a-z]+)\((.*)\)/.exec(format = format.toLowerCase()); + if (m1) { + m2 = m1[2].split(","); + switch (m1[1]) { + case "hsl": + { + return hsl(parseFloat(m2[0]), parseFloat(m2[1]) / 100, parseFloat(m2[2]) / 100); + } + + case "rgb": + { + return rgb(d3_rgb_parseNumber(m2[0]), d3_rgb_parseNumber(m2[1]), d3_rgb_parseNumber(m2[2])); + } + } + } + if (color = d3_rgb_names.get(format)) { + return rgb(color.r, color.g, color.b); + } + if (format != null && format.charAt(0) === "#" && !isNaN(color = parseInt(format.slice(1), 16))) { + if (format.length === 4) { + r = (color & 3840) >> 4; + r = r >> 4 | r; + g = color & 240; + g = g >> 4 | g; + b = color & 15; + b = b << 4 | b; + } else if (format.length === 7) { + r = (color & 16711680) >> 16; + g = (color & 65280) >> 8; + b = color & 255; + } + } + return rgb(r, g, b); + } + function d3_rgb_hsl(r, g, b) { + var min = Math.min(r /= 255, g /= 255, b /= 255), max = Math.max(r, g, b), d = max - min, h, s, l = (max + min) / 2; + if (d) { + s = l < .5 ? d / (max + min) : d / (2 - max - min); + if (r == max) h = (g - b) / d + (g < b ? 6 : 0); else if (g == max) h = (b - r) / d + 2; else h = (r - g) / d + 4; + h *= 60; + } else { + h = NaN; + s = l > 0 && l < 1 ? 0 : h; + } + return new d3_hsl(h, s, l); + } + function d3_rgb_lab(r, g, b) { + r = d3_rgb_xyz(r); + g = d3_rgb_xyz(g); + b = d3_rgb_xyz(b); + var x = d3_xyz_lab((.4124564 * r + .3575761 * g + .1804375 * b) / d3_lab_X), y = d3_xyz_lab((.2126729 * r + .7151522 * g + .072175 * b) / d3_lab_Y), z = d3_xyz_lab((.0193339 * r + .119192 * g + .9503041 * b) / d3_lab_Z); + return d3_lab(116 * y - 16, 500 * (x - y), 200 * (y - z)); + } + function d3_rgb_xyz(r) { + return (r /= 255) <= .04045 ? r / 12.92 : Math.pow((r + .055) / 1.055, 2.4); + } + function d3_rgb_parseNumber(c) { + var f = parseFloat(c); + return c.charAt(c.length - 1) === "%" ? Math.round(f * 2.55) : f; + } + var d3_rgb_names = d3.map({ + aliceblue: 15792383, + antiquewhite: 16444375, + aqua: 65535, + aquamarine: 8388564, + azure: 15794175, + beige: 16119260, + bisque: 16770244, + black: 0, + blanchedalmond: 16772045, + blue: 255, + blueviolet: 9055202, + brown: 10824234, + burlywood: 14596231, + cadetblue: 6266528, + chartreuse: 8388352, + chocolate: 13789470, + coral: 16744272, + cornflowerblue: 6591981, + cornsilk: 16775388, + crimson: 14423100, + cyan: 65535, + darkblue: 139, + darkcyan: 35723, + darkgoldenrod: 12092939, + darkgray: 11119017, + darkgreen: 25600, + darkgrey: 11119017, + darkkhaki: 12433259, + darkmagenta: 9109643, + darkolivegreen: 5597999, + darkorange: 16747520, + darkorchid: 10040012, + darkred: 9109504, + darksalmon: 15308410, + darkseagreen: 9419919, + darkslateblue: 4734347, + darkslategray: 3100495, + darkslategrey: 3100495, + darkturquoise: 52945, + darkviolet: 9699539, + deeppink: 16716947, + deepskyblue: 49151, + dimgray: 6908265, + dimgrey: 6908265, + dodgerblue: 2003199, + firebrick: 11674146, + floralwhite: 16775920, + forestgreen: 2263842, + fuchsia: 16711935, + gainsboro: 14474460, + ghostwhite: 16316671, + gold: 16766720, + goldenrod: 14329120, + gray: 8421504, + green: 32768, + greenyellow: 11403055, + grey: 8421504, + honeydew: 15794160, + hotpink: 16738740, + indianred: 13458524, + indigo: 4915330, + ivory: 16777200, + khaki: 15787660, + lavender: 15132410, + lavenderblush: 16773365, + lawngreen: 8190976, + lemonchiffon: 16775885, + lightblue: 11393254, + lightcoral: 15761536, + lightcyan: 14745599, + lightgoldenrodyellow: 16448210, + lightgray: 13882323, + lightgreen: 9498256, + lightgrey: 13882323, + lightpink: 16758465, + lightsalmon: 16752762, + lightseagreen: 2142890, + lightskyblue: 8900346, + lightslategray: 7833753, + lightslategrey: 7833753, + lightsteelblue: 11584734, + lightyellow: 16777184, + lime: 65280, + limegreen: 3329330, + linen: 16445670, + magenta: 16711935, + maroon: 8388608, + mediumaquamarine: 6737322, + mediumblue: 205, + mediumorchid: 12211667, + mediumpurple: 9662683, + mediumseagreen: 3978097, + mediumslateblue: 8087790, + mediumspringgreen: 64154, + mediumturquoise: 4772300, + mediumvioletred: 13047173, + midnightblue: 1644912, + mintcream: 16121850, + mistyrose: 16770273, + moccasin: 16770229, + navajowhite: 16768685, + navy: 128, + oldlace: 16643558, + olive: 8421376, + olivedrab: 7048739, + orange: 16753920, + orangered: 16729344, + orchid: 14315734, + palegoldenrod: 15657130, + palegreen: 10025880, + paleturquoise: 11529966, + palevioletred: 14381203, + papayawhip: 16773077, + peachpuff: 16767673, + peru: 13468991, + pink: 16761035, + plum: 14524637, + powderblue: 11591910, + purple: 8388736, + rebeccapurple: 6697881, + red: 16711680, + rosybrown: 12357519, + royalblue: 4286945, + saddlebrown: 9127187, + salmon: 16416882, + sandybrown: 16032864, + seagreen: 3050327, + seashell: 16774638, + sienna: 10506797, + silver: 12632256, + skyblue: 8900331, + slateblue: 6970061, + slategray: 7372944, + slategrey: 7372944, + snow: 16775930, + springgreen: 65407, + steelblue: 4620980, + tan: 13808780, + teal: 32896, + thistle: 14204888, + tomato: 16737095, + turquoise: 4251856, + violet: 15631086, + wheat: 16113331, + white: 16777215, + whitesmoke: 16119285, + yellow: 16776960, + yellowgreen: 10145074 + }); + d3_rgb_names.forEach(function(key, value) { + d3_rgb_names.set(key, d3_rgbNumber(value)); + }); + function d3_functor(v) { + return typeof v === "function" ? v : function() { + return v; + }; + } + d3.functor = d3_functor; + d3.xhr = d3_xhrType(d3_identity); + function d3_xhrType(response) { + return function(url, mimeType, callback) { + if (arguments.length === 2 && typeof mimeType === "function") callback = mimeType, + mimeType = null; + return d3_xhr(url, mimeType, response, callback); + }; + } + function d3_xhr(url, mimeType, response, callback) { + var xhr = {}, dispatch = d3.dispatch("beforesend", "progress", "load", "error"), headers = {}, request = new XMLHttpRequest(), responseType = null; + if (this.XDomainRequest && !("withCredentials" in request) && /^(http(s)?:)?\/\//.test(url)) request = new XDomainRequest(); + "onload" in request ? request.onload = request.onerror = respond : request.onreadystatechange = function() { + request.readyState > 3 && respond(); + }; + function respond() { + var status = request.status, result; + if (!status && d3_xhrHasResponse(request) || status >= 200 && status < 300 || status === 304) { + try { + result = response.call(xhr, request); + } catch (e) { + dispatch.error.call(xhr, e); + return; + } + dispatch.load.call(xhr, result); + } else { + dispatch.error.call(xhr, request); + } + } + request.onprogress = function(event) { + var o = d3.event; + d3.event = event; + try { + dispatch.progress.call(xhr, request); + } finally { + d3.event = o; + } + }; + xhr.header = function(name, value) { + name = (name + "").toLowerCase(); + if (arguments.length < 2) return headers[name]; + if (value == null) delete headers[name]; else headers[name] = value + ""; + return xhr; + }; + xhr.mimeType = function(value) { + if (!arguments.length) return mimeType; + mimeType = value == null ? null : value + ""; + return xhr; + }; + xhr.responseType = function(value) { + if (!arguments.length) return responseType; + responseType = value; + return xhr; + }; + xhr.response = function(value) { + response = value; + return xhr; + }; + [ "get", "post" ].forEach(function(method) { + xhr[method] = function() { + return xhr.send.apply(xhr, [ method ].concat(d3_array(arguments))); + }; + }); + xhr.send = function(method, data, callback) { + if (arguments.length === 2 && typeof data === "function") callback = data, data = null; + request.open(method, url, true); + if (mimeType != null && !("accept" in headers)) headers["accept"] = mimeType + ",*/*"; + if (request.setRequestHeader) for (var name in headers) request.setRequestHeader(name, headers[name]); + if (mimeType != null && request.overrideMimeType) request.overrideMimeType(mimeType); + if (responseType != null) request.responseType = responseType; + if (callback != null) xhr.on("error", callback).on("load", function(request) { + callback(null, request); + }); + dispatch.beforesend.call(xhr, request); + request.send(data == null ? null : data); + return xhr; + }; + xhr.abort = function() { + request.abort(); + return xhr; + }; + d3.rebind(xhr, dispatch, "on"); + return callback == null ? xhr : xhr.get(d3_xhr_fixCallback(callback)); + } + function d3_xhr_fixCallback(callback) { + return callback.length === 1 ? function(error, request) { + callback(error == null ? request : null); + } : callback; + } + function d3_xhrHasResponse(request) { + var type = request.responseType; + return type && type !== "text" ? request.response : request.responseText; + } + d3.dsv = function(delimiter, mimeType) { + var reFormat = new RegExp('["' + delimiter + "\n]"), delimiterCode = delimiter.charCodeAt(0); + function dsv(url, row, callback) { + if (arguments.length < 3) callback = row, row = null; + var xhr = d3_xhr(url, mimeType, row == null ? response : typedResponse(row), callback); + xhr.row = function(_) { + return arguments.length ? xhr.response((row = _) == null ? response : typedResponse(_)) : row; + }; + return xhr; + } + function response(request) { + return dsv.parse(request.responseText); + } + function typedResponse(f) { + return function(request) { + return dsv.parse(request.responseText, f); + }; + } + dsv.parse = function(text, f) { + var o; + return dsv.parseRows(text, function(row, i) { + if (o) return o(row, i - 1); + var a = new Function("d", "return {" + row.map(function(name, i) { + return JSON.stringify(name) + ": d[" + i + "]"; + }).join(",") + "}"); + o = f ? function(row, i) { + return f(a(row), i); + } : a; + }); + }; + dsv.parseRows = function(text, f) { + var EOL = {}, EOF = {}, rows = [], N = text.length, I = 0, n = 0, t, eol; + function token() { + if (I >= N) return EOF; + if (eol) return eol = false, EOL; + var j = I; + if (text.charCodeAt(j) === 34) { + var i = j; + while (i++ < N) { + if (text.charCodeAt(i) === 34) { + if (text.charCodeAt(i + 1) !== 34) break; + ++i; + } + } + I = i + 2; + var c = text.charCodeAt(i + 1); + if (c === 13) { + eol = true; + if (text.charCodeAt(i + 2) === 10) ++I; + } else if (c === 10) { + eol = true; + } + return text.slice(j + 1, i).replace(/""/g, '"'); + } + while (I < N) { + var c = text.charCodeAt(I++), k = 1; + if (c === 10) eol = true; else if (c === 13) { + eol = true; + if (text.charCodeAt(I) === 10) ++I, ++k; + } else if (c !== delimiterCode) continue; + return text.slice(j, I - k); + } + return text.slice(j); + } + while ((t = token()) !== EOF) { + var a = []; + while (t !== EOL && t !== EOF) { + a.push(t); + t = token(); + } + if (f && (a = f(a, n++)) == null) continue; + rows.push(a); + } + return rows; + }; + dsv.format = function(rows) { + if (Array.isArray(rows[0])) return dsv.formatRows(rows); + var fieldSet = new d3_Set(), fields = []; + rows.forEach(function(row) { + for (var field in row) { + if (!fieldSet.has(field)) { + fields.push(fieldSet.add(field)); + } + } + }); + return [ fields.map(formatValue).join(delimiter) ].concat(rows.map(function(row) { + return fields.map(function(field) { + return formatValue(row[field]); + }).join(delimiter); + })).join("\n"); + }; + dsv.formatRows = function(rows) { + return rows.map(formatRow).join("\n"); + }; + function formatRow(row) { + return row.map(formatValue).join(delimiter); + } + function formatValue(text) { + return reFormat.test(text) ? '"' + text.replace(/\"/g, '""') + '"' : text; + } + return dsv; + }; + d3.csv = d3.dsv(",", "text/csv"); + d3.tsv = d3.dsv(" ", "text/tab-separated-values"); + var d3_timer_queueHead, d3_timer_queueTail, d3_timer_interval, d3_timer_timeout, d3_timer_frame = this[d3_vendorSymbol(this, "requestAnimationFrame")] || function(callback) { + setTimeout(callback, 17); + }; + d3.timer = function() { + d3_timer.apply(this, arguments); + }; + function d3_timer(callback, delay, then) { + var n = arguments.length; + if (n < 2) delay = 0; + if (n < 3) then = Date.now(); + var time = then + delay, timer = { + c: callback, + t: time, + n: null + }; + if (d3_timer_queueTail) d3_timer_queueTail.n = timer; else d3_timer_queueHead = timer; + d3_timer_queueTail = timer; + if (!d3_timer_interval) { + d3_timer_timeout = clearTimeout(d3_timer_timeout); + d3_timer_interval = 1; + d3_timer_frame(d3_timer_step); + } + return timer; + } + function d3_timer_step() { + var now = d3_timer_mark(), delay = d3_timer_sweep() - now; + if (delay > 24) { + if (isFinite(delay)) { + clearTimeout(d3_timer_timeout); + d3_timer_timeout = setTimeout(d3_timer_step, delay); + } + d3_timer_interval = 0; + } else { + d3_timer_interval = 1; + d3_timer_frame(d3_timer_step); + } + } + d3.timer.flush = function() { + d3_timer_mark(); + d3_timer_sweep(); + }; + function d3_timer_mark() { + var now = Date.now(), timer = d3_timer_queueHead; + while (timer) { + if (now >= timer.t && timer.c(now - timer.t)) timer.c = null; + timer = timer.n; + } + return now; + } + function d3_timer_sweep() { + var t0, t1 = d3_timer_queueHead, time = Infinity; + while (t1) { + if (t1.c) { + if (t1.t < time) time = t1.t; + t1 = (t0 = t1).n; + } else { + t1 = t0 ? t0.n = t1.n : d3_timer_queueHead = t1.n; + } + } + d3_timer_queueTail = t0; + return time; + } + function d3_format_precision(x, p) { + return p - (x ? Math.ceil(Math.log(x) / Math.LN10) : 1); + } + d3.round = function(x, n) { + return n ? Math.round(x * (n = Math.pow(10, n))) / n : Math.round(x); + }; + var d3_formatPrefixes = [ "y", "z", "a", "f", "p", "n", "µ", "m", "", "k", "M", "G", "T", "P", "E", "Z", "Y" ].map(d3_formatPrefix); + d3.formatPrefix = function(value, precision) { + var i = 0; + if (value = +value) { + if (value < 0) value *= -1; + if (precision) value = d3.round(value, d3_format_precision(value, precision)); + i = 1 + Math.floor(1e-12 + Math.log(value) / Math.LN10); + i = Math.max(-24, Math.min(24, Math.floor((i - 1) / 3) * 3)); + } + return d3_formatPrefixes[8 + i / 3]; + }; + function d3_formatPrefix(d, i) { + var k = Math.pow(10, abs(8 - i) * 3); + return { + scale: i > 8 ? function(d) { + return d / k; + } : function(d) { + return d * k; + }, + symbol: d + }; + } + function d3_locale_numberFormat(locale) { + var locale_decimal = locale.decimal, locale_thousands = locale.thousands, locale_grouping = locale.grouping, locale_currency = locale.currency, formatGroup = locale_grouping && locale_thousands ? function(value, width) { + var i = value.length, t = [], j = 0, g = locale_grouping[0], length = 0; + while (i > 0 && g > 0) { + if (length + g + 1 > width) g = Math.max(1, width - length); + t.push(value.substring(i -= g, i + g)); + if ((length += g + 1) > width) break; + g = locale_grouping[j = (j + 1) % locale_grouping.length]; + } + return t.reverse().join(locale_thousands); + } : d3_identity; + return function(specifier) { + var match = d3_format_re.exec(specifier), fill = match[1] || " ", align = match[2] || ">", sign = match[3] || "-", symbol = match[4] || "", zfill = match[5], width = +match[6], comma = match[7], precision = match[8], type = match[9], scale = 1, prefix = "", suffix = "", integer = false, exponent = true; + if (precision) precision = +precision.substring(1); + if (zfill || fill === "0" && align === "=") { + zfill = fill = "0"; + align = "="; + } + switch (type) { + case "n": + comma = true; + type = "g"; + break; + + case "%": + scale = 100; + suffix = "%"; + type = "f"; + break; + + case "p": + scale = 100; + suffix = "%"; + type = "r"; + break; + + case "b": + case "o": + case "x": + case "X": + if (symbol === "#") prefix = "0" + type.toLowerCase(); + + case "c": + exponent = false; + + case "d": + integer = true; + precision = 0; + break; + + case "s": + scale = -1; + type = "r"; + break; + } + if (symbol === "$") prefix = locale_currency[0], suffix = locale_currency[1]; + if (type == "r" && !precision) type = "g"; + if (precision != null) { + if (type == "g") precision = Math.max(1, Math.min(21, precision)); else if (type == "e" || type == "f") precision = Math.max(0, Math.min(20, precision)); + } + type = d3_format_types.get(type) || d3_format_typeDefault; + var zcomma = zfill && comma; + return function(value) { + var fullSuffix = suffix; + if (integer && value % 1) return ""; + var negative = value < 0 || value === 0 && 1 / value < 0 ? (value = -value, "-") : sign === "-" ? "" : sign; + if (scale < 0) { + var unit = d3.formatPrefix(value, precision); + value = unit.scale(value); + fullSuffix = unit.symbol + suffix; + } else { + value *= scale; + } + value = type(value, precision); + var i = value.lastIndexOf("."), before, after; + if (i < 0) { + var j = exponent ? value.lastIndexOf("e") : -1; + if (j < 0) before = value, after = ""; else before = value.substring(0, j), after = value.substring(j); + } else { + before = value.substring(0, i); + after = locale_decimal + value.substring(i + 1); + } + if (!zfill && comma) before = formatGroup(before, Infinity); + var length = prefix.length + before.length + after.length + (zcomma ? 0 : negative.length), padding = length < width ? new Array(length = width - length + 1).join(fill) : ""; + if (zcomma) before = formatGroup(padding + before, padding.length ? width - after.length : Infinity); + negative += prefix; + value = before + after; + return (align === "<" ? negative + value + padding : align === ">" ? padding + negative + value : align === "^" ? padding.substring(0, length >>= 1) + negative + value + padding.substring(length) : negative + (zcomma ? value : padding + value)) + fullSuffix; + }; + }; + } + var d3_format_re = /(?:([^{])?([<>=^]))?([+\- ])?([$#])?(0)?(\d+)?(,)?(\.-?\d+)?([a-z%])?/i; + var d3_format_types = d3.map({ + b: function(x) { + return x.toString(2); + }, + c: function(x) { + return String.fromCharCode(x); + }, + o: function(x) { + return x.toString(8); + }, + x: function(x) { + return x.toString(16); + }, + X: function(x) { + return x.toString(16).toUpperCase(); + }, + g: function(x, p) { + return x.toPrecision(p); + }, + e: function(x, p) { + return x.toExponential(p); + }, + f: function(x, p) { + return x.toFixed(p); + }, + r: function(x, p) { + return (x = d3.round(x, d3_format_precision(x, p))).toFixed(Math.max(0, Math.min(20, d3_format_precision(x * (1 + 1e-15), p)))); + } + }); + function d3_format_typeDefault(x) { + return x + ""; + } + var d3_time = d3.time = {}, d3_date = Date; + function d3_date_utc() { + this._ = new Date(arguments.length > 1 ? Date.UTC.apply(this, arguments) : arguments[0]); + } + d3_date_utc.prototype = { + getDate: function() { + return this._.getUTCDate(); + }, + getDay: function() { + return this._.getUTCDay(); + }, + getFullYear: function() { + return this._.getUTCFullYear(); + }, + getHours: function() { + return this._.getUTCHours(); + }, + getMilliseconds: function() { + return this._.getUTCMilliseconds(); + }, + getMinutes: function() { + return this._.getUTCMinutes(); + }, + getMonth: function() { + return this._.getUTCMonth(); + }, + getSeconds: function() { + return this._.getUTCSeconds(); + }, + getTime: function() { + return this._.getTime(); + }, + getTimezoneOffset: function() { + return 0; + }, + valueOf: function() { + return this._.valueOf(); + }, + setDate: function() { + d3_time_prototype.setUTCDate.apply(this._, arguments); + }, + setDay: function() { + d3_time_prototype.setUTCDay.apply(this._, arguments); + }, + setFullYear: function() { + d3_time_prototype.setUTCFullYear.apply(this._, arguments); + }, + setHours: function() { + d3_time_prototype.setUTCHours.apply(this._, arguments); + }, + setMilliseconds: function() { + d3_time_prototype.setUTCMilliseconds.apply(this._, arguments); + }, + setMinutes: function() { + d3_time_prototype.setUTCMinutes.apply(this._, arguments); + }, + setMonth: function() { + d3_time_prototype.setUTCMonth.apply(this._, arguments); + }, + setSeconds: function() { + d3_time_prototype.setUTCSeconds.apply(this._, arguments); + }, + setTime: function() { + d3_time_prototype.setTime.apply(this._, arguments); + } + }; + var d3_time_prototype = Date.prototype; + function d3_time_interval(local, step, number) { + function round(date) { + var d0 = local(date), d1 = offset(d0, 1); + return date - d0 < d1 - date ? d0 : d1; + } + function ceil(date) { + step(date = local(new d3_date(date - 1)), 1); + return date; + } + function offset(date, k) { + step(date = new d3_date(+date), k); + return date; + } + function range(t0, t1, dt) { + var time = ceil(t0), times = []; + if (dt > 1) { + while (time < t1) { + if (!(number(time) % dt)) times.push(new Date(+time)); + step(time, 1); + } + } else { + while (time < t1) times.push(new Date(+time)), step(time, 1); + } + return times; + } + function range_utc(t0, t1, dt) { + try { + d3_date = d3_date_utc; + var utc = new d3_date_utc(); + utc._ = t0; + return range(utc, t1, dt); + } finally { + d3_date = Date; + } + } + local.floor = local; + local.round = round; + local.ceil = ceil; + local.offset = offset; + local.range = range; + var utc = local.utc = d3_time_interval_utc(local); + utc.floor = utc; + utc.round = d3_time_interval_utc(round); + utc.ceil = d3_time_interval_utc(ceil); + utc.offset = d3_time_interval_utc(offset); + utc.range = range_utc; + return local; + } + function d3_time_interval_utc(method) { + return function(date, k) { + try { + d3_date = d3_date_utc; + var utc = new d3_date_utc(); + utc._ = date; + return method(utc, k)._; + } finally { + d3_date = Date; + } + }; + } + d3_time.year = d3_time_interval(function(date) { + date = d3_time.day(date); + date.setMonth(0, 1); + return date; + }, function(date, offset) { + date.setFullYear(date.getFullYear() + offset); + }, function(date) { + return date.getFullYear(); + }); + d3_time.years = d3_time.year.range; + d3_time.years.utc = d3_time.year.utc.range; + d3_time.day = d3_time_interval(function(date) { + var day = new d3_date(2e3, 0); + day.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + return day; + }, function(date, offset) { + date.setDate(date.getDate() + offset); + }, function(date) { + return date.getDate() - 1; + }); + d3_time.days = d3_time.day.range; + d3_time.days.utc = d3_time.day.utc.range; + d3_time.dayOfYear = function(date) { + var year = d3_time.year(date); + return Math.floor((date - year - (date.getTimezoneOffset() - year.getTimezoneOffset()) * 6e4) / 864e5); + }; + [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ].forEach(function(day, i) { + i = 7 - i; + var interval = d3_time[day] = d3_time_interval(function(date) { + (date = d3_time.day(date)).setDate(date.getDate() - (date.getDay() + i) % 7); + return date; + }, function(date, offset) { + date.setDate(date.getDate() + Math.floor(offset) * 7); + }, function(date) { + var day = d3_time.year(date).getDay(); + return Math.floor((d3_time.dayOfYear(date) + (day + i) % 7) / 7) - (day !== i); + }); + d3_time[day + "s"] = interval.range; + d3_time[day + "s"].utc = interval.utc.range; + d3_time[day + "OfYear"] = function(date) { + var day = d3_time.year(date).getDay(); + return Math.floor((d3_time.dayOfYear(date) + (day + i) % 7) / 7); + }; + }); + d3_time.week = d3_time.sunday; + d3_time.weeks = d3_time.sunday.range; + d3_time.weeks.utc = d3_time.sunday.utc.range; + d3_time.weekOfYear = d3_time.sundayOfYear; + function d3_locale_timeFormat(locale) { + var locale_dateTime = locale.dateTime, locale_date = locale.date, locale_time = locale.time, locale_periods = locale.periods, locale_days = locale.days, locale_shortDays = locale.shortDays, locale_months = locale.months, locale_shortMonths = locale.shortMonths; + function d3_time_format(template) { + var n = template.length; + function format(date) { + var string = [], i = -1, j = 0, c, p, f; + while (++i < n) { + if (template.charCodeAt(i) === 37) { + string.push(template.slice(j, i)); + if ((p = d3_time_formatPads[c = template.charAt(++i)]) != null) c = template.charAt(++i); + if (f = d3_time_formats[c]) c = f(date, p == null ? c === "e" ? " " : "0" : p); + string.push(c); + j = i + 1; + } + } + string.push(template.slice(j, i)); + return string.join(""); + } + format.parse = function(string) { + var d = { + y: 1900, + m: 0, + d: 1, + H: 0, + M: 0, + S: 0, + L: 0, + Z: null + }, i = d3_time_parse(d, template, string, 0); + if (i != string.length) return null; + if ("p" in d) d.H = d.H % 12 + d.p * 12; + var localZ = d.Z != null && d3_date !== d3_date_utc, date = new (localZ ? d3_date_utc : d3_date)(); + if ("j" in d) date.setFullYear(d.y, 0, d.j); else if ("W" in d || "U" in d) { + if (!("w" in d)) d.w = "W" in d ? 1 : 0; + date.setFullYear(d.y, 0, 1); + date.setFullYear(d.y, 0, "W" in d ? (d.w + 6) % 7 + d.W * 7 - (date.getDay() + 5) % 7 : d.w + d.U * 7 - (date.getDay() + 6) % 7); + } else date.setFullYear(d.y, d.m, d.d); + date.setHours(d.H + (d.Z / 100 | 0), d.M + d.Z % 100, d.S, d.L); + return localZ ? date._ : date; + }; + format.toString = function() { + return template; + }; + return format; + } + function d3_time_parse(date, template, string, j) { + var c, p, t, i = 0, n = template.length, m = string.length; + while (i < n) { + if (j >= m) return -1; + c = template.charCodeAt(i++); + if (c === 37) { + t = template.charAt(i++); + p = d3_time_parsers[t in d3_time_formatPads ? template.charAt(i++) : t]; + if (!p || (j = p(date, string, j)) < 0) return -1; + } else if (c != string.charCodeAt(j++)) { + return -1; + } + } + return j; + } + d3_time_format.utc = function(template) { + var local = d3_time_format(template); + function format(date) { + try { + d3_date = d3_date_utc; + var utc = new d3_date(); + utc._ = date; + return local(utc); + } finally { + d3_date = Date; + } + } + format.parse = function(string) { + try { + d3_date = d3_date_utc; + var date = local.parse(string); + return date && date._; + } finally { + d3_date = Date; + } + }; + format.toString = local.toString; + return format; + }; + d3_time_format.multi = d3_time_format.utc.multi = d3_time_formatMulti; + var d3_time_periodLookup = d3.map(), d3_time_dayRe = d3_time_formatRe(locale_days), d3_time_dayLookup = d3_time_formatLookup(locale_days), d3_time_dayAbbrevRe = d3_time_formatRe(locale_shortDays), d3_time_dayAbbrevLookup = d3_time_formatLookup(locale_shortDays), d3_time_monthRe = d3_time_formatRe(locale_months), d3_time_monthLookup = d3_time_formatLookup(locale_months), d3_time_monthAbbrevRe = d3_time_formatRe(locale_shortMonths), d3_time_monthAbbrevLookup = d3_time_formatLookup(locale_shortMonths); + locale_periods.forEach(function(p, i) { + d3_time_periodLookup.set(p.toLowerCase(), i); + }); + var d3_time_formats = { + a: function(d) { + return locale_shortDays[d.getDay()]; + }, + A: function(d) { + return locale_days[d.getDay()]; + }, + b: function(d) { + return locale_shortMonths[d.getMonth()]; + }, + B: function(d) { + return locale_months[d.getMonth()]; + }, + c: d3_time_format(locale_dateTime), + d: function(d, p) { + return d3_time_formatPad(d.getDate(), p, 2); + }, + e: function(d, p) { + return d3_time_formatPad(d.getDate(), p, 2); + }, + H: function(d, p) { + return d3_time_formatPad(d.getHours(), p, 2); + }, + I: function(d, p) { + return d3_time_formatPad(d.getHours() % 12 || 12, p, 2); + }, + j: function(d, p) { + return d3_time_formatPad(1 + d3_time.dayOfYear(d), p, 3); + }, + L: function(d, p) { + return d3_time_formatPad(d.getMilliseconds(), p, 3); + }, + m: function(d, p) { + return d3_time_formatPad(d.getMonth() + 1, p, 2); + }, + M: function(d, p) { + return d3_time_formatPad(d.getMinutes(), p, 2); + }, + p: function(d) { + return locale_periods[+(d.getHours() >= 12)]; + }, + S: function(d, p) { + return d3_time_formatPad(d.getSeconds(), p, 2); + }, + U: function(d, p) { + return d3_time_formatPad(d3_time.sundayOfYear(d), p, 2); + }, + w: function(d) { + return d.getDay(); + }, + W: function(d, p) { + return d3_time_formatPad(d3_time.mondayOfYear(d), p, 2); + }, + x: d3_time_format(locale_date), + X: d3_time_format(locale_time), + y: function(d, p) { + return d3_time_formatPad(d.getFullYear() % 100, p, 2); + }, + Y: function(d, p) { + return d3_time_formatPad(d.getFullYear() % 1e4, p, 4); + }, + Z: d3_time_zone, + "%": function() { + return "%"; + } + }; + var d3_time_parsers = { + a: d3_time_parseWeekdayAbbrev, + A: d3_time_parseWeekday, + b: d3_time_parseMonthAbbrev, + B: d3_time_parseMonth, + c: d3_time_parseLocaleFull, + d: d3_time_parseDay, + e: d3_time_parseDay, + H: d3_time_parseHour24, + I: d3_time_parseHour24, + j: d3_time_parseDayOfYear, + L: d3_time_parseMilliseconds, + m: d3_time_parseMonthNumber, + M: d3_time_parseMinutes, + p: d3_time_parseAmPm, + S: d3_time_parseSeconds, + U: d3_time_parseWeekNumberSunday, + w: d3_time_parseWeekdayNumber, + W: d3_time_parseWeekNumberMonday, + x: d3_time_parseLocaleDate, + X: d3_time_parseLocaleTime, + y: d3_time_parseYear, + Y: d3_time_parseFullYear, + Z: d3_time_parseZone, + "%": d3_time_parseLiteralPercent + }; + function d3_time_parseWeekdayAbbrev(date, string, i) { + d3_time_dayAbbrevRe.lastIndex = 0; + var n = d3_time_dayAbbrevRe.exec(string.slice(i)); + return n ? (date.w = d3_time_dayAbbrevLookup.get(n[0].toLowerCase()), i + n[0].length) : -1; + } + function d3_time_parseWeekday(date, string, i) { + d3_time_dayRe.lastIndex = 0; + var n = d3_time_dayRe.exec(string.slice(i)); + return n ? (date.w = d3_time_dayLookup.get(n[0].toLowerCase()), i + n[0].length) : -1; + } + function d3_time_parseMonthAbbrev(date, string, i) { + d3_time_monthAbbrevRe.lastIndex = 0; + var n = d3_time_monthAbbrevRe.exec(string.slice(i)); + return n ? (date.m = d3_time_monthAbbrevLookup.get(n[0].toLowerCase()), i + n[0].length) : -1; + } + function d3_time_parseMonth(date, string, i) { + d3_time_monthRe.lastIndex = 0; + var n = d3_time_monthRe.exec(string.slice(i)); + return n ? (date.m = d3_time_monthLookup.get(n[0].toLowerCase()), i + n[0].length) : -1; + } + function d3_time_parseLocaleFull(date, string, i) { + return d3_time_parse(date, d3_time_formats.c.toString(), string, i); + } + function d3_time_parseLocaleDate(date, string, i) { + return d3_time_parse(date, d3_time_formats.x.toString(), string, i); + } + function d3_time_parseLocaleTime(date, string, i) { + return d3_time_parse(date, d3_time_formats.X.toString(), string, i); + } + function d3_time_parseAmPm(date, string, i) { + var n = d3_time_periodLookup.get(string.slice(i, i += 2).toLowerCase()); + return n == null ? -1 : (date.p = n, i); + } + return d3_time_format; + } + var d3_time_formatPads = { + "-": "", + _: " ", + "0": "0" + }, d3_time_numberRe = /^\s*\d+/, d3_time_percentRe = /^%/; + function d3_time_formatPad(value, fill, width) { + var sign = value < 0 ? "-" : "", string = (sign ? -value : value) + "", length = string.length; + return sign + (length < width ? new Array(width - length + 1).join(fill) + string : string); + } + function d3_time_formatRe(names) { + return new RegExp("^(?:" + names.map(d3.requote).join("|") + ")", "i"); + } + function d3_time_formatLookup(names) { + var map = new d3_Map(), i = -1, n = names.length; + while (++i < n) map.set(names[i].toLowerCase(), i); + return map; + } + function d3_time_parseWeekdayNumber(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 1)); + return n ? (date.w = +n[0], i + n[0].length) : -1; + } + function d3_time_parseWeekNumberSunday(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i)); + return n ? (date.U = +n[0], i + n[0].length) : -1; + } + function d3_time_parseWeekNumberMonday(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i)); + return n ? (date.W = +n[0], i + n[0].length) : -1; + } + function d3_time_parseFullYear(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 4)); + return n ? (date.y = +n[0], i + n[0].length) : -1; + } + function d3_time_parseYear(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 2)); + return n ? (date.y = d3_time_expandYear(+n[0]), i + n[0].length) : -1; + } + function d3_time_parseZone(date, string, i) { + return /^[+-]\d{4}$/.test(string = string.slice(i, i + 5)) ? (date.Z = -string, + i + 5) : -1; + } + function d3_time_expandYear(d) { + return d + (d > 68 ? 1900 : 2e3); + } + function d3_time_parseMonthNumber(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 2)); + return n ? (date.m = n[0] - 1, i + n[0].length) : -1; + } + function d3_time_parseDay(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 2)); + return n ? (date.d = +n[0], i + n[0].length) : -1; + } + function d3_time_parseDayOfYear(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 3)); + return n ? (date.j = +n[0], i + n[0].length) : -1; + } + function d3_time_parseHour24(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 2)); + return n ? (date.H = +n[0], i + n[0].length) : -1; + } + function d3_time_parseMinutes(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 2)); + return n ? (date.M = +n[0], i + n[0].length) : -1; + } + function d3_time_parseSeconds(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 2)); + return n ? (date.S = +n[0], i + n[0].length) : -1; + } + function d3_time_parseMilliseconds(date, string, i) { + d3_time_numberRe.lastIndex = 0; + var n = d3_time_numberRe.exec(string.slice(i, i + 3)); + return n ? (date.L = +n[0], i + n[0].length) : -1; + } + function d3_time_zone(d) { + var z = d.getTimezoneOffset(), zs = z > 0 ? "-" : "+", zh = abs(z) / 60 | 0, zm = abs(z) % 60; + return zs + d3_time_formatPad(zh, "0", 2) + d3_time_formatPad(zm, "0", 2); + } + function d3_time_parseLiteralPercent(date, string, i) { + d3_time_percentRe.lastIndex = 0; + var n = d3_time_percentRe.exec(string.slice(i, i + 1)); + return n ? i + n[0].length : -1; + } + function d3_time_formatMulti(formats) { + var n = formats.length, i = -1; + while (++i < n) formats[i][0] = this(formats[i][0]); + return function(date) { + var i = 0, f = formats[i]; + while (!f[1](date)) f = formats[++i]; + return f[0](date); + }; + } + d3.locale = function(locale) { + return { + numberFormat: d3_locale_numberFormat(locale), + timeFormat: d3_locale_timeFormat(locale) + }; + }; + var d3_locale_enUS = d3.locale({ + decimal: ".", + thousands: ",", + grouping: [ 3 ], + currency: [ "$", "" ], + dateTime: "%a %b %e %X %Y", + date: "%m/%d/%Y", + time: "%H:%M:%S", + periods: [ "AM", "PM" ], + days: [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ], + shortDays: [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ], + months: [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ], + shortMonths: [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ] + }); + d3.format = d3_locale_enUS.numberFormat; + d3.geo = {}; + function d3_adder() {} + d3_adder.prototype = { + s: 0, + t: 0, + add: function(y) { + d3_adderSum(y, this.t, d3_adderTemp); + d3_adderSum(d3_adderTemp.s, this.s, this); + if (this.s) this.t += d3_adderTemp.t; else this.s = d3_adderTemp.t; + }, + reset: function() { + this.s = this.t = 0; + }, + valueOf: function() { + return this.s; + } + }; + var d3_adderTemp = new d3_adder(); + function d3_adderSum(a, b, o) { + var x = o.s = a + b, bv = x - a, av = x - bv; + o.t = a - av + (b - bv); + } + d3.geo.stream = function(object, listener) { + if (object && d3_geo_streamObjectType.hasOwnProperty(object.type)) { + d3_geo_streamObjectType[object.type](object, listener); + } else { + d3_geo_streamGeometry(object, listener); + } + }; + function d3_geo_streamGeometry(geometry, listener) { + if (geometry && d3_geo_streamGeometryType.hasOwnProperty(geometry.type)) { + d3_geo_streamGeometryType[geometry.type](geometry, listener); + } + } + var d3_geo_streamObjectType = { + Feature: function(feature, listener) { + d3_geo_streamGeometry(feature.geometry, listener); + }, + FeatureCollection: function(object, listener) { + var features = object.features, i = -1, n = features.length; + while (++i < n) d3_geo_streamGeometry(features[i].geometry, listener); + } + }; + var d3_geo_streamGeometryType = { + Sphere: function(object, listener) { + listener.sphere(); + }, + Point: function(object, listener) { + object = object.coordinates; + listener.point(object[0], object[1], object[2]); + }, + MultiPoint: function(object, listener) { + var coordinates = object.coordinates, i = -1, n = coordinates.length; + while (++i < n) object = coordinates[i], listener.point(object[0], object[1], object[2]); + }, + LineString: function(object, listener) { + d3_geo_streamLine(object.coordinates, listener, 0); + }, + MultiLineString: function(object, listener) { + var coordinates = object.coordinates, i = -1, n = coordinates.length; + while (++i < n) d3_geo_streamLine(coordinates[i], listener, 0); + }, + Polygon: function(object, listener) { + d3_geo_streamPolygon(object.coordinates, listener); + }, + MultiPolygon: function(object, listener) { + var coordinates = object.coordinates, i = -1, n = coordinates.length; + while (++i < n) d3_geo_streamPolygon(coordinates[i], listener); + }, + GeometryCollection: function(object, listener) { + var geometries = object.geometries, i = -1, n = geometries.length; + while (++i < n) d3_geo_streamGeometry(geometries[i], listener); + } + }; + function d3_geo_streamLine(coordinates, listener, closed) { + var i = -1, n = coordinates.length - closed, coordinate; + listener.lineStart(); + while (++i < n) coordinate = coordinates[i], listener.point(coordinate[0], coordinate[1], coordinate[2]); + listener.lineEnd(); + } + function d3_geo_streamPolygon(coordinates, listener) { + var i = -1, n = coordinates.length; + listener.polygonStart(); + while (++i < n) d3_geo_streamLine(coordinates[i], listener, 1); + listener.polygonEnd(); + } + d3.geo.area = function(object) { + d3_geo_areaSum = 0; + d3.geo.stream(object, d3_geo_area); + return d3_geo_areaSum; + }; + var d3_geo_areaSum, d3_geo_areaRingSum = new d3_adder(); + var d3_geo_area = { + sphere: function() { + d3_geo_areaSum += 4 * π; + }, + point: d3_noop, + lineStart: d3_noop, + lineEnd: d3_noop, + polygonStart: function() { + d3_geo_areaRingSum.reset(); + d3_geo_area.lineStart = d3_geo_areaRingStart; + }, + polygonEnd: function() { + var area = 2 * d3_geo_areaRingSum; + d3_geo_areaSum += area < 0 ? 4 * π + area : area; + d3_geo_area.lineStart = d3_geo_area.lineEnd = d3_geo_area.point = d3_noop; + } + }; + function d3_geo_areaRingStart() { + var λ00, φ00, λ0, cosφ0, sinφ0; + d3_geo_area.point = function(λ, φ) { + d3_geo_area.point = nextPoint; + λ0 = (λ00 = λ) * d3_radians, cosφ0 = Math.cos(φ = (φ00 = φ) * d3_radians / 2 + π / 4), + sinφ0 = Math.sin(φ); + }; + function nextPoint(λ, φ) { + λ *= d3_radians; + φ = φ * d3_radians / 2 + π / 4; + var dλ = λ - λ0, sdλ = dλ >= 0 ? 1 : -1, adλ = sdλ * dλ, cosφ = Math.cos(φ), sinφ = Math.sin(φ), k = sinφ0 * sinφ, u = cosφ0 * cosφ + k * Math.cos(adλ), v = k * sdλ * Math.sin(adλ); + d3_geo_areaRingSum.add(Math.atan2(v, u)); + λ0 = λ, cosφ0 = cosφ, sinφ0 = sinφ; + } + d3_geo_area.lineEnd = function() { + nextPoint(λ00, φ00); + }; + } + function d3_geo_cartesian(spherical) { + var λ = spherical[0], φ = spherical[1], cosφ = Math.cos(φ); + return [ cosφ * Math.cos(λ), cosφ * Math.sin(λ), Math.sin(φ) ]; + } + function d3_geo_cartesianDot(a, b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + } + function d3_geo_cartesianCross(a, b) { + return [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0] ]; + } + function d3_geo_cartesianAdd(a, b) { + a[0] += b[0]; + a[1] += b[1]; + a[2] += b[2]; + } + function d3_geo_cartesianScale(vector, k) { + return [ vector[0] * k, vector[1] * k, vector[2] * k ]; + } + function d3_geo_cartesianNormalize(d) { + var l = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]); + d[0] /= l; + d[1] /= l; + d[2] /= l; + } + function d3_geo_spherical(cartesian) { + return [ Math.atan2(cartesian[1], cartesian[0]), d3_asin(cartesian[2]) ]; + } + function d3_geo_sphericalEqual(a, b) { + return abs(a[0] - b[0]) < ε && abs(a[1] - b[1]) < ε; + } + d3.geo.bounds = function() { + var λ0, φ0, λ1, φ1, λ_, λ__, φ__, p0, dλSum, ranges, range; + var bound = { + point: point, + lineStart: lineStart, + lineEnd: lineEnd, + polygonStart: function() { + bound.point = ringPoint; + bound.lineStart = ringStart; + bound.lineEnd = ringEnd; + dλSum = 0; + d3_geo_area.polygonStart(); + }, + polygonEnd: function() { + d3_geo_area.polygonEnd(); + bound.point = point; + bound.lineStart = lineStart; + bound.lineEnd = lineEnd; + if (d3_geo_areaRingSum < 0) λ0 = -(λ1 = 180), φ0 = -(φ1 = 90); else if (dλSum > ε) φ1 = 90; else if (dλSum < -ε) φ0 = -90; + range[0] = λ0, range[1] = λ1; + } + }; + function point(λ, φ) { + ranges.push(range = [ λ0 = λ, λ1 = λ ]); + if (φ < φ0) φ0 = φ; + if (φ > φ1) φ1 = φ; + } + function linePoint(λ, φ) { + var p = d3_geo_cartesian([ λ * d3_radians, φ * d3_radians ]); + if (p0) { + var normal = d3_geo_cartesianCross(p0, p), equatorial = [ normal[1], -normal[0], 0 ], inflection = d3_geo_cartesianCross(equatorial, normal); + d3_geo_cartesianNormalize(inflection); + inflection = d3_geo_spherical(inflection); + var dλ = λ - λ_, s = dλ > 0 ? 1 : -1, λi = inflection[0] * d3_degrees * s, antimeridian = abs(dλ) > 180; + if (antimeridian ^ (s * λ_ < λi && λi < s * λ)) { + var φi = inflection[1] * d3_degrees; + if (φi > φ1) φ1 = φi; + } else if (λi = (λi + 360) % 360 - 180, antimeridian ^ (s * λ_ < λi && λi < s * λ)) { + var φi = -inflection[1] * d3_degrees; + if (φi < φ0) φ0 = φi; + } else { + if (φ < φ0) φ0 = φ; + if (φ > φ1) φ1 = φ; + } + if (antimeridian) { + if (λ < λ_) { + if (angle(λ0, λ) > angle(λ0, λ1)) λ1 = λ; + } else { + if (angle(λ, λ1) > angle(λ0, λ1)) λ0 = λ; + } + } else { + if (λ1 >= λ0) { + if (λ < λ0) λ0 = λ; + if (λ > λ1) λ1 = λ; + } else { + if (λ > λ_) { + if (angle(λ0, λ) > angle(λ0, λ1)) λ1 = λ; + } else { + if (angle(λ, λ1) > angle(λ0, λ1)) λ0 = λ; + } + } + } + } else { + point(λ, φ); + } + p0 = p, λ_ = λ; + } + function lineStart() { + bound.point = linePoint; + } + function lineEnd() { + range[0] = λ0, range[1] = λ1; + bound.point = point; + p0 = null; + } + function ringPoint(λ, φ) { + if (p0) { + var dλ = λ - λ_; + dλSum += abs(dλ) > 180 ? dλ + (dλ > 0 ? 360 : -360) : dλ; + } else λ__ = λ, φ__ = φ; + d3_geo_area.point(λ, φ); + linePoint(λ, φ); + } + function ringStart() { + d3_geo_area.lineStart(); + } + function ringEnd() { + ringPoint(λ__, φ__); + d3_geo_area.lineEnd(); + if (abs(dλSum) > ε) λ0 = -(λ1 = 180); + range[0] = λ0, range[1] = λ1; + p0 = null; + } + function angle(λ0, λ1) { + return (λ1 -= λ0) < 0 ? λ1 + 360 : λ1; + } + function compareRanges(a, b) { + return a[0] - b[0]; + } + function withinRange(x, range) { + return range[0] <= range[1] ? range[0] <= x && x <= range[1] : x < range[0] || range[1] < x; + } + return function(feature) { + φ1 = λ1 = -(λ0 = φ0 = Infinity); + ranges = []; + d3.geo.stream(feature, bound); + var n = ranges.length; + if (n) { + ranges.sort(compareRanges); + for (var i = 1, a = ranges[0], b, merged = [ a ]; i < n; ++i) { + b = ranges[i]; + if (withinRange(b[0], a) || withinRange(b[1], a)) { + if (angle(a[0], b[1]) > angle(a[0], a[1])) a[1] = b[1]; + if (angle(b[0], a[1]) > angle(a[0], a[1])) a[0] = b[0]; + } else { + merged.push(a = b); + } + } + var best = -Infinity, dλ; + for (var n = merged.length - 1, i = 0, a = merged[n], b; i <= n; a = b, ++i) { + b = merged[i]; + if ((dλ = angle(a[1], b[0])) > best) best = dλ, λ0 = b[0], λ1 = a[1]; + } + } + ranges = range = null; + return λ0 === Infinity || φ0 === Infinity ? [ [ NaN, NaN ], [ NaN, NaN ] ] : [ [ λ0, φ0 ], [ λ1, φ1 ] ]; + }; + }(); + d3.geo.centroid = function(object) { + d3_geo_centroidW0 = d3_geo_centroidW1 = d3_geo_centroidX0 = d3_geo_centroidY0 = d3_geo_centroidZ0 = d3_geo_centroidX1 = d3_geo_centroidY1 = d3_geo_centroidZ1 = d3_geo_centroidX2 = d3_geo_centroidY2 = d3_geo_centroidZ2 = 0; + d3.geo.stream(object, d3_geo_centroid); + var x = d3_geo_centroidX2, y = d3_geo_centroidY2, z = d3_geo_centroidZ2, m = x * x + y * y + z * z; + if (m < ε2) { + x = d3_geo_centroidX1, y = d3_geo_centroidY1, z = d3_geo_centroidZ1; + if (d3_geo_centroidW1 < ε) x = d3_geo_centroidX0, y = d3_geo_centroidY0, z = d3_geo_centroidZ0; + m = x * x + y * y + z * z; + if (m < ε2) return [ NaN, NaN ]; + } + return [ Math.atan2(y, x) * d3_degrees, d3_asin(z / Math.sqrt(m)) * d3_degrees ]; + }; + var d3_geo_centroidW0, d3_geo_centroidW1, d3_geo_centroidX0, d3_geo_centroidY0, d3_geo_centroidZ0, d3_geo_centroidX1, d3_geo_centroidY1, d3_geo_centroidZ1, d3_geo_centroidX2, d3_geo_centroidY2, d3_geo_centroidZ2; + var d3_geo_centroid = { + sphere: d3_noop, + point: d3_geo_centroidPoint, + lineStart: d3_geo_centroidLineStart, + lineEnd: d3_geo_centroidLineEnd, + polygonStart: function() { + d3_geo_centroid.lineStart = d3_geo_centroidRingStart; + }, + polygonEnd: function() { + d3_geo_centroid.lineStart = d3_geo_centroidLineStart; + } + }; + function d3_geo_centroidPoint(λ, φ) { + λ *= d3_radians; + var cosφ = Math.cos(φ *= d3_radians); + d3_geo_centroidPointXYZ(cosφ * Math.cos(λ), cosφ * Math.sin(λ), Math.sin(φ)); + } + function d3_geo_centroidPointXYZ(x, y, z) { + ++d3_geo_centroidW0; + d3_geo_centroidX0 += (x - d3_geo_centroidX0) / d3_geo_centroidW0; + d3_geo_centroidY0 += (y - d3_geo_centroidY0) / d3_geo_centroidW0; + d3_geo_centroidZ0 += (z - d3_geo_centroidZ0) / d3_geo_centroidW0; + } + function d3_geo_centroidLineStart() { + var x0, y0, z0; + d3_geo_centroid.point = function(λ, φ) { + λ *= d3_radians; + var cosφ = Math.cos(φ *= d3_radians); + x0 = cosφ * Math.cos(λ); + y0 = cosφ * Math.sin(λ); + z0 = Math.sin(φ); + d3_geo_centroid.point = nextPoint; + d3_geo_centroidPointXYZ(x0, y0, z0); + }; + function nextPoint(λ, φ) { + λ *= d3_radians; + var cosφ = Math.cos(φ *= d3_radians), x = cosφ * Math.cos(λ), y = cosφ * Math.sin(λ), z = Math.sin(φ), w = Math.atan2(Math.sqrt((w = y0 * z - z0 * y) * w + (w = z0 * x - x0 * z) * w + (w = x0 * y - y0 * x) * w), x0 * x + y0 * y + z0 * z); + d3_geo_centroidW1 += w; + d3_geo_centroidX1 += w * (x0 + (x0 = x)); + d3_geo_centroidY1 += w * (y0 + (y0 = y)); + d3_geo_centroidZ1 += w * (z0 + (z0 = z)); + d3_geo_centroidPointXYZ(x0, y0, z0); + } + } + function d3_geo_centroidLineEnd() { + d3_geo_centroid.point = d3_geo_centroidPoint; + } + function d3_geo_centroidRingStart() { + var λ00, φ00, x0, y0, z0; + d3_geo_centroid.point = function(λ, φ) { + λ00 = λ, φ00 = φ; + d3_geo_centroid.point = nextPoint; + λ *= d3_radians; + var cosφ = Math.cos(φ *= d3_radians); + x0 = cosφ * Math.cos(λ); + y0 = cosφ * Math.sin(λ); + z0 = Math.sin(φ); + d3_geo_centroidPointXYZ(x0, y0, z0); + }; + d3_geo_centroid.lineEnd = function() { + nextPoint(λ00, φ00); + d3_geo_centroid.lineEnd = d3_geo_centroidLineEnd; + d3_geo_centroid.point = d3_geo_centroidPoint; + }; + function nextPoint(λ, φ) { + λ *= d3_radians; + var cosφ = Math.cos(φ *= d3_radians), x = cosφ * Math.cos(λ), y = cosφ * Math.sin(λ), z = Math.sin(φ), cx = y0 * z - z0 * y, cy = z0 * x - x0 * z, cz = x0 * y - y0 * x, m = Math.sqrt(cx * cx + cy * cy + cz * cz), u = x0 * x + y0 * y + z0 * z, v = m && -d3_acos(u) / m, w = Math.atan2(m, u); + d3_geo_centroidX2 += v * cx; + d3_geo_centroidY2 += v * cy; + d3_geo_centroidZ2 += v * cz; + d3_geo_centroidW1 += w; + d3_geo_centroidX1 += w * (x0 + (x0 = x)); + d3_geo_centroidY1 += w * (y0 + (y0 = y)); + d3_geo_centroidZ1 += w * (z0 + (z0 = z)); + d3_geo_centroidPointXYZ(x0, y0, z0); + } + } + function d3_geo_compose(a, b) { + function compose(x, y) { + return x = a(x, y), b(x[0], x[1]); + } + if (a.invert && b.invert) compose.invert = function(x, y) { + return x = b.invert(x, y), x && a.invert(x[0], x[1]); + }; + return compose; + } + function d3_true() { + return true; + } + function d3_geo_clipPolygon(segments, compare, clipStartInside, interpolate, listener) { + var subject = [], clip = []; + segments.forEach(function(segment) { + if ((n = segment.length - 1) <= 0) return; + var n, p0 = segment[0], p1 = segment[n]; + if (d3_geo_sphericalEqual(p0, p1)) { + listener.lineStart(); + for (var i = 0; i < n; ++i) listener.point((p0 = segment[i])[0], p0[1]); + listener.lineEnd(); + return; + } + var a = new d3_geo_clipPolygonIntersection(p0, segment, null, true), b = new d3_geo_clipPolygonIntersection(p0, null, a, false); + a.o = b; + subject.push(a); + clip.push(b); + a = new d3_geo_clipPolygonIntersection(p1, segment, null, false); + b = new d3_geo_clipPolygonIntersection(p1, null, a, true); + a.o = b; + subject.push(a); + clip.push(b); + }); + clip.sort(compare); + d3_geo_clipPolygonLinkCircular(subject); + d3_geo_clipPolygonLinkCircular(clip); + if (!subject.length) return; + for (var i = 0, entry = clipStartInside, n = clip.length; i < n; ++i) { + clip[i].e = entry = !entry; + } + var start = subject[0], points, point; + while (1) { + var current = start, isSubject = true; + while (current.v) if ((current = current.n) === start) return; + points = current.z; + listener.lineStart(); + do { + current.v = current.o.v = true; + if (current.e) { + if (isSubject) { + for (var i = 0, n = points.length; i < n; ++i) listener.point((point = points[i])[0], point[1]); + } else { + interpolate(current.x, current.n.x, 1, listener); + } + current = current.n; + } else { + if (isSubject) { + points = current.p.z; + for (var i = points.length - 1; i >= 0; --i) listener.point((point = points[i])[0], point[1]); + } else { + interpolate(current.x, current.p.x, -1, listener); + } + current = current.p; + } + current = current.o; + points = current.z; + isSubject = !isSubject; + } while (!current.v); + listener.lineEnd(); + } + } + function d3_geo_clipPolygonLinkCircular(array) { + if (!(n = array.length)) return; + var n, i = 0, a = array[0], b; + while (++i < n) { + a.n = b = array[i]; + b.p = a; + a = b; + } + a.n = b = array[0]; + b.p = a; + } + function d3_geo_clipPolygonIntersection(point, points, other, entry) { + this.x = point; + this.z = points; + this.o = other; + this.e = entry; + this.v = false; + this.n = this.p = null; + } + function d3_geo_clip(pointVisible, clipLine, interpolate, clipStart) { + return function(rotate, listener) { + var line = clipLine(listener), rotatedClipStart = rotate.invert(clipStart[0], clipStart[1]); + var clip = { + point: point, + lineStart: lineStart, + lineEnd: lineEnd, + polygonStart: function() { + clip.point = pointRing; + clip.lineStart = ringStart; + clip.lineEnd = ringEnd; + segments = []; + polygon = []; + }, + polygonEnd: function() { + clip.point = point; + clip.lineStart = lineStart; + clip.lineEnd = lineEnd; + segments = d3.merge(segments); + var clipStartInside = d3_geo_pointInPolygon(rotatedClipStart, polygon); + if (segments.length) { + if (!polygonStarted) listener.polygonStart(), polygonStarted = true; + d3_geo_clipPolygon(segments, d3_geo_clipSort, clipStartInside, interpolate, listener); + } else if (clipStartInside) { + if (!polygonStarted) listener.polygonStart(), polygonStarted = true; + listener.lineStart(); + interpolate(null, null, 1, listener); + listener.lineEnd(); + } + if (polygonStarted) listener.polygonEnd(), polygonStarted = false; + segments = polygon = null; + }, + sphere: function() { + listener.polygonStart(); + listener.lineStart(); + interpolate(null, null, 1, listener); + listener.lineEnd(); + listener.polygonEnd(); + } + }; + function point(λ, φ) { + var point = rotate(λ, φ); + if (pointVisible(λ = point[0], φ = point[1])) listener.point(λ, φ); + } + function pointLine(λ, φ) { + var point = rotate(λ, φ); + line.point(point[0], point[1]); + } + function lineStart() { + clip.point = pointLine; + line.lineStart(); + } + function lineEnd() { + clip.point = point; + line.lineEnd(); + } + var segments; + var buffer = d3_geo_clipBufferListener(), ringListener = clipLine(buffer), polygonStarted = false, polygon, ring; + function pointRing(λ, φ) { + ring.push([ λ, φ ]); + var point = rotate(λ, φ); + ringListener.point(point[0], point[1]); + } + function ringStart() { + ringListener.lineStart(); + ring = []; + } + function ringEnd() { + pointRing(ring[0][0], ring[0][1]); + ringListener.lineEnd(); + var clean = ringListener.clean(), ringSegments = buffer.buffer(), segment, n = ringSegments.length; + ring.pop(); + polygon.push(ring); + ring = null; + if (!n) return; + if (clean & 1) { + segment = ringSegments[0]; + var n = segment.length - 1, i = -1, point; + if (n > 0) { + if (!polygonStarted) listener.polygonStart(), polygonStarted = true; + listener.lineStart(); + while (++i < n) listener.point((point = segment[i])[0], point[1]); + listener.lineEnd(); + } + return; + } + if (n > 1 && clean & 2) ringSegments.push(ringSegments.pop().concat(ringSegments.shift())); + segments.push(ringSegments.filter(d3_geo_clipSegmentLength1)); + } + return clip; + }; + } + function d3_geo_clipSegmentLength1(segment) { + return segment.length > 1; + } + function d3_geo_clipBufferListener() { + var lines = [], line; + return { + lineStart: function() { + lines.push(line = []); + }, + point: function(λ, φ) { + line.push([ λ, φ ]); + }, + lineEnd: d3_noop, + buffer: function() { + var buffer = lines; + lines = []; + line = null; + return buffer; + }, + rejoin: function() { + if (lines.length > 1) lines.push(lines.pop().concat(lines.shift())); + } + }; + } + function d3_geo_clipSort(a, b) { + return ((a = a.x)[0] < 0 ? a[1] - halfπ - ε : halfπ - a[1]) - ((b = b.x)[0] < 0 ? b[1] - halfπ - ε : halfπ - b[1]); + } + var d3_geo_clipAntimeridian = d3_geo_clip(d3_true, d3_geo_clipAntimeridianLine, d3_geo_clipAntimeridianInterpolate, [ -π, -π / 2 ]); + function d3_geo_clipAntimeridianLine(listener) { + var λ0 = NaN, φ0 = NaN, sλ0 = NaN, clean; + return { + lineStart: function() { + listener.lineStart(); + clean = 1; + }, + point: function(λ1, φ1) { + var sλ1 = λ1 > 0 ? π : -π, dλ = abs(λ1 - λ0); + if (abs(dλ - π) < ε) { + listener.point(λ0, φ0 = (φ0 + φ1) / 2 > 0 ? halfπ : -halfπ); + listener.point(sλ0, φ0); + listener.lineEnd(); + listener.lineStart(); + listener.point(sλ1, φ0); + listener.point(λ1, φ0); + clean = 0; + } else if (sλ0 !== sλ1 && dλ >= π) { + if (abs(λ0 - sλ0) < ε) λ0 -= sλ0 * ε; + if (abs(λ1 - sλ1) < ε) λ1 -= sλ1 * ε; + φ0 = d3_geo_clipAntimeridianIntersect(λ0, φ0, λ1, φ1); + listener.point(sλ0, φ0); + listener.lineEnd(); + listener.lineStart(); + listener.point(sλ1, φ0); + clean = 0; + } + listener.point(λ0 = λ1, φ0 = φ1); + sλ0 = sλ1; + }, + lineEnd: function() { + listener.lineEnd(); + λ0 = φ0 = NaN; + }, + clean: function() { + return 2 - clean; + } + }; + } + function d3_geo_clipAntimeridianIntersect(λ0, φ0, λ1, φ1) { + var cosφ0, cosφ1, sinλ0_λ1 = Math.sin(λ0 - λ1); + return abs(sinλ0_λ1) > ε ? Math.atan((Math.sin(φ0) * (cosφ1 = Math.cos(φ1)) * Math.sin(λ1) - Math.sin(φ1) * (cosφ0 = Math.cos(φ0)) * Math.sin(λ0)) / (cosφ0 * cosφ1 * sinλ0_λ1)) : (φ0 + φ1) / 2; + } + function d3_geo_clipAntimeridianInterpolate(from, to, direction, listener) { + var φ; + if (from == null) { + φ = direction * halfπ; + listener.point(-π, φ); + listener.point(0, φ); + listener.point(π, φ); + listener.point(π, 0); + listener.point(π, -φ); + listener.point(0, -φ); + listener.point(-π, -φ); + listener.point(-π, 0); + listener.point(-π, φ); + } else if (abs(from[0] - to[0]) > ε) { + var s = from[0] < to[0] ? π : -π; + φ = direction * s / 2; + listener.point(-s, φ); + listener.point(0, φ); + listener.point(s, φ); + } else { + listener.point(to[0], to[1]); + } + } + function d3_geo_pointInPolygon(point, polygon) { + var meridian = point[0], parallel = point[1], meridianNormal = [ Math.sin(meridian), -Math.cos(meridian), 0 ], polarAngle = 0, winding = 0; + d3_geo_areaRingSum.reset(); + for (var i = 0, n = polygon.length; i < n; ++i) { + var ring = polygon[i], m = ring.length; + if (!m) continue; + var point0 = ring[0], λ0 = point0[0], φ0 = point0[1] / 2 + π / 4, sinφ0 = Math.sin(φ0), cosφ0 = Math.cos(φ0), j = 1; + while (true) { + if (j === m) j = 0; + point = ring[j]; + var λ = point[0], φ = point[1] / 2 + π / 4, sinφ = Math.sin(φ), cosφ = Math.cos(φ), dλ = λ - λ0, sdλ = dλ >= 0 ? 1 : -1, adλ = sdλ * dλ, antimeridian = adλ > π, k = sinφ0 * sinφ; + d3_geo_areaRingSum.add(Math.atan2(k * sdλ * Math.sin(adλ), cosφ0 * cosφ + k * Math.cos(adλ))); + polarAngle += antimeridian ? dλ + sdλ * τ : dλ; + if (antimeridian ^ λ0 >= meridian ^ λ >= meridian) { + var arc = d3_geo_cartesianCross(d3_geo_cartesian(point0), d3_geo_cartesian(point)); + d3_geo_cartesianNormalize(arc); + var intersection = d3_geo_cartesianCross(meridianNormal, arc); + d3_geo_cartesianNormalize(intersection); + var φarc = (antimeridian ^ dλ >= 0 ? -1 : 1) * d3_asin(intersection[2]); + if (parallel > φarc || parallel === φarc && (arc[0] || arc[1])) { + winding += antimeridian ^ dλ >= 0 ? 1 : -1; + } + } + if (!j++) break; + λ0 = λ, sinφ0 = sinφ, cosφ0 = cosφ, point0 = point; + } + } + return (polarAngle < -ε || polarAngle < ε && d3_geo_areaRingSum < -ε) ^ winding & 1; + } + function d3_geo_clipCircle(radius) { + var cr = Math.cos(radius), smallRadius = cr > 0, notHemisphere = abs(cr) > ε, interpolate = d3_geo_circleInterpolate(radius, 6 * d3_radians); + return d3_geo_clip(visible, clipLine, interpolate, smallRadius ? [ 0, -radius ] : [ -π, radius - π ]); + function visible(λ, φ) { + return Math.cos(λ) * Math.cos(φ) > cr; + } + function clipLine(listener) { + var point0, c0, v0, v00, clean; + return { + lineStart: function() { + v00 = v0 = false; + clean = 1; + }, + point: function(λ, φ) { + var point1 = [ λ, φ ], point2, v = visible(λ, φ), c = smallRadius ? v ? 0 : code(λ, φ) : v ? code(λ + (λ < 0 ? π : -π), φ) : 0; + if (!point0 && (v00 = v0 = v)) listener.lineStart(); + if (v !== v0) { + point2 = intersect(point0, point1); + if (d3_geo_sphericalEqual(point0, point2) || d3_geo_sphericalEqual(point1, point2)) { + point1[0] += ε; + point1[1] += ε; + v = visible(point1[0], point1[1]); + } + } + if (v !== v0) { + clean = 0; + if (v) { + listener.lineStart(); + point2 = intersect(point1, point0); + listener.point(point2[0], point2[1]); + } else { + point2 = intersect(point0, point1); + listener.point(point2[0], point2[1]); + listener.lineEnd(); + } + point0 = point2; + } else if (notHemisphere && point0 && smallRadius ^ v) { + var t; + if (!(c & c0) && (t = intersect(point1, point0, true))) { + clean = 0; + if (smallRadius) { + listener.lineStart(); + listener.point(t[0][0], t[0][1]); + listener.point(t[1][0], t[1][1]); + listener.lineEnd(); + } else { + listener.point(t[1][0], t[1][1]); + listener.lineEnd(); + listener.lineStart(); + listener.point(t[0][0], t[0][1]); + } + } + } + if (v && (!point0 || !d3_geo_sphericalEqual(point0, point1))) { + listener.point(point1[0], point1[1]); + } + point0 = point1, v0 = v, c0 = c; + }, + lineEnd: function() { + if (v0) listener.lineEnd(); + point0 = null; + }, + clean: function() { + return clean | (v00 && v0) << 1; + } + }; + } + function intersect(a, b, two) { + var pa = d3_geo_cartesian(a), pb = d3_geo_cartesian(b); + var n1 = [ 1, 0, 0 ], n2 = d3_geo_cartesianCross(pa, pb), n2n2 = d3_geo_cartesianDot(n2, n2), n1n2 = n2[0], determinant = n2n2 - n1n2 * n1n2; + if (!determinant) return !two && a; + var c1 = cr * n2n2 / determinant, c2 = -cr * n1n2 / determinant, n1xn2 = d3_geo_cartesianCross(n1, n2), A = d3_geo_cartesianScale(n1, c1), B = d3_geo_cartesianScale(n2, c2); + d3_geo_cartesianAdd(A, B); + var u = n1xn2, w = d3_geo_cartesianDot(A, u), uu = d3_geo_cartesianDot(u, u), t2 = w * w - uu * (d3_geo_cartesianDot(A, A) - 1); + if (t2 < 0) return; + var t = Math.sqrt(t2), q = d3_geo_cartesianScale(u, (-w - t) / uu); + d3_geo_cartesianAdd(q, A); + q = d3_geo_spherical(q); + if (!two) return q; + var λ0 = a[0], λ1 = b[0], φ0 = a[1], φ1 = b[1], z; + if (λ1 < λ0) z = λ0, λ0 = λ1, λ1 = z; + var δλ = λ1 - λ0, polar = abs(δλ - π) < ε, meridian = polar || δλ < ε; + if (!polar && φ1 < φ0) z = φ0, φ0 = φ1, φ1 = z; + if (meridian ? polar ? φ0 + φ1 > 0 ^ q[1] < (abs(q[0] - λ0) < ε ? φ0 : φ1) : φ0 <= q[1] && q[1] <= φ1 : δλ > π ^ (λ0 <= q[0] && q[0] <= λ1)) { + var q1 = d3_geo_cartesianScale(u, (-w + t) / uu); + d3_geo_cartesianAdd(q1, A); + return [ q, d3_geo_spherical(q1) ]; + } + } + function code(λ, φ) { + var r = smallRadius ? radius : π - radius, code = 0; + if (λ < -r) code |= 1; else if (λ > r) code |= 2; + if (φ < -r) code |= 4; else if (φ > r) code |= 8; + return code; + } + } + function d3_geom_clipLine(x0, y0, x1, y1) { + return function(line) { + var a = line.a, b = line.b, ax = a.x, ay = a.y, bx = b.x, by = b.y, t0 = 0, t1 = 1, dx = bx - ax, dy = by - ay, r; + r = x0 - ax; + if (!dx && r > 0) return; + r /= dx; + if (dx < 0) { + if (r < t0) return; + if (r < t1) t1 = r; + } else if (dx > 0) { + if (r > t1) return; + if (r > t0) t0 = r; + } + r = x1 - ax; + if (!dx && r < 0) return; + r /= dx; + if (dx < 0) { + if (r > t1) return; + if (r > t0) t0 = r; + } else if (dx > 0) { + if (r < t0) return; + if (r < t1) t1 = r; + } + r = y0 - ay; + if (!dy && r > 0) return; + r /= dy; + if (dy < 0) { + if (r < t0) return; + if (r < t1) t1 = r; + } else if (dy > 0) { + if (r > t1) return; + if (r > t0) t0 = r; + } + r = y1 - ay; + if (!dy && r < 0) return; + r /= dy; + if (dy < 0) { + if (r > t1) return; + if (r > t0) t0 = r; + } else if (dy > 0) { + if (r < t0) return; + if (r < t1) t1 = r; + } + if (t0 > 0) line.a = { + x: ax + t0 * dx, + y: ay + t0 * dy + }; + if (t1 < 1) line.b = { + x: ax + t1 * dx, + y: ay + t1 * dy + }; + return line; + }; + } + var d3_geo_clipExtentMAX = 1e9; + d3.geo.clipExtent = function() { + var x0, y0, x1, y1, stream, clip, clipExtent = { + stream: function(output) { + if (stream) stream.valid = false; + stream = clip(output); + stream.valid = true; + return stream; + }, + extent: function(_) { + if (!arguments.length) return [ [ x0, y0 ], [ x1, y1 ] ]; + clip = d3_geo_clipExtent(x0 = +_[0][0], y0 = +_[0][1], x1 = +_[1][0], y1 = +_[1][1]); + if (stream) stream.valid = false, stream = null; + return clipExtent; + } + }; + return clipExtent.extent([ [ 0, 0 ], [ 960, 500 ] ]); + }; + function d3_geo_clipExtent(x0, y0, x1, y1) { + return function(listener) { + var listener_ = listener, bufferListener = d3_geo_clipBufferListener(), clipLine = d3_geom_clipLine(x0, y0, x1, y1), segments, polygon, ring; + var clip = { + point: point, + lineStart: lineStart, + lineEnd: lineEnd, + polygonStart: function() { + listener = bufferListener; + segments = []; + polygon = []; + clean = true; + }, + polygonEnd: function() { + listener = listener_; + segments = d3.merge(segments); + var clipStartInside = insidePolygon([ x0, y1 ]), inside = clean && clipStartInside, visible = segments.length; + if (inside || visible) { + listener.polygonStart(); + if (inside) { + listener.lineStart(); + interpolate(null, null, 1, listener); + listener.lineEnd(); + } + if (visible) { + d3_geo_clipPolygon(segments, compare, clipStartInside, interpolate, listener); + } + listener.polygonEnd(); + } + segments = polygon = ring = null; + } + }; + function insidePolygon(p) { + var wn = 0, n = polygon.length, y = p[1]; + for (var i = 0; i < n; ++i) { + for (var j = 1, v = polygon[i], m = v.length, a = v[0], b; j < m; ++j) { + b = v[j]; + if (a[1] <= y) { + if (b[1] > y && d3_cross2d(a, b, p) > 0) ++wn; + } else { + if (b[1] <= y && d3_cross2d(a, b, p) < 0) --wn; + } + a = b; + } + } + return wn !== 0; + } + function interpolate(from, to, direction, listener) { + var a = 0, a1 = 0; + if (from == null || (a = corner(from, direction)) !== (a1 = corner(to, direction)) || comparePoints(from, to) < 0 ^ direction > 0) { + do { + listener.point(a === 0 || a === 3 ? x0 : x1, a > 1 ? y1 : y0); + } while ((a = (a + direction + 4) % 4) !== a1); + } else { + listener.point(to[0], to[1]); + } + } + function pointVisible(x, y) { + return x0 <= x && x <= x1 && y0 <= y && y <= y1; + } + function point(x, y) { + if (pointVisible(x, y)) listener.point(x, y); + } + var x__, y__, v__, x_, y_, v_, first, clean; + function lineStart() { + clip.point = linePoint; + if (polygon) polygon.push(ring = []); + first = true; + v_ = false; + x_ = y_ = NaN; + } + function lineEnd() { + if (segments) { + linePoint(x__, y__); + if (v__ && v_) bufferListener.rejoin(); + segments.push(bufferListener.buffer()); + } + clip.point = point; + if (v_) listener.lineEnd(); + } + function linePoint(x, y) { + x = Math.max(-d3_geo_clipExtentMAX, Math.min(d3_geo_clipExtentMAX, x)); + y = Math.max(-d3_geo_clipExtentMAX, Math.min(d3_geo_clipExtentMAX, y)); + var v = pointVisible(x, y); + if (polygon) ring.push([ x, y ]); + if (first) { + x__ = x, y__ = y, v__ = v; + first = false; + if (v) { + listener.lineStart(); + listener.point(x, y); + } + } else { + if (v && v_) listener.point(x, y); else { + var l = { + a: { + x: x_, + y: y_ + }, + b: { + x: x, + y: y + } + }; + if (clipLine(l)) { + if (!v_) { + listener.lineStart(); + listener.point(l.a.x, l.a.y); + } + listener.point(l.b.x, l.b.y); + if (!v) listener.lineEnd(); + clean = false; + } else if (v) { + listener.lineStart(); + listener.point(x, y); + clean = false; + } + } + } + x_ = x, y_ = y, v_ = v; + } + return clip; + }; + function corner(p, direction) { + return abs(p[0] - x0) < ε ? direction > 0 ? 0 : 3 : abs(p[0] - x1) < ε ? direction > 0 ? 2 : 1 : abs(p[1] - y0) < ε ? direction > 0 ? 1 : 0 : direction > 0 ? 3 : 2; + } + function compare(a, b) { + return comparePoints(a.x, b.x); + } + function comparePoints(a, b) { + var ca = corner(a, 1), cb = corner(b, 1); + return ca !== cb ? ca - cb : ca === 0 ? b[1] - a[1] : ca === 1 ? a[0] - b[0] : ca === 2 ? a[1] - b[1] : b[0] - a[0]; + } + } + function d3_geo_conic(projectAt) { + var φ0 = 0, φ1 = π / 3, m = d3_geo_projectionMutator(projectAt), p = m(φ0, φ1); + p.parallels = function(_) { + if (!arguments.length) return [ φ0 / π * 180, φ1 / π * 180 ]; + return m(φ0 = _[0] * π / 180, φ1 = _[1] * π / 180); + }; + return p; + } + function d3_geo_conicEqualArea(φ0, φ1) { + var sinφ0 = Math.sin(φ0), n = (sinφ0 + Math.sin(φ1)) / 2, C = 1 + sinφ0 * (2 * n - sinφ0), ρ0 = Math.sqrt(C) / n; + function forward(λ, φ) { + var ρ = Math.sqrt(C - 2 * n * Math.sin(φ)) / n; + return [ ρ * Math.sin(λ *= n), ρ0 - ρ * Math.cos(λ) ]; + } + forward.invert = function(x, y) { + var ρ0_y = ρ0 - y; + return [ Math.atan2(x, ρ0_y) / n, d3_asin((C - (x * x + ρ0_y * ρ0_y) * n * n) / (2 * n)) ]; + }; + return forward; + } + (d3.geo.conicEqualArea = function() { + return d3_geo_conic(d3_geo_conicEqualArea); + }).raw = d3_geo_conicEqualArea; + d3.geo.albers = function() { + return d3.geo.conicEqualArea().rotate([ 96, 0 ]).center([ -.6, 38.7 ]).parallels([ 29.5, 45.5 ]).scale(1070); + }; + d3.geo.albersUsa = function() { + var lower48 = d3.geo.albers(); + var alaska = d3.geo.conicEqualArea().rotate([ 154, 0 ]).center([ -2, 58.5 ]).parallels([ 55, 65 ]); + var hawaii = d3.geo.conicEqualArea().rotate([ 157, 0 ]).center([ -3, 19.9 ]).parallels([ 8, 18 ]); + var point, pointStream = { + point: function(x, y) { + point = [ x, y ]; + } + }, lower48Point, alaskaPoint, hawaiiPoint; + function albersUsa(coordinates) { + var x = coordinates[0], y = coordinates[1]; + point = null; + (lower48Point(x, y), point) || (alaskaPoint(x, y), point) || hawaiiPoint(x, y); + return point; + } + albersUsa.invert = function(coordinates) { + var k = lower48.scale(), t = lower48.translate(), x = (coordinates[0] - t[0]) / k, y = (coordinates[1] - t[1]) / k; + return (y >= .12 && y < .234 && x >= -.425 && x < -.214 ? alaska : y >= .166 && y < .234 && x >= -.214 && x < -.115 ? hawaii : lower48).invert(coordinates); + }; + albersUsa.stream = function(stream) { + var lower48Stream = lower48.stream(stream), alaskaStream = alaska.stream(stream), hawaiiStream = hawaii.stream(stream); + return { + point: function(x, y) { + lower48Stream.point(x, y); + alaskaStream.point(x, y); + hawaiiStream.point(x, y); + }, + sphere: function() { + lower48Stream.sphere(); + alaskaStream.sphere(); + hawaiiStream.sphere(); + }, + lineStart: function() { + lower48Stream.lineStart(); + alaskaStream.lineStart(); + hawaiiStream.lineStart(); + }, + lineEnd: function() { + lower48Stream.lineEnd(); + alaskaStream.lineEnd(); + hawaiiStream.lineEnd(); + }, + polygonStart: function() { + lower48Stream.polygonStart(); + alaskaStream.polygonStart(); + hawaiiStream.polygonStart(); + }, + polygonEnd: function() { + lower48Stream.polygonEnd(); + alaskaStream.polygonEnd(); + hawaiiStream.polygonEnd(); + } + }; + }; + albersUsa.precision = function(_) { + if (!arguments.length) return lower48.precision(); + lower48.precision(_); + alaska.precision(_); + hawaii.precision(_); + return albersUsa; + }; + albersUsa.scale = function(_) { + if (!arguments.length) return lower48.scale(); + lower48.scale(_); + alaska.scale(_ * .35); + hawaii.scale(_); + return albersUsa.translate(lower48.translate()); + }; + albersUsa.translate = function(_) { + if (!arguments.length) return lower48.translate(); + var k = lower48.scale(), x = +_[0], y = +_[1]; + lower48Point = lower48.translate(_).clipExtent([ [ x - .455 * k, y - .238 * k ], [ x + .455 * k, y + .238 * k ] ]).stream(pointStream).point; + alaskaPoint = alaska.translate([ x - .307 * k, y + .201 * k ]).clipExtent([ [ x - .425 * k + ε, y + .12 * k + ε ], [ x - .214 * k - ε, y + .234 * k - ε ] ]).stream(pointStream).point; + hawaiiPoint = hawaii.translate([ x - .205 * k, y + .212 * k ]).clipExtent([ [ x - .214 * k + ε, y + .166 * k + ε ], [ x - .115 * k - ε, y + .234 * k - ε ] ]).stream(pointStream).point; + return albersUsa; + }; + return albersUsa.scale(1070); + }; + var d3_geo_pathAreaSum, d3_geo_pathAreaPolygon, d3_geo_pathArea = { + point: d3_noop, + lineStart: d3_noop, + lineEnd: d3_noop, + polygonStart: function() { + d3_geo_pathAreaPolygon = 0; + d3_geo_pathArea.lineStart = d3_geo_pathAreaRingStart; + }, + polygonEnd: function() { + d3_geo_pathArea.lineStart = d3_geo_pathArea.lineEnd = d3_geo_pathArea.point = d3_noop; + d3_geo_pathAreaSum += abs(d3_geo_pathAreaPolygon / 2); + } + }; + function d3_geo_pathAreaRingStart() { + var x00, y00, x0, y0; + d3_geo_pathArea.point = function(x, y) { + d3_geo_pathArea.point = nextPoint; + x00 = x0 = x, y00 = y0 = y; + }; + function nextPoint(x, y) { + d3_geo_pathAreaPolygon += y0 * x - x0 * y; + x0 = x, y0 = y; + } + d3_geo_pathArea.lineEnd = function() { + nextPoint(x00, y00); + }; + } + var d3_geo_pathBoundsX0, d3_geo_pathBoundsY0, d3_geo_pathBoundsX1, d3_geo_pathBoundsY1; + var d3_geo_pathBounds = { + point: d3_geo_pathBoundsPoint, + lineStart: d3_noop, + lineEnd: d3_noop, + polygonStart: d3_noop, + polygonEnd: d3_noop + }; + function d3_geo_pathBoundsPoint(x, y) { + if (x < d3_geo_pathBoundsX0) d3_geo_pathBoundsX0 = x; + if (x > d3_geo_pathBoundsX1) d3_geo_pathBoundsX1 = x; + if (y < d3_geo_pathBoundsY0) d3_geo_pathBoundsY0 = y; + if (y > d3_geo_pathBoundsY1) d3_geo_pathBoundsY1 = y; + } + function d3_geo_pathBuffer() { + var pointCircle = d3_geo_pathBufferCircle(4.5), buffer = []; + var stream = { + point: point, + lineStart: function() { + stream.point = pointLineStart; + }, + lineEnd: lineEnd, + polygonStart: function() { + stream.lineEnd = lineEndPolygon; + }, + polygonEnd: function() { + stream.lineEnd = lineEnd; + stream.point = point; + }, + pointRadius: function(_) { + pointCircle = d3_geo_pathBufferCircle(_); + return stream; + }, + result: function() { + if (buffer.length) { + var result = buffer.join(""); + buffer = []; + return result; + } + } + }; + function point(x, y) { + buffer.push("M", x, ",", y, pointCircle); + } + function pointLineStart(x, y) { + buffer.push("M", x, ",", y); + stream.point = pointLine; + } + function pointLine(x, y) { + buffer.push("L", x, ",", y); + } + function lineEnd() { + stream.point = point; + } + function lineEndPolygon() { + buffer.push("Z"); + } + return stream; + } + function d3_geo_pathBufferCircle(radius) { + return "m0," + radius + "a" + radius + "," + radius + " 0 1,1 0," + -2 * radius + "a" + radius + "," + radius + " 0 1,1 0," + 2 * radius + "z"; + } + var d3_geo_pathCentroid = { + point: d3_geo_pathCentroidPoint, + lineStart: d3_geo_pathCentroidLineStart, + lineEnd: d3_geo_pathCentroidLineEnd, + polygonStart: function() { + d3_geo_pathCentroid.lineStart = d3_geo_pathCentroidRingStart; + }, + polygonEnd: function() { + d3_geo_pathCentroid.point = d3_geo_pathCentroidPoint; + d3_geo_pathCentroid.lineStart = d3_geo_pathCentroidLineStart; + d3_geo_pathCentroid.lineEnd = d3_geo_pathCentroidLineEnd; + } + }; + function d3_geo_pathCentroidPoint(x, y) { + d3_geo_centroidX0 += x; + d3_geo_centroidY0 += y; + ++d3_geo_centroidZ0; + } + function d3_geo_pathCentroidLineStart() { + var x0, y0; + d3_geo_pathCentroid.point = function(x, y) { + d3_geo_pathCentroid.point = nextPoint; + d3_geo_pathCentroidPoint(x0 = x, y0 = y); + }; + function nextPoint(x, y) { + var dx = x - x0, dy = y - y0, z = Math.sqrt(dx * dx + dy * dy); + d3_geo_centroidX1 += z * (x0 + x) / 2; + d3_geo_centroidY1 += z * (y0 + y) / 2; + d3_geo_centroidZ1 += z; + d3_geo_pathCentroidPoint(x0 = x, y0 = y); + } + } + function d3_geo_pathCentroidLineEnd() { + d3_geo_pathCentroid.point = d3_geo_pathCentroidPoint; + } + function d3_geo_pathCentroidRingStart() { + var x00, y00, x0, y0; + d3_geo_pathCentroid.point = function(x, y) { + d3_geo_pathCentroid.point = nextPoint; + d3_geo_pathCentroidPoint(x00 = x0 = x, y00 = y0 = y); + }; + function nextPoint(x, y) { + var dx = x - x0, dy = y - y0, z = Math.sqrt(dx * dx + dy * dy); + d3_geo_centroidX1 += z * (x0 + x) / 2; + d3_geo_centroidY1 += z * (y0 + y) / 2; + d3_geo_centroidZ1 += z; + z = y0 * x - x0 * y; + d3_geo_centroidX2 += z * (x0 + x); + d3_geo_centroidY2 += z * (y0 + y); + d3_geo_centroidZ2 += z * 3; + d3_geo_pathCentroidPoint(x0 = x, y0 = y); + } + d3_geo_pathCentroid.lineEnd = function() { + nextPoint(x00, y00); + }; + } + function d3_geo_pathContext(context) { + var pointRadius = 4.5; + var stream = { + point: point, + lineStart: function() { + stream.point = pointLineStart; + }, + lineEnd: lineEnd, + polygonStart: function() { + stream.lineEnd = lineEndPolygon; + }, + polygonEnd: function() { + stream.lineEnd = lineEnd; + stream.point = point; + }, + pointRadius: function(_) { + pointRadius = _; + return stream; + }, + result: d3_noop + }; + function point(x, y) { + context.moveTo(x + pointRadius, y); + context.arc(x, y, pointRadius, 0, τ); + } + function pointLineStart(x, y) { + context.moveTo(x, y); + stream.point = pointLine; + } + function pointLine(x, y) { + context.lineTo(x, y); + } + function lineEnd() { + stream.point = point; + } + function lineEndPolygon() { + context.closePath(); + } + return stream; + } + function d3_geo_resample(project) { + var δ2 = .5, cosMinDistance = Math.cos(30 * d3_radians), maxDepth = 16; + function resample(stream) { + return (maxDepth ? resampleRecursive : resampleNone)(stream); + } + function resampleNone(stream) { + return d3_geo_transformPoint(stream, function(x, y) { + x = project(x, y); + stream.point(x[0], x[1]); + }); + } + function resampleRecursive(stream) { + var λ00, φ00, x00, y00, a00, b00, c00, λ0, x0, y0, a0, b0, c0; + var resample = { + point: point, + lineStart: lineStart, + lineEnd: lineEnd, + polygonStart: function() { + stream.polygonStart(); + resample.lineStart = ringStart; + }, + polygonEnd: function() { + stream.polygonEnd(); + resample.lineStart = lineStart; + } + }; + function point(x, y) { + x = project(x, y); + stream.point(x[0], x[1]); + } + function lineStart() { + x0 = NaN; + resample.point = linePoint; + stream.lineStart(); + } + function linePoint(λ, φ) { + var c = d3_geo_cartesian([ λ, φ ]), p = project(λ, φ); + resampleLineTo(x0, y0, λ0, a0, b0, c0, x0 = p[0], y0 = p[1], λ0 = λ, a0 = c[0], b0 = c[1], c0 = c[2], maxDepth, stream); + stream.point(x0, y0); + } + function lineEnd() { + resample.point = point; + stream.lineEnd(); + } + function ringStart() { + lineStart(); + resample.point = ringPoint; + resample.lineEnd = ringEnd; + } + function ringPoint(λ, φ) { + linePoint(λ00 = λ, φ00 = φ), x00 = x0, y00 = y0, a00 = a0, b00 = b0, c00 = c0; + resample.point = linePoint; + } + function ringEnd() { + resampleLineTo(x0, y0, λ0, a0, b0, c0, x00, y00, λ00, a00, b00, c00, maxDepth, stream); + resample.lineEnd = lineEnd; + lineEnd(); + } + return resample; + } + function resampleLineTo(x0, y0, λ0, a0, b0, c0, x1, y1, λ1, a1, b1, c1, depth, stream) { + var dx = x1 - x0, dy = y1 - y0, d2 = dx * dx + dy * dy; + if (d2 > 4 * δ2 && depth--) { + var a = a0 + a1, b = b0 + b1, c = c0 + c1, m = Math.sqrt(a * a + b * b + c * c), φ2 = Math.asin(c /= m), λ2 = abs(abs(c) - 1) < ε || abs(λ0 - λ1) < ε ? (λ0 + λ1) / 2 : Math.atan2(b, a), p = project(λ2, φ2), x2 = p[0], y2 = p[1], dx2 = x2 - x0, dy2 = y2 - y0, dz = dy * dx2 - dx * dy2; + if (dz * dz / d2 > δ2 || abs((dx * dx2 + dy * dy2) / d2 - .5) > .3 || a0 * a1 + b0 * b1 + c0 * c1 < cosMinDistance) { + resampleLineTo(x0, y0, λ0, a0, b0, c0, x2, y2, λ2, a /= m, b /= m, c, depth, stream); + stream.point(x2, y2); + resampleLineTo(x2, y2, λ2, a, b, c, x1, y1, λ1, a1, b1, c1, depth, stream); + } + } + } + resample.precision = function(_) { + if (!arguments.length) return Math.sqrt(δ2); + maxDepth = (δ2 = _ * _) > 0 && 16; + return resample; + }; + return resample; + } + d3.geo.path = function() { + var pointRadius = 4.5, projection, context, projectStream, contextStream, cacheStream; + function path(object) { + if (object) { + if (typeof pointRadius === "function") contextStream.pointRadius(+pointRadius.apply(this, arguments)); + if (!cacheStream || !cacheStream.valid) cacheStream = projectStream(contextStream); + d3.geo.stream(object, cacheStream); + } + return contextStream.result(); + } + path.area = function(object) { + d3_geo_pathAreaSum = 0; + d3.geo.stream(object, projectStream(d3_geo_pathArea)); + return d3_geo_pathAreaSum; + }; + path.centroid = function(object) { + d3_geo_centroidX0 = d3_geo_centroidY0 = d3_geo_centroidZ0 = d3_geo_centroidX1 = d3_geo_centroidY1 = d3_geo_centroidZ1 = d3_geo_centroidX2 = d3_geo_centroidY2 = d3_geo_centroidZ2 = 0; + d3.geo.stream(object, projectStream(d3_geo_pathCentroid)); + return d3_geo_centroidZ2 ? [ d3_geo_centroidX2 / d3_geo_centroidZ2, d3_geo_centroidY2 / d3_geo_centroidZ2 ] : d3_geo_centroidZ1 ? [ d3_geo_centroidX1 / d3_geo_centroidZ1, d3_geo_centroidY1 / d3_geo_centroidZ1 ] : d3_geo_centroidZ0 ? [ d3_geo_centroidX0 / d3_geo_centroidZ0, d3_geo_centroidY0 / d3_geo_centroidZ0 ] : [ NaN, NaN ]; + }; + path.bounds = function(object) { + d3_geo_pathBoundsX1 = d3_geo_pathBoundsY1 = -(d3_geo_pathBoundsX0 = d3_geo_pathBoundsY0 = Infinity); + d3.geo.stream(object, projectStream(d3_geo_pathBounds)); + return [ [ d3_geo_pathBoundsX0, d3_geo_pathBoundsY0 ], [ d3_geo_pathBoundsX1, d3_geo_pathBoundsY1 ] ]; + }; + path.projection = function(_) { + if (!arguments.length) return projection; + projectStream = (projection = _) ? _.stream || d3_geo_pathProjectStream(_) : d3_identity; + return reset(); + }; + path.context = function(_) { + if (!arguments.length) return context; + contextStream = (context = _) == null ? new d3_geo_pathBuffer() : new d3_geo_pathContext(_); + if (typeof pointRadius !== "function") contextStream.pointRadius(pointRadius); + return reset(); + }; + path.pointRadius = function(_) { + if (!arguments.length) return pointRadius; + pointRadius = typeof _ === "function" ? _ : (contextStream.pointRadius(+_), +_); + return path; + }; + function reset() { + cacheStream = null; + return path; + } + return path.projection(d3.geo.albersUsa()).context(null); + }; + function d3_geo_pathProjectStream(project) { + var resample = d3_geo_resample(function(x, y) { + return project([ x * d3_degrees, y * d3_degrees ]); + }); + return function(stream) { + return d3_geo_projectionRadians(resample(stream)); + }; + } + d3.geo.transform = function(methods) { + return { + stream: function(stream) { + var transform = new d3_geo_transform(stream); + for (var k in methods) transform[k] = methods[k]; + return transform; + } + }; + }; + function d3_geo_transform(stream) { + this.stream = stream; + } + d3_geo_transform.prototype = { + point: function(x, y) { + this.stream.point(x, y); + }, + sphere: function() { + this.stream.sphere(); + }, + lineStart: function() { + this.stream.lineStart(); + }, + lineEnd: function() { + this.stream.lineEnd(); + }, + polygonStart: function() { + this.stream.polygonStart(); + }, + polygonEnd: function() { + this.stream.polygonEnd(); + } + }; + function d3_geo_transformPoint(stream, point) { + return { + point: point, + sphere: function() { + stream.sphere(); + }, + lineStart: function() { + stream.lineStart(); + }, + lineEnd: function() { + stream.lineEnd(); + }, + polygonStart: function() { + stream.polygonStart(); + }, + polygonEnd: function() { + stream.polygonEnd(); + } + }; + } + d3.geo.projection = d3_geo_projection; + d3.geo.projectionMutator = d3_geo_projectionMutator; + function d3_geo_projection(project) { + return d3_geo_projectionMutator(function() { + return project; + })(); + } + function d3_geo_projectionMutator(projectAt) { + var project, rotate, projectRotate, projectResample = d3_geo_resample(function(x, y) { + x = project(x, y); + return [ x[0] * k + δx, δy - x[1] * k ]; + }), k = 150, x = 480, y = 250, λ = 0, φ = 0, δλ = 0, δφ = 0, δγ = 0, δx, δy, preclip = d3_geo_clipAntimeridian, postclip = d3_identity, clipAngle = null, clipExtent = null, stream; + function projection(point) { + point = projectRotate(point[0] * d3_radians, point[1] * d3_radians); + return [ point[0] * k + δx, δy - point[1] * k ]; + } + function invert(point) { + point = projectRotate.invert((point[0] - δx) / k, (δy - point[1]) / k); + return point && [ point[0] * d3_degrees, point[1] * d3_degrees ]; + } + projection.stream = function(output) { + if (stream) stream.valid = false; + stream = d3_geo_projectionRadians(preclip(rotate, projectResample(postclip(output)))); + stream.valid = true; + return stream; + }; + projection.clipAngle = function(_) { + if (!arguments.length) return clipAngle; + preclip = _ == null ? (clipAngle = _, d3_geo_clipAntimeridian) : d3_geo_clipCircle((clipAngle = +_) * d3_radians); + return invalidate(); + }; + projection.clipExtent = function(_) { + if (!arguments.length) return clipExtent; + clipExtent = _; + postclip = _ ? d3_geo_clipExtent(_[0][0], _[0][1], _[1][0], _[1][1]) : d3_identity; + return invalidate(); + }; + projection.scale = function(_) { + if (!arguments.length) return k; + k = +_; + return reset(); + }; + projection.translate = function(_) { + if (!arguments.length) return [ x, y ]; + x = +_[0]; + y = +_[1]; + return reset(); + }; + projection.center = function(_) { + if (!arguments.length) return [ λ * d3_degrees, φ * d3_degrees ]; + λ = _[0] % 360 * d3_radians; + φ = _[1] % 360 * d3_radians; + return reset(); + }; + projection.rotate = function(_) { + if (!arguments.length) return [ δλ * d3_degrees, δφ * d3_degrees, δγ * d3_degrees ]; + δλ = _[0] % 360 * d3_radians; + δφ = _[1] % 360 * d3_radians; + δγ = _.length > 2 ? _[2] % 360 * d3_radians : 0; + return reset(); + }; + d3.rebind(projection, projectResample, "precision"); + function reset() { + projectRotate = d3_geo_compose(rotate = d3_geo_rotation(δλ, δφ, δγ), project); + var center = project(λ, φ); + δx = x - center[0] * k; + δy = y + center[1] * k; + return invalidate(); + } + function invalidate() { + if (stream) stream.valid = false, stream = null; + return projection; + } + return function() { + project = projectAt.apply(this, arguments); + projection.invert = project.invert && invert; + return reset(); + }; + } + function d3_geo_projectionRadians(stream) { + return d3_geo_transformPoint(stream, function(x, y) { + stream.point(x * d3_radians, y * d3_radians); + }); + } + function d3_geo_equirectangular(λ, φ) { + return [ λ, φ ]; + } + (d3.geo.equirectangular = function() { + return d3_geo_projection(d3_geo_equirectangular); + }).raw = d3_geo_equirectangular.invert = d3_geo_equirectangular; + d3.geo.rotation = function(rotate) { + rotate = d3_geo_rotation(rotate[0] % 360 * d3_radians, rotate[1] * d3_radians, rotate.length > 2 ? rotate[2] * d3_radians : 0); + function forward(coordinates) { + coordinates = rotate(coordinates[0] * d3_radians, coordinates[1] * d3_radians); + return coordinates[0] *= d3_degrees, coordinates[1] *= d3_degrees, coordinates; + } + forward.invert = function(coordinates) { + coordinates = rotate.invert(coordinates[0] * d3_radians, coordinates[1] * d3_radians); + return coordinates[0] *= d3_degrees, coordinates[1] *= d3_degrees, coordinates; + }; + return forward; + }; + function d3_geo_identityRotation(λ, φ) { + return [ λ > π ? λ - τ : λ < -π ? λ + τ : λ, φ ]; + } + d3_geo_identityRotation.invert = d3_geo_equirectangular; + function d3_geo_rotation(δλ, δφ, δγ) { + return δλ ? δφ || δγ ? d3_geo_compose(d3_geo_rotationλ(δλ), d3_geo_rotationφγ(δφ, δγ)) : d3_geo_rotationλ(δλ) : δφ || δγ ? d3_geo_rotationφγ(δφ, δγ) : d3_geo_identityRotation; + } + function d3_geo_forwardRotationλ(δλ) { + return function(λ, φ) { + return λ += δλ, [ λ > π ? λ - τ : λ < -π ? λ + τ : λ, φ ]; + }; + } + function d3_geo_rotationλ(δλ) { + var rotation = d3_geo_forwardRotationλ(δλ); + rotation.invert = d3_geo_forwardRotationλ(-δλ); + return rotation; + } + function d3_geo_rotationφγ(δφ, δγ) { + var cosδφ = Math.cos(δφ), sinδφ = Math.sin(δφ), cosδγ = Math.cos(δγ), sinδγ = Math.sin(δγ); + function rotation(λ, φ) { + var cosφ = Math.cos(φ), x = Math.cos(λ) * cosφ, y = Math.sin(λ) * cosφ, z = Math.sin(φ), k = z * cosδφ + x * sinδφ; + return [ Math.atan2(y * cosδγ - k * sinδγ, x * cosδφ - z * sinδφ), d3_asin(k * cosδγ + y * sinδγ) ]; + } + rotation.invert = function(λ, φ) { + var cosφ = Math.cos(φ), x = Math.cos(λ) * cosφ, y = Math.sin(λ) * cosφ, z = Math.sin(φ), k = z * cosδγ - y * sinδγ; + return [ Math.atan2(y * cosδγ + z * sinδγ, x * cosδφ + k * sinδφ), d3_asin(k * cosδφ - x * sinδφ) ]; + }; + return rotation; + } + d3.geo.circle = function() { + var origin = [ 0, 0 ], angle, precision = 6, interpolate; + function circle() { + var center = typeof origin === "function" ? origin.apply(this, arguments) : origin, rotate = d3_geo_rotation(-center[0] * d3_radians, -center[1] * d3_radians, 0).invert, ring = []; + interpolate(null, null, 1, { + point: function(x, y) { + ring.push(x = rotate(x, y)); + x[0] *= d3_degrees, x[1] *= d3_degrees; + } + }); + return { + type: "Polygon", + coordinates: [ ring ] + }; + } + circle.origin = function(x) { + if (!arguments.length) return origin; + origin = x; + return circle; + }; + circle.angle = function(x) { + if (!arguments.length) return angle; + interpolate = d3_geo_circleInterpolate((angle = +x) * d3_radians, precision * d3_radians); + return circle; + }; + circle.precision = function(_) { + if (!arguments.length) return precision; + interpolate = d3_geo_circleInterpolate(angle * d3_radians, (precision = +_) * d3_radians); + return circle; + }; + return circle.angle(90); + }; + function d3_geo_circleInterpolate(radius, precision) { + var cr = Math.cos(radius), sr = Math.sin(radius); + return function(from, to, direction, listener) { + var step = direction * precision; + if (from != null) { + from = d3_geo_circleAngle(cr, from); + to = d3_geo_circleAngle(cr, to); + if (direction > 0 ? from < to : from > to) from += direction * τ; + } else { + from = radius + direction * τ; + to = radius - .5 * step; + } + for (var point, t = from; direction > 0 ? t > to : t < to; t -= step) { + listener.point((point = d3_geo_spherical([ cr, -sr * Math.cos(t), -sr * Math.sin(t) ]))[0], point[1]); + } + }; + } + function d3_geo_circleAngle(cr, point) { + var a = d3_geo_cartesian(point); + a[0] -= cr; + d3_geo_cartesianNormalize(a); + var angle = d3_acos(-a[1]); + return ((-a[2] < 0 ? -angle : angle) + 2 * Math.PI - ε) % (2 * Math.PI); + } + d3.geo.distance = function(a, b) { + var Δλ = (b[0] - a[0]) * d3_radians, φ0 = a[1] * d3_radians, φ1 = b[1] * d3_radians, sinΔλ = Math.sin(Δλ), cosΔλ = Math.cos(Δλ), sinφ0 = Math.sin(φ0), cosφ0 = Math.cos(φ0), sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1), t; + return Math.atan2(Math.sqrt((t = cosφ1 * sinΔλ) * t + (t = cosφ0 * sinφ1 - sinφ0 * cosφ1 * cosΔλ) * t), sinφ0 * sinφ1 + cosφ0 * cosφ1 * cosΔλ); + }; + d3.geo.graticule = function() { + var x1, x0, X1, X0, y1, y0, Y1, Y0, dx = 10, dy = dx, DX = 90, DY = 360, x, y, X, Y, precision = 2.5; + function graticule() { + return { + type: "MultiLineString", + coordinates: lines() + }; + } + function lines() { + return d3.range(Math.ceil(X0 / DX) * DX, X1, DX).map(X).concat(d3.range(Math.ceil(Y0 / DY) * DY, Y1, DY).map(Y)).concat(d3.range(Math.ceil(x0 / dx) * dx, x1, dx).filter(function(x) { + return abs(x % DX) > ε; + }).map(x)).concat(d3.range(Math.ceil(y0 / dy) * dy, y1, dy).filter(function(y) { + return abs(y % DY) > ε; + }).map(y)); + } + graticule.lines = function() { + return lines().map(function(coordinates) { + return { + type: "LineString", + coordinates: coordinates + }; + }); + }; + graticule.outline = function() { + return { + type: "Polygon", + coordinates: [ X(X0).concat(Y(Y1).slice(1), X(X1).reverse().slice(1), Y(Y0).reverse().slice(1)) ] + }; + }; + graticule.extent = function(_) { + if (!arguments.length) return graticule.minorExtent(); + return graticule.majorExtent(_).minorExtent(_); + }; + graticule.majorExtent = function(_) { + if (!arguments.length) return [ [ X0, Y0 ], [ X1, Y1 ] ]; + X0 = +_[0][0], X1 = +_[1][0]; + Y0 = +_[0][1], Y1 = +_[1][1]; + if (X0 > X1) _ = X0, X0 = X1, X1 = _; + if (Y0 > Y1) _ = Y0, Y0 = Y1, Y1 = _; + return graticule.precision(precision); + }; + graticule.minorExtent = function(_) { + if (!arguments.length) return [ [ x0, y0 ], [ x1, y1 ] ]; + x0 = +_[0][0], x1 = +_[1][0]; + y0 = +_[0][1], y1 = +_[1][1]; + if (x0 > x1) _ = x0, x0 = x1, x1 = _; + if (y0 > y1) _ = y0, y0 = y1, y1 = _; + return graticule.precision(precision); + }; + graticule.step = function(_) { + if (!arguments.length) return graticule.minorStep(); + return graticule.majorStep(_).minorStep(_); + }; + graticule.majorStep = function(_) { + if (!arguments.length) return [ DX, DY ]; + DX = +_[0], DY = +_[1]; + return graticule; + }; + graticule.minorStep = function(_) { + if (!arguments.length) return [ dx, dy ]; + dx = +_[0], dy = +_[1]; + return graticule; + }; + graticule.precision = function(_) { + if (!arguments.length) return precision; + precision = +_; + x = d3_geo_graticuleX(y0, y1, 90); + y = d3_geo_graticuleY(x0, x1, precision); + X = d3_geo_graticuleX(Y0, Y1, 90); + Y = d3_geo_graticuleY(X0, X1, precision); + return graticule; + }; + return graticule.majorExtent([ [ -180, -90 + ε ], [ 180, 90 - ε ] ]).minorExtent([ [ -180, -80 - ε ], [ 180, 80 + ε ] ]); + }; + function d3_geo_graticuleX(y0, y1, dy) { + var y = d3.range(y0, y1 - ε, dy).concat(y1); + return function(x) { + return y.map(function(y) { + return [ x, y ]; + }); + }; + } + function d3_geo_graticuleY(x0, x1, dx) { + var x = d3.range(x0, x1 - ε, dx).concat(x1); + return function(y) { + return x.map(function(x) { + return [ x, y ]; + }); + }; + } + function d3_source(d) { + return d.source; + } + function d3_target(d) { + return d.target; + } + d3.geo.greatArc = function() { + var source = d3_source, source_, target = d3_target, target_; + function greatArc() { + return { + type: "LineString", + coordinates: [ source_ || source.apply(this, arguments), target_ || target.apply(this, arguments) ] + }; + } + greatArc.distance = function() { + return d3.geo.distance(source_ || source.apply(this, arguments), target_ || target.apply(this, arguments)); + }; + greatArc.source = function(_) { + if (!arguments.length) return source; + source = _, source_ = typeof _ === "function" ? null : _; + return greatArc; + }; + greatArc.target = function(_) { + if (!arguments.length) return target; + target = _, target_ = typeof _ === "function" ? null : _; + return greatArc; + }; + greatArc.precision = function() { + return arguments.length ? greatArc : 0; + }; + return greatArc; + }; + d3.geo.interpolate = function(source, target) { + return d3_geo_interpolate(source[0] * d3_radians, source[1] * d3_radians, target[0] * d3_radians, target[1] * d3_radians); + }; + function d3_geo_interpolate(x0, y0, x1, y1) { + var cy0 = Math.cos(y0), sy0 = Math.sin(y0), cy1 = Math.cos(y1), sy1 = Math.sin(y1), kx0 = cy0 * Math.cos(x0), ky0 = cy0 * Math.sin(x0), kx1 = cy1 * Math.cos(x1), ky1 = cy1 * Math.sin(x1), d = 2 * Math.asin(Math.sqrt(d3_haversin(y1 - y0) + cy0 * cy1 * d3_haversin(x1 - x0))), k = 1 / Math.sin(d); + var interpolate = d ? function(t) { + var B = Math.sin(t *= d) * k, A = Math.sin(d - t) * k, x = A * kx0 + B * kx1, y = A * ky0 + B * ky1, z = A * sy0 + B * sy1; + return [ Math.atan2(y, x) * d3_degrees, Math.atan2(z, Math.sqrt(x * x + y * y)) * d3_degrees ]; + } : function() { + return [ x0 * d3_degrees, y0 * d3_degrees ]; + }; + interpolate.distance = d; + return interpolate; + } + d3.geo.length = function(object) { + d3_geo_lengthSum = 0; + d3.geo.stream(object, d3_geo_length); + return d3_geo_lengthSum; + }; + var d3_geo_lengthSum; + var d3_geo_length = { + sphere: d3_noop, + point: d3_noop, + lineStart: d3_geo_lengthLineStart, + lineEnd: d3_noop, + polygonStart: d3_noop, + polygonEnd: d3_noop + }; + function d3_geo_lengthLineStart() { + var λ0, sinφ0, cosφ0; + d3_geo_length.point = function(λ, φ) { + λ0 = λ * d3_radians, sinφ0 = Math.sin(φ *= d3_radians), cosφ0 = Math.cos(φ); + d3_geo_length.point = nextPoint; + }; + d3_geo_length.lineEnd = function() { + d3_geo_length.point = d3_geo_length.lineEnd = d3_noop; + }; + function nextPoint(λ, φ) { + var sinφ = Math.sin(φ *= d3_radians), cosφ = Math.cos(φ), t = abs((λ *= d3_radians) - λ0), cosΔλ = Math.cos(t); + d3_geo_lengthSum += Math.atan2(Math.sqrt((t = cosφ * Math.sin(t)) * t + (t = cosφ0 * sinφ - sinφ0 * cosφ * cosΔλ) * t), sinφ0 * sinφ + cosφ0 * cosφ * cosΔλ); + λ0 = λ, sinφ0 = sinφ, cosφ0 = cosφ; + } + } + function d3_geo_azimuthal(scale, angle) { + function azimuthal(λ, φ) { + var cosλ = Math.cos(λ), cosφ = Math.cos(φ), k = scale(cosλ * cosφ); + return [ k * cosφ * Math.sin(λ), k * Math.sin(φ) ]; + } + azimuthal.invert = function(x, y) { + var ρ = Math.sqrt(x * x + y * y), c = angle(ρ), sinc = Math.sin(c), cosc = Math.cos(c); + return [ Math.atan2(x * sinc, ρ * cosc), Math.asin(ρ && y * sinc / ρ) ]; + }; + return azimuthal; + } + var d3_geo_azimuthalEqualArea = d3_geo_azimuthal(function(cosλcosφ) { + return Math.sqrt(2 / (1 + cosλcosφ)); + }, function(ρ) { + return 2 * Math.asin(ρ / 2); + }); + (d3.geo.azimuthalEqualArea = function() { + return d3_geo_projection(d3_geo_azimuthalEqualArea); + }).raw = d3_geo_azimuthalEqualArea; + var d3_geo_azimuthalEquidistant = d3_geo_azimuthal(function(cosλcosφ) { + var c = Math.acos(cosλcosφ); + return c && c / Math.sin(c); + }, d3_identity); + (d3.geo.azimuthalEquidistant = function() { + return d3_geo_projection(d3_geo_azimuthalEquidistant); + }).raw = d3_geo_azimuthalEquidistant; + function d3_geo_conicConformal(φ0, φ1) { + var cosφ0 = Math.cos(φ0), t = function(φ) { + return Math.tan(π / 4 + φ / 2); + }, n = φ0 === φ1 ? Math.sin(φ0) : Math.log(cosφ0 / Math.cos(φ1)) / Math.log(t(φ1) / t(φ0)), F = cosφ0 * Math.pow(t(φ0), n) / n; + if (!n) return d3_geo_mercator; + function forward(λ, φ) { + if (F > 0) { + if (φ < -halfπ + ε) φ = -halfπ + ε; + } else { + if (φ > halfπ - ε) φ = halfπ - ε; + } + var ρ = F / Math.pow(t(φ), n); + return [ ρ * Math.sin(n * λ), F - ρ * Math.cos(n * λ) ]; + } + forward.invert = function(x, y) { + var ρ0_y = F - y, ρ = d3_sgn(n) * Math.sqrt(x * x + ρ0_y * ρ0_y); + return [ Math.atan2(x, ρ0_y) / n, 2 * Math.atan(Math.pow(F / ρ, 1 / n)) - halfπ ]; + }; + return forward; + } + (d3.geo.conicConformal = function() { + return d3_geo_conic(d3_geo_conicConformal); + }).raw = d3_geo_conicConformal; + function d3_geo_conicEquidistant(φ0, φ1) { + var cosφ0 = Math.cos(φ0), n = φ0 === φ1 ? Math.sin(φ0) : (cosφ0 - Math.cos(φ1)) / (φ1 - φ0), G = cosφ0 / n + φ0; + if (abs(n) < ε) return d3_geo_equirectangular; + function forward(λ, φ) { + var ρ = G - φ; + return [ ρ * Math.sin(n * λ), G - ρ * Math.cos(n * λ) ]; + } + forward.invert = function(x, y) { + var ρ0_y = G - y; + return [ Math.atan2(x, ρ0_y) / n, G - d3_sgn(n) * Math.sqrt(x * x + ρ0_y * ρ0_y) ]; + }; + return forward; + } + (d3.geo.conicEquidistant = function() { + return d3_geo_conic(d3_geo_conicEquidistant); + }).raw = d3_geo_conicEquidistant; + var d3_geo_gnomonic = d3_geo_azimuthal(function(cosλcosφ) { + return 1 / cosλcosφ; + }, Math.atan); + (d3.geo.gnomonic = function() { + return d3_geo_projection(d3_geo_gnomonic); + }).raw = d3_geo_gnomonic; + function d3_geo_mercator(λ, φ) { + return [ λ, Math.log(Math.tan(π / 4 + φ / 2)) ]; + } + d3_geo_mercator.invert = function(x, y) { + return [ x, 2 * Math.atan(Math.exp(y)) - halfπ ]; + }; + function d3_geo_mercatorProjection(project) { + var m = d3_geo_projection(project), scale = m.scale, translate = m.translate, clipExtent = m.clipExtent, clipAuto; + m.scale = function() { + var v = scale.apply(m, arguments); + return v === m ? clipAuto ? m.clipExtent(null) : m : v; + }; + m.translate = function() { + var v = translate.apply(m, arguments); + return v === m ? clipAuto ? m.clipExtent(null) : m : v; + }; + m.clipExtent = function(_) { + var v = clipExtent.apply(m, arguments); + if (v === m) { + if (clipAuto = _ == null) { + var k = π * scale(), t = translate(); + clipExtent([ [ t[0] - k, t[1] - k ], [ t[0] + k, t[1] + k ] ]); + } + } else if (clipAuto) { + v = null; + } + return v; + }; + return m.clipExtent(null); + } + (d3.geo.mercator = function() { + return d3_geo_mercatorProjection(d3_geo_mercator); + }).raw = d3_geo_mercator; + var d3_geo_orthographic = d3_geo_azimuthal(function() { + return 1; + }, Math.asin); + (d3.geo.orthographic = function() { + return d3_geo_projection(d3_geo_orthographic); + }).raw = d3_geo_orthographic; + var d3_geo_stereographic = d3_geo_azimuthal(function(cosλcosφ) { + return 1 / (1 + cosλcosφ); + }, function(ρ) { + return 2 * Math.atan(ρ); + }); + (d3.geo.stereographic = function() { + return d3_geo_projection(d3_geo_stereographic); + }).raw = d3_geo_stereographic; + function d3_geo_transverseMercator(λ, φ) { + return [ Math.log(Math.tan(π / 4 + φ / 2)), -λ ]; + } + d3_geo_transverseMercator.invert = function(x, y) { + return [ -y, 2 * Math.atan(Math.exp(x)) - halfπ ]; + }; + (d3.geo.transverseMercator = function() { + var projection = d3_geo_mercatorProjection(d3_geo_transverseMercator), center = projection.center, rotate = projection.rotate; + projection.center = function(_) { + return _ ? center([ -_[1], _[0] ]) : (_ = center(), [ _[1], -_[0] ]); + }; + projection.rotate = function(_) { + return _ ? rotate([ _[0], _[1], _.length > 2 ? _[2] + 90 : 90 ]) : (_ = rotate(), + [ _[0], _[1], _[2] - 90 ]); + }; + return rotate([ 0, 0, 90 ]); + }).raw = d3_geo_transverseMercator; + d3.geom = {}; + function d3_geom_pointX(d) { + return d[0]; + } + function d3_geom_pointY(d) { + return d[1]; + } + d3.geom.hull = function(vertices) { + var x = d3_geom_pointX, y = d3_geom_pointY; + if (arguments.length) return hull(vertices); + function hull(data) { + if (data.length < 3) return []; + var fx = d3_functor(x), fy = d3_functor(y), i, n = data.length, points = [], flippedPoints = []; + for (i = 0; i < n; i++) { + points.push([ +fx.call(this, data[i], i), +fy.call(this, data[i], i), i ]); + } + points.sort(d3_geom_hullOrder); + for (i = 0; i < n; i++) flippedPoints.push([ points[i][0], -points[i][1] ]); + var upper = d3_geom_hullUpper(points), lower = d3_geom_hullUpper(flippedPoints); + var skipLeft = lower[0] === upper[0], skipRight = lower[lower.length - 1] === upper[upper.length - 1], polygon = []; + for (i = upper.length - 1; i >= 0; --i) polygon.push(data[points[upper[i]][2]]); + for (i = +skipLeft; i < lower.length - skipRight; ++i) polygon.push(data[points[lower[i]][2]]); + return polygon; + } + hull.x = function(_) { + return arguments.length ? (x = _, hull) : x; + }; + hull.y = function(_) { + return arguments.length ? (y = _, hull) : y; + }; + return hull; + }; + function d3_geom_hullUpper(points) { + var n = points.length, hull = [ 0, 1 ], hs = 2; + for (var i = 2; i < n; i++) { + while (hs > 1 && d3_cross2d(points[hull[hs - 2]], points[hull[hs - 1]], points[i]) <= 0) --hs; + hull[hs++] = i; + } + return hull.slice(0, hs); + } + function d3_geom_hullOrder(a, b) { + return a[0] - b[0] || a[1] - b[1]; + } + d3.geom.polygon = function(coordinates) { + d3_subclass(coordinates, d3_geom_polygonPrototype); + return coordinates; + }; + var d3_geom_polygonPrototype = d3.geom.polygon.prototype = []; + d3_geom_polygonPrototype.area = function() { + var i = -1, n = this.length, a, b = this[n - 1], area = 0; + while (++i < n) { + a = b; + b = this[i]; + area += a[1] * b[0] - a[0] * b[1]; + } + return area * .5; + }; + d3_geom_polygonPrototype.centroid = function(k) { + var i = -1, n = this.length, x = 0, y = 0, a, b = this[n - 1], c; + if (!arguments.length) k = -1 / (6 * this.area()); + while (++i < n) { + a = b; + b = this[i]; + c = a[0] * b[1] - b[0] * a[1]; + x += (a[0] + b[0]) * c; + y += (a[1] + b[1]) * c; + } + return [ x * k, y * k ]; + }; + d3_geom_polygonPrototype.clip = function(subject) { + var input, closed = d3_geom_polygonClosed(subject), i = -1, n = this.length - d3_geom_polygonClosed(this), j, m, a = this[n - 1], b, c, d; + while (++i < n) { + input = subject.slice(); + subject.length = 0; + b = this[i]; + c = input[(m = input.length - closed) - 1]; + j = -1; + while (++j < m) { + d = input[j]; + if (d3_geom_polygonInside(d, a, b)) { + if (!d3_geom_polygonInside(c, a, b)) { + subject.push(d3_geom_polygonIntersect(c, d, a, b)); + } + subject.push(d); + } else if (d3_geom_polygonInside(c, a, b)) { + subject.push(d3_geom_polygonIntersect(c, d, a, b)); + } + c = d; + } + if (closed) subject.push(subject[0]); + a = b; + } + return subject; + }; + function d3_geom_polygonInside(p, a, b) { + return (b[0] - a[0]) * (p[1] - a[1]) < (b[1] - a[1]) * (p[0] - a[0]); + } + function d3_geom_polygonIntersect(c, d, a, b) { + var x1 = c[0], x3 = a[0], x21 = d[0] - x1, x43 = b[0] - x3, y1 = c[1], y3 = a[1], y21 = d[1] - y1, y43 = b[1] - y3, ua = (x43 * (y1 - y3) - y43 * (x1 - x3)) / (y43 * x21 - x43 * y21); + return [ x1 + ua * x21, y1 + ua * y21 ]; + } + function d3_geom_polygonClosed(coordinates) { + var a = coordinates[0], b = coordinates[coordinates.length - 1]; + return !(a[0] - b[0] || a[1] - b[1]); + } + var d3_geom_voronoiEdges, d3_geom_voronoiCells, d3_geom_voronoiBeaches, d3_geom_voronoiBeachPool = [], d3_geom_voronoiFirstCircle, d3_geom_voronoiCircles, d3_geom_voronoiCirclePool = []; + function d3_geom_voronoiBeach() { + d3_geom_voronoiRedBlackNode(this); + this.edge = this.site = this.circle = null; + } + function d3_geom_voronoiCreateBeach(site) { + var beach = d3_geom_voronoiBeachPool.pop() || new d3_geom_voronoiBeach(); + beach.site = site; + return beach; + } + function d3_geom_voronoiDetachBeach(beach) { + d3_geom_voronoiDetachCircle(beach); + d3_geom_voronoiBeaches.remove(beach); + d3_geom_voronoiBeachPool.push(beach); + d3_geom_voronoiRedBlackNode(beach); + } + function d3_geom_voronoiRemoveBeach(beach) { + var circle = beach.circle, x = circle.x, y = circle.cy, vertex = { + x: x, + y: y + }, previous = beach.P, next = beach.N, disappearing = [ beach ]; + d3_geom_voronoiDetachBeach(beach); + var lArc = previous; + while (lArc.circle && abs(x - lArc.circle.x) < ε && abs(y - lArc.circle.cy) < ε) { + previous = lArc.P; + disappearing.unshift(lArc); + d3_geom_voronoiDetachBeach(lArc); + lArc = previous; + } + disappearing.unshift(lArc); + d3_geom_voronoiDetachCircle(lArc); + var rArc = next; + while (rArc.circle && abs(x - rArc.circle.x) < ε && abs(y - rArc.circle.cy) < ε) { + next = rArc.N; + disappearing.push(rArc); + d3_geom_voronoiDetachBeach(rArc); + rArc = next; + } + disappearing.push(rArc); + d3_geom_voronoiDetachCircle(rArc); + var nArcs = disappearing.length, iArc; + for (iArc = 1; iArc < nArcs; ++iArc) { + rArc = disappearing[iArc]; + lArc = disappearing[iArc - 1]; + d3_geom_voronoiSetEdgeEnd(rArc.edge, lArc.site, rArc.site, vertex); + } + lArc = disappearing[0]; + rArc = disappearing[nArcs - 1]; + rArc.edge = d3_geom_voronoiCreateEdge(lArc.site, rArc.site, null, vertex); + d3_geom_voronoiAttachCircle(lArc); + d3_geom_voronoiAttachCircle(rArc); + } + function d3_geom_voronoiAddBeach(site) { + var x = site.x, directrix = site.y, lArc, rArc, dxl, dxr, node = d3_geom_voronoiBeaches._; + while (node) { + dxl = d3_geom_voronoiLeftBreakPoint(node, directrix) - x; + if (dxl > ε) node = node.L; else { + dxr = x - d3_geom_voronoiRightBreakPoint(node, directrix); + if (dxr > ε) { + if (!node.R) { + lArc = node; + break; + } + node = node.R; + } else { + if (dxl > -ε) { + lArc = node.P; + rArc = node; + } else if (dxr > -ε) { + lArc = node; + rArc = node.N; + } else { + lArc = rArc = node; + } + break; + } + } + } + var newArc = d3_geom_voronoiCreateBeach(site); + d3_geom_voronoiBeaches.insert(lArc, newArc); + if (!lArc && !rArc) return; + if (lArc === rArc) { + d3_geom_voronoiDetachCircle(lArc); + rArc = d3_geom_voronoiCreateBeach(lArc.site); + d3_geom_voronoiBeaches.insert(newArc, rArc); + newArc.edge = rArc.edge = d3_geom_voronoiCreateEdge(lArc.site, newArc.site); + d3_geom_voronoiAttachCircle(lArc); + d3_geom_voronoiAttachCircle(rArc); + return; + } + if (!rArc) { + newArc.edge = d3_geom_voronoiCreateEdge(lArc.site, newArc.site); + return; + } + d3_geom_voronoiDetachCircle(lArc); + d3_geom_voronoiDetachCircle(rArc); + var lSite = lArc.site, ax = lSite.x, ay = lSite.y, bx = site.x - ax, by = site.y - ay, rSite = rArc.site, cx = rSite.x - ax, cy = rSite.y - ay, d = 2 * (bx * cy - by * cx), hb = bx * bx + by * by, hc = cx * cx + cy * cy, vertex = { + x: (cy * hb - by * hc) / d + ax, + y: (bx * hc - cx * hb) / d + ay + }; + d3_geom_voronoiSetEdgeEnd(rArc.edge, lSite, rSite, vertex); + newArc.edge = d3_geom_voronoiCreateEdge(lSite, site, null, vertex); + rArc.edge = d3_geom_voronoiCreateEdge(site, rSite, null, vertex); + d3_geom_voronoiAttachCircle(lArc); + d3_geom_voronoiAttachCircle(rArc); + } + function d3_geom_voronoiLeftBreakPoint(arc, directrix) { + var site = arc.site, rfocx = site.x, rfocy = site.y, pby2 = rfocy - directrix; + if (!pby2) return rfocx; + var lArc = arc.P; + if (!lArc) return -Infinity; + site = lArc.site; + var lfocx = site.x, lfocy = site.y, plby2 = lfocy - directrix; + if (!plby2) return lfocx; + var hl = lfocx - rfocx, aby2 = 1 / pby2 - 1 / plby2, b = hl / plby2; + if (aby2) return (-b + Math.sqrt(b * b - 2 * aby2 * (hl * hl / (-2 * plby2) - lfocy + plby2 / 2 + rfocy - pby2 / 2))) / aby2 + rfocx; + return (rfocx + lfocx) / 2; + } + function d3_geom_voronoiRightBreakPoint(arc, directrix) { + var rArc = arc.N; + if (rArc) return d3_geom_voronoiLeftBreakPoint(rArc, directrix); + var site = arc.site; + return site.y === directrix ? site.x : Infinity; + } + function d3_geom_voronoiCell(site) { + this.site = site; + this.edges = []; + } + d3_geom_voronoiCell.prototype.prepare = function() { + var halfEdges = this.edges, iHalfEdge = halfEdges.length, edge; + while (iHalfEdge--) { + edge = halfEdges[iHalfEdge].edge; + if (!edge.b || !edge.a) halfEdges.splice(iHalfEdge, 1); + } + halfEdges.sort(d3_geom_voronoiHalfEdgeOrder); + return halfEdges.length; + }; + function d3_geom_voronoiCloseCells(extent) { + var x0 = extent[0][0], x1 = extent[1][0], y0 = extent[0][1], y1 = extent[1][1], x2, y2, x3, y3, cells = d3_geom_voronoiCells, iCell = cells.length, cell, iHalfEdge, halfEdges, nHalfEdges, start, end; + while (iCell--) { + cell = cells[iCell]; + if (!cell || !cell.prepare()) continue; + halfEdges = cell.edges; + nHalfEdges = halfEdges.length; + iHalfEdge = 0; + while (iHalfEdge < nHalfEdges) { + end = halfEdges[iHalfEdge].end(), x3 = end.x, y3 = end.y; + start = halfEdges[++iHalfEdge % nHalfEdges].start(), x2 = start.x, y2 = start.y; + if (abs(x3 - x2) > ε || abs(y3 - y2) > ε) { + halfEdges.splice(iHalfEdge, 0, new d3_geom_voronoiHalfEdge(d3_geom_voronoiCreateBorderEdge(cell.site, end, abs(x3 - x0) < ε && y1 - y3 > ε ? { + x: x0, + y: abs(x2 - x0) < ε ? y2 : y1 + } : abs(y3 - y1) < ε && x1 - x3 > ε ? { + x: abs(y2 - y1) < ε ? x2 : x1, + y: y1 + } : abs(x3 - x1) < ε && y3 - y0 > ε ? { + x: x1, + y: abs(x2 - x1) < ε ? y2 : y0 + } : abs(y3 - y0) < ε && x3 - x0 > ε ? { + x: abs(y2 - y0) < ε ? x2 : x0, + y: y0 + } : null), cell.site, null)); + ++nHalfEdges; + } + } + } + } + function d3_geom_voronoiHalfEdgeOrder(a, b) { + return b.angle - a.angle; + } + function d3_geom_voronoiCircle() { + d3_geom_voronoiRedBlackNode(this); + this.x = this.y = this.arc = this.site = this.cy = null; + } + function d3_geom_voronoiAttachCircle(arc) { + var lArc = arc.P, rArc = arc.N; + if (!lArc || !rArc) return; + var lSite = lArc.site, cSite = arc.site, rSite = rArc.site; + if (lSite === rSite) return; + var bx = cSite.x, by = cSite.y, ax = lSite.x - bx, ay = lSite.y - by, cx = rSite.x - bx, cy = rSite.y - by; + var d = 2 * (ax * cy - ay * cx); + if (d >= -ε2) return; + var ha = ax * ax + ay * ay, hc = cx * cx + cy * cy, x = (cy * ha - ay * hc) / d, y = (ax * hc - cx * ha) / d, cy = y + by; + var circle = d3_geom_voronoiCirclePool.pop() || new d3_geom_voronoiCircle(); + circle.arc = arc; + circle.site = cSite; + circle.x = x + bx; + circle.y = cy + Math.sqrt(x * x + y * y); + circle.cy = cy; + arc.circle = circle; + var before = null, node = d3_geom_voronoiCircles._; + while (node) { + if (circle.y < node.y || circle.y === node.y && circle.x <= node.x) { + if (node.L) node = node.L; else { + before = node.P; + break; + } + } else { + if (node.R) node = node.R; else { + before = node; + break; + } + } + } + d3_geom_voronoiCircles.insert(before, circle); + if (!before) d3_geom_voronoiFirstCircle = circle; + } + function d3_geom_voronoiDetachCircle(arc) { + var circle = arc.circle; + if (circle) { + if (!circle.P) d3_geom_voronoiFirstCircle = circle.N; + d3_geom_voronoiCircles.remove(circle); + d3_geom_voronoiCirclePool.push(circle); + d3_geom_voronoiRedBlackNode(circle); + arc.circle = null; + } + } + function d3_geom_voronoiClipEdges(extent) { + var edges = d3_geom_voronoiEdges, clip = d3_geom_clipLine(extent[0][0], extent[0][1], extent[1][0], extent[1][1]), i = edges.length, e; + while (i--) { + e = edges[i]; + if (!d3_geom_voronoiConnectEdge(e, extent) || !clip(e) || abs(e.a.x - e.b.x) < ε && abs(e.a.y - e.b.y) < ε) { + e.a = e.b = null; + edges.splice(i, 1); + } + } + } + function d3_geom_voronoiConnectEdge(edge, extent) { + var vb = edge.b; + if (vb) return true; + var va = edge.a, x0 = extent[0][0], x1 = extent[1][0], y0 = extent[0][1], y1 = extent[1][1], lSite = edge.l, rSite = edge.r, lx = lSite.x, ly = lSite.y, rx = rSite.x, ry = rSite.y, fx = (lx + rx) / 2, fy = (ly + ry) / 2, fm, fb; + if (ry === ly) { + if (fx < x0 || fx >= x1) return; + if (lx > rx) { + if (!va) va = { + x: fx, + y: y0 + }; else if (va.y >= y1) return; + vb = { + x: fx, + y: y1 + }; + } else { + if (!va) va = { + x: fx, + y: y1 + }; else if (va.y < y0) return; + vb = { + x: fx, + y: y0 + }; + } + } else { + fm = (lx - rx) / (ry - ly); + fb = fy - fm * fx; + if (fm < -1 || fm > 1) { + if (lx > rx) { + if (!va) va = { + x: (y0 - fb) / fm, + y: y0 + }; else if (va.y >= y1) return; + vb = { + x: (y1 - fb) / fm, + y: y1 + }; + } else { + if (!va) va = { + x: (y1 - fb) / fm, + y: y1 + }; else if (va.y < y0) return; + vb = { + x: (y0 - fb) / fm, + y: y0 + }; + } + } else { + if (ly < ry) { + if (!va) va = { + x: x0, + y: fm * x0 + fb + }; else if (va.x >= x1) return; + vb = { + x: x1, + y: fm * x1 + fb + }; + } else { + if (!va) va = { + x: x1, + y: fm * x1 + fb + }; else if (va.x < x0) return; + vb = { + x: x0, + y: fm * x0 + fb + }; + } + } + } + edge.a = va; + edge.b = vb; + return true; + } + function d3_geom_voronoiEdge(lSite, rSite) { + this.l = lSite; + this.r = rSite; + this.a = this.b = null; + } + function d3_geom_voronoiCreateEdge(lSite, rSite, va, vb) { + var edge = new d3_geom_voronoiEdge(lSite, rSite); + d3_geom_voronoiEdges.push(edge); + if (va) d3_geom_voronoiSetEdgeEnd(edge, lSite, rSite, va); + if (vb) d3_geom_voronoiSetEdgeEnd(edge, rSite, lSite, vb); + d3_geom_voronoiCells[lSite.i].edges.push(new d3_geom_voronoiHalfEdge(edge, lSite, rSite)); + d3_geom_voronoiCells[rSite.i].edges.push(new d3_geom_voronoiHalfEdge(edge, rSite, lSite)); + return edge; + } + function d3_geom_voronoiCreateBorderEdge(lSite, va, vb) { + var edge = new d3_geom_voronoiEdge(lSite, null); + edge.a = va; + edge.b = vb; + d3_geom_voronoiEdges.push(edge); + return edge; + } + function d3_geom_voronoiSetEdgeEnd(edge, lSite, rSite, vertex) { + if (!edge.a && !edge.b) { + edge.a = vertex; + edge.l = lSite; + edge.r = rSite; + } else if (edge.l === rSite) { + edge.b = vertex; + } else { + edge.a = vertex; + } + } + function d3_geom_voronoiHalfEdge(edge, lSite, rSite) { + var va = edge.a, vb = edge.b; + this.edge = edge; + this.site = lSite; + this.angle = rSite ? Math.atan2(rSite.y - lSite.y, rSite.x - lSite.x) : edge.l === lSite ? Math.atan2(vb.x - va.x, va.y - vb.y) : Math.atan2(va.x - vb.x, vb.y - va.y); + } + d3_geom_voronoiHalfEdge.prototype = { + start: function() { + return this.edge.l === this.site ? this.edge.a : this.edge.b; + }, + end: function() { + return this.edge.l === this.site ? this.edge.b : this.edge.a; + } + }; + function d3_geom_voronoiRedBlackTree() { + this._ = null; + } + function d3_geom_voronoiRedBlackNode(node) { + node.U = node.C = node.L = node.R = node.P = node.N = null; + } + d3_geom_voronoiRedBlackTree.prototype = { + insert: function(after, node) { + var parent, grandpa, uncle; + if (after) { + node.P = after; + node.N = after.N; + if (after.N) after.N.P = node; + after.N = node; + if (after.R) { + after = after.R; + while (after.L) after = after.L; + after.L = node; + } else { + after.R = node; + } + parent = after; + } else if (this._) { + after = d3_geom_voronoiRedBlackFirst(this._); + node.P = null; + node.N = after; + after.P = after.L = node; + parent = after; + } else { + node.P = node.N = null; + this._ = node; + parent = null; + } + node.L = node.R = null; + node.U = parent; + node.C = true; + after = node; + while (parent && parent.C) { + grandpa = parent.U; + if (parent === grandpa.L) { + uncle = grandpa.R; + if (uncle && uncle.C) { + parent.C = uncle.C = false; + grandpa.C = true; + after = grandpa; + } else { + if (after === parent.R) { + d3_geom_voronoiRedBlackRotateLeft(this, parent); + after = parent; + parent = after.U; + } + parent.C = false; + grandpa.C = true; + d3_geom_voronoiRedBlackRotateRight(this, grandpa); + } + } else { + uncle = grandpa.L; + if (uncle && uncle.C) { + parent.C = uncle.C = false; + grandpa.C = true; + after = grandpa; + } else { + if (after === parent.L) { + d3_geom_voronoiRedBlackRotateRight(this, parent); + after = parent; + parent = after.U; + } + parent.C = false; + grandpa.C = true; + d3_geom_voronoiRedBlackRotateLeft(this, grandpa); + } + } + parent = after.U; + } + this._.C = false; + }, + remove: function(node) { + if (node.N) node.N.P = node.P; + if (node.P) node.P.N = node.N; + node.N = node.P = null; + var parent = node.U, sibling, left = node.L, right = node.R, next, red; + if (!left) next = right; else if (!right) next = left; else next = d3_geom_voronoiRedBlackFirst(right); + if (parent) { + if (parent.L === node) parent.L = next; else parent.R = next; + } else { + this._ = next; + } + if (left && right) { + red = next.C; + next.C = node.C; + next.L = left; + left.U = next; + if (next !== right) { + parent = next.U; + next.U = node.U; + node = next.R; + parent.L = node; + next.R = right; + right.U = next; + } else { + next.U = parent; + parent = next; + node = next.R; + } + } else { + red = node.C; + node = next; + } + if (node) node.U = parent; + if (red) return; + if (node && node.C) { + node.C = false; + return; + } + do { + if (node === this._) break; + if (node === parent.L) { + sibling = parent.R; + if (sibling.C) { + sibling.C = false; + parent.C = true; + d3_geom_voronoiRedBlackRotateLeft(this, parent); + sibling = parent.R; + } + if (sibling.L && sibling.L.C || sibling.R && sibling.R.C) { + if (!sibling.R || !sibling.R.C) { + sibling.L.C = false; + sibling.C = true; + d3_geom_voronoiRedBlackRotateRight(this, sibling); + sibling = parent.R; + } + sibling.C = parent.C; + parent.C = sibling.R.C = false; + d3_geom_voronoiRedBlackRotateLeft(this, parent); + node = this._; + break; + } + } else { + sibling = parent.L; + if (sibling.C) { + sibling.C = false; + parent.C = true; + d3_geom_voronoiRedBlackRotateRight(this, parent); + sibling = parent.L; + } + if (sibling.L && sibling.L.C || sibling.R && sibling.R.C) { + if (!sibling.L || !sibling.L.C) { + sibling.R.C = false; + sibling.C = true; + d3_geom_voronoiRedBlackRotateLeft(this, sibling); + sibling = parent.L; + } + sibling.C = parent.C; + parent.C = sibling.L.C = false; + d3_geom_voronoiRedBlackRotateRight(this, parent); + node = this._; + break; + } + } + sibling.C = true; + node = parent; + parent = parent.U; + } while (!node.C); + if (node) node.C = false; + } + }; + function d3_geom_voronoiRedBlackRotateLeft(tree, node) { + var p = node, q = node.R, parent = p.U; + if (parent) { + if (parent.L === p) parent.L = q; else parent.R = q; + } else { + tree._ = q; + } + q.U = parent; + p.U = q; + p.R = q.L; + if (p.R) p.R.U = p; + q.L = p; + } + function d3_geom_voronoiRedBlackRotateRight(tree, node) { + var p = node, q = node.L, parent = p.U; + if (parent) { + if (parent.L === p) parent.L = q; else parent.R = q; + } else { + tree._ = q; + } + q.U = parent; + p.U = q; + p.L = q.R; + if (p.L) p.L.U = p; + q.R = p; + } + function d3_geom_voronoiRedBlackFirst(node) { + while (node.L) node = node.L; + return node; + } + function d3_geom_voronoi(sites, bbox) { + var site = sites.sort(d3_geom_voronoiVertexOrder).pop(), x0, y0, circle; + d3_geom_voronoiEdges = []; + d3_geom_voronoiCells = new Array(sites.length); + d3_geom_voronoiBeaches = new d3_geom_voronoiRedBlackTree(); + d3_geom_voronoiCircles = new d3_geom_voronoiRedBlackTree(); + while (true) { + circle = d3_geom_voronoiFirstCircle; + if (site && (!circle || site.y < circle.y || site.y === circle.y && site.x < circle.x)) { + if (site.x !== x0 || site.y !== y0) { + d3_geom_voronoiCells[site.i] = new d3_geom_voronoiCell(site); + d3_geom_voronoiAddBeach(site); + x0 = site.x, y0 = site.y; + } + site = sites.pop(); + } else if (circle) { + d3_geom_voronoiRemoveBeach(circle.arc); + } else { + break; + } + } + if (bbox) d3_geom_voronoiClipEdges(bbox), d3_geom_voronoiCloseCells(bbox); + var diagram = { + cells: d3_geom_voronoiCells, + edges: d3_geom_voronoiEdges + }; + d3_geom_voronoiBeaches = d3_geom_voronoiCircles = d3_geom_voronoiEdges = d3_geom_voronoiCells = null; + return diagram; + } + function d3_geom_voronoiVertexOrder(a, b) { + return b.y - a.y || b.x - a.x; + } + d3.geom.voronoi = function(points) { + var x = d3_geom_pointX, y = d3_geom_pointY, fx = x, fy = y, clipExtent = d3_geom_voronoiClipExtent; + if (points) return voronoi(points); + function voronoi(data) { + var polygons = new Array(data.length), x0 = clipExtent[0][0], y0 = clipExtent[0][1], x1 = clipExtent[1][0], y1 = clipExtent[1][1]; + d3_geom_voronoi(sites(data), clipExtent).cells.forEach(function(cell, i) { + var edges = cell.edges, site = cell.site, polygon = polygons[i] = edges.length ? edges.map(function(e) { + var s = e.start(); + return [ s.x, s.y ]; + }) : site.x >= x0 && site.x <= x1 && site.y >= y0 && site.y <= y1 ? [ [ x0, y1 ], [ x1, y1 ], [ x1, y0 ], [ x0, y0 ] ] : []; + polygon.point = data[i]; + }); + return polygons; + } + function sites(data) { + return data.map(function(d, i) { + return { + x: Math.round(fx(d, i) / ε) * ε, + y: Math.round(fy(d, i) / ε) * ε, + i: i + }; + }); + } + voronoi.links = function(data) { + return d3_geom_voronoi(sites(data)).edges.filter(function(edge) { + return edge.l && edge.r; + }).map(function(edge) { + return { + source: data[edge.l.i], + target: data[edge.r.i] + }; + }); + }; + voronoi.triangles = function(data) { + var triangles = []; + d3_geom_voronoi(sites(data)).cells.forEach(function(cell, i) { + var site = cell.site, edges = cell.edges.sort(d3_geom_voronoiHalfEdgeOrder), j = -1, m = edges.length, e0, s0, e1 = edges[m - 1].edge, s1 = e1.l === site ? e1.r : e1.l; + while (++j < m) { + e0 = e1; + s0 = s1; + e1 = edges[j].edge; + s1 = e1.l === site ? e1.r : e1.l; + if (i < s0.i && i < s1.i && d3_geom_voronoiTriangleArea(site, s0, s1) < 0) { + triangles.push([ data[i], data[s0.i], data[s1.i] ]); + } + } + }); + return triangles; + }; + voronoi.x = function(_) { + return arguments.length ? (fx = d3_functor(x = _), voronoi) : x; + }; + voronoi.y = function(_) { + return arguments.length ? (fy = d3_functor(y = _), voronoi) : y; + }; + voronoi.clipExtent = function(_) { + if (!arguments.length) return clipExtent === d3_geom_voronoiClipExtent ? null : clipExtent; + clipExtent = _ == null ? d3_geom_voronoiClipExtent : _; + return voronoi; + }; + voronoi.size = function(_) { + if (!arguments.length) return clipExtent === d3_geom_voronoiClipExtent ? null : clipExtent && clipExtent[1]; + return voronoi.clipExtent(_ && [ [ 0, 0 ], _ ]); + }; + return voronoi; + }; + var d3_geom_voronoiClipExtent = [ [ -1e6, -1e6 ], [ 1e6, 1e6 ] ]; + function d3_geom_voronoiTriangleArea(a, b, c) { + return (a.x - c.x) * (b.y - a.y) - (a.x - b.x) * (c.y - a.y); + } + d3.geom.delaunay = function(vertices) { + return d3.geom.voronoi().triangles(vertices); + }; + d3.geom.quadtree = function(points, x1, y1, x2, y2) { + var x = d3_geom_pointX, y = d3_geom_pointY, compat; + if (compat = arguments.length) { + x = d3_geom_quadtreeCompatX; + y = d3_geom_quadtreeCompatY; + if (compat === 3) { + y2 = y1; + x2 = x1; + y1 = x1 = 0; + } + return quadtree(points); + } + function quadtree(data) { + var d, fx = d3_functor(x), fy = d3_functor(y), xs, ys, i, n, x1_, y1_, x2_, y2_; + if (x1 != null) { + x1_ = x1, y1_ = y1, x2_ = x2, y2_ = y2; + } else { + x2_ = y2_ = -(x1_ = y1_ = Infinity); + xs = [], ys = []; + n = data.length; + if (compat) for (i = 0; i < n; ++i) { + d = data[i]; + if (d.x < x1_) x1_ = d.x; + if (d.y < y1_) y1_ = d.y; + if (d.x > x2_) x2_ = d.x; + if (d.y > y2_) y2_ = d.y; + xs.push(d.x); + ys.push(d.y); + } else for (i = 0; i < n; ++i) { + var x_ = +fx(d = data[i], i), y_ = +fy(d, i); + if (x_ < x1_) x1_ = x_; + if (y_ < y1_) y1_ = y_; + if (x_ > x2_) x2_ = x_; + if (y_ > y2_) y2_ = y_; + xs.push(x_); + ys.push(y_); + } + } + var dx = x2_ - x1_, dy = y2_ - y1_; + if (dx > dy) y2_ = y1_ + dx; else x2_ = x1_ + dy; + function insert(n, d, x, y, x1, y1, x2, y2) { + if (isNaN(x) || isNaN(y)) return; + if (n.leaf) { + var nx = n.x, ny = n.y; + if (nx != null) { + if (abs(nx - x) + abs(ny - y) < .01) { + insertChild(n, d, x, y, x1, y1, x2, y2); + } else { + var nPoint = n.point; + n.x = n.y = n.point = null; + insertChild(n, nPoint, nx, ny, x1, y1, x2, y2); + insertChild(n, d, x, y, x1, y1, x2, y2); + } + } else { + n.x = x, n.y = y, n.point = d; + } + } else { + insertChild(n, d, x, y, x1, y1, x2, y2); + } + } + function insertChild(n, d, x, y, x1, y1, x2, y2) { + var xm = (x1 + x2) * .5, ym = (y1 + y2) * .5, right = x >= xm, below = y >= ym, i = below << 1 | right; + n.leaf = false; + n = n.nodes[i] || (n.nodes[i] = d3_geom_quadtreeNode()); + if (right) x1 = xm; else x2 = xm; + if (below) y1 = ym; else y2 = ym; + insert(n, d, x, y, x1, y1, x2, y2); + } + var root = d3_geom_quadtreeNode(); + root.add = function(d) { + insert(root, d, +fx(d, ++i), +fy(d, i), x1_, y1_, x2_, y2_); + }; + root.visit = function(f) { + d3_geom_quadtreeVisit(f, root, x1_, y1_, x2_, y2_); + }; + root.find = function(point) { + return d3_geom_quadtreeFind(root, point[0], point[1], x1_, y1_, x2_, y2_); + }; + i = -1; + if (x1 == null) { + while (++i < n) { + insert(root, data[i], xs[i], ys[i], x1_, y1_, x2_, y2_); + } + --i; + } else data.forEach(root.add); + xs = ys = data = d = null; + return root; + } + quadtree.x = function(_) { + return arguments.length ? (x = _, quadtree) : x; + }; + quadtree.y = function(_) { + return arguments.length ? (y = _, quadtree) : y; + }; + quadtree.extent = function(_) { + if (!arguments.length) return x1 == null ? null : [ [ x1, y1 ], [ x2, y2 ] ]; + if (_ == null) x1 = y1 = x2 = y2 = null; else x1 = +_[0][0], y1 = +_[0][1], x2 = +_[1][0], + y2 = +_[1][1]; + return quadtree; + }; + quadtree.size = function(_) { + if (!arguments.length) return x1 == null ? null : [ x2 - x1, y2 - y1 ]; + if (_ == null) x1 = y1 = x2 = y2 = null; else x1 = y1 = 0, x2 = +_[0], y2 = +_[1]; + return quadtree; + }; + return quadtree; + }; + function d3_geom_quadtreeCompatX(d) { + return d.x; + } + function d3_geom_quadtreeCompatY(d) { + return d.y; + } + function d3_geom_quadtreeNode() { + return { + leaf: true, + nodes: [], + point: null, + x: null, + y: null + }; + } + function d3_geom_quadtreeVisit(f, node, x1, y1, x2, y2) { + if (!f(node, x1, y1, x2, y2)) { + var sx = (x1 + x2) * .5, sy = (y1 + y2) * .5, children = node.nodes; + if (children[0]) d3_geom_quadtreeVisit(f, children[0], x1, y1, sx, sy); + if (children[1]) d3_geom_quadtreeVisit(f, children[1], sx, y1, x2, sy); + if (children[2]) d3_geom_quadtreeVisit(f, children[2], x1, sy, sx, y2); + if (children[3]) d3_geom_quadtreeVisit(f, children[3], sx, sy, x2, y2); + } + } + function d3_geom_quadtreeFind(root, x, y, x0, y0, x3, y3) { + var minDistance2 = Infinity, closestPoint; + (function find(node, x1, y1, x2, y2) { + if (x1 > x3 || y1 > y3 || x2 < x0 || y2 < y0) return; + if (point = node.point) { + var point, dx = x - node.x, dy = y - node.y, distance2 = dx * dx + dy * dy; + if (distance2 < minDistance2) { + var distance = Math.sqrt(minDistance2 = distance2); + x0 = x - distance, y0 = y - distance; + x3 = x + distance, y3 = y + distance; + closestPoint = point; + } + } + var children = node.nodes, xm = (x1 + x2) * .5, ym = (y1 + y2) * .5, right = x >= xm, below = y >= ym; + for (var i = below << 1 | right, j = i + 4; i < j; ++i) { + if (node = children[i & 3]) switch (i & 3) { + case 0: + find(node, x1, y1, xm, ym); + break; + + case 1: + find(node, xm, y1, x2, ym); + break; + + case 2: + find(node, x1, ym, xm, y2); + break; + + case 3: + find(node, xm, ym, x2, y2); + break; + } + } + })(root, x0, y0, x3, y3); + return closestPoint; + } + d3.interpolateRgb = d3_interpolateRgb; + function d3_interpolateRgb(a, b) { + a = d3.rgb(a); + b = d3.rgb(b); + var ar = a.r, ag = a.g, ab = a.b, br = b.r - ar, bg = b.g - ag, bb = b.b - ab; + return function(t) { + return "#" + d3_rgb_hex(Math.round(ar + br * t)) + d3_rgb_hex(Math.round(ag + bg * t)) + d3_rgb_hex(Math.round(ab + bb * t)); + }; + } + d3.interpolateObject = d3_interpolateObject; + function d3_interpolateObject(a, b) { + var i = {}, c = {}, k; + for (k in a) { + if (k in b) { + i[k] = d3_interpolate(a[k], b[k]); + } else { + c[k] = a[k]; + } + } + for (k in b) { + if (!(k in a)) { + c[k] = b[k]; + } + } + return function(t) { + for (k in i) c[k] = i[k](t); + return c; + }; + } + d3.interpolateNumber = d3_interpolateNumber; + function d3_interpolateNumber(a, b) { + a = +a, b = +b; + return function(t) { + return a * (1 - t) + b * t; + }; + } + d3.interpolateString = d3_interpolateString; + function d3_interpolateString(a, b) { + var bi = d3_interpolate_numberA.lastIndex = d3_interpolate_numberB.lastIndex = 0, am, bm, bs, i = -1, s = [], q = []; + a = a + "", b = b + ""; + while ((am = d3_interpolate_numberA.exec(a)) && (bm = d3_interpolate_numberB.exec(b))) { + if ((bs = bm.index) > bi) { + bs = b.slice(bi, bs); + if (s[i]) s[i] += bs; else s[++i] = bs; + } + if ((am = am[0]) === (bm = bm[0])) { + if (s[i]) s[i] += bm; else s[++i] = bm; + } else { + s[++i] = null; + q.push({ + i: i, + x: d3_interpolateNumber(am, bm) + }); + } + bi = d3_interpolate_numberB.lastIndex; + } + if (bi < b.length) { + bs = b.slice(bi); + if (s[i]) s[i] += bs; else s[++i] = bs; + } + return s.length < 2 ? q[0] ? (b = q[0].x, function(t) { + return b(t) + ""; + }) : function() { + return b; + } : (b = q.length, function(t) { + for (var i = 0, o; i < b; ++i) s[(o = q[i]).i] = o.x(t); + return s.join(""); + }); + } + var d3_interpolate_numberA = /[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g, d3_interpolate_numberB = new RegExp(d3_interpolate_numberA.source, "g"); + d3.interpolate = d3_interpolate; + function d3_interpolate(a, b) { + var i = d3.interpolators.length, f; + while (--i >= 0 && !(f = d3.interpolators[i](a, b))) ; + return f; + } + d3.interpolators = [ function(a, b) { + var t = typeof b; + return (t === "string" ? d3_rgb_names.has(b.toLowerCase()) || /^(#|rgb\(|hsl\()/i.test(b) ? d3_interpolateRgb : d3_interpolateString : b instanceof d3_color ? d3_interpolateRgb : Array.isArray(b) ? d3_interpolateArray : t === "object" && isNaN(b) ? d3_interpolateObject : d3_interpolateNumber)(a, b); + } ]; + d3.interpolateArray = d3_interpolateArray; + function d3_interpolateArray(a, b) { + var x = [], c = [], na = a.length, nb = b.length, n0 = Math.min(a.length, b.length), i; + for (i = 0; i < n0; ++i) x.push(d3_interpolate(a[i], b[i])); + for (;i < na; ++i) c[i] = a[i]; + for (;i < nb; ++i) c[i] = b[i]; + return function(t) { + for (i = 0; i < n0; ++i) c[i] = x[i](t); + return c; + }; + } + var d3_ease_default = function() { + return d3_identity; + }; + var d3_ease = d3.map({ + linear: d3_ease_default, + poly: d3_ease_poly, + quad: function() { + return d3_ease_quad; + }, + cubic: function() { + return d3_ease_cubic; + }, + sin: function() { + return d3_ease_sin; + }, + exp: function() { + return d3_ease_exp; + }, + circle: function() { + return d3_ease_circle; + }, + elastic: d3_ease_elastic, + back: d3_ease_back, + bounce: function() { + return d3_ease_bounce; + } + }); + var d3_ease_mode = d3.map({ + "in": d3_identity, + out: d3_ease_reverse, + "in-out": d3_ease_reflect, + "out-in": function(f) { + return d3_ease_reflect(d3_ease_reverse(f)); + } + }); + d3.ease = function(name) { + var i = name.indexOf("-"), t = i >= 0 ? name.slice(0, i) : name, m = i >= 0 ? name.slice(i + 1) : "in"; + t = d3_ease.get(t) || d3_ease_default; + m = d3_ease_mode.get(m) || d3_identity; + return d3_ease_clamp(m(t.apply(null, d3_arraySlice.call(arguments, 1)))); + }; + function d3_ease_clamp(f) { + return function(t) { + return t <= 0 ? 0 : t >= 1 ? 1 : f(t); + }; + } + function d3_ease_reverse(f) { + return function(t) { + return 1 - f(1 - t); + }; + } + function d3_ease_reflect(f) { + return function(t) { + return .5 * (t < .5 ? f(2 * t) : 2 - f(2 - 2 * t)); + }; + } + function d3_ease_quad(t) { + return t * t; + } + function d3_ease_cubic(t) { + return t * t * t; + } + function d3_ease_cubicInOut(t) { + if (t <= 0) return 0; + if (t >= 1) return 1; + var t2 = t * t, t3 = t2 * t; + return 4 * (t < .5 ? t3 : 3 * (t - t2) + t3 - .75); + } + function d3_ease_poly(e) { + return function(t) { + return Math.pow(t, e); + }; + } + function d3_ease_sin(t) { + return 1 - Math.cos(t * halfπ); + } + function d3_ease_exp(t) { + return Math.pow(2, 10 * (t - 1)); + } + function d3_ease_circle(t) { + return 1 - Math.sqrt(1 - t * t); + } + function d3_ease_elastic(a, p) { + var s; + if (arguments.length < 2) p = .45; + if (arguments.length) s = p / τ * Math.asin(1 / a); else a = 1, s = p / 4; + return function(t) { + return 1 + a * Math.pow(2, -10 * t) * Math.sin((t - s) * τ / p); + }; + } + function d3_ease_back(s) { + if (!s) s = 1.70158; + return function(t) { + return t * t * ((s + 1) * t - s); + }; + } + function d3_ease_bounce(t) { + return t < 1 / 2.75 ? 7.5625 * t * t : t < 2 / 2.75 ? 7.5625 * (t -= 1.5 / 2.75) * t + .75 : t < 2.5 / 2.75 ? 7.5625 * (t -= 2.25 / 2.75) * t + .9375 : 7.5625 * (t -= 2.625 / 2.75) * t + .984375; + } + d3.interpolateHcl = d3_interpolateHcl; + function d3_interpolateHcl(a, b) { + a = d3.hcl(a); + b = d3.hcl(b); + var ah = a.h, ac = a.c, al = a.l, bh = b.h - ah, bc = b.c - ac, bl = b.l - al; + if (isNaN(bc)) bc = 0, ac = isNaN(ac) ? b.c : ac; + if (isNaN(bh)) bh = 0, ah = isNaN(ah) ? b.h : ah; else if (bh > 180) bh -= 360; else if (bh < -180) bh += 360; + return function(t) { + return d3_hcl_lab(ah + bh * t, ac + bc * t, al + bl * t) + ""; + }; + } + d3.interpolateHsl = d3_interpolateHsl; + function d3_interpolateHsl(a, b) { + a = d3.hsl(a); + b = d3.hsl(b); + var ah = a.h, as = a.s, al = a.l, bh = b.h - ah, bs = b.s - as, bl = b.l - al; + if (isNaN(bs)) bs = 0, as = isNaN(as) ? b.s : as; + if (isNaN(bh)) bh = 0, ah = isNaN(ah) ? b.h : ah; else if (bh > 180) bh -= 360; else if (bh < -180) bh += 360; + return function(t) { + return d3_hsl_rgb(ah + bh * t, as + bs * t, al + bl * t) + ""; + }; + } + d3.interpolateLab = d3_interpolateLab; + function d3_interpolateLab(a, b) { + a = d3.lab(a); + b = d3.lab(b); + var al = a.l, aa = a.a, ab = a.b, bl = b.l - al, ba = b.a - aa, bb = b.b - ab; + return function(t) { + return d3_lab_rgb(al + bl * t, aa + ba * t, ab + bb * t) + ""; + }; + } + d3.interpolateRound = d3_interpolateRound; + function d3_interpolateRound(a, b) { + b -= a; + return function(t) { + return Math.round(a + b * t); + }; + } + d3.transform = function(string) { + var g = d3_document.createElementNS(d3.ns.prefix.svg, "g"); + return (d3.transform = function(string) { + if (string != null) { + g.setAttribute("transform", string); + var t = g.transform.baseVal.consolidate(); + } + return new d3_transform(t ? t.matrix : d3_transformIdentity); + })(string); + }; + function d3_transform(m) { + var r0 = [ m.a, m.b ], r1 = [ m.c, m.d ], kx = d3_transformNormalize(r0), kz = d3_transformDot(r0, r1), ky = d3_transformNormalize(d3_transformCombine(r1, r0, -kz)) || 0; + if (r0[0] * r1[1] < r1[0] * r0[1]) { + r0[0] *= -1; + r0[1] *= -1; + kx *= -1; + kz *= -1; + } + this.rotate = (kx ? Math.atan2(r0[1], r0[0]) : Math.atan2(-r1[0], r1[1])) * d3_degrees; + this.translate = [ m.e, m.f ]; + this.scale = [ kx, ky ]; + this.skew = ky ? Math.atan2(kz, ky) * d3_degrees : 0; + } + d3_transform.prototype.toString = function() { + return "translate(" + this.translate + ")rotate(" + this.rotate + ")skewX(" + this.skew + ")scale(" + this.scale + ")"; + }; + function d3_transformDot(a, b) { + return a[0] * b[0] + a[1] * b[1]; + } + function d3_transformNormalize(a) { + var k = Math.sqrt(d3_transformDot(a, a)); + if (k) { + a[0] /= k; + a[1] /= k; + } + return k; + } + function d3_transformCombine(a, b, k) { + a[0] += k * b[0]; + a[1] += k * b[1]; + return a; + } + var d3_transformIdentity = { + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0 + }; + d3.interpolateTransform = d3_interpolateTransform; + function d3_interpolateTransformPop(s) { + return s.length ? s.pop() + "," : ""; + } + function d3_interpolateTranslate(ta, tb, s, q) { + if (ta[0] !== tb[0] || ta[1] !== tb[1]) { + var i = s.push("translate(", null, ",", null, ")"); + q.push({ + i: i - 4, + x: d3_interpolateNumber(ta[0], tb[0]) + }, { + i: i - 2, + x: d3_interpolateNumber(ta[1], tb[1]) + }); + } else if (tb[0] || tb[1]) { + s.push("translate(" + tb + ")"); + } + } + function d3_interpolateRotate(ra, rb, s, q) { + if (ra !== rb) { + if (ra - rb > 180) rb += 360; else if (rb - ra > 180) ra += 360; + q.push({ + i: s.push(d3_interpolateTransformPop(s) + "rotate(", null, ")") - 2, + x: d3_interpolateNumber(ra, rb) + }); + } else if (rb) { + s.push(d3_interpolateTransformPop(s) + "rotate(" + rb + ")"); + } + } + function d3_interpolateSkew(wa, wb, s, q) { + if (wa !== wb) { + q.push({ + i: s.push(d3_interpolateTransformPop(s) + "skewX(", null, ")") - 2, + x: d3_interpolateNumber(wa, wb) + }); + } else if (wb) { + s.push(d3_interpolateTransformPop(s) + "skewX(" + wb + ")"); + } + } + function d3_interpolateScale(ka, kb, s, q) { + if (ka[0] !== kb[0] || ka[1] !== kb[1]) { + var i = s.push(d3_interpolateTransformPop(s) + "scale(", null, ",", null, ")"); + q.push({ + i: i - 4, + x: d3_interpolateNumber(ka[0], kb[0]) + }, { + i: i - 2, + x: d3_interpolateNumber(ka[1], kb[1]) + }); + } else if (kb[0] !== 1 || kb[1] !== 1) { + s.push(d3_interpolateTransformPop(s) + "scale(" + kb + ")"); + } + } + function d3_interpolateTransform(a, b) { + var s = [], q = []; + a = d3.transform(a), b = d3.transform(b); + d3_interpolateTranslate(a.translate, b.translate, s, q); + d3_interpolateRotate(a.rotate, b.rotate, s, q); + d3_interpolateSkew(a.skew, b.skew, s, q); + d3_interpolateScale(a.scale, b.scale, s, q); + a = b = null; + return function(t) { + var i = -1, n = q.length, o; + while (++i < n) s[(o = q[i]).i] = o.x(t); + return s.join(""); + }; + } + function d3_uninterpolateNumber(a, b) { + b = (b -= a = +a) || 1 / b; + return function(x) { + return (x - a) / b; + }; + } + function d3_uninterpolateClamp(a, b) { + b = (b -= a = +a) || 1 / b; + return function(x) { + return Math.max(0, Math.min(1, (x - a) / b)); + }; + } + d3.layout = {}; + d3.layout.bundle = function() { + return function(links) { + var paths = [], i = -1, n = links.length; + while (++i < n) paths.push(d3_layout_bundlePath(links[i])); + return paths; + }; + }; + function d3_layout_bundlePath(link) { + var start = link.source, end = link.target, lca = d3_layout_bundleLeastCommonAncestor(start, end), points = [ start ]; + while (start !== lca) { + start = start.parent; + points.push(start); + } + var k = points.length; + while (end !== lca) { + points.splice(k, 0, end); + end = end.parent; + } + return points; + } + function d3_layout_bundleAncestors(node) { + var ancestors = [], parent = node.parent; + while (parent != null) { + ancestors.push(node); + node = parent; + parent = parent.parent; + } + ancestors.push(node); + return ancestors; + } + function d3_layout_bundleLeastCommonAncestor(a, b) { + if (a === b) return a; + var aNodes = d3_layout_bundleAncestors(a), bNodes = d3_layout_bundleAncestors(b), aNode = aNodes.pop(), bNode = bNodes.pop(), sharedNode = null; + while (aNode === bNode) { + sharedNode = aNode; + aNode = aNodes.pop(); + bNode = bNodes.pop(); + } + return sharedNode; + } + d3.layout.chord = function() { + var chord = {}, chords, groups, matrix, n, padding = 0, sortGroups, sortSubgroups, sortChords; + function relayout() { + var subgroups = {}, groupSums = [], groupIndex = d3.range(n), subgroupIndex = [], k, x, x0, i, j; + chords = []; + groups = []; + k = 0, i = -1; + while (++i < n) { + x = 0, j = -1; + while (++j < n) { + x += matrix[i][j]; + } + groupSums.push(x); + subgroupIndex.push(d3.range(n)); + k += x; + } + if (sortGroups) { + groupIndex.sort(function(a, b) { + return sortGroups(groupSums[a], groupSums[b]); + }); + } + if (sortSubgroups) { + subgroupIndex.forEach(function(d, i) { + d.sort(function(a, b) { + return sortSubgroups(matrix[i][a], matrix[i][b]); + }); + }); + } + k = (τ - padding * n) / k; + x = 0, i = -1; + while (++i < n) { + x0 = x, j = -1; + while (++j < n) { + var di = groupIndex[i], dj = subgroupIndex[di][j], v = matrix[di][dj], a0 = x, a1 = x += v * k; + subgroups[di + "-" + dj] = { + index: di, + subindex: dj, + startAngle: a0, + endAngle: a1, + value: v + }; + } + groups[di] = { + index: di, + startAngle: x0, + endAngle: x, + value: groupSums[di] + }; + x += padding; + } + i = -1; + while (++i < n) { + j = i - 1; + while (++j < n) { + var source = subgroups[i + "-" + j], target = subgroups[j + "-" + i]; + if (source.value || target.value) { + chords.push(source.value < target.value ? { + source: target, + target: source + } : { + source: source, + target: target + }); + } + } + } + if (sortChords) resort(); + } + function resort() { + chords.sort(function(a, b) { + return sortChords((a.source.value + a.target.value) / 2, (b.source.value + b.target.value) / 2); + }); + } + chord.matrix = function(x) { + if (!arguments.length) return matrix; + n = (matrix = x) && matrix.length; + chords = groups = null; + return chord; + }; + chord.padding = function(x) { + if (!arguments.length) return padding; + padding = x; + chords = groups = null; + return chord; + }; + chord.sortGroups = function(x) { + if (!arguments.length) return sortGroups; + sortGroups = x; + chords = groups = null; + return chord; + }; + chord.sortSubgroups = function(x) { + if (!arguments.length) return sortSubgroups; + sortSubgroups = x; + chords = null; + return chord; + }; + chord.sortChords = function(x) { + if (!arguments.length) return sortChords; + sortChords = x; + if (chords) resort(); + return chord; + }; + chord.chords = function() { + if (!chords) relayout(); + return chords; + }; + chord.groups = function() { + if (!groups) relayout(); + return groups; + }; + return chord; + }; + d3.layout.force = function() { + var force = {}, event = d3.dispatch("start", "tick", "end"), timer, size = [ 1, 1 ], drag, alpha, friction = .9, linkDistance = d3_layout_forceLinkDistance, linkStrength = d3_layout_forceLinkStrength, charge = -30, chargeDistance2 = d3_layout_forceChargeDistance2, gravity = .1, theta2 = .64, nodes = [], links = [], distances, strengths, charges; + function repulse(node) { + return function(quad, x1, _, x2) { + if (quad.point !== node) { + var dx = quad.cx - node.x, dy = quad.cy - node.y, dw = x2 - x1, dn = dx * dx + dy * dy; + if (dw * dw / theta2 < dn) { + if (dn < chargeDistance2) { + var k = quad.charge / dn; + node.px -= dx * k; + node.py -= dy * k; + } + return true; + } + if (quad.point && dn && dn < chargeDistance2) { + var k = quad.pointCharge / dn; + node.px -= dx * k; + node.py -= dy * k; + } + } + return !quad.charge; + }; + } + force.tick = function() { + if ((alpha *= .99) < .005) { + timer = null; + event.end({ + type: "end", + alpha: alpha = 0 + }); + return true; + } + var n = nodes.length, m = links.length, q, i, o, s, t, l, k, x, y; + for (i = 0; i < m; ++i) { + o = links[i]; + s = o.source; + t = o.target; + x = t.x - s.x; + y = t.y - s.y; + if (l = x * x + y * y) { + l = alpha * strengths[i] * ((l = Math.sqrt(l)) - distances[i]) / l; + x *= l; + y *= l; + t.x -= x * (k = s.weight + t.weight ? s.weight / (s.weight + t.weight) : .5); + t.y -= y * k; + s.x += x * (k = 1 - k); + s.y += y * k; + } + } + if (k = alpha * gravity) { + x = size[0] / 2; + y = size[1] / 2; + i = -1; + if (k) while (++i < n) { + o = nodes[i]; + o.x += (x - o.x) * k; + o.y += (y - o.y) * k; + } + } + if (charge) { + d3_layout_forceAccumulate(q = d3.geom.quadtree(nodes), alpha, charges); + i = -1; + while (++i < n) { + if (!(o = nodes[i]).fixed) { + q.visit(repulse(o)); + } + } + } + i = -1; + while (++i < n) { + o = nodes[i]; + if (o.fixed) { + o.x = o.px; + o.y = o.py; + } else { + o.x -= (o.px - (o.px = o.x)) * friction; + o.y -= (o.py - (o.py = o.y)) * friction; + } + } + event.tick({ + type: "tick", + alpha: alpha + }); + }; + force.nodes = function(x) { + if (!arguments.length) return nodes; + nodes = x; + return force; + }; + force.links = function(x) { + if (!arguments.length) return links; + links = x; + return force; + }; + force.size = function(x) { + if (!arguments.length) return size; + size = x; + return force; + }; + force.linkDistance = function(x) { + if (!arguments.length) return linkDistance; + linkDistance = typeof x === "function" ? x : +x; + return force; + }; + force.distance = force.linkDistance; + force.linkStrength = function(x) { + if (!arguments.length) return linkStrength; + linkStrength = typeof x === "function" ? x : +x; + return force; + }; + force.friction = function(x) { + if (!arguments.length) return friction; + friction = +x; + return force; + }; + force.charge = function(x) { + if (!arguments.length) return charge; + charge = typeof x === "function" ? x : +x; + return force; + }; + force.chargeDistance = function(x) { + if (!arguments.length) return Math.sqrt(chargeDistance2); + chargeDistance2 = x * x; + return force; + }; + force.gravity = function(x) { + if (!arguments.length) return gravity; + gravity = +x; + return force; + }; + force.theta = function(x) { + if (!arguments.length) return Math.sqrt(theta2); + theta2 = x * x; + return force; + }; + force.alpha = function(x) { + if (!arguments.length) return alpha; + x = +x; + if (alpha) { + if (x > 0) { + alpha = x; + } else { + timer.c = null, timer.t = NaN, timer = null; + event.end({ + type: "end", + alpha: alpha = 0 + }); + } + } else if (x > 0) { + event.start({ + type: "start", + alpha: alpha = x + }); + timer = d3_timer(force.tick); + } + return force; + }; + force.start = function() { + var i, n = nodes.length, m = links.length, w = size[0], h = size[1], neighbors, o; + for (i = 0; i < n; ++i) { + (o = nodes[i]).index = i; + o.weight = 0; + } + for (i = 0; i < m; ++i) { + o = links[i]; + if (typeof o.source == "number") o.source = nodes[o.source]; + if (typeof o.target == "number") o.target = nodes[o.target]; + ++o.source.weight; + ++o.target.weight; + } + for (i = 0; i < n; ++i) { + o = nodes[i]; + if (isNaN(o.x)) o.x = position("x", w); + if (isNaN(o.y)) o.y = position("y", h); + if (isNaN(o.px)) o.px = o.x; + if (isNaN(o.py)) o.py = o.y; + } + distances = []; + if (typeof linkDistance === "function") for (i = 0; i < m; ++i) distances[i] = +linkDistance.call(this, links[i], i); else for (i = 0; i < m; ++i) distances[i] = linkDistance; + strengths = []; + if (typeof linkStrength === "function") for (i = 0; i < m; ++i) strengths[i] = +linkStrength.call(this, links[i], i); else for (i = 0; i < m; ++i) strengths[i] = linkStrength; + charges = []; + if (typeof charge === "function") for (i = 0; i < n; ++i) charges[i] = +charge.call(this, nodes[i], i); else for (i = 0; i < n; ++i) charges[i] = charge; + function position(dimension, size) { + if (!neighbors) { + neighbors = new Array(n); + for (j = 0; j < n; ++j) { + neighbors[j] = []; + } + for (j = 0; j < m; ++j) { + var o = links[j]; + neighbors[o.source.index].push(o.target); + neighbors[o.target.index].push(o.source); + } + } + var candidates = neighbors[i], j = -1, l = candidates.length, x; + while (++j < l) if (!isNaN(x = candidates[j][dimension])) return x; + return Math.random() * size; + } + return force.resume(); + }; + force.resume = function() { + return force.alpha(.1); + }; + force.stop = function() { + return force.alpha(0); + }; + force.drag = function() { + if (!drag) drag = d3.behavior.drag().origin(d3_identity).on("dragstart.force", d3_layout_forceDragstart).on("drag.force", dragmove).on("dragend.force", d3_layout_forceDragend); + if (!arguments.length) return drag; + this.on("mouseover.force", d3_layout_forceMouseover).on("mouseout.force", d3_layout_forceMouseout).call(drag); + }; + function dragmove(d) { + d.px = d3.event.x, d.py = d3.event.y; + force.resume(); + } + return d3.rebind(force, event, "on"); + }; + function d3_layout_forceDragstart(d) { + d.fixed |= 2; + } + function d3_layout_forceDragend(d) { + d.fixed &= ~6; + } + function d3_layout_forceMouseover(d) { + d.fixed |= 4; + d.px = d.x, d.py = d.y; + } + function d3_layout_forceMouseout(d) { + d.fixed &= ~4; + } + function d3_layout_forceAccumulate(quad, alpha, charges) { + var cx = 0, cy = 0; + quad.charge = 0; + if (!quad.leaf) { + var nodes = quad.nodes, n = nodes.length, i = -1, c; + while (++i < n) { + c = nodes[i]; + if (c == null) continue; + d3_layout_forceAccumulate(c, alpha, charges); + quad.charge += c.charge; + cx += c.charge * c.cx; + cy += c.charge * c.cy; + } + } + if (quad.point) { + if (!quad.leaf) { + quad.point.x += Math.random() - .5; + quad.point.y += Math.random() - .5; + } + var k = alpha * charges[quad.point.index]; + quad.charge += quad.pointCharge = k; + cx += k * quad.point.x; + cy += k * quad.point.y; + } + quad.cx = cx / quad.charge; + quad.cy = cy / quad.charge; + } + var d3_layout_forceLinkDistance = 20, d3_layout_forceLinkStrength = 1, d3_layout_forceChargeDistance2 = Infinity; + d3.layout.hierarchy = function() { + var sort = d3_layout_hierarchySort, children = d3_layout_hierarchyChildren, value = d3_layout_hierarchyValue; + function hierarchy(root) { + var stack = [ root ], nodes = [], node; + root.depth = 0; + while ((node = stack.pop()) != null) { + nodes.push(node); + if ((childs = children.call(hierarchy, node, node.depth)) && (n = childs.length)) { + var n, childs, child; + while (--n >= 0) { + stack.push(child = childs[n]); + child.parent = node; + child.depth = node.depth + 1; + } + if (value) node.value = 0; + node.children = childs; + } else { + if (value) node.value = +value.call(hierarchy, node, node.depth) || 0; + delete node.children; + } + } + d3_layout_hierarchyVisitAfter(root, function(node) { + var childs, parent; + if (sort && (childs = node.children)) childs.sort(sort); + if (value && (parent = node.parent)) parent.value += node.value; + }); + return nodes; + } + hierarchy.sort = function(x) { + if (!arguments.length) return sort; + sort = x; + return hierarchy; + }; + hierarchy.children = function(x) { + if (!arguments.length) return children; + children = x; + return hierarchy; + }; + hierarchy.value = function(x) { + if (!arguments.length) return value; + value = x; + return hierarchy; + }; + hierarchy.revalue = function(root) { + if (value) { + d3_layout_hierarchyVisitBefore(root, function(node) { + if (node.children) node.value = 0; + }); + d3_layout_hierarchyVisitAfter(root, function(node) { + var parent; + if (!node.children) node.value = +value.call(hierarchy, node, node.depth) || 0; + if (parent = node.parent) parent.value += node.value; + }); + } + return root; + }; + return hierarchy; + }; + function d3_layout_hierarchyRebind(object, hierarchy) { + d3.rebind(object, hierarchy, "sort", "children", "value"); + object.nodes = object; + object.links = d3_layout_hierarchyLinks; + return object; + } + function d3_layout_hierarchyVisitBefore(node, callback) { + var nodes = [ node ]; + while ((node = nodes.pop()) != null) { + callback(node); + if ((children = node.children) && (n = children.length)) { + var n, children; + while (--n >= 0) nodes.push(children[n]); + } + } + } + function d3_layout_hierarchyVisitAfter(node, callback) { + var nodes = [ node ], nodes2 = []; + while ((node = nodes.pop()) != null) { + nodes2.push(node); + if ((children = node.children) && (n = children.length)) { + var i = -1, n, children; + while (++i < n) nodes.push(children[i]); + } + } + while ((node = nodes2.pop()) != null) { + callback(node); + } + } + function d3_layout_hierarchyChildren(d) { + return d.children; + } + function d3_layout_hierarchyValue(d) { + return d.value; + } + function d3_layout_hierarchySort(a, b) { + return b.value - a.value; + } + function d3_layout_hierarchyLinks(nodes) { + return d3.merge(nodes.map(function(parent) { + return (parent.children || []).map(function(child) { + return { + source: parent, + target: child + }; + }); + })); + } + d3.layout.partition = function() { + var hierarchy = d3.layout.hierarchy(), size = [ 1, 1 ]; + function position(node, x, dx, dy) { + var children = node.children; + node.x = x; + node.y = node.depth * dy; + node.dx = dx; + node.dy = dy; + if (children && (n = children.length)) { + var i = -1, n, c, d; + dx = node.value ? dx / node.value : 0; + while (++i < n) { + position(c = children[i], x, d = c.value * dx, dy); + x += d; + } + } + } + function depth(node) { + var children = node.children, d = 0; + if (children && (n = children.length)) { + var i = -1, n; + while (++i < n) d = Math.max(d, depth(children[i])); + } + return 1 + d; + } + function partition(d, i) { + var nodes = hierarchy.call(this, d, i); + position(nodes[0], 0, size[0], size[1] / depth(nodes[0])); + return nodes; + } + partition.size = function(x) { + if (!arguments.length) return size; + size = x; + return partition; + }; + return d3_layout_hierarchyRebind(partition, hierarchy); + }; + d3.layout.pie = function() { + var value = Number, sort = d3_layout_pieSortByValue, startAngle = 0, endAngle = τ, padAngle = 0; + function pie(data) { + var n = data.length, values = data.map(function(d, i) { + return +value.call(pie, d, i); + }), a = +(typeof startAngle === "function" ? startAngle.apply(this, arguments) : startAngle), da = (typeof endAngle === "function" ? endAngle.apply(this, arguments) : endAngle) - a, p = Math.min(Math.abs(da) / n, +(typeof padAngle === "function" ? padAngle.apply(this, arguments) : padAngle)), pa = p * (da < 0 ? -1 : 1), sum = d3.sum(values), k = sum ? (da - n * pa) / sum : 0, index = d3.range(n), arcs = [], v; + if (sort != null) index.sort(sort === d3_layout_pieSortByValue ? function(i, j) { + return values[j] - values[i]; + } : function(i, j) { + return sort(data[i], data[j]); + }); + index.forEach(function(i) { + arcs[i] = { + data: data[i], + value: v = values[i], + startAngle: a, + endAngle: a += v * k + pa, + padAngle: p + }; + }); + return arcs; + } + pie.value = function(_) { + if (!arguments.length) return value; + value = _; + return pie; + }; + pie.sort = function(_) { + if (!arguments.length) return sort; + sort = _; + return pie; + }; + pie.startAngle = function(_) { + if (!arguments.length) return startAngle; + startAngle = _; + return pie; + }; + pie.endAngle = function(_) { + if (!arguments.length) return endAngle; + endAngle = _; + return pie; + }; + pie.padAngle = function(_) { + if (!arguments.length) return padAngle; + padAngle = _; + return pie; + }; + return pie; + }; + var d3_layout_pieSortByValue = {}; + d3.layout.stack = function() { + var values = d3_identity, order = d3_layout_stackOrderDefault, offset = d3_layout_stackOffsetZero, out = d3_layout_stackOut, x = d3_layout_stackX, y = d3_layout_stackY; + function stack(data, index) { + if (!(n = data.length)) return data; + var series = data.map(function(d, i) { + return values.call(stack, d, i); + }); + var points = series.map(function(d) { + return d.map(function(v, i) { + return [ x.call(stack, v, i), y.call(stack, v, i) ]; + }); + }); + var orders = order.call(stack, points, index); + series = d3.permute(series, orders); + points = d3.permute(points, orders); + var offsets = offset.call(stack, points, index); + var m = series[0].length, n, i, j, o; + for (j = 0; j < m; ++j) { + out.call(stack, series[0][j], o = offsets[j], points[0][j][1]); + for (i = 1; i < n; ++i) { + out.call(stack, series[i][j], o += points[i - 1][j][1], points[i][j][1]); + } + } + return data; + } + stack.values = function(x) { + if (!arguments.length) return values; + values = x; + return stack; + }; + stack.order = function(x) { + if (!arguments.length) return order; + order = typeof x === "function" ? x : d3_layout_stackOrders.get(x) || d3_layout_stackOrderDefault; + return stack; + }; + stack.offset = function(x) { + if (!arguments.length) return offset; + offset = typeof x === "function" ? x : d3_layout_stackOffsets.get(x) || d3_layout_stackOffsetZero; + return stack; + }; + stack.x = function(z) { + if (!arguments.length) return x; + x = z; + return stack; + }; + stack.y = function(z) { + if (!arguments.length) return y; + y = z; + return stack; + }; + stack.out = function(z) { + if (!arguments.length) return out; + out = z; + return stack; + }; + return stack; + }; + function d3_layout_stackX(d) { + return d.x; + } + function d3_layout_stackY(d) { + return d.y; + } + function d3_layout_stackOut(d, y0, y) { + d.y0 = y0; + d.y = y; + } + var d3_layout_stackOrders = d3.map({ + "inside-out": function(data) { + var n = data.length, i, j, max = data.map(d3_layout_stackMaxIndex), sums = data.map(d3_layout_stackReduceSum), index = d3.range(n).sort(function(a, b) { + return max[a] - max[b]; + }), top = 0, bottom = 0, tops = [], bottoms = []; + for (i = 0; i < n; ++i) { + j = index[i]; + if (top < bottom) { + top += sums[j]; + tops.push(j); + } else { + bottom += sums[j]; + bottoms.push(j); + } + } + return bottoms.reverse().concat(tops); + }, + reverse: function(data) { + return d3.range(data.length).reverse(); + }, + "default": d3_layout_stackOrderDefault + }); + var d3_layout_stackOffsets = d3.map({ + silhouette: function(data) { + var n = data.length, m = data[0].length, sums = [], max = 0, i, j, o, y0 = []; + for (j = 0; j < m; ++j) { + for (i = 0, o = 0; i < n; i++) o += data[i][j][1]; + if (o > max) max = o; + sums.push(o); + } + for (j = 0; j < m; ++j) { + y0[j] = (max - sums[j]) / 2; + } + return y0; + }, + wiggle: function(data) { + var n = data.length, x = data[0], m = x.length, i, j, k, s1, s2, s3, dx, o, o0, y0 = []; + y0[0] = o = o0 = 0; + for (j = 1; j < m; ++j) { + for (i = 0, s1 = 0; i < n; ++i) s1 += data[i][j][1]; + for (i = 0, s2 = 0, dx = x[j][0] - x[j - 1][0]; i < n; ++i) { + for (k = 0, s3 = (data[i][j][1] - data[i][j - 1][1]) / (2 * dx); k < i; ++k) { + s3 += (data[k][j][1] - data[k][j - 1][1]) / dx; + } + s2 += s3 * data[i][j][1]; + } + y0[j] = o -= s1 ? s2 / s1 * dx : 0; + if (o < o0) o0 = o; + } + for (j = 0; j < m; ++j) y0[j] -= o0; + return y0; + }, + expand: function(data) { + var n = data.length, m = data[0].length, k = 1 / n, i, j, o, y0 = []; + for (j = 0; j < m; ++j) { + for (i = 0, o = 0; i < n; i++) o += data[i][j][1]; + if (o) for (i = 0; i < n; i++) data[i][j][1] /= o; else for (i = 0; i < n; i++) data[i][j][1] = k; + } + for (j = 0; j < m; ++j) y0[j] = 0; + return y0; + }, + zero: d3_layout_stackOffsetZero + }); + function d3_layout_stackOrderDefault(data) { + return d3.range(data.length); + } + function d3_layout_stackOffsetZero(data) { + var j = -1, m = data[0].length, y0 = []; + while (++j < m) y0[j] = 0; + return y0; + } + function d3_layout_stackMaxIndex(array) { + var i = 1, j = 0, v = array[0][1], k, n = array.length; + for (;i < n; ++i) { + if ((k = array[i][1]) > v) { + j = i; + v = k; + } + } + return j; + } + function d3_layout_stackReduceSum(d) { + return d.reduce(d3_layout_stackSum, 0); + } + function d3_layout_stackSum(p, d) { + return p + d[1]; + } + d3.layout.histogram = function() { + var frequency = true, valuer = Number, ranger = d3_layout_histogramRange, binner = d3_layout_histogramBinSturges; + function histogram(data, i) { + var bins = [], values = data.map(valuer, this), range = ranger.call(this, values, i), thresholds = binner.call(this, range, values, i), bin, i = -1, n = values.length, m = thresholds.length - 1, k = frequency ? 1 : 1 / n, x; + while (++i < m) { + bin = bins[i] = []; + bin.dx = thresholds[i + 1] - (bin.x = thresholds[i]); + bin.y = 0; + } + if (m > 0) { + i = -1; + while (++i < n) { + x = values[i]; + if (x >= range[0] && x <= range[1]) { + bin = bins[d3.bisect(thresholds, x, 1, m) - 1]; + bin.y += k; + bin.push(data[i]); + } + } + } + return bins; + } + histogram.value = function(x) { + if (!arguments.length) return valuer; + valuer = x; + return histogram; + }; + histogram.range = function(x) { + if (!arguments.length) return ranger; + ranger = d3_functor(x); + return histogram; + }; + histogram.bins = function(x) { + if (!arguments.length) return binner; + binner = typeof x === "number" ? function(range) { + return d3_layout_histogramBinFixed(range, x); + } : d3_functor(x); + return histogram; + }; + histogram.frequency = function(x) { + if (!arguments.length) return frequency; + frequency = !!x; + return histogram; + }; + return histogram; + }; + function d3_layout_histogramBinSturges(range, values) { + return d3_layout_histogramBinFixed(range, Math.ceil(Math.log(values.length) / Math.LN2 + 1)); + } + function d3_layout_histogramBinFixed(range, n) { + var x = -1, b = +range[0], m = (range[1] - b) / n, f = []; + while (++x <= n) f[x] = m * x + b; + return f; + } + function d3_layout_histogramRange(values) { + return [ d3.min(values), d3.max(values) ]; + } + d3.layout.pack = function() { + var hierarchy = d3.layout.hierarchy().sort(d3_layout_packSort), padding = 0, size = [ 1, 1 ], radius; + function pack(d, i) { + var nodes = hierarchy.call(this, d, i), root = nodes[0], w = size[0], h = size[1], r = radius == null ? Math.sqrt : typeof radius === "function" ? radius : function() { + return radius; + }; + root.x = root.y = 0; + d3_layout_hierarchyVisitAfter(root, function(d) { + d.r = +r(d.value); + }); + d3_layout_hierarchyVisitAfter(root, d3_layout_packSiblings); + if (padding) { + var dr = padding * (radius ? 1 : Math.max(2 * root.r / w, 2 * root.r / h)) / 2; + d3_layout_hierarchyVisitAfter(root, function(d) { + d.r += dr; + }); + d3_layout_hierarchyVisitAfter(root, d3_layout_packSiblings); + d3_layout_hierarchyVisitAfter(root, function(d) { + d.r -= dr; + }); + } + d3_layout_packTransform(root, w / 2, h / 2, radius ? 1 : 1 / Math.max(2 * root.r / w, 2 * root.r / h)); + return nodes; + } + pack.size = function(_) { + if (!arguments.length) return size; + size = _; + return pack; + }; + pack.radius = function(_) { + if (!arguments.length) return radius; + radius = _ == null || typeof _ === "function" ? _ : +_; + return pack; + }; + pack.padding = function(_) { + if (!arguments.length) return padding; + padding = +_; + return pack; + }; + return d3_layout_hierarchyRebind(pack, hierarchy); + }; + function d3_layout_packSort(a, b) { + return a.value - b.value; + } + function d3_layout_packInsert(a, b) { + var c = a._pack_next; + a._pack_next = b; + b._pack_prev = a; + b._pack_next = c; + c._pack_prev = b; + } + function d3_layout_packSplice(a, b) { + a._pack_next = b; + b._pack_prev = a; + } + function d3_layout_packIntersects(a, b) { + var dx = b.x - a.x, dy = b.y - a.y, dr = a.r + b.r; + return .999 * dr * dr > dx * dx + dy * dy; + } + function d3_layout_packSiblings(node) { + if (!(nodes = node.children) || !(n = nodes.length)) return; + var nodes, xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity, a, b, c, i, j, k, n; + function bound(node) { + xMin = Math.min(node.x - node.r, xMin); + xMax = Math.max(node.x + node.r, xMax); + yMin = Math.min(node.y - node.r, yMin); + yMax = Math.max(node.y + node.r, yMax); + } + nodes.forEach(d3_layout_packLink); + a = nodes[0]; + a.x = -a.r; + a.y = 0; + bound(a); + if (n > 1) { + b = nodes[1]; + b.x = b.r; + b.y = 0; + bound(b); + if (n > 2) { + c = nodes[2]; + d3_layout_packPlace(a, b, c); + bound(c); + d3_layout_packInsert(a, c); + a._pack_prev = c; + d3_layout_packInsert(c, b); + b = a._pack_next; + for (i = 3; i < n; i++) { + d3_layout_packPlace(a, b, c = nodes[i]); + var isect = 0, s1 = 1, s2 = 1; + for (j = b._pack_next; j !== b; j = j._pack_next, s1++) { + if (d3_layout_packIntersects(j, c)) { + isect = 1; + break; + } + } + if (isect == 1) { + for (k = a._pack_prev; k !== j._pack_prev; k = k._pack_prev, s2++) { + if (d3_layout_packIntersects(k, c)) { + break; + } + } + } + if (isect) { + if (s1 < s2 || s1 == s2 && b.r < a.r) d3_layout_packSplice(a, b = j); else d3_layout_packSplice(a = k, b); + i--; + } else { + d3_layout_packInsert(a, c); + b = c; + bound(c); + } + } + } + } + var cx = (xMin + xMax) / 2, cy = (yMin + yMax) / 2, cr = 0; + for (i = 0; i < n; i++) { + c = nodes[i]; + c.x -= cx; + c.y -= cy; + cr = Math.max(cr, c.r + Math.sqrt(c.x * c.x + c.y * c.y)); + } + node.r = cr; + nodes.forEach(d3_layout_packUnlink); + } + function d3_layout_packLink(node) { + node._pack_next = node._pack_prev = node; + } + function d3_layout_packUnlink(node) { + delete node._pack_next; + delete node._pack_prev; + } + function d3_layout_packTransform(node, x, y, k) { + var children = node.children; + node.x = x += k * node.x; + node.y = y += k * node.y; + node.r *= k; + if (children) { + var i = -1, n = children.length; + while (++i < n) d3_layout_packTransform(children[i], x, y, k); + } + } + function d3_layout_packPlace(a, b, c) { + var db = a.r + c.r, dx = b.x - a.x, dy = b.y - a.y; + if (db && (dx || dy)) { + var da = b.r + c.r, dc = dx * dx + dy * dy; + da *= da; + db *= db; + var x = .5 + (db - da) / (2 * dc), y = Math.sqrt(Math.max(0, 2 * da * (db + dc) - (db -= dc) * db - da * da)) / (2 * dc); + c.x = a.x + x * dx + y * dy; + c.y = a.y + x * dy - y * dx; + } else { + c.x = a.x + db; + c.y = a.y; + } + } + d3.layout.tree = function() { + var hierarchy = d3.layout.hierarchy().sort(null).value(null), separation = d3_layout_treeSeparation, size = [ 1, 1 ], nodeSize = null; + function tree(d, i) { + var nodes = hierarchy.call(this, d, i), root0 = nodes[0], root1 = wrapTree(root0); + d3_layout_hierarchyVisitAfter(root1, firstWalk), root1.parent.m = -root1.z; + d3_layout_hierarchyVisitBefore(root1, secondWalk); + if (nodeSize) d3_layout_hierarchyVisitBefore(root0, sizeNode); else { + var left = root0, right = root0, bottom = root0; + d3_layout_hierarchyVisitBefore(root0, function(node) { + if (node.x < left.x) left = node; + if (node.x > right.x) right = node; + if (node.depth > bottom.depth) bottom = node; + }); + var tx = separation(left, right) / 2 - left.x, kx = size[0] / (right.x + separation(right, left) / 2 + tx), ky = size[1] / (bottom.depth || 1); + d3_layout_hierarchyVisitBefore(root0, function(node) { + node.x = (node.x + tx) * kx; + node.y = node.depth * ky; + }); + } + return nodes; + } + function wrapTree(root0) { + var root1 = { + A: null, + children: [ root0 ] + }, queue = [ root1 ], node1; + while ((node1 = queue.pop()) != null) { + for (var children = node1.children, child, i = 0, n = children.length; i < n; ++i) { + queue.push((children[i] = child = { + _: children[i], + parent: node1, + children: (child = children[i].children) && child.slice() || [], + A: null, + a: null, + z: 0, + m: 0, + c: 0, + s: 0, + t: null, + i: i + }).a = child); + } + } + return root1.children[0]; + } + function firstWalk(v) { + var children = v.children, siblings = v.parent.children, w = v.i ? siblings[v.i - 1] : null; + if (children.length) { + d3_layout_treeShift(v); + var midpoint = (children[0].z + children[children.length - 1].z) / 2; + if (w) { + v.z = w.z + separation(v._, w._); + v.m = v.z - midpoint; + } else { + v.z = midpoint; + } + } else if (w) { + v.z = w.z + separation(v._, w._); + } + v.parent.A = apportion(v, w, v.parent.A || siblings[0]); + } + function secondWalk(v) { + v._.x = v.z + v.parent.m; + v.m += v.parent.m; + } + function apportion(v, w, ancestor) { + if (w) { + var vip = v, vop = v, vim = w, vom = vip.parent.children[0], sip = vip.m, sop = vop.m, sim = vim.m, som = vom.m, shift; + while (vim = d3_layout_treeRight(vim), vip = d3_layout_treeLeft(vip), vim && vip) { + vom = d3_layout_treeLeft(vom); + vop = d3_layout_treeRight(vop); + vop.a = v; + shift = vim.z + sim - vip.z - sip + separation(vim._, vip._); + if (shift > 0) { + d3_layout_treeMove(d3_layout_treeAncestor(vim, v, ancestor), v, shift); + sip += shift; + sop += shift; + } + sim += vim.m; + sip += vip.m; + som += vom.m; + sop += vop.m; + } + if (vim && !d3_layout_treeRight(vop)) { + vop.t = vim; + vop.m += sim - sop; + } + if (vip && !d3_layout_treeLeft(vom)) { + vom.t = vip; + vom.m += sip - som; + ancestor = v; + } + } + return ancestor; + } + function sizeNode(node) { + node.x *= size[0]; + node.y = node.depth * size[1]; + } + tree.separation = function(x) { + if (!arguments.length) return separation; + separation = x; + return tree; + }; + tree.size = function(x) { + if (!arguments.length) return nodeSize ? null : size; + nodeSize = (size = x) == null ? sizeNode : null; + return tree; + }; + tree.nodeSize = function(x) { + if (!arguments.length) return nodeSize ? size : null; + nodeSize = (size = x) == null ? null : sizeNode; + return tree; + }; + return d3_layout_hierarchyRebind(tree, hierarchy); + }; + function d3_layout_treeSeparation(a, b) { + return a.parent == b.parent ? 1 : 2; + } + function d3_layout_treeLeft(v) { + var children = v.children; + return children.length ? children[0] : v.t; + } + function d3_layout_treeRight(v) { + var children = v.children, n; + return (n = children.length) ? children[n - 1] : v.t; + } + function d3_layout_treeMove(wm, wp, shift) { + var change = shift / (wp.i - wm.i); + wp.c -= change; + wp.s += shift; + wm.c += change; + wp.z += shift; + wp.m += shift; + } + function d3_layout_treeShift(v) { + var shift = 0, change = 0, children = v.children, i = children.length, w; + while (--i >= 0) { + w = children[i]; + w.z += shift; + w.m += shift; + shift += w.s + (change += w.c); + } + } + function d3_layout_treeAncestor(vim, v, ancestor) { + return vim.a.parent === v.parent ? vim.a : ancestor; + } + d3.layout.cluster = function() { + var hierarchy = d3.layout.hierarchy().sort(null).value(null), separation = d3_layout_treeSeparation, size = [ 1, 1 ], nodeSize = false; + function cluster(d, i) { + var nodes = hierarchy.call(this, d, i), root = nodes[0], previousNode, x = 0; + d3_layout_hierarchyVisitAfter(root, function(node) { + var children = node.children; + if (children && children.length) { + node.x = d3_layout_clusterX(children); + node.y = d3_layout_clusterY(children); + } else { + node.x = previousNode ? x += separation(node, previousNode) : 0; + node.y = 0; + previousNode = node; + } + }); + var left = d3_layout_clusterLeft(root), right = d3_layout_clusterRight(root), x0 = left.x - separation(left, right) / 2, x1 = right.x + separation(right, left) / 2; + d3_layout_hierarchyVisitAfter(root, nodeSize ? function(node) { + node.x = (node.x - root.x) * size[0]; + node.y = (root.y - node.y) * size[1]; + } : function(node) { + node.x = (node.x - x0) / (x1 - x0) * size[0]; + node.y = (1 - (root.y ? node.y / root.y : 1)) * size[1]; + }); + return nodes; + } + cluster.separation = function(x) { + if (!arguments.length) return separation; + separation = x; + return cluster; + }; + cluster.size = function(x) { + if (!arguments.length) return nodeSize ? null : size; + nodeSize = (size = x) == null; + return cluster; + }; + cluster.nodeSize = function(x) { + if (!arguments.length) return nodeSize ? size : null; + nodeSize = (size = x) != null; + return cluster; + }; + return d3_layout_hierarchyRebind(cluster, hierarchy); + }; + function d3_layout_clusterY(children) { + return 1 + d3.max(children, function(child) { + return child.y; + }); + } + function d3_layout_clusterX(children) { + return children.reduce(function(x, child) { + return x + child.x; + }, 0) / children.length; + } + function d3_layout_clusterLeft(node) { + var children = node.children; + return children && children.length ? d3_layout_clusterLeft(children[0]) : node; + } + function d3_layout_clusterRight(node) { + var children = node.children, n; + return children && (n = children.length) ? d3_layout_clusterRight(children[n - 1]) : node; + } + d3.layout.treemap = function() { + var hierarchy = d3.layout.hierarchy(), round = Math.round, size = [ 1, 1 ], padding = null, pad = d3_layout_treemapPadNull, sticky = false, stickies, mode = "squarify", ratio = .5 * (1 + Math.sqrt(5)); + function scale(children, k) { + var i = -1, n = children.length, child, area; + while (++i < n) { + area = (child = children[i]).value * (k < 0 ? 0 : k); + child.area = isNaN(area) || area <= 0 ? 0 : area; + } + } + function squarify(node) { + var children = node.children; + if (children && children.length) { + var rect = pad(node), row = [], remaining = children.slice(), child, best = Infinity, score, u = mode === "slice" ? rect.dx : mode === "dice" ? rect.dy : mode === "slice-dice" ? node.depth & 1 ? rect.dy : rect.dx : Math.min(rect.dx, rect.dy), n; + scale(remaining, rect.dx * rect.dy / node.value); + row.area = 0; + while ((n = remaining.length) > 0) { + row.push(child = remaining[n - 1]); + row.area += child.area; + if (mode !== "squarify" || (score = worst(row, u)) <= best) { + remaining.pop(); + best = score; + } else { + row.area -= row.pop().area; + position(row, u, rect, false); + u = Math.min(rect.dx, rect.dy); + row.length = row.area = 0; + best = Infinity; + } + } + if (row.length) { + position(row, u, rect, true); + row.length = row.area = 0; + } + children.forEach(squarify); + } + } + function stickify(node) { + var children = node.children; + if (children && children.length) { + var rect = pad(node), remaining = children.slice(), child, row = []; + scale(remaining, rect.dx * rect.dy / node.value); + row.area = 0; + while (child = remaining.pop()) { + row.push(child); + row.area += child.area; + if (child.z != null) { + position(row, child.z ? rect.dx : rect.dy, rect, !remaining.length); + row.length = row.area = 0; + } + } + children.forEach(stickify); + } + } + function worst(row, u) { + var s = row.area, r, rmax = 0, rmin = Infinity, i = -1, n = row.length; + while (++i < n) { + if (!(r = row[i].area)) continue; + if (r < rmin) rmin = r; + if (r > rmax) rmax = r; + } + s *= s; + u *= u; + return s ? Math.max(u * rmax * ratio / s, s / (u * rmin * ratio)) : Infinity; + } + function position(row, u, rect, flush) { + var i = -1, n = row.length, x = rect.x, y = rect.y, v = u ? round(row.area / u) : 0, o; + if (u == rect.dx) { + if (flush || v > rect.dy) v = rect.dy; + while (++i < n) { + o = row[i]; + o.x = x; + o.y = y; + o.dy = v; + x += o.dx = Math.min(rect.x + rect.dx - x, v ? round(o.area / v) : 0); + } + o.z = true; + o.dx += rect.x + rect.dx - x; + rect.y += v; + rect.dy -= v; + } else { + if (flush || v > rect.dx) v = rect.dx; + while (++i < n) { + o = row[i]; + o.x = x; + o.y = y; + o.dx = v; + y += o.dy = Math.min(rect.y + rect.dy - y, v ? round(o.area / v) : 0); + } + o.z = false; + o.dy += rect.y + rect.dy - y; + rect.x += v; + rect.dx -= v; + } + } + function treemap(d) { + var nodes = stickies || hierarchy(d), root = nodes[0]; + root.x = root.y = 0; + if (root.value) root.dx = size[0], root.dy = size[1]; else root.dx = root.dy = 0; + if (stickies) hierarchy.revalue(root); + scale([ root ], root.dx * root.dy / root.value); + (stickies ? stickify : squarify)(root); + if (sticky) stickies = nodes; + return nodes; + } + treemap.size = function(x) { + if (!arguments.length) return size; + size = x; + return treemap; + }; + treemap.padding = function(x) { + if (!arguments.length) return padding; + function padFunction(node) { + var p = x.call(treemap, node, node.depth); + return p == null ? d3_layout_treemapPadNull(node) : d3_layout_treemapPad(node, typeof p === "number" ? [ p, p, p, p ] : p); + } + function padConstant(node) { + return d3_layout_treemapPad(node, x); + } + var type; + pad = (padding = x) == null ? d3_layout_treemapPadNull : (type = typeof x) === "function" ? padFunction : type === "number" ? (x = [ x, x, x, x ], + padConstant) : padConstant; + return treemap; + }; + treemap.round = function(x) { + if (!arguments.length) return round != Number; + round = x ? Math.round : Number; + return treemap; + }; + treemap.sticky = function(x) { + if (!arguments.length) return sticky; + sticky = x; + stickies = null; + return treemap; + }; + treemap.ratio = function(x) { + if (!arguments.length) return ratio; + ratio = x; + return treemap; + }; + treemap.mode = function(x) { + if (!arguments.length) return mode; + mode = x + ""; + return treemap; + }; + return d3_layout_hierarchyRebind(treemap, hierarchy); + }; + function d3_layout_treemapPadNull(node) { + return { + x: node.x, + y: node.y, + dx: node.dx, + dy: node.dy + }; + } + function d3_layout_treemapPad(node, padding) { + var x = node.x + padding[3], y = node.y + padding[0], dx = node.dx - padding[1] - padding[3], dy = node.dy - padding[0] - padding[2]; + if (dx < 0) { + x += dx / 2; + dx = 0; + } + if (dy < 0) { + y += dy / 2; + dy = 0; + } + return { + x: x, + y: y, + dx: dx, + dy: dy + }; + } + d3.random = { + normal: function(µ, σ) { + var n = arguments.length; + if (n < 2) σ = 1; + if (n < 1) µ = 0; + return function() { + var x, y, r; + do { + x = Math.random() * 2 - 1; + y = Math.random() * 2 - 1; + r = x * x + y * y; + } while (!r || r > 1); + return µ + σ * x * Math.sqrt(-2 * Math.log(r) / r); + }; + }, + logNormal: function() { + var random = d3.random.normal.apply(d3, arguments); + return function() { + return Math.exp(random()); + }; + }, + bates: function(m) { + var random = d3.random.irwinHall(m); + return function() { + return random() / m; + }; + }, + irwinHall: function(m) { + return function() { + for (var s = 0, j = 0; j < m; j++) s += Math.random(); + return s; + }; + } + }; + d3.scale = {}; + function d3_scaleExtent(domain) { + var start = domain[0], stop = domain[domain.length - 1]; + return start < stop ? [ start, stop ] : [ stop, start ]; + } + function d3_scaleRange(scale) { + return scale.rangeExtent ? scale.rangeExtent() : d3_scaleExtent(scale.range()); + } + function d3_scale_bilinear(domain, range, uninterpolate, interpolate) { + var u = uninterpolate(domain[0], domain[1]), i = interpolate(range[0], range[1]); + return function(x) { + return i(u(x)); + }; + } + function d3_scale_nice(domain, nice) { + var i0 = 0, i1 = domain.length - 1, x0 = domain[i0], x1 = domain[i1], dx; + if (x1 < x0) { + dx = i0, i0 = i1, i1 = dx; + dx = x0, x0 = x1, x1 = dx; + } + domain[i0] = nice.floor(x0); + domain[i1] = nice.ceil(x1); + return domain; + } + function d3_scale_niceStep(step) { + return step ? { + floor: function(x) { + return Math.floor(x / step) * step; + }, + ceil: function(x) { + return Math.ceil(x / step) * step; + } + } : d3_scale_niceIdentity; + } + var d3_scale_niceIdentity = { + floor: d3_identity, + ceil: d3_identity + }; + function d3_scale_polylinear(domain, range, uninterpolate, interpolate) { + var u = [], i = [], j = 0, k = Math.min(domain.length, range.length) - 1; + if (domain[k] < domain[0]) { + domain = domain.slice().reverse(); + range = range.slice().reverse(); + } + while (++j <= k) { + u.push(uninterpolate(domain[j - 1], domain[j])); + i.push(interpolate(range[j - 1], range[j])); + } + return function(x) { + var j = d3.bisect(domain, x, 1, k) - 1; + return i[j](u[j](x)); + }; + } + d3.scale.linear = function() { + return d3_scale_linear([ 0, 1 ], [ 0, 1 ], d3_interpolate, false); + }; + function d3_scale_linear(domain, range, interpolate, clamp) { + var output, input; + function rescale() { + var linear = Math.min(domain.length, range.length) > 2 ? d3_scale_polylinear : d3_scale_bilinear, uninterpolate = clamp ? d3_uninterpolateClamp : d3_uninterpolateNumber; + output = linear(domain, range, uninterpolate, interpolate); + input = linear(range, domain, uninterpolate, d3_interpolate); + return scale; + } + function scale(x) { + return output(x); + } + scale.invert = function(y) { + return input(y); + }; + scale.domain = function(x) { + if (!arguments.length) return domain; + domain = x.map(Number); + return rescale(); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + return rescale(); + }; + scale.rangeRound = function(x) { + return scale.range(x).interpolate(d3_interpolateRound); + }; + scale.clamp = function(x) { + if (!arguments.length) return clamp; + clamp = x; + return rescale(); + }; + scale.interpolate = function(x) { + if (!arguments.length) return interpolate; + interpolate = x; + return rescale(); + }; + scale.ticks = function(m) { + return d3_scale_linearTicks(domain, m); + }; + scale.tickFormat = function(m, format) { + return d3_scale_linearTickFormat(domain, m, format); + }; + scale.nice = function(m) { + d3_scale_linearNice(domain, m); + return rescale(); + }; + scale.copy = function() { + return d3_scale_linear(domain, range, interpolate, clamp); + }; + return rescale(); + } + function d3_scale_linearRebind(scale, linear) { + return d3.rebind(scale, linear, "range", "rangeRound", "interpolate", "clamp"); + } + function d3_scale_linearNice(domain, m) { + d3_scale_nice(domain, d3_scale_niceStep(d3_scale_linearTickRange(domain, m)[2])); + d3_scale_nice(domain, d3_scale_niceStep(d3_scale_linearTickRange(domain, m)[2])); + return domain; + } + function d3_scale_linearTickRange(domain, m) { + if (m == null) m = 10; + var extent = d3_scaleExtent(domain), span = extent[1] - extent[0], step = Math.pow(10, Math.floor(Math.log(span / m) / Math.LN10)), err = m / span * step; + if (err <= .15) step *= 10; else if (err <= .35) step *= 5; else if (err <= .75) step *= 2; + extent[0] = Math.ceil(extent[0] / step) * step; + extent[1] = Math.floor(extent[1] / step) * step + step * .5; + extent[2] = step; + return extent; + } + function d3_scale_linearTicks(domain, m) { + return d3.range.apply(d3, d3_scale_linearTickRange(domain, m)); + } + function d3_scale_linearTickFormat(domain, m, format) { + var range = d3_scale_linearTickRange(domain, m); + if (format) { + var match = d3_format_re.exec(format); + match.shift(); + if (match[8] === "s") { + var prefix = d3.formatPrefix(Math.max(abs(range[0]), abs(range[1]))); + if (!match[7]) match[7] = "." + d3_scale_linearPrecision(prefix.scale(range[2])); + match[8] = "f"; + format = d3.format(match.join("")); + return function(d) { + return format(prefix.scale(d)) + prefix.symbol; + }; + } + if (!match[7]) match[7] = "." + d3_scale_linearFormatPrecision(match[8], range); + format = match.join(""); + } else { + format = ",." + d3_scale_linearPrecision(range[2]) + "f"; + } + return d3.format(format); + } + var d3_scale_linearFormatSignificant = { + s: 1, + g: 1, + p: 1, + r: 1, + e: 1 + }; + function d3_scale_linearPrecision(value) { + return -Math.floor(Math.log(value) / Math.LN10 + .01); + } + function d3_scale_linearFormatPrecision(type, range) { + var p = d3_scale_linearPrecision(range[2]); + return type in d3_scale_linearFormatSignificant ? Math.abs(p - d3_scale_linearPrecision(Math.max(abs(range[0]), abs(range[1])))) + +(type !== "e") : p - (type === "%") * 2; + } + d3.scale.log = function() { + return d3_scale_log(d3.scale.linear().domain([ 0, 1 ]), 10, true, [ 1, 10 ]); + }; + function d3_scale_log(linear, base, positive, domain) { + function log(x) { + return (positive ? Math.log(x < 0 ? 0 : x) : -Math.log(x > 0 ? 0 : -x)) / Math.log(base); + } + function pow(x) { + return positive ? Math.pow(base, x) : -Math.pow(base, -x); + } + function scale(x) { + return linear(log(x)); + } + scale.invert = function(x) { + return pow(linear.invert(x)); + }; + scale.domain = function(x) { + if (!arguments.length) return domain; + positive = x[0] >= 0; + linear.domain((domain = x.map(Number)).map(log)); + return scale; + }; + scale.base = function(_) { + if (!arguments.length) return base; + base = +_; + linear.domain(domain.map(log)); + return scale; + }; + scale.nice = function() { + var niced = d3_scale_nice(domain.map(log), positive ? Math : d3_scale_logNiceNegative); + linear.domain(niced); + domain = niced.map(pow); + return scale; + }; + scale.ticks = function() { + var extent = d3_scaleExtent(domain), ticks = [], u = extent[0], v = extent[1], i = Math.floor(log(u)), j = Math.ceil(log(v)), n = base % 1 ? 2 : base; + if (isFinite(j - i)) { + if (positive) { + for (;i < j; i++) for (var k = 1; k < n; k++) ticks.push(pow(i) * k); + ticks.push(pow(i)); + } else { + ticks.push(pow(i)); + for (;i++ < j; ) for (var k = n - 1; k > 0; k--) ticks.push(pow(i) * k); + } + for (i = 0; ticks[i] < u; i++) {} + for (j = ticks.length; ticks[j - 1] > v; j--) {} + ticks = ticks.slice(i, j); + } + return ticks; + }; + scale.tickFormat = function(n, format) { + if (!arguments.length) return d3_scale_logFormat; + if (arguments.length < 2) format = d3_scale_logFormat; else if (typeof format !== "function") format = d3.format(format); + var k = Math.max(1, base * n / scale.ticks().length); + return function(d) { + var i = d / pow(Math.round(log(d))); + if (i * base < base - .5) i *= base; + return i <= k ? format(d) : ""; + }; + }; + scale.copy = function() { + return d3_scale_log(linear.copy(), base, positive, domain); + }; + return d3_scale_linearRebind(scale, linear); + } + var d3_scale_logFormat = d3.format(".0e"), d3_scale_logNiceNegative = { + floor: function(x) { + return -Math.ceil(-x); + }, + ceil: function(x) { + return -Math.floor(-x); + } + }; + d3.scale.pow = function() { + return d3_scale_pow(d3.scale.linear(), 1, [ 0, 1 ]); + }; + function d3_scale_pow(linear, exponent, domain) { + var powp = d3_scale_powPow(exponent), powb = d3_scale_powPow(1 / exponent); + function scale(x) { + return linear(powp(x)); + } + scale.invert = function(x) { + return powb(linear.invert(x)); + }; + scale.domain = function(x) { + if (!arguments.length) return domain; + linear.domain((domain = x.map(Number)).map(powp)); + return scale; + }; + scale.ticks = function(m) { + return d3_scale_linearTicks(domain, m); + }; + scale.tickFormat = function(m, format) { + return d3_scale_linearTickFormat(domain, m, format); + }; + scale.nice = function(m) { + return scale.domain(d3_scale_linearNice(domain, m)); + }; + scale.exponent = function(x) { + if (!arguments.length) return exponent; + powp = d3_scale_powPow(exponent = x); + powb = d3_scale_powPow(1 / exponent); + linear.domain(domain.map(powp)); + return scale; + }; + scale.copy = function() { + return d3_scale_pow(linear.copy(), exponent, domain); + }; + return d3_scale_linearRebind(scale, linear); + } + function d3_scale_powPow(e) { + return function(x) { + return x < 0 ? -Math.pow(-x, e) : Math.pow(x, e); + }; + } + d3.scale.sqrt = function() { + return d3.scale.pow().exponent(.5); + }; + d3.scale.ordinal = function() { + return d3_scale_ordinal([], { + t: "range", + a: [ [] ] + }); + }; + function d3_scale_ordinal(domain, ranger) { + var index, range, rangeBand; + function scale(x) { + return range[((index.get(x) || (ranger.t === "range" ? index.set(x, domain.push(x)) : NaN)) - 1) % range.length]; + } + function steps(start, step) { + return d3.range(domain.length).map(function(i) { + return start + step * i; + }); + } + scale.domain = function(x) { + if (!arguments.length) return domain; + domain = []; + index = new d3_Map(); + var i = -1, n = x.length, xi; + while (++i < n) if (!index.has(xi = x[i])) index.set(xi, domain.push(xi)); + return scale[ranger.t].apply(scale, ranger.a); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + rangeBand = 0; + ranger = { + t: "range", + a: arguments + }; + return scale; + }; + scale.rangePoints = function(x, padding) { + if (arguments.length < 2) padding = 0; + var start = x[0], stop = x[1], step = domain.length < 2 ? (start = (start + stop) / 2, + 0) : (stop - start) / (domain.length - 1 + padding); + range = steps(start + step * padding / 2, step); + rangeBand = 0; + ranger = { + t: "rangePoints", + a: arguments + }; + return scale; + }; + scale.rangeRoundPoints = function(x, padding) { + if (arguments.length < 2) padding = 0; + var start = x[0], stop = x[1], step = domain.length < 2 ? (start = stop = Math.round((start + stop) / 2), + 0) : (stop - start) / (domain.length - 1 + padding) | 0; + range = steps(start + Math.round(step * padding / 2 + (stop - start - (domain.length - 1 + padding) * step) / 2), step); + rangeBand = 0; + ranger = { + t: "rangeRoundPoints", + a: arguments + }; + return scale; + }; + scale.rangeBands = function(x, padding, outerPadding) { + if (arguments.length < 2) padding = 0; + if (arguments.length < 3) outerPadding = padding; + var reverse = x[1] < x[0], start = x[reverse - 0], stop = x[1 - reverse], step = (stop - start) / (domain.length - padding + 2 * outerPadding); + range = steps(start + step * outerPadding, step); + if (reverse) range.reverse(); + rangeBand = step * (1 - padding); + ranger = { + t: "rangeBands", + a: arguments + }; + return scale; + }; + scale.rangeRoundBands = function(x, padding, outerPadding) { + if (arguments.length < 2) padding = 0; + if (arguments.length < 3) outerPadding = padding; + var reverse = x[1] < x[0], start = x[reverse - 0], stop = x[1 - reverse], step = Math.floor((stop - start) / (domain.length - padding + 2 * outerPadding)); + range = steps(start + Math.round((stop - start - (domain.length - padding) * step) / 2), step); + if (reverse) range.reverse(); + rangeBand = Math.round(step * (1 - padding)); + ranger = { + t: "rangeRoundBands", + a: arguments + }; + return scale; + }; + scale.rangeBand = function() { + return rangeBand; + }; + scale.rangeExtent = function() { + return d3_scaleExtent(ranger.a[0]); + }; + scale.copy = function() { + return d3_scale_ordinal(domain, ranger); + }; + return scale.domain(domain); + } + d3.scale.category10 = function() { + return d3.scale.ordinal().range(d3_category10); + }; + d3.scale.category20 = function() { + return d3.scale.ordinal().range(d3_category20); + }; + d3.scale.category20b = function() { + return d3.scale.ordinal().range(d3_category20b); + }; + d3.scale.category20c = function() { + return d3.scale.ordinal().range(d3_category20c); + }; + var d3_category10 = [ 2062260, 16744206, 2924588, 14034728, 9725885, 9197131, 14907330, 8355711, 12369186, 1556175 ].map(d3_rgbString); + var d3_category20 = [ 2062260, 11454440, 16744206, 16759672, 2924588, 10018698, 14034728, 16750742, 9725885, 12955861, 9197131, 12885140, 14907330, 16234194, 8355711, 13092807, 12369186, 14408589, 1556175, 10410725 ].map(d3_rgbString); + var d3_category20b = [ 3750777, 5395619, 7040719, 10264286, 6519097, 9216594, 11915115, 13556636, 9202993, 12426809, 15186514, 15190932, 8666169, 11356490, 14049643, 15177372, 8077683, 10834324, 13528509, 14589654 ].map(d3_rgbString); + var d3_category20c = [ 3244733, 7057110, 10406625, 13032431, 15095053, 16616764, 16625259, 16634018, 3253076, 7652470, 10607003, 13101504, 7695281, 10394312, 12369372, 14342891, 6513507, 9868950, 12434877, 14277081 ].map(d3_rgbString); + d3.scale.quantile = function() { + return d3_scale_quantile([], []); + }; + function d3_scale_quantile(domain, range) { + var thresholds; + function rescale() { + var k = 0, q = range.length; + thresholds = []; + while (++k < q) thresholds[k - 1] = d3.quantile(domain, k / q); + return scale; + } + function scale(x) { + if (!isNaN(x = +x)) return range[d3.bisect(thresholds, x)]; + } + scale.domain = function(x) { + if (!arguments.length) return domain; + domain = x.map(d3_number).filter(d3_numeric).sort(d3_ascending); + return rescale(); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + return rescale(); + }; + scale.quantiles = function() { + return thresholds; + }; + scale.invertExtent = function(y) { + y = range.indexOf(y); + return y < 0 ? [ NaN, NaN ] : [ y > 0 ? thresholds[y - 1] : domain[0], y < thresholds.length ? thresholds[y] : domain[domain.length - 1] ]; + }; + scale.copy = function() { + return d3_scale_quantile(domain, range); + }; + return rescale(); + } + d3.scale.quantize = function() { + return d3_scale_quantize(0, 1, [ 0, 1 ]); + }; + function d3_scale_quantize(x0, x1, range) { + var kx, i; + function scale(x) { + return range[Math.max(0, Math.min(i, Math.floor(kx * (x - x0))))]; + } + function rescale() { + kx = range.length / (x1 - x0); + i = range.length - 1; + return scale; + } + scale.domain = function(x) { + if (!arguments.length) return [ x0, x1 ]; + x0 = +x[0]; + x1 = +x[x.length - 1]; + return rescale(); + }; + scale.range = function(x) { + if (!arguments.length) return range; + range = x; + return rescale(); + }; + scale.invertExtent = function(y) { + y = range.indexOf(y); + y = y < 0 ? NaN : y / kx + x0; + return [ y, y + 1 / kx ]; + }; + scale.copy = function() { + return d3_scale_quantize(x0, x1, range); + }; + return rescale(); + } + d3.scale.threshold = function() { + return d3_scale_threshold([ .5 ], [ 0, 1 ]); + }; + function d3_scale_threshold(domain, range) { + function scale(x) { + if (x <= x) return range[d3.bisect(domain, x)]; + } + scale.domain = function(_) { + if (!arguments.length) return domain; + domain = _; + return scale; + }; + scale.range = function(_) { + if (!arguments.length) return range; + range = _; + return scale; + }; + scale.invertExtent = function(y) { + y = range.indexOf(y); + return [ domain[y - 1], domain[y] ]; + }; + scale.copy = function() { + return d3_scale_threshold(domain, range); + }; + return scale; + } + d3.scale.identity = function() { + return d3_scale_identity([ 0, 1 ]); + }; + function d3_scale_identity(domain) { + function identity(x) { + return +x; + } + identity.invert = identity; + identity.domain = identity.range = function(x) { + if (!arguments.length) return domain; + domain = x.map(identity); + return identity; + }; + identity.ticks = function(m) { + return d3_scale_linearTicks(domain, m); + }; + identity.tickFormat = function(m, format) { + return d3_scale_linearTickFormat(domain, m, format); + }; + identity.copy = function() { + return d3_scale_identity(domain); + }; + return identity; + } + d3.svg = {}; + function d3_zero() { + return 0; + } + d3.svg.arc = function() { + var innerRadius = d3_svg_arcInnerRadius, outerRadius = d3_svg_arcOuterRadius, cornerRadius = d3_zero, padRadius = d3_svg_arcAuto, startAngle = d3_svg_arcStartAngle, endAngle = d3_svg_arcEndAngle, padAngle = d3_svg_arcPadAngle; + function arc() { + var r0 = Math.max(0, +innerRadius.apply(this, arguments)), r1 = Math.max(0, +outerRadius.apply(this, arguments)), a0 = startAngle.apply(this, arguments) - halfπ, a1 = endAngle.apply(this, arguments) - halfπ, da = Math.abs(a1 - a0), cw = a0 > a1 ? 0 : 1; + if (r1 < r0) rc = r1, r1 = r0, r0 = rc; + if (da >= τε) return circleSegment(r1, cw) + (r0 ? circleSegment(r0, 1 - cw) : "") + "Z"; + var rc, cr, rp, ap, p0 = 0, p1 = 0, x0, y0, x1, y1, x2, y2, x3, y3, path = []; + if (ap = (+padAngle.apply(this, arguments) || 0) / 2) { + rp = padRadius === d3_svg_arcAuto ? Math.sqrt(r0 * r0 + r1 * r1) : +padRadius.apply(this, arguments); + if (!cw) p1 *= -1; + if (r1) p1 = d3_asin(rp / r1 * Math.sin(ap)); + if (r0) p0 = d3_asin(rp / r0 * Math.sin(ap)); + } + if (r1) { + x0 = r1 * Math.cos(a0 + p1); + y0 = r1 * Math.sin(a0 + p1); + x1 = r1 * Math.cos(a1 - p1); + y1 = r1 * Math.sin(a1 - p1); + var l1 = Math.abs(a1 - a0 - 2 * p1) <= π ? 0 : 1; + if (p1 && d3_svg_arcSweep(x0, y0, x1, y1) === cw ^ l1) { + var h1 = (a0 + a1) / 2; + x0 = r1 * Math.cos(h1); + y0 = r1 * Math.sin(h1); + x1 = y1 = null; + } + } else { + x0 = y0 = 0; + } + if (r0) { + x2 = r0 * Math.cos(a1 - p0); + y2 = r0 * Math.sin(a1 - p0); + x3 = r0 * Math.cos(a0 + p0); + y3 = r0 * Math.sin(a0 + p0); + var l0 = Math.abs(a0 - a1 + 2 * p0) <= π ? 0 : 1; + if (p0 && d3_svg_arcSweep(x2, y2, x3, y3) === 1 - cw ^ l0) { + var h0 = (a0 + a1) / 2; + x2 = r0 * Math.cos(h0); + y2 = r0 * Math.sin(h0); + x3 = y3 = null; + } + } else { + x2 = y2 = 0; + } + if (da > ε && (rc = Math.min(Math.abs(r1 - r0) / 2, +cornerRadius.apply(this, arguments))) > .001) { + cr = r0 < r1 ^ cw ? 0 : 1; + var rc1 = rc, rc0 = rc; + if (da < π) { + var oc = x3 == null ? [ x2, y2 ] : x1 == null ? [ x0, y0 ] : d3_geom_polygonIntersect([ x0, y0 ], [ x3, y3 ], [ x1, y1 ], [ x2, y2 ]), ax = x0 - oc[0], ay = y0 - oc[1], bx = x1 - oc[0], by = y1 - oc[1], kc = 1 / Math.sin(Math.acos((ax * bx + ay * by) / (Math.sqrt(ax * ax + ay * ay) * Math.sqrt(bx * bx + by * by))) / 2), lc = Math.sqrt(oc[0] * oc[0] + oc[1] * oc[1]); + rc0 = Math.min(rc, (r0 - lc) / (kc - 1)); + rc1 = Math.min(rc, (r1 - lc) / (kc + 1)); + } + if (x1 != null) { + var t30 = d3_svg_arcCornerTangents(x3 == null ? [ x2, y2 ] : [ x3, y3 ], [ x0, y0 ], r1, rc1, cw), t12 = d3_svg_arcCornerTangents([ x1, y1 ], [ x2, y2 ], r1, rc1, cw); + if (rc === rc1) { + path.push("M", t30[0], "A", rc1, ",", rc1, " 0 0,", cr, " ", t30[1], "A", r1, ",", r1, " 0 ", 1 - cw ^ d3_svg_arcSweep(t30[1][0], t30[1][1], t12[1][0], t12[1][1]), ",", cw, " ", t12[1], "A", rc1, ",", rc1, " 0 0,", cr, " ", t12[0]); + } else { + path.push("M", t30[0], "A", rc1, ",", rc1, " 0 1,", cr, " ", t12[0]); + } + } else { + path.push("M", x0, ",", y0); + } + if (x3 != null) { + var t03 = d3_svg_arcCornerTangents([ x0, y0 ], [ x3, y3 ], r0, -rc0, cw), t21 = d3_svg_arcCornerTangents([ x2, y2 ], x1 == null ? [ x0, y0 ] : [ x1, y1 ], r0, -rc0, cw); + if (rc === rc0) { + path.push("L", t21[0], "A", rc0, ",", rc0, " 0 0,", cr, " ", t21[1], "A", r0, ",", r0, " 0 ", cw ^ d3_svg_arcSweep(t21[1][0], t21[1][1], t03[1][0], t03[1][1]), ",", 1 - cw, " ", t03[1], "A", rc0, ",", rc0, " 0 0,", cr, " ", t03[0]); + } else { + path.push("L", t21[0], "A", rc0, ",", rc0, " 0 0,", cr, " ", t03[0]); + } + } else { + path.push("L", x2, ",", y2); + } + } else { + path.push("M", x0, ",", y0); + if (x1 != null) path.push("A", r1, ",", r1, " 0 ", l1, ",", cw, " ", x1, ",", y1); + path.push("L", x2, ",", y2); + if (x3 != null) path.push("A", r0, ",", r0, " 0 ", l0, ",", 1 - cw, " ", x3, ",", y3); + } + path.push("Z"); + return path.join(""); + } + function circleSegment(r1, cw) { + return "M0," + r1 + "A" + r1 + "," + r1 + " 0 1," + cw + " 0," + -r1 + "A" + r1 + "," + r1 + " 0 1," + cw + " 0," + r1; + } + arc.innerRadius = function(v) { + if (!arguments.length) return innerRadius; + innerRadius = d3_functor(v); + return arc; + }; + arc.outerRadius = function(v) { + if (!arguments.length) return outerRadius; + outerRadius = d3_functor(v); + return arc; + }; + arc.cornerRadius = function(v) { + if (!arguments.length) return cornerRadius; + cornerRadius = d3_functor(v); + return arc; + }; + arc.padRadius = function(v) { + if (!arguments.length) return padRadius; + padRadius = v == d3_svg_arcAuto ? d3_svg_arcAuto : d3_functor(v); + return arc; + }; + arc.startAngle = function(v) { + if (!arguments.length) return startAngle; + startAngle = d3_functor(v); + return arc; + }; + arc.endAngle = function(v) { + if (!arguments.length) return endAngle; + endAngle = d3_functor(v); + return arc; + }; + arc.padAngle = function(v) { + if (!arguments.length) return padAngle; + padAngle = d3_functor(v); + return arc; + }; + arc.centroid = function() { + var r = (+innerRadius.apply(this, arguments) + +outerRadius.apply(this, arguments)) / 2, a = (+startAngle.apply(this, arguments) + +endAngle.apply(this, arguments)) / 2 - halfπ; + return [ Math.cos(a) * r, Math.sin(a) * r ]; + }; + return arc; + }; + var d3_svg_arcAuto = "auto"; + function d3_svg_arcInnerRadius(d) { + return d.innerRadius; + } + function d3_svg_arcOuterRadius(d) { + return d.outerRadius; + } + function d3_svg_arcStartAngle(d) { + return d.startAngle; + } + function d3_svg_arcEndAngle(d) { + return d.endAngle; + } + function d3_svg_arcPadAngle(d) { + return d && d.padAngle; + } + function d3_svg_arcSweep(x0, y0, x1, y1) { + return (x0 - x1) * y0 - (y0 - y1) * x0 > 0 ? 0 : 1; + } + function d3_svg_arcCornerTangents(p0, p1, r1, rc, cw) { + var x01 = p0[0] - p1[0], y01 = p0[1] - p1[1], lo = (cw ? rc : -rc) / Math.sqrt(x01 * x01 + y01 * y01), ox = lo * y01, oy = -lo * x01, x1 = p0[0] + ox, y1 = p0[1] + oy, x2 = p1[0] + ox, y2 = p1[1] + oy, x3 = (x1 + x2) / 2, y3 = (y1 + y2) / 2, dx = x2 - x1, dy = y2 - y1, d2 = dx * dx + dy * dy, r = r1 - rc, D = x1 * y2 - x2 * y1, d = (dy < 0 ? -1 : 1) * Math.sqrt(Math.max(0, r * r * d2 - D * D)), cx0 = (D * dy - dx * d) / d2, cy0 = (-D * dx - dy * d) / d2, cx1 = (D * dy + dx * d) / d2, cy1 = (-D * dx + dy * d) / d2, dx0 = cx0 - x3, dy0 = cy0 - y3, dx1 = cx1 - x3, dy1 = cy1 - y3; + if (dx0 * dx0 + dy0 * dy0 > dx1 * dx1 + dy1 * dy1) cx0 = cx1, cy0 = cy1; + return [ [ cx0 - ox, cy0 - oy ], [ cx0 * r1 / r, cy0 * r1 / r ] ]; + } + function d3_svg_line(projection) { + var x = d3_geom_pointX, y = d3_geom_pointY, defined = d3_true, interpolate = d3_svg_lineLinear, interpolateKey = interpolate.key, tension = .7; + function line(data) { + var segments = [], points = [], i = -1, n = data.length, d, fx = d3_functor(x), fy = d3_functor(y); + function segment() { + segments.push("M", interpolate(projection(points), tension)); + } + while (++i < n) { + if (defined.call(this, d = data[i], i)) { + points.push([ +fx.call(this, d, i), +fy.call(this, d, i) ]); + } else if (points.length) { + segment(); + points = []; + } + } + if (points.length) segment(); + return segments.length ? segments.join("") : null; + } + line.x = function(_) { + if (!arguments.length) return x; + x = _; + return line; + }; + line.y = function(_) { + if (!arguments.length) return y; + y = _; + return line; + }; + line.defined = function(_) { + if (!arguments.length) return defined; + defined = _; + return line; + }; + line.interpolate = function(_) { + if (!arguments.length) return interpolateKey; + if (typeof _ === "function") interpolateKey = interpolate = _; else interpolateKey = (interpolate = d3_svg_lineInterpolators.get(_) || d3_svg_lineLinear).key; + return line; + }; + line.tension = function(_) { + if (!arguments.length) return tension; + tension = _; + return line; + }; + return line; + } + d3.svg.line = function() { + return d3_svg_line(d3_identity); + }; + var d3_svg_lineInterpolators = d3.map({ + linear: d3_svg_lineLinear, + "linear-closed": d3_svg_lineLinearClosed, + step: d3_svg_lineStep, + "step-before": d3_svg_lineStepBefore, + "step-after": d3_svg_lineStepAfter, + basis: d3_svg_lineBasis, + "basis-open": d3_svg_lineBasisOpen, + "basis-closed": d3_svg_lineBasisClosed, + bundle: d3_svg_lineBundle, + cardinal: d3_svg_lineCardinal, + "cardinal-open": d3_svg_lineCardinalOpen, + "cardinal-closed": d3_svg_lineCardinalClosed, + monotone: d3_svg_lineMonotone + }); + d3_svg_lineInterpolators.forEach(function(key, value) { + value.key = key; + value.closed = /-closed$/.test(key); + }); + function d3_svg_lineLinear(points) { + return points.length > 1 ? points.join("L") : points + "Z"; + } + function d3_svg_lineLinearClosed(points) { + return points.join("L") + "Z"; + } + function d3_svg_lineStep(points) { + var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ]; + while (++i < n) path.push("H", (p[0] + (p = points[i])[0]) / 2, "V", p[1]); + if (n > 1) path.push("H", p[0]); + return path.join(""); + } + function d3_svg_lineStepBefore(points) { + var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ]; + while (++i < n) path.push("V", (p = points[i])[1], "H", p[0]); + return path.join(""); + } + function d3_svg_lineStepAfter(points) { + var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ]; + while (++i < n) path.push("H", (p = points[i])[0], "V", p[1]); + return path.join(""); + } + function d3_svg_lineCardinalOpen(points, tension) { + return points.length < 4 ? d3_svg_lineLinear(points) : points[1] + d3_svg_lineHermite(points.slice(1, -1), d3_svg_lineCardinalTangents(points, tension)); + } + function d3_svg_lineCardinalClosed(points, tension) { + return points.length < 3 ? d3_svg_lineLinearClosed(points) : points[0] + d3_svg_lineHermite((points.push(points[0]), + points), d3_svg_lineCardinalTangents([ points[points.length - 2] ].concat(points, [ points[1] ]), tension)); + } + function d3_svg_lineCardinal(points, tension) { + return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite(points, d3_svg_lineCardinalTangents(points, tension)); + } + function d3_svg_lineHermite(points, tangents) { + if (tangents.length < 1 || points.length != tangents.length && points.length != tangents.length + 2) { + return d3_svg_lineLinear(points); + } + var quad = points.length != tangents.length, path = "", p0 = points[0], p = points[1], t0 = tangents[0], t = t0, pi = 1; + if (quad) { + path += "Q" + (p[0] - t0[0] * 2 / 3) + "," + (p[1] - t0[1] * 2 / 3) + "," + p[0] + "," + p[1]; + p0 = points[1]; + pi = 2; + } + if (tangents.length > 1) { + t = tangents[1]; + p = points[pi]; + pi++; + path += "C" + (p0[0] + t0[0]) + "," + (p0[1] + t0[1]) + "," + (p[0] - t[0]) + "," + (p[1] - t[1]) + "," + p[0] + "," + p[1]; + for (var i = 2; i < tangents.length; i++, pi++) { + p = points[pi]; + t = tangents[i]; + path += "S" + (p[0] - t[0]) + "," + (p[1] - t[1]) + "," + p[0] + "," + p[1]; + } + } + if (quad) { + var lp = points[pi]; + path += "Q" + (p[0] + t[0] * 2 / 3) + "," + (p[1] + t[1] * 2 / 3) + "," + lp[0] + "," + lp[1]; + } + return path; + } + function d3_svg_lineCardinalTangents(points, tension) { + var tangents = [], a = (1 - tension) / 2, p0, p1 = points[0], p2 = points[1], i = 1, n = points.length; + while (++i < n) { + p0 = p1; + p1 = p2; + p2 = points[i]; + tangents.push([ a * (p2[0] - p0[0]), a * (p2[1] - p0[1]) ]); + } + return tangents; + } + function d3_svg_lineBasis(points) { + if (points.length < 3) return d3_svg_lineLinear(points); + var i = 1, n = points.length, pi = points[0], x0 = pi[0], y0 = pi[1], px = [ x0, x0, x0, (pi = points[1])[0] ], py = [ y0, y0, y0, pi[1] ], path = [ x0, ",", y0, "L", d3_svg_lineDot4(d3_svg_lineBasisBezier3, px), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, py) ]; + points.push(points[n - 1]); + while (++i <= n) { + pi = points[i]; + px.shift(); + px.push(pi[0]); + py.shift(); + py.push(pi[1]); + d3_svg_lineBasisBezier(path, px, py); + } + points.pop(); + path.push("L", pi); + return path.join(""); + } + function d3_svg_lineBasisOpen(points) { + if (points.length < 4) return d3_svg_lineLinear(points); + var path = [], i = -1, n = points.length, pi, px = [ 0 ], py = [ 0 ]; + while (++i < 3) { + pi = points[i]; + px.push(pi[0]); + py.push(pi[1]); + } + path.push(d3_svg_lineDot4(d3_svg_lineBasisBezier3, px) + "," + d3_svg_lineDot4(d3_svg_lineBasisBezier3, py)); + --i; + while (++i < n) { + pi = points[i]; + px.shift(); + px.push(pi[0]); + py.shift(); + py.push(pi[1]); + d3_svg_lineBasisBezier(path, px, py); + } + return path.join(""); + } + function d3_svg_lineBasisClosed(points) { + var path, i = -1, n = points.length, m = n + 4, pi, px = [], py = []; + while (++i < 4) { + pi = points[i % n]; + px.push(pi[0]); + py.push(pi[1]); + } + path = [ d3_svg_lineDot4(d3_svg_lineBasisBezier3, px), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, py) ]; + --i; + while (++i < m) { + pi = points[i % n]; + px.shift(); + px.push(pi[0]); + py.shift(); + py.push(pi[1]); + d3_svg_lineBasisBezier(path, px, py); + } + return path.join(""); + } + function d3_svg_lineBundle(points, tension) { + var n = points.length - 1; + if (n) { + var x0 = points[0][0], y0 = points[0][1], dx = points[n][0] - x0, dy = points[n][1] - y0, i = -1, p, t; + while (++i <= n) { + p = points[i]; + t = i / n; + p[0] = tension * p[0] + (1 - tension) * (x0 + t * dx); + p[1] = tension * p[1] + (1 - tension) * (y0 + t * dy); + } + } + return d3_svg_lineBasis(points); + } + function d3_svg_lineDot4(a, b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; + } + var d3_svg_lineBasisBezier1 = [ 0, 2 / 3, 1 / 3, 0 ], d3_svg_lineBasisBezier2 = [ 0, 1 / 3, 2 / 3, 0 ], d3_svg_lineBasisBezier3 = [ 0, 1 / 6, 2 / 3, 1 / 6 ]; + function d3_svg_lineBasisBezier(path, x, y) { + path.push("C", d3_svg_lineDot4(d3_svg_lineBasisBezier1, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier1, y), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier2, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier2, y), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, y)); + } + function d3_svg_lineSlope(p0, p1) { + return (p1[1] - p0[1]) / (p1[0] - p0[0]); + } + function d3_svg_lineFiniteDifferences(points) { + var i = 0, j = points.length - 1, m = [], p0 = points[0], p1 = points[1], d = m[0] = d3_svg_lineSlope(p0, p1); + while (++i < j) { + m[i] = (d + (d = d3_svg_lineSlope(p0 = p1, p1 = points[i + 1]))) / 2; + } + m[i] = d; + return m; + } + function d3_svg_lineMonotoneTangents(points) { + var tangents = [], d, a, b, s, m = d3_svg_lineFiniteDifferences(points), i = -1, j = points.length - 1; + while (++i < j) { + d = d3_svg_lineSlope(points[i], points[i + 1]); + if (abs(d) < ε) { + m[i] = m[i + 1] = 0; + } else { + a = m[i] / d; + b = m[i + 1] / d; + s = a * a + b * b; + if (s > 9) { + s = d * 3 / Math.sqrt(s); + m[i] = s * a; + m[i + 1] = s * b; + } + } + } + i = -1; + while (++i <= j) { + s = (points[Math.min(j, i + 1)][0] - points[Math.max(0, i - 1)][0]) / (6 * (1 + m[i] * m[i])); + tangents.push([ s || 0, m[i] * s || 0 ]); + } + return tangents; + } + function d3_svg_lineMonotone(points) { + return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite(points, d3_svg_lineMonotoneTangents(points)); + } + d3.svg.line.radial = function() { + var line = d3_svg_line(d3_svg_lineRadial); + line.radius = line.x, delete line.x; + line.angle = line.y, delete line.y; + return line; + }; + function d3_svg_lineRadial(points) { + var point, i = -1, n = points.length, r, a; + while (++i < n) { + point = points[i]; + r = point[0]; + a = point[1] - halfπ; + point[0] = r * Math.cos(a); + point[1] = r * Math.sin(a); + } + return points; + } + function d3_svg_area(projection) { + var x0 = d3_geom_pointX, x1 = d3_geom_pointX, y0 = 0, y1 = d3_geom_pointY, defined = d3_true, interpolate = d3_svg_lineLinear, interpolateKey = interpolate.key, interpolateReverse = interpolate, L = "L", tension = .7; + function area(data) { + var segments = [], points0 = [], points1 = [], i = -1, n = data.length, d, fx0 = d3_functor(x0), fy0 = d3_functor(y0), fx1 = x0 === x1 ? function() { + return x; + } : d3_functor(x1), fy1 = y0 === y1 ? function() { + return y; + } : d3_functor(y1), x, y; + function segment() { + segments.push("M", interpolate(projection(points1), tension), L, interpolateReverse(projection(points0.reverse()), tension), "Z"); + } + while (++i < n) { + if (defined.call(this, d = data[i], i)) { + points0.push([ x = +fx0.call(this, d, i), y = +fy0.call(this, d, i) ]); + points1.push([ +fx1.call(this, d, i), +fy1.call(this, d, i) ]); + } else if (points0.length) { + segment(); + points0 = []; + points1 = []; + } + } + if (points0.length) segment(); + return segments.length ? segments.join("") : null; + } + area.x = function(_) { + if (!arguments.length) return x1; + x0 = x1 = _; + return area; + }; + area.x0 = function(_) { + if (!arguments.length) return x0; + x0 = _; + return area; + }; + area.x1 = function(_) { + if (!arguments.length) return x1; + x1 = _; + return area; + }; + area.y = function(_) { + if (!arguments.length) return y1; + y0 = y1 = _; + return area; + }; + area.y0 = function(_) { + if (!arguments.length) return y0; + y0 = _; + return area; + }; + area.y1 = function(_) { + if (!arguments.length) return y1; + y1 = _; + return area; + }; + area.defined = function(_) { + if (!arguments.length) return defined; + defined = _; + return area; + }; + area.interpolate = function(_) { + if (!arguments.length) return interpolateKey; + if (typeof _ === "function") interpolateKey = interpolate = _; else interpolateKey = (interpolate = d3_svg_lineInterpolators.get(_) || d3_svg_lineLinear).key; + interpolateReverse = interpolate.reverse || interpolate; + L = interpolate.closed ? "M" : "L"; + return area; + }; + area.tension = function(_) { + if (!arguments.length) return tension; + tension = _; + return area; + }; + return area; + } + d3_svg_lineStepBefore.reverse = d3_svg_lineStepAfter; + d3_svg_lineStepAfter.reverse = d3_svg_lineStepBefore; + d3.svg.area = function() { + return d3_svg_area(d3_identity); + }; + d3.svg.area.radial = function() { + var area = d3_svg_area(d3_svg_lineRadial); + area.radius = area.x, delete area.x; + area.innerRadius = area.x0, delete area.x0; + area.outerRadius = area.x1, delete area.x1; + area.angle = area.y, delete area.y; + area.startAngle = area.y0, delete area.y0; + area.endAngle = area.y1, delete area.y1; + return area; + }; + d3.svg.chord = function() { + var source = d3_source, target = d3_target, radius = d3_svg_chordRadius, startAngle = d3_svg_arcStartAngle, endAngle = d3_svg_arcEndAngle; + function chord(d, i) { + var s = subgroup(this, source, d, i), t = subgroup(this, target, d, i); + return "M" + s.p0 + arc(s.r, s.p1, s.a1 - s.a0) + (equals(s, t) ? curve(s.r, s.p1, s.r, s.p0) : curve(s.r, s.p1, t.r, t.p0) + arc(t.r, t.p1, t.a1 - t.a0) + curve(t.r, t.p1, s.r, s.p0)) + "Z"; + } + function subgroup(self, f, d, i) { + var subgroup = f.call(self, d, i), r = radius.call(self, subgroup, i), a0 = startAngle.call(self, subgroup, i) - halfπ, a1 = endAngle.call(self, subgroup, i) - halfπ; + return { + r: r, + a0: a0, + a1: a1, + p0: [ r * Math.cos(a0), r * Math.sin(a0) ], + p1: [ r * Math.cos(a1), r * Math.sin(a1) ] + }; + } + function equals(a, b) { + return a.a0 == b.a0 && a.a1 == b.a1; + } + function arc(r, p, a) { + return "A" + r + "," + r + " 0 " + +(a > π) + ",1 " + p; + } + function curve(r0, p0, r1, p1) { + return "Q 0,0 " + p1; + } + chord.radius = function(v) { + if (!arguments.length) return radius; + radius = d3_functor(v); + return chord; + }; + chord.source = function(v) { + if (!arguments.length) return source; + source = d3_functor(v); + return chord; + }; + chord.target = function(v) { + if (!arguments.length) return target; + target = d3_functor(v); + return chord; + }; + chord.startAngle = function(v) { + if (!arguments.length) return startAngle; + startAngle = d3_functor(v); + return chord; + }; + chord.endAngle = function(v) { + if (!arguments.length) return endAngle; + endAngle = d3_functor(v); + return chord; + }; + return chord; + }; + function d3_svg_chordRadius(d) { + return d.radius; + } + d3.svg.diagonal = function() { + var source = d3_source, target = d3_target, projection = d3_svg_diagonalProjection; + function diagonal(d, i) { + var p0 = source.call(this, d, i), p3 = target.call(this, d, i), m = (p0.y + p3.y) / 2, p = [ p0, { + x: p0.x, + y: m + }, { + x: p3.x, + y: m + }, p3 ]; + p = p.map(projection); + return "M" + p[0] + "C" + p[1] + " " + p[2] + " " + p[3]; + } + diagonal.source = function(x) { + if (!arguments.length) return source; + source = d3_functor(x); + return diagonal; + }; + diagonal.target = function(x) { + if (!arguments.length) return target; + target = d3_functor(x); + return diagonal; + }; + diagonal.projection = function(x) { + if (!arguments.length) return projection; + projection = x; + return diagonal; + }; + return diagonal; + }; + function d3_svg_diagonalProjection(d) { + return [ d.x, d.y ]; + } + d3.svg.diagonal.radial = function() { + var diagonal = d3.svg.diagonal(), projection = d3_svg_diagonalProjection, projection_ = diagonal.projection; + diagonal.projection = function(x) { + return arguments.length ? projection_(d3_svg_diagonalRadialProjection(projection = x)) : projection; + }; + return diagonal; + }; + function d3_svg_diagonalRadialProjection(projection) { + return function() { + var d = projection.apply(this, arguments), r = d[0], a = d[1] - halfπ; + return [ r * Math.cos(a), r * Math.sin(a) ]; + }; + } + d3.svg.symbol = function() { + var type = d3_svg_symbolType, size = d3_svg_symbolSize; + function symbol(d, i) { + return (d3_svg_symbols.get(type.call(this, d, i)) || d3_svg_symbolCircle)(size.call(this, d, i)); + } + symbol.type = function(x) { + if (!arguments.length) return type; + type = d3_functor(x); + return symbol; + }; + symbol.size = function(x) { + if (!arguments.length) return size; + size = d3_functor(x); + return symbol; + }; + return symbol; + }; + function d3_svg_symbolSize() { + return 64; + } + function d3_svg_symbolType() { + return "circle"; + } + function d3_svg_symbolCircle(size) { + var r = Math.sqrt(size / π); + return "M0," + r + "A" + r + "," + r + " 0 1,1 0," + -r + "A" + r + "," + r + " 0 1,1 0," + r + "Z"; + } + var d3_svg_symbols = d3.map({ + circle: d3_svg_symbolCircle, + cross: function(size) { + var r = Math.sqrt(size / 5) / 2; + return "M" + -3 * r + "," + -r + "H" + -r + "V" + -3 * r + "H" + r + "V" + -r + "H" + 3 * r + "V" + r + "H" + r + "V" + 3 * r + "H" + -r + "V" + r + "H" + -3 * r + "Z"; + }, + diamond: function(size) { + var ry = Math.sqrt(size / (2 * d3_svg_symbolTan30)), rx = ry * d3_svg_symbolTan30; + return "M0," + -ry + "L" + rx + ",0" + " 0," + ry + " " + -rx + ",0" + "Z"; + }, + square: function(size) { + var r = Math.sqrt(size) / 2; + return "M" + -r + "," + -r + "L" + r + "," + -r + " " + r + "," + r + " " + -r + "," + r + "Z"; + }, + "triangle-down": function(size) { + var rx = Math.sqrt(size / d3_svg_symbolSqrt3), ry = rx * d3_svg_symbolSqrt3 / 2; + return "M0," + ry + "L" + rx + "," + -ry + " " + -rx + "," + -ry + "Z"; + }, + "triangle-up": function(size) { + var rx = Math.sqrt(size / d3_svg_symbolSqrt3), ry = rx * d3_svg_symbolSqrt3 / 2; + return "M0," + -ry + "L" + rx + "," + ry + " " + -rx + "," + ry + "Z"; + } + }); + d3.svg.symbolTypes = d3_svg_symbols.keys(); + var d3_svg_symbolSqrt3 = Math.sqrt(3), d3_svg_symbolTan30 = Math.tan(30 * d3_radians); + d3_selectionPrototype.transition = function(name) { + var id = d3_transitionInheritId || ++d3_transitionId, ns = d3_transitionNamespace(name), subgroups = [], subgroup, node, transition = d3_transitionInherit || { + time: Date.now(), + ease: d3_ease_cubicInOut, + delay: 0, + duration: 250 + }; + for (var j = -1, m = this.length; ++j < m; ) { + subgroups.push(subgroup = []); + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) d3_transitionNode(node, i, ns, id, transition); + subgroup.push(node); + } + } + return d3_transition(subgroups, ns, id); + }; + d3_selectionPrototype.interrupt = function(name) { + return this.each(name == null ? d3_selection_interrupt : d3_selection_interruptNS(d3_transitionNamespace(name))); + }; + var d3_selection_interrupt = d3_selection_interruptNS(d3_transitionNamespace()); + function d3_selection_interruptNS(ns) { + return function() { + var lock, activeId, active; + if ((lock = this[ns]) && (active = lock[activeId = lock.active])) { + active.timer.c = null; + active.timer.t = NaN; + if (--lock.count) delete lock[activeId]; else delete this[ns]; + lock.active += .5; + active.event && active.event.interrupt.call(this, this.__data__, active.index); + } + }; + } + function d3_transition(groups, ns, id) { + d3_subclass(groups, d3_transitionPrototype); + groups.namespace = ns; + groups.id = id; + return groups; + } + var d3_transitionPrototype = [], d3_transitionId = 0, d3_transitionInheritId, d3_transitionInherit; + d3_transitionPrototype.call = d3_selectionPrototype.call; + d3_transitionPrototype.empty = d3_selectionPrototype.empty; + d3_transitionPrototype.node = d3_selectionPrototype.node; + d3_transitionPrototype.size = d3_selectionPrototype.size; + d3.transition = function(selection, name) { + return selection && selection.transition ? d3_transitionInheritId ? selection.transition(name) : selection : d3.selection().transition(selection); + }; + d3.transition.prototype = d3_transitionPrototype; + d3_transitionPrototype.select = function(selector) { + var id = this.id, ns = this.namespace, subgroups = [], subgroup, subnode, node; + selector = d3_selection_selector(selector); + for (var j = -1, m = this.length; ++j < m; ) { + subgroups.push(subgroup = []); + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + if ((node = group[i]) && (subnode = selector.call(node, node.__data__, i, j))) { + if ("__data__" in node) subnode.__data__ = node.__data__; + d3_transitionNode(subnode, i, ns, id, node[ns][id]); + subgroup.push(subnode); + } else { + subgroup.push(null); + } + } + } + return d3_transition(subgroups, ns, id); + }; + d3_transitionPrototype.selectAll = function(selector) { + var id = this.id, ns = this.namespace, subgroups = [], subgroup, subnodes, node, subnode, transition; + selector = d3_selection_selectorAll(selector); + for (var j = -1, m = this.length; ++j < m; ) { + for (var group = this[j], i = -1, n = group.length; ++i < n; ) { + if (node = group[i]) { + transition = node[ns][id]; + subnodes = selector.call(node, node.__data__, i, j); + subgroups.push(subgroup = []); + for (var k = -1, o = subnodes.length; ++k < o; ) { + if (subnode = subnodes[k]) d3_transitionNode(subnode, k, ns, id, transition); + subgroup.push(subnode); + } + } + } + } + return d3_transition(subgroups, ns, id); + }; + d3_transitionPrototype.filter = function(filter) { + var subgroups = [], subgroup, group, node; + if (typeof filter !== "function") filter = d3_selection_filter(filter); + for (var j = 0, m = this.length; j < m; j++) { + subgroups.push(subgroup = []); + for (var group = this[j], i = 0, n = group.length; i < n; i++) { + if ((node = group[i]) && filter.call(node, node.__data__, i, j)) { + subgroup.push(node); + } + } + } + return d3_transition(subgroups, this.namespace, this.id); + }; + d3_transitionPrototype.tween = function(name, tween) { + var id = this.id, ns = this.namespace; + if (arguments.length < 2) return this.node()[ns][id].tween.get(name); + return d3_selection_each(this, tween == null ? function(node) { + node[ns][id].tween.remove(name); + } : function(node) { + node[ns][id].tween.set(name, tween); + }); + }; + function d3_transition_tween(groups, name, value, tween) { + var id = groups.id, ns = groups.namespace; + return d3_selection_each(groups, typeof value === "function" ? function(node, i, j) { + node[ns][id].tween.set(name, tween(value.call(node, node.__data__, i, j))); + } : (value = tween(value), function(node) { + node[ns][id].tween.set(name, value); + })); + } + d3_transitionPrototype.attr = function(nameNS, value) { + if (arguments.length < 2) { + for (value in nameNS) this.attr(value, nameNS[value]); + return this; + } + var interpolate = nameNS == "transform" ? d3_interpolateTransform : d3_interpolate, name = d3.ns.qualify(nameNS); + function attrNull() { + this.removeAttribute(name); + } + function attrNullNS() { + this.removeAttributeNS(name.space, name.local); + } + function attrTween(b) { + return b == null ? attrNull : (b += "", function() { + var a = this.getAttribute(name), i; + return a !== b && (i = interpolate(a, b), function(t) { + this.setAttribute(name, i(t)); + }); + }); + } + function attrTweenNS(b) { + return b == null ? attrNullNS : (b += "", function() { + var a = this.getAttributeNS(name.space, name.local), i; + return a !== b && (i = interpolate(a, b), function(t) { + this.setAttributeNS(name.space, name.local, i(t)); + }); + }); + } + return d3_transition_tween(this, "attr." + nameNS, value, name.local ? attrTweenNS : attrTween); + }; + d3_transitionPrototype.attrTween = function(nameNS, tween) { + var name = d3.ns.qualify(nameNS); + function attrTween(d, i) { + var f = tween.call(this, d, i, this.getAttribute(name)); + return f && function(t) { + this.setAttribute(name, f(t)); + }; + } + function attrTweenNS(d, i) { + var f = tween.call(this, d, i, this.getAttributeNS(name.space, name.local)); + return f && function(t) { + this.setAttributeNS(name.space, name.local, f(t)); + }; + } + return this.tween("attr." + nameNS, name.local ? attrTweenNS : attrTween); + }; + d3_transitionPrototype.style = function(name, value, priority) { + var n = arguments.length; + if (n < 3) { + if (typeof name !== "string") { + if (n < 2) value = ""; + for (priority in name) this.style(priority, name[priority], value); + return this; + } + priority = ""; + } + function styleNull() { + this.style.removeProperty(name); + } + function styleString(b) { + return b == null ? styleNull : (b += "", function() { + var a = d3_window(this).getComputedStyle(this, null).getPropertyValue(name), i; + return a !== b && (i = d3_interpolate(a, b), function(t) { + this.style.setProperty(name, i(t), priority); + }); + }); + } + return d3_transition_tween(this, "style." + name, value, styleString); + }; + d3_transitionPrototype.styleTween = function(name, tween, priority) { + if (arguments.length < 3) priority = ""; + function styleTween(d, i) { + var f = tween.call(this, d, i, d3_window(this).getComputedStyle(this, null).getPropertyValue(name)); + return f && function(t) { + this.style.setProperty(name, f(t), priority); + }; + } + return this.tween("style." + name, styleTween); + }; + d3_transitionPrototype.text = function(value) { + return d3_transition_tween(this, "text", value, d3_transition_text); + }; + function d3_transition_text(b) { + if (b == null) b = ""; + return function() { + this.textContent = b; + }; + } + d3_transitionPrototype.remove = function() { + var ns = this.namespace; + return this.each("end.transition", function() { + var p; + if (this[ns].count < 2 && (p = this.parentNode)) p.removeChild(this); + }); + }; + d3_transitionPrototype.ease = function(value) { + var id = this.id, ns = this.namespace; + if (arguments.length < 1) return this.node()[ns][id].ease; + if (typeof value !== "function") value = d3.ease.apply(d3, arguments); + return d3_selection_each(this, function(node) { + node[ns][id].ease = value; + }); + }; + d3_transitionPrototype.delay = function(value) { + var id = this.id, ns = this.namespace; + if (arguments.length < 1) return this.node()[ns][id].delay; + return d3_selection_each(this, typeof value === "function" ? function(node, i, j) { + node[ns][id].delay = +value.call(node, node.__data__, i, j); + } : (value = +value, function(node) { + node[ns][id].delay = value; + })); + }; + d3_transitionPrototype.duration = function(value) { + var id = this.id, ns = this.namespace; + if (arguments.length < 1) return this.node()[ns][id].duration; + return d3_selection_each(this, typeof value === "function" ? function(node, i, j) { + node[ns][id].duration = Math.max(1, value.call(node, node.__data__, i, j)); + } : (value = Math.max(1, value), function(node) { + node[ns][id].duration = value; + })); + }; + d3_transitionPrototype.each = function(type, listener) { + var id = this.id, ns = this.namespace; + if (arguments.length < 2) { + var inherit = d3_transitionInherit, inheritId = d3_transitionInheritId; + try { + d3_transitionInheritId = id; + d3_selection_each(this, function(node, i, j) { + d3_transitionInherit = node[ns][id]; + type.call(node, node.__data__, i, j); + }); + } finally { + d3_transitionInherit = inherit; + d3_transitionInheritId = inheritId; + } + } else { + d3_selection_each(this, function(node) { + var transition = node[ns][id]; + (transition.event || (transition.event = d3.dispatch("start", "end", "interrupt"))).on(type, listener); + }); + } + return this; + }; + d3_transitionPrototype.transition = function() { + var id0 = this.id, id1 = ++d3_transitionId, ns = this.namespace, subgroups = [], subgroup, group, node, transition; + for (var j = 0, m = this.length; j < m; j++) { + subgroups.push(subgroup = []); + for (var group = this[j], i = 0, n = group.length; i < n; i++) { + if (node = group[i]) { + transition = node[ns][id0]; + d3_transitionNode(node, i, ns, id1, { + time: transition.time, + ease: transition.ease, + delay: transition.delay + transition.duration, + duration: transition.duration + }); + } + subgroup.push(node); + } + } + return d3_transition(subgroups, ns, id1); + }; + function d3_transitionNamespace(name) { + return name == null ? "__transition__" : "__transition_" + name + "__"; + } + function d3_transitionNode(node, i, ns, id, inherit) { + var lock = node[ns] || (node[ns] = { + active: 0, + count: 0 + }), transition = lock[id], time, timer, duration, ease, tweens; + function schedule(elapsed) { + var delay = transition.delay; + timer.t = delay + time; + if (delay <= elapsed) return start(elapsed - delay); + timer.c = start; + } + function start(elapsed) { + var activeId = lock.active, active = lock[activeId]; + if (active) { + active.timer.c = null; + active.timer.t = NaN; + --lock.count; + delete lock[activeId]; + active.event && active.event.interrupt.call(node, node.__data__, active.index); + } + for (var cancelId in lock) { + if (+cancelId < id) { + var cancel = lock[cancelId]; + cancel.timer.c = null; + cancel.timer.t = NaN; + --lock.count; + delete lock[cancelId]; + } + } + timer.c = tick; + d3_timer(function() { + if (timer.c && tick(elapsed || 1)) { + timer.c = null; + timer.t = NaN; + } + return 1; + }, 0, time); + lock.active = id; + transition.event && transition.event.start.call(node, node.__data__, i); + tweens = []; + transition.tween.forEach(function(key, value) { + if (value = value.call(node, node.__data__, i)) { + tweens.push(value); + } + }); + ease = transition.ease; + duration = transition.duration; + } + function tick(elapsed) { + var t = elapsed / duration, e = ease(t), n = tweens.length; + while (n > 0) { + tweens[--n].call(node, e); + } + if (t >= 1) { + transition.event && transition.event.end.call(node, node.__data__, i); + if (--lock.count) delete lock[id]; else delete node[ns]; + return 1; + } + } + if (!transition) { + time = inherit.time; + timer = d3_timer(schedule, 0, time); + transition = lock[id] = { + tween: new d3_Map(), + time: time, + timer: timer, + delay: inherit.delay, + duration: inherit.duration, + ease: inherit.ease, + index: i + }; + inherit = null; + ++lock.count; + } + } + d3.svg.axis = function() { + var scale = d3.scale.linear(), orient = d3_svg_axisDefaultOrient, innerTickSize = 6, outerTickSize = 6, tickPadding = 3, tickArguments_ = [ 10 ], tickValues = null, tickFormat_; + function axis(g) { + g.each(function() { + var g = d3.select(this); + var scale0 = this.__chart__ || scale, scale1 = this.__chart__ = scale.copy(); + var ticks = tickValues == null ? scale1.ticks ? scale1.ticks.apply(scale1, tickArguments_) : scale1.domain() : tickValues, tickFormat = tickFormat_ == null ? scale1.tickFormat ? scale1.tickFormat.apply(scale1, tickArguments_) : d3_identity : tickFormat_, tick = g.selectAll(".tick").data(ticks, scale1), tickEnter = tick.enter().insert("g", ".domain").attr("class", "tick").style("opacity", ε), tickExit = d3.transition(tick.exit()).style("opacity", ε).remove(), tickUpdate = d3.transition(tick.order()).style("opacity", 1), tickSpacing = Math.max(innerTickSize, 0) + tickPadding, tickTransform; + var range = d3_scaleRange(scale1), path = g.selectAll(".domain").data([ 0 ]), pathUpdate = (path.enter().append("path").attr("class", "domain"), + d3.transition(path)); + tickEnter.append("line"); + tickEnter.append("text"); + var lineEnter = tickEnter.select("line"), lineUpdate = tickUpdate.select("line"), text = tick.select("text").text(tickFormat), textEnter = tickEnter.select("text"), textUpdate = tickUpdate.select("text"), sign = orient === "top" || orient === "left" ? -1 : 1, x1, x2, y1, y2; + if (orient === "bottom" || orient === "top") { + tickTransform = d3_svg_axisX, x1 = "x", y1 = "y", x2 = "x2", y2 = "y2"; + text.attr("dy", sign < 0 ? "0em" : ".71em").style("text-anchor", "middle"); + pathUpdate.attr("d", "M" + range[0] + "," + sign * outerTickSize + "V0H" + range[1] + "V" + sign * outerTickSize); + } else { + tickTransform = d3_svg_axisY, x1 = "y", y1 = "x", x2 = "y2", y2 = "x2"; + text.attr("dy", ".32em").style("text-anchor", sign < 0 ? "end" : "start"); + pathUpdate.attr("d", "M" + sign * outerTickSize + "," + range[0] + "H0V" + range[1] + "H" + sign * outerTickSize); + } + lineEnter.attr(y2, sign * innerTickSize); + textEnter.attr(y1, sign * tickSpacing); + lineUpdate.attr(x2, 0).attr(y2, sign * innerTickSize); + textUpdate.attr(x1, 0).attr(y1, sign * tickSpacing); + if (scale1.rangeBand) { + var x = scale1, dx = x.rangeBand() / 2; + scale0 = scale1 = function(d) { + return x(d) + dx; + }; + } else if (scale0.rangeBand) { + scale0 = scale1; + } else { + tickExit.call(tickTransform, scale1, scale0); + } + tickEnter.call(tickTransform, scale0, scale1); + tickUpdate.call(tickTransform, scale1, scale1); + }); + } + axis.scale = function(x) { + if (!arguments.length) return scale; + scale = x; + return axis; + }; + axis.orient = function(x) { + if (!arguments.length) return orient; + orient = x in d3_svg_axisOrients ? x + "" : d3_svg_axisDefaultOrient; + return axis; + }; + axis.ticks = function() { + if (!arguments.length) return tickArguments_; + tickArguments_ = d3_array(arguments); + return axis; + }; + axis.tickValues = function(x) { + if (!arguments.length) return tickValues; + tickValues = x; + return axis; + }; + axis.tickFormat = function(x) { + if (!arguments.length) return tickFormat_; + tickFormat_ = x; + return axis; + }; + axis.tickSize = function(x) { + var n = arguments.length; + if (!n) return innerTickSize; + innerTickSize = +x; + outerTickSize = +arguments[n - 1]; + return axis; + }; + axis.innerTickSize = function(x) { + if (!arguments.length) return innerTickSize; + innerTickSize = +x; + return axis; + }; + axis.outerTickSize = function(x) { + if (!arguments.length) return outerTickSize; + outerTickSize = +x; + return axis; + }; + axis.tickPadding = function(x) { + if (!arguments.length) return tickPadding; + tickPadding = +x; + return axis; + }; + axis.tickSubdivide = function() { + return arguments.length && axis; + }; + return axis; + }; + var d3_svg_axisDefaultOrient = "bottom", d3_svg_axisOrients = { + top: 1, + right: 1, + bottom: 1, + left: 1 + }; + function d3_svg_axisX(selection, x0, x1) { + selection.attr("transform", function(d) { + var v0 = x0(d); + return "translate(" + (isFinite(v0) ? v0 : x1(d)) + ",0)"; + }); + } + function d3_svg_axisY(selection, y0, y1) { + selection.attr("transform", function(d) { + var v0 = y0(d); + return "translate(0," + (isFinite(v0) ? v0 : y1(d)) + ")"; + }); + } + d3.svg.brush = function() { + var event = d3_eventDispatch(brush, "brushstart", "brush", "brushend"), x = null, y = null, xExtent = [ 0, 0 ], yExtent = [ 0, 0 ], xExtentDomain, yExtentDomain, xClamp = true, yClamp = true, resizes = d3_svg_brushResizes[0]; + function brush(g) { + g.each(function() { + var g = d3.select(this).style("pointer-events", "all").style("-webkit-tap-highlight-color", "rgba(0,0,0,0)").on("mousedown.brush", brushstart).on("touchstart.brush", brushstart); + var background = g.selectAll(".background").data([ 0 ]); + background.enter().append("rect").attr("class", "background").style("visibility", "hidden").style("cursor", "crosshair"); + g.selectAll(".extent").data([ 0 ]).enter().append("rect").attr("class", "extent").style("cursor", "move"); + var resize = g.selectAll(".resize").data(resizes, d3_identity); + resize.exit().remove(); + resize.enter().append("g").attr("class", function(d) { + return "resize " + d; + }).style("cursor", function(d) { + return d3_svg_brushCursor[d]; + }).append("rect").attr("x", function(d) { + return /[ew]$/.test(d) ? -3 : null; + }).attr("y", function(d) { + return /^[ns]/.test(d) ? -3 : null; + }).attr("width", 6).attr("height", 6).style("visibility", "hidden"); + resize.style("display", brush.empty() ? "none" : null); + var gUpdate = d3.transition(g), backgroundUpdate = d3.transition(background), range; + if (x) { + range = d3_scaleRange(x); + backgroundUpdate.attr("x", range[0]).attr("width", range[1] - range[0]); + redrawX(gUpdate); + } + if (y) { + range = d3_scaleRange(y); + backgroundUpdate.attr("y", range[0]).attr("height", range[1] - range[0]); + redrawY(gUpdate); + } + redraw(gUpdate); + }); + } + brush.event = function(g) { + g.each(function() { + var event_ = event.of(this, arguments), extent1 = { + x: xExtent, + y: yExtent, + i: xExtentDomain, + j: yExtentDomain + }, extent0 = this.__chart__ || extent1; + this.__chart__ = extent1; + if (d3_transitionInheritId) { + d3.select(this).transition().each("start.brush", function() { + xExtentDomain = extent0.i; + yExtentDomain = extent0.j; + xExtent = extent0.x; + yExtent = extent0.y; + event_({ + type: "brushstart" + }); + }).tween("brush:brush", function() { + var xi = d3_interpolateArray(xExtent, extent1.x), yi = d3_interpolateArray(yExtent, extent1.y); + xExtentDomain = yExtentDomain = null; + return function(t) { + xExtent = extent1.x = xi(t); + yExtent = extent1.y = yi(t); + event_({ + type: "brush", + mode: "resize" + }); + }; + }).each("end.brush", function() { + xExtentDomain = extent1.i; + yExtentDomain = extent1.j; + event_({ + type: "brush", + mode: "resize" + }); + event_({ + type: "brushend" + }); + }); + } else { + event_({ + type: "brushstart" + }); + event_({ + type: "brush", + mode: "resize" + }); + event_({ + type: "brushend" + }); + } + }); + }; + function redraw(g) { + g.selectAll(".resize").attr("transform", function(d) { + return "translate(" + xExtent[+/e$/.test(d)] + "," + yExtent[+/^s/.test(d)] + ")"; + }); + } + function redrawX(g) { + g.select(".extent").attr("x", xExtent[0]); + g.selectAll(".extent,.n>rect,.s>rect").attr("width", xExtent[1] - xExtent[0]); + } + function redrawY(g) { + g.select(".extent").attr("y", yExtent[0]); + g.selectAll(".extent,.e>rect,.w>rect").attr("height", yExtent[1] - yExtent[0]); + } + function brushstart() { + var target = this, eventTarget = d3.select(d3.event.target), event_ = event.of(target, arguments), g = d3.select(target), resizing = eventTarget.datum(), resizingX = !/^(n|s)$/.test(resizing) && x, resizingY = !/^(e|w)$/.test(resizing) && y, dragging = eventTarget.classed("extent"), dragRestore = d3_event_dragSuppress(target), center, origin = d3.mouse(target), offset; + var w = d3.select(d3_window(target)).on("keydown.brush", keydown).on("keyup.brush", keyup); + if (d3.event.changedTouches) { + w.on("touchmove.brush", brushmove).on("touchend.brush", brushend); + } else { + w.on("mousemove.brush", brushmove).on("mouseup.brush", brushend); + } + g.interrupt().selectAll("*").interrupt(); + if (dragging) { + origin[0] = xExtent[0] - origin[0]; + origin[1] = yExtent[0] - origin[1]; + } else if (resizing) { + var ex = +/w$/.test(resizing), ey = +/^n/.test(resizing); + offset = [ xExtent[1 - ex] - origin[0], yExtent[1 - ey] - origin[1] ]; + origin[0] = xExtent[ex]; + origin[1] = yExtent[ey]; + } else if (d3.event.altKey) center = origin.slice(); + g.style("pointer-events", "none").selectAll(".resize").style("display", null); + d3.select("body").style("cursor", eventTarget.style("cursor")); + event_({ + type: "brushstart" + }); + brushmove(); + function keydown() { + if (d3.event.keyCode == 32) { + if (!dragging) { + center = null; + origin[0] -= xExtent[1]; + origin[1] -= yExtent[1]; + dragging = 2; + } + d3_eventPreventDefault(); + } + } + function keyup() { + if (d3.event.keyCode == 32 && dragging == 2) { + origin[0] += xExtent[1]; + origin[1] += yExtent[1]; + dragging = 0; + d3_eventPreventDefault(); + } + } + function brushmove() { + var point = d3.mouse(target), moved = false; + if (offset) { + point[0] += offset[0]; + point[1] += offset[1]; + } + if (!dragging) { + if (d3.event.altKey) { + if (!center) center = [ (xExtent[0] + xExtent[1]) / 2, (yExtent[0] + yExtent[1]) / 2 ]; + origin[0] = xExtent[+(point[0] < center[0])]; + origin[1] = yExtent[+(point[1] < center[1])]; + } else center = null; + } + if (resizingX && move1(point, x, 0)) { + redrawX(g); + moved = true; + } + if (resizingY && move1(point, y, 1)) { + redrawY(g); + moved = true; + } + if (moved) { + redraw(g); + event_({ + type: "brush", + mode: dragging ? "move" : "resize" + }); + } + } + function move1(point, scale, i) { + var range = d3_scaleRange(scale), r0 = range[0], r1 = range[1], position = origin[i], extent = i ? yExtent : xExtent, size = extent[1] - extent[0], min, max; + if (dragging) { + r0 -= position; + r1 -= size + position; + } + min = (i ? yClamp : xClamp) ? Math.max(r0, Math.min(r1, point[i])) : point[i]; + if (dragging) { + max = (min += position) + size; + } else { + if (center) position = Math.max(r0, Math.min(r1, 2 * center[i] - min)); + if (position < min) { + max = min; + min = position; + } else { + max = position; + } + } + if (extent[0] != min || extent[1] != max) { + if (i) yExtentDomain = null; else xExtentDomain = null; + extent[0] = min; + extent[1] = max; + return true; + } + } + function brushend() { + brushmove(); + g.style("pointer-events", "all").selectAll(".resize").style("display", brush.empty() ? "none" : null); + d3.select("body").style("cursor", null); + w.on("mousemove.brush", null).on("mouseup.brush", null).on("touchmove.brush", null).on("touchend.brush", null).on("keydown.brush", null).on("keyup.brush", null); + dragRestore(); + event_({ + type: "brushend" + }); + } + } + brush.x = function(z) { + if (!arguments.length) return x; + x = z; + resizes = d3_svg_brushResizes[!x << 1 | !y]; + return brush; + }; + brush.y = function(z) { + if (!arguments.length) return y; + y = z; + resizes = d3_svg_brushResizes[!x << 1 | !y]; + return brush; + }; + brush.clamp = function(z) { + if (!arguments.length) return x && y ? [ xClamp, yClamp ] : x ? xClamp : y ? yClamp : null; + if (x && y) xClamp = !!z[0], yClamp = !!z[1]; else if (x) xClamp = !!z; else if (y) yClamp = !!z; + return brush; + }; + brush.extent = function(z) { + var x0, x1, y0, y1, t; + if (!arguments.length) { + if (x) { + if (xExtentDomain) { + x0 = xExtentDomain[0], x1 = xExtentDomain[1]; + } else { + x0 = xExtent[0], x1 = xExtent[1]; + if (x.invert) x0 = x.invert(x0), x1 = x.invert(x1); + if (x1 < x0) t = x0, x0 = x1, x1 = t; + } + } + if (y) { + if (yExtentDomain) { + y0 = yExtentDomain[0], y1 = yExtentDomain[1]; + } else { + y0 = yExtent[0], y1 = yExtent[1]; + if (y.invert) y0 = y.invert(y0), y1 = y.invert(y1); + if (y1 < y0) t = y0, y0 = y1, y1 = t; + } + } + return x && y ? [ [ x0, y0 ], [ x1, y1 ] ] : x ? [ x0, x1 ] : y && [ y0, y1 ]; + } + if (x) { + x0 = z[0], x1 = z[1]; + if (y) x0 = x0[0], x1 = x1[0]; + xExtentDomain = [ x0, x1 ]; + if (x.invert) x0 = x(x0), x1 = x(x1); + if (x1 < x0) t = x0, x0 = x1, x1 = t; + if (x0 != xExtent[0] || x1 != xExtent[1]) xExtent = [ x0, x1 ]; + } + if (y) { + y0 = z[0], y1 = z[1]; + if (x) y0 = y0[1], y1 = y1[1]; + yExtentDomain = [ y0, y1 ]; + if (y.invert) y0 = y(y0), y1 = y(y1); + if (y1 < y0) t = y0, y0 = y1, y1 = t; + if (y0 != yExtent[0] || y1 != yExtent[1]) yExtent = [ y0, y1 ]; + } + return brush; + }; + brush.clear = function() { + if (!brush.empty()) { + xExtent = [ 0, 0 ], yExtent = [ 0, 0 ]; + xExtentDomain = yExtentDomain = null; + } + return brush; + }; + brush.empty = function() { + return !!x && xExtent[0] == xExtent[1] || !!y && yExtent[0] == yExtent[1]; + }; + return d3.rebind(brush, event, "on"); + }; + var d3_svg_brushCursor = { + n: "ns-resize", + e: "ew-resize", + s: "ns-resize", + w: "ew-resize", + nw: "nwse-resize", + ne: "nesw-resize", + se: "nwse-resize", + sw: "nesw-resize" + }; + var d3_svg_brushResizes = [ [ "n", "e", "s", "w", "nw", "ne", "se", "sw" ], [ "e", "w" ], [ "n", "s" ], [] ]; + var d3_time_format = d3_time.format = d3_locale_enUS.timeFormat; + var d3_time_formatUtc = d3_time_format.utc; + var d3_time_formatIso = d3_time_formatUtc("%Y-%m-%dT%H:%M:%S.%LZ"); + d3_time_format.iso = Date.prototype.toISOString && +new Date("2000-01-01T00:00:00.000Z") ? d3_time_formatIsoNative : d3_time_formatIso; + function d3_time_formatIsoNative(date) { + return date.toISOString(); + } + d3_time_formatIsoNative.parse = function(string) { + var date = new Date(string); + return isNaN(date) ? null : date; + }; + d3_time_formatIsoNative.toString = d3_time_formatIso.toString; + d3_time.second = d3_time_interval(function(date) { + return new d3_date(Math.floor(date / 1e3) * 1e3); + }, function(date, offset) { + date.setTime(date.getTime() + Math.floor(offset) * 1e3); + }, function(date) { + return date.getSeconds(); + }); + d3_time.seconds = d3_time.second.range; + d3_time.seconds.utc = d3_time.second.utc.range; + d3_time.minute = d3_time_interval(function(date) { + return new d3_date(Math.floor(date / 6e4) * 6e4); + }, function(date, offset) { + date.setTime(date.getTime() + Math.floor(offset) * 6e4); + }, function(date) { + return date.getMinutes(); + }); + d3_time.minutes = d3_time.minute.range; + d3_time.minutes.utc = d3_time.minute.utc.range; + d3_time.hour = d3_time_interval(function(date) { + var timezone = date.getTimezoneOffset() / 60; + return new d3_date((Math.floor(date / 36e5 - timezone) + timezone) * 36e5); + }, function(date, offset) { + date.setTime(date.getTime() + Math.floor(offset) * 36e5); + }, function(date) { + return date.getHours(); + }); + d3_time.hours = d3_time.hour.range; + d3_time.hours.utc = d3_time.hour.utc.range; + d3_time.month = d3_time_interval(function(date) { + date = d3_time.day(date); + date.setDate(1); + return date; + }, function(date, offset) { + date.setMonth(date.getMonth() + offset); + }, function(date) { + return date.getMonth(); + }); + d3_time.months = d3_time.month.range; + d3_time.months.utc = d3_time.month.utc.range; + function d3_time_scale(linear, methods, format) { + function scale(x) { + return linear(x); + } + scale.invert = function(x) { + return d3_time_scaleDate(linear.invert(x)); + }; + scale.domain = function(x) { + if (!arguments.length) return linear.domain().map(d3_time_scaleDate); + linear.domain(x); + return scale; + }; + function tickMethod(extent, count) { + var span = extent[1] - extent[0], target = span / count, i = d3.bisect(d3_time_scaleSteps, target); + return i == d3_time_scaleSteps.length ? [ methods.year, d3_scale_linearTickRange(extent.map(function(d) { + return d / 31536e6; + }), count)[2] ] : !i ? [ d3_time_scaleMilliseconds, d3_scale_linearTickRange(extent, count)[2] ] : methods[target / d3_time_scaleSteps[i - 1] < d3_time_scaleSteps[i] / target ? i - 1 : i]; + } + scale.nice = function(interval, skip) { + var domain = scale.domain(), extent = d3_scaleExtent(domain), method = interval == null ? tickMethod(extent, 10) : typeof interval === "number" && tickMethod(extent, interval); + if (method) interval = method[0], skip = method[1]; + function skipped(date) { + return !isNaN(date) && !interval.range(date, d3_time_scaleDate(+date + 1), skip).length; + } + return scale.domain(d3_scale_nice(domain, skip > 1 ? { + floor: function(date) { + while (skipped(date = interval.floor(date))) date = d3_time_scaleDate(date - 1); + return date; + }, + ceil: function(date) { + while (skipped(date = interval.ceil(date))) date = d3_time_scaleDate(+date + 1); + return date; + } + } : interval)); + }; + scale.ticks = function(interval, skip) { + var extent = d3_scaleExtent(scale.domain()), method = interval == null ? tickMethod(extent, 10) : typeof interval === "number" ? tickMethod(extent, interval) : !interval.range && [ { + range: interval + }, skip ]; + if (method) interval = method[0], skip = method[1]; + return interval.range(extent[0], d3_time_scaleDate(+extent[1] + 1), skip < 1 ? 1 : skip); + }; + scale.tickFormat = function() { + return format; + }; + scale.copy = function() { + return d3_time_scale(linear.copy(), methods, format); + }; + return d3_scale_linearRebind(scale, linear); + } + function d3_time_scaleDate(t) { + return new Date(t); + } + var d3_time_scaleSteps = [ 1e3, 5e3, 15e3, 3e4, 6e4, 3e5, 9e5, 18e5, 36e5, 108e5, 216e5, 432e5, 864e5, 1728e5, 6048e5, 2592e6, 7776e6, 31536e6 ]; + var d3_time_scaleLocalMethods = [ [ d3_time.second, 1 ], [ d3_time.second, 5 ], [ d3_time.second, 15 ], [ d3_time.second, 30 ], [ d3_time.minute, 1 ], [ d3_time.minute, 5 ], [ d3_time.minute, 15 ], [ d3_time.minute, 30 ], [ d3_time.hour, 1 ], [ d3_time.hour, 3 ], [ d3_time.hour, 6 ], [ d3_time.hour, 12 ], [ d3_time.day, 1 ], [ d3_time.day, 2 ], [ d3_time.week, 1 ], [ d3_time.month, 1 ], [ d3_time.month, 3 ], [ d3_time.year, 1 ] ]; + var d3_time_scaleLocalFormat = d3_time_format.multi([ [ ".%L", function(d) { + return d.getMilliseconds(); + } ], [ ":%S", function(d) { + return d.getSeconds(); + } ], [ "%I:%M", function(d) { + return d.getMinutes(); + } ], [ "%I %p", function(d) { + return d.getHours(); + } ], [ "%a %d", function(d) { + return d.getDay() && d.getDate() != 1; + } ], [ "%b %d", function(d) { + return d.getDate() != 1; + } ], [ "%B", function(d) { + return d.getMonth(); + } ], [ "%Y", d3_true ] ]); + var d3_time_scaleMilliseconds = { + range: function(start, stop, step) { + return d3.range(Math.ceil(start / step) * step, +stop, step).map(d3_time_scaleDate); + }, + floor: d3_identity, + ceil: d3_identity + }; + d3_time_scaleLocalMethods.year = d3_time.year; + d3_time.scale = function() { + return d3_time_scale(d3.scale.linear(), d3_time_scaleLocalMethods, d3_time_scaleLocalFormat); + }; + var d3_time_scaleUtcMethods = d3_time_scaleLocalMethods.map(function(m) { + return [ m[0].utc, m[1] ]; + }); + var d3_time_scaleUtcFormat = d3_time_formatUtc.multi([ [ ".%L", function(d) { + return d.getUTCMilliseconds(); + } ], [ ":%S", function(d) { + return d.getUTCSeconds(); + } ], [ "%I:%M", function(d) { + return d.getUTCMinutes(); + } ], [ "%I %p", function(d) { + return d.getUTCHours(); + } ], [ "%a %d", function(d) { + return d.getUTCDay() && d.getUTCDate() != 1; + } ], [ "%b %d", function(d) { + return d.getUTCDate() != 1; + } ], [ "%B", function(d) { + return d.getUTCMonth(); + } ], [ "%Y", d3_true ] ]); + d3_time_scaleUtcMethods.year = d3_time.year.utc; + d3_time.scale.utc = function() { + return d3_time_scale(d3.scale.linear(), d3_time_scaleUtcMethods, d3_time_scaleUtcFormat); + }; + d3.text = d3_xhrType(function(request) { + return request.responseText; + }); + d3.json = function(url, callback) { + return d3_xhr(url, "application/json", d3_json, callback); + }; + function d3_json(request) { + return JSON.parse(request.responseText); + } + d3.html = function(url, callback) { + return d3_xhr(url, "text/html", d3_html, callback); + }; + function d3_html(request) { + var range = d3_document.createRange(); + range.selectNode(d3_document.body); + return range.createContextualFragment(request.responseText); + } + d3.xml = d3_xhrType(function(request) { + return request.responseXML; + }); + if (typeof define === "function" && define.amd) this.d3 = d3, define(d3); else if (typeof module === "object" && module.exports) module.exports = d3; else this.d3 = d3; +}(); \ No newline at end of file diff --git a/report/js/functions.js b/report/js/functions.js new file mode 100644 index 00000000..74d290e6 --- /dev/null +++ b/report/js/functions.js @@ -0,0 +1,40 @@ +function loadJSON(filename, callback) { + + var xobj = new XMLHttpRequest(); + xobj.overrideMimeType("application/json"); + xobj.open('GET', filename, true); + xobj.onreadystatechange = function () { + if (xobj.readyState == 4 && xobj.status == "200") { + // Required use of an anonymous callback as .open will NOT return a value but simply returns undefined in asynchronous mode + callback(xobj.responseText); + } + }; + xobj.send(null); +} + + +function equalsHeightOf(node1, node2) { + var w1 = node1.style.height; + node2.style.height = w1 + 'px'; +} + +function saveSvgAsImage(svg, name, width, height) { + width = width || 600; + height = height || 600; + var img = new Image(), + serializer = new XMLSerializer(), + svgStr = serializer.serializeToString(svg); + + img.src = 'data:image/svg+xml;base64,' + window.btoa(svgStr); + var canvas = document.createElement("canvas"); + document.body.appendChild(canvas); + canvas.width = width; + canvas.height = height; + img.onload = function () { + canvas.getContext("2d").drawImage(img,0,0, width, height); + canvas.toBlob(function (blob) { + saveAs(blob, name + ".png"); + }); + }; + canvas.parentNode.removeChild(canvas); +} diff --git a/report/js/graph-licenses.js b/report/js/graph-licenses.js new file mode 100644 index 00000000..f9066ca5 --- /dev/null +++ b/report/js/graph-licenses.js @@ -0,0 +1,52 @@ +function chartLicenses(json) { + + var diameter = document.getElementById('svg-licenses').offsetWidth; + + var svg = d3.select('#svg-licenses').append('svg'), + width = 300,//document.getElementById('svg-licenses').offsetWidth, + height = 300,//document.getElementById('svg-licenses').offsetWidth, + radius = Math.min(width, height) / 2; + + //var r = 300; // outer radius + + var color = d3.scale.ordinal() + .range(["#BBDEFB", "#90CAF9", "#64B5F6", "#42A5F5", "#2196F3", "#1E88E5", "#1976D2", "#1565C0", "#0D47A1"]); + + svg + .attr("width", width) + .attr("height", height); + + var group = svg.append("g") + .attr("transform", "translate(" + Math.ceil(width / 2) + ", " + Math.ceil(height / 2) + ")"); // set center of pie + + var arc = d3.svg.arc() + .innerRadius(radius - 10) + .outerRadius(0); + + var pie = d3.layout.pie() + .value(function (d) { + return d.value; + }); + + var arcs = group.selectAll(".arc") + .data(pie(json)) + .enter() + .append("g") + .attr("class", "arc"); + + arcs.append("path") + .attr("d", arc) // here the arc function works on every record d of data + .attr("fill", function (d) { + return color(d.data.value); + }); + + arcs.append("text") + .attr("transform", function (d) { + return "translate(" + arc.centroid(d) + ")"; + }) + .attr("text-anchor", "middle") + .attr('color', '#FFF') + .text(function (d) { + return d.data.name; + }); +} \ No newline at end of file diff --git a/report/js/graph-maintainability.js b/report/js/graph-maintainability.js new file mode 100644 index 00000000..223a9fba --- /dev/null +++ b/report/js/graph-maintainability.js @@ -0,0 +1,124 @@ +function chartMaintainability(withoutComment) { + var chartId = 'svg-maintainability'; + withoutComment = typeof (withoutComment) !== 'undefined' ? withoutComment : false; + var diameter = document.getElementById(chartId).offsetWidth; + + var json = { + name: 'chart', + children: classes + }; + + // if already loaded, removed previous node + var previous = d3.select('#' + chartId).select('svg'); + if (previous) { + previous.remove(); + } + previous = d3.select('#' + chartId).select('button'); + if (previous) { + previous.remove(); + } + + var svg = d3.select('#' + chartId).append('svg') + .attr('width', diameter) + .attr('height', diameter); + + var bubble = d3.layout.pack() + .size([diameter, diameter]) + .padding(3) + .value(function (d) { + return d.ccn; + }); + + var nodes = bubble.nodes(json) + .filter(function (d) { + return !d.children; + }); // filter out the outer bubble* + + var vis = svg.selectAll('circle') + .data(nodes, function (d) { + return d.name; + }); + + vis.enter().append('circle') + .attr('transform', function (d) { + return 'translate(' + d.x + ',' + d.y + ')'; + }) + .attr('r', function (d) { + return d.r; + }) + .style("fill", function (d) { + if (true === withoutComment) { + if (d.mIwoC > 65) { + return '#8BC34A'; + } else if (d.mIwoC > 53) { + return '#FFC107'; + } else { + return '#F44336'; + } + } else { + if (d.mi > 85) { + return '#8BC34A'; + } else if (d.mi > 69) { + return '#FFC107'; + } else { + return '#F44336'; + } + } + }) + .on('mouseover', function (d) { + var text = ''; + if (true === withoutComment) { + text = '' + d.name + '' + + "
Cyclomatic Complexity : " + d.ccn + + "
Maintainability Index (w/o comments): " + d.mIwoC; + } else { + text = '' + d.name + '' + + "
Cyclomatic Complexity : " + d.ccn + + "
Maintainability Index: " + d.mi; + } + d3.select('.tooltip').html(text); + d3.select(".tooltip") + .style("opacity", 1) + .style("z-index", 1); + }) + .on('mousemove', function () { + d3.select(".tooltip") + .style("left", (d3.event.pageX + 5) + "px") + .style("top", (d3.event.pageY + 5) + "px"); + }) + .on('mouseout', function () { + d3.select(".tooltip") + .style("opacity", 0) + .style("z-index", -1); + }); + + d3.select("body") + .append("div") + .attr("class", "tooltip") + .style("opacity", 0); + + // button for saving image + var button = d3.select('#' + chartId).append('button'); + button + .classed('btn-save-image', true) + .text('download') + .on('click', function () { + var svg = d3.select('#' + chartId + ' svg')[0][0]; + var nameImage = (withoutComment) + ? 'PhpMetrics maintainability without comments / complexity' + : 'PhpMetrics maintainability / complexity'; + saveSvgAsImage(svg, nameImage, 1900, 1900); + }); +} + +function toggleChartMaintainability(item) { + if (item.getAttribute('data-current') === 'with-comments') { + item.setAttribute('data-current', 'without-comments'); + item.innerHTML = '(without comments)'; + } else { + item.setAttribute('data-current', 'with-comments'); + item.innerHTML = '(with comments)'; + } + + chartMaintainability(item.getAttribute('data-current') !== 'with-comments') +} diff --git a/report/js/history-1.json b/report/js/history-1.json new file mode 100644 index 00000000..80066541 --- /dev/null +++ b/report/js/history-1.json @@ -0,0 +1,42 @@ +{ + "avg": { + "wmc": 6.35, + "ccn": 4.27, + "bugs": 0.08, + "kanDefect": 0.3, + "relativeSystemComplexity": 112.57, + "relativeDataComplexity": 0.71, + "relativeStructuralComplexity": 111.87, + "volume": 244.85, + "commentWeight": 24.75, + "intelligentContent": 49.79, + "lcom": 2.04, + "instability": 0.77, + "afferentCoupling": 1.23, + "efferentCoupling": 3.32, + "difficulty": 4.76, + "mi": 87.79, + "distance": 0.13, + "incomingCDep": 2.18, + "incomingPDep": 1.22, + "outgoingCDep": 5.58, + "outgoingPDep": 3.62, + "classesPerPackage": 2.86 + }, + "sum": { + "loc": 4961, + "cloc": 789, + "lloc": 4172, + "nbMethods": 438, + "nbClasses": 142, + "nbInterfaces": 1, + "nbPackages": 50, + "violations": { + "total": 26, + "information": 0, + "warning": 18, + "error": 8, + "critical": 0 + } + } +} \ No newline at end of file diff --git a/report/js/latest.json b/report/js/latest.json new file mode 100644 index 00000000..80066541 --- /dev/null +++ b/report/js/latest.json @@ -0,0 +1,42 @@ +{ + "avg": { + "wmc": 6.35, + "ccn": 4.27, + "bugs": 0.08, + "kanDefect": 0.3, + "relativeSystemComplexity": 112.57, + "relativeDataComplexity": 0.71, + "relativeStructuralComplexity": 111.87, + "volume": 244.85, + "commentWeight": 24.75, + "intelligentContent": 49.79, + "lcom": 2.04, + "instability": 0.77, + "afferentCoupling": 1.23, + "efferentCoupling": 3.32, + "difficulty": 4.76, + "mi": 87.79, + "distance": 0.13, + "incomingCDep": 2.18, + "incomingPDep": 1.22, + "outgoingCDep": 5.58, + "outgoingPDep": 3.62, + "classesPerPackage": 2.86 + }, + "sum": { + "loc": 4961, + "cloc": 789, + "lloc": 4172, + "nbMethods": 438, + "nbClasses": 142, + "nbInterfaces": 1, + "nbPackages": 50, + "violations": { + "total": 26, + "information": 0, + "warning": 18, + "error": 8, + "critical": 0 + } + } +} \ No newline at end of file diff --git a/report/js/sort-table.min.js b/report/js/sort-table.min.js new file mode 100644 index 00000000..ab6a8838 --- /dev/null +++ b/report/js/sort-table.min.js @@ -0,0 +1,8 @@ +/* Copyright (c) 2006-2013 Tyler Uebele * Released under the MIT license. * latest at https://github.com/tyleruebele/sort-table * minified by Google Closure Compiler */ +function sortTable(a,b,d){var c;sortTable.sortCol=-1;c=a.className.match(/js-sort-\d+/);null!=c&&(sortTable.sortCol=c[0].replace(/js-sort-/,""),a.className=a.className.replace(RegExp(" ?"+c[0]+"\\b"),""));"undefined"===typeof b&&(b=sortTable.sortCol);"undefined"!==typeof d?sortTable.sortDir=-1==d||"desc"==d?-1:1:(c=a.className.match(/js-sort-(a|de)sc/),sortTable.sortDir=null!=c&&sortTable.sortCol==b?"js-sort-asc"==c[0]?-1:1:1);a.className=a.className.replace(/ ?js-sort-(a|de)sc/g,"");a.className+= +" js-sort-"+b;sortTable.sortCol=b;a.className+=" js-sort-"+(-1==sortTable.sortDir?"desc":"asc");bc?1:-1)}; +sortTable.stripTags=function(a){return a.replace(/<\/?[a-z][a-z0-9]*\b[^>]*>/gi,"")};sortTable.date=function(a){return new Date(sortTable.stripTags(a.innerHTML))};sortTable.number=function(a){return Number(sortTable.stripTags(a.innerHTML).replace(/[^-\d.]/g,""))};sortTable.string=function(a){return sortTable.stripTags(a.innerHTML).toLowerCase()};sortTable.last=function(a){return sortTable.stripTags(a.innerHTML).split(" ").pop().toLowerCase()}; +sortTable.input=function(a){for(var b=0;b + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + +
Please use the --junit option to enable this report
\ No newline at end of file diff --git a/report/loc.html b/report/loc.html new file mode 100644 index 00000000..d5072a95 --- /dev/null +++ b/report/loc.html @@ -0,0 +1,3656 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + + + + +
+
+
+

Percentile distribution of logical lines of code by class

+
+
Percentile
+
+
+
+ +
+
+
+

Explore

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassLLOCCLOCVolumeIntelligent contentComment Weight
App\Auth\CustomerUserProvider + + 93 + + + 16 + + + 797.01 + + + 40.1 + + + 27.97 +
App\Providers\AppServiceProvider + + 40 + + + 13 + + + 104 + + + 60.67 + + + 34.71 +
App\Providers\FortifyServiceProvider + + 38 + + + 16 + + + 116.76 + + + 50.76 + + + 37.34 +
App\Models\OrderLine + + 27 + + + 16 + + + 88.81 + + + 112.18 + + + 40.52 +
App\Models\WebhookSubscription + + 15 + + + 7 + + + 44.97 + + + 81.77 + + + 38.34 +
App\Models\ThemeFile + + 15 + + + 7 + + + 30 + + + 52.5 + + + 38.34 +
App\Models\ProductOption + + 15 + + + 7 + + + 16.25 + + + 26.01 + + + 38.34 +
App\Models\NavigationItem + + 24 + + + 7 + + + 120 + + + 45.22 + + + 33.57 +
App\Models\Refund + + 19 + + + 10 + + + 36 + + + 56 + + + 39.47 +
App\Models\InventoryItem + + 19 + + + 7 + + + 50.72 + + + 29.59 + + + 36 +
App\Models\NavigationMenu + + 10 + + + 4 + + + 19.65 + + + 39.3 + + + 36.83 +
App\Models\App + + 15 + + + 7 + + + 34.87 + + + 61.99 + + + 38.34 +
App\Models\CartLine + + 15 + + + 7 + + + 38.04 + + + 60.86 + + + 38.34 +
App\Models\AppInstallation + + 21 + + + 10 + + + 55.35 + + + 85.16 + + + 38.53 +
App\Models\Cart + + 22 + + + 10 + + + 64.53 + + + 29.78 + + + 38.09 +
App\Models\Discount + + 27 + + + 4 + + + 222.97 + + + 38.36 + + + 26.41 +
App\Models\Product + + 26 + + + 16 + + + 97.67 + + + 136.74 + + + 40.85 +
App\Models\Order + + 50 + + + 19 + + + 322.02 + + + 55.91 + + + 36.32 +
App\Models\Store + + 26 + + + 16 + + + 65.73 + + + 93.9 + + + 40.85 +
App\Models\StoreDomain + + 15 + + + 7 + + + 33 + + + 51.33 + + + 38.34 +
App\Models\Theme + + 18 + + + 10 + + + 39 + + + 54.6 + + + 39.96 +
App\Models\ProductMedia + + 16 + + + 7 + + + 59.21 + + + 101.5 + + + 37.72 +
App\Models\User + + 32 + + + 24 + + + 267.19 + + + 79.52 + + + 42.45 +
App\Models\WebhookDelivery + + 16 + + + 7 + + + 57.36 + + + 90.14 + + + 37.72 +
App\Models\Fulfillment + + 19 + + + 10 + + + 56.47 + + + 72.61 + + + 39.47 +
App\Models\ThemeSettings + + 19 + + + 7 + + + 28.07 + + + 42.11 + + + 36 +
App\Models\Checkout + + 14 + + + 7 + + + 110.36 + + + 141.26 + + + 38.99 +
App\Models\Payment + + 19 + + + 10 + + + 58.81 + + + 84.01 + + + 39.47 +
App\Models\AnalyticsDaily + + 23 + + + 15 + + + 107.31 + + + 99.06 + + + 41.34 +
App\Models\Customer + + 35 + + + 16 + + + 89.62 + + + 109.54 + + + 38.14 +
App\Models\ProductVariant + + 22 + + + 13 + + + 110.36 + + + 153.55 + + + 40.5 +
App\Models\ProductOptionValue + + 11 + + + 4 + + + 13.93 + + + 22.29 + + + 35.87 +
App\Models\TaxSettings + + 19 + + + 7 + + + 55.35 + + + 79.07 + + + 36 +
App\Models\ShippingZone + + 14 + + + 7 + + + 36 + + + 50.4 + + + 38.99 +
App\Models\Collection + + 14 + + + 7 + + + 43.19 + + + 70.67 + + + 38.99 +
App\Models\Page + + 10 + + + 4 + + + 30 + + + 46.67 + + + 36.83 +
App\Models\ShippingRate + + 14 + + + 7 + + + 44.38 + + + 59.17 + + + 38.99 +
App\Models\StoreSettings + + 18 + + + 7 + + + 23.26 + + + 33.24 + + + 36.55 +
App\Models\CustomerAddress + + 15 + + + 7 + + + 33 + + + 51.33 + + + 38.34 +
App\Models\StoreUser + + 20 + + + 3 + + + 25.85 + + + 12.92 + + + 26.54 +
App\Models\FulfillmentLine + + 15 + + + 7 + + + 16.25 + + + 26.01 + + + 38.34 +
App\Models\AnalyticsEvent + + 12 + + + 4 + + + 44.97 + + + 74.95 + + + 34.97 +
App\Models\Scopes\StoreScope + + 12 + + + 1 + + + 41.21 + + + 11.45 + + + 20.83 +
App\Models\Organization + + 10 + + + 4 + + + 8 + + + 16 + + + 36.83 +
App\Models\Concerns\BelongsToStore + + 18 + + + 4 + + + 44.38 + + + 8.88 + + + 30.68 +
App\Exceptions\InsufficientInventoryException + + 4 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Exceptions\FulfillmentGuardException + + 4 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Exceptions\InvalidDiscountException + + 32 + + + 0 + + + 106.27 + + + 83.9 + + + 0 +
App\Exceptions\PaymentFailedException + + 4 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Policies\StorePolicy + + 18 + + + 0 + + + 56.15 + + + 6.02 + + + 0 +
App\Policies\Concerns\ChecksStoreRole + + 17 + + + 1 + + + 76.11 + + + 13.05 + + + 17.85 +
App\Livewire\Settings\TwoFactor + + 84 + + + 32 + + + 470.65 + + + 67.98 + + + 36.34 +
App\Livewire\Settings\DeleteUserForm + + 12 + + + 3 + + + 18.58 + + + 23.22 + + + 31.94 +
App\Livewire\Settings\TwoFactor\RecoveryCodes + + 26 + + + 10 + + + 60.23 + + + 11.58 + + + 36.44 +
App\Livewire\Settings\Password + + 20 + + + 3 + + + 86.37 + + + 28.79 + + + 26.54 +
App\Livewire\Settings\Profile + + 41 + + + 11 + + + 148.49 + + + 21.38 + + + 32.69 +
App\Livewire\Settings\Appearance + + 4 + + + 1 + + + 0 + + + 0 + + + 31.94 +
App\Livewire\Storefront\Products\Show + + 52 + + + 1 + + + 416.15 + + + 38.59 + + + 10.56 +
App\Livewire\Storefront\Home + + 31 + + + 7 + + + 95.18 + + + 33.99 + + + 30.85 +
App\Livewire\Storefront\Checkout\Show + + 121 + + + 14 + + + 1932.88 + + + 105.05 + + + 23.92 +
App\Livewire\Storefront\Checkout\Confirmation + + 15 + + + 1 + + + 46.51 + + + 19.73 + + + 18.88 +
App\Livewire\Storefront\Search\Index + + 20 + + + 2 + + + 78.87 + + + 26.29 + + + 22.51 +
App\Livewire\Storefront\CartDrawer + + 27 + + + 2 + + + 132.83 + + + 20.44 + + + 19.79 +
App\Livewire\Storefront\Cart\Show + + 51 + + + 1 + + + 284.98 + + + 27.72 + + + 10.66 +
App\Livewire\Storefront\Account\Dashboard + + 15 + + + 2 + + + 56.47 + + + 32.27 + + + 25.34 +
App\Livewire\Storefront\Account\Auth\Login + + 32 + + + 1 + + + 169.92 + + + 29.74 + + + 13.32 +
App\Livewire\Storefront\Account\Auth\Register + + 30 + + + 2 + + + 197.65 + + + 57.85 + + + 18.88 +
App\Livewire\Storefront\Account\Addresses\Index + + 47 + + + 5 + + + 518.06 + + + 120.13 + + + 23.11 +
App\Livewire\Storefront\Account\Orders\Index + + 15 + + + 2 + + + 49.83 + + + 33.22 + + + 25.34 +
App\Livewire\Storefront\Account\Orders\Show + + 16 + + + 2 + + + 70.31 + + + 31.25 + + + 24.69 +
App\Livewire\Storefront\Collections\Index + + 14 + + + 1 + + + 34.87 + + + 27.12 + + + 19.47 +
App\Livewire\Storefront\Collections\Show + + 30 + + + 2 + + + 177.2 + + + 42.96 + + + 18.88 +
App\Livewire\Storefront\Pages\Show + + 16 + + + 1 + + + 50.72 + + + 27.31 + + + 18.35 +
App\Livewire\Storefront\Concerns\EnsuresStore + + 13 + + + 2 + + + 33.69 + + + 7.22 + + + 26.8 +
App\Livewire\Admin\Customers\Index + + 20 + + + 2 + + + 152.93 + + + 39.55 + + + 22.51 +
App\Livewire\Admin\Customers\Show + + 15 + + + 1 + + + 148.68 + + + 38.45 + + + 18.88 +
App\Livewire\Admin\Settings\Taxes + + 29 + + + 6 + + + 293.25 + + + 101.82 + + + 29.92 +
App\Livewire\Admin\Settings\Index + + 27 + + + 7 + + + 200.67 + + + 105.62 + + + 32.32 +
App\Livewire\Admin\Settings\Shipping + + 61 + + + 7 + + + 562.32 + + + 129.27 + + + 23.84 +
App\Livewire\Admin\Dashboard + + 30 + + + 9 + + + 240.37 + + + 73.96 + + + 33.87 +
App\Livewire\Admin\Products\Index + + 38 + + + 4 + + + 242.03 + + + 26.89 + + + 23 +
App\Livewire\Admin\Products\Form + + 62 + + + 12 + + + 1005.38 + + + 84.49 + + + 29.21 +
App\Livewire\Admin\Auth\Login + + 37 + + + 3 + + + 294.41 + + + 52.34 + + + 20.58 +
App\Livewire\Admin\Navigation\Index + + 71 + + + 7 + + + 860.77 + + + 89.25 + + + 22.38 +
App\Livewire\Admin\Discounts\Index + + 21 + + + 3 + + + 96.79 + + + 25.25 + + + 26.04 +
App\Livewire\Admin\Discounts\Form + + 56 + + + 11 + + + 729.11 + + + 71.41 + + + 29.36 +
App\Livewire\Admin\Orders\Index + + 35 + + + 5 + + + 272.32 + + + 44.51 + + + 26.04 +
App\Livewire\Admin\Orders\Show + + 99 + + + 2 + + + 846.19 + + + 60.9 + + + 10.81 +
App\Livewire\Admin\Collections\Index + + 16 + + + 2 + + + 97.67 + + + 26.86 + + + 24.69 +
App\Livewire\Admin\Collections\Form + + 62 + + + 8 + + + 972.06 + + + 65.16 + + + 25.01 +
App\Livewire\Admin\Pages\Index + + 22 + + + 2 + + + 135.93 + + + 39.21 + + + 21.62 +
App\Livewire\Admin\Pages\Form + + 45 + + + 7 + + + 641.02 + + + 52.05 + + + 26.91 +
App\Livewire\Admin\Apps\Index + + 29 + + + 4 + + + 325.53 + + + 55.17 + + + 25.68 +
App\Livewire\Admin\Themes\Index + + 35 + + + 3 + + + 279.68 + + + 41.68 + + + 21.08 +
App\Livewire\Admin\Analytics\Index + + 19 + + + 2 + + + 301.19 + + + 55.61 + + + 23 +
App\Livewire\Admin\Developers\Index + + 44 + + + 8 + + + 400.08 + + + 197.06 + + + 28.55 +
App\Livewire\Actions\Logout + + 11 + + + 3 + + + 4.75 + + + 9.51 + + + 32.86 +
App\Support\HandleGenerator + + 26 + + + 0 + + + 248.8 + + + 28.43 + + + 0 +
App\Support\CartSession + + 34 + + + 0 + + + 181.32 + + + 11.96 + + + 0 +
App\Http\Middleware\ResolveStore + + 52 + + + 0 + + + 390.14 + + + 63.02 + + + 0 +
App\Http\Controllers\Controller + + 4 + + + 1 + + + 0 + + + 0 + + + 31.94 +
App\Actions\Fortify\ResetUserPassword + + 10 + + + 5 + + + 18 + + + 16 + + + 38.99 +
App\Actions\Fortify\CreateNewUser + + 10 + + + 5 + + + 38.77 + + + 27.7 + + + 38.99 +
App\Jobs\ExpireAbandonedCheckouts + + 11 + + + 0 + + + 20.9 + + + 23.22 + + + 0 +
App\Jobs\CleanupAbandonedCarts + + 9 + + + 0 + + + 10 + + + 16 + + + 0 +
App\Jobs\AggregateAnalytics + + 27 + + + 0 + + + 532.19 + + + 114.04 + + + 0 +
App\Jobs\CancelUnpaidBankTransferOrders + + 11 + + + 0 + + + 33 + + + 48 + + + 0 +
App\Jobs\ProcessMediaUpload + + 14 + + + 0 + + + 23.26 + + + 29.08 + + + 0 +
App\Jobs\DeliverWebhook + + 44 + + + 7 + + + 618.62 + + + 54.3 + + + 27.15 +
App\Events\OrderRefunded + + 8 + + + 0 + + + 2 + + + 4 + + + 0 +
App\Events\OrderCancelled + + 8 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Events\OrderCreated + + 8 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Events\OrderPaid + + 8 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Events\FulfillmentDelivered + + 8 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Events\OrderFulfilled + + 8 + + + 0 + + + 0 + + + 0 + + + 0 +
App\Observers\ProductObserver + + 19 + + + 0 + + + 15.85 + + + 9.51 + + + 0 +
App\Listeners\DispatchOrderWebhooks + + 23 + + + 0 + + + 93.21 + + + 86.04 + + + 0 +
App\Services\WebhookService + + 19 + + + 3 + + + 130.8 + + + 43.6 + + + 27.07 +
App\Services\OrderService + + 79 + + + 2 + + + 1476.23 + + + 103.18 + + + 12.05 +
App\Services\Payments\MockPaymentProvider + + 35 + + + 6 + + + 335.2 + + + 76.18 + + + 27.93 +
App\Services\CheckoutService + + 160 + + + 10 + + + 1835.38 + + + 120.13 + + + 18.35 +
App\Services\FulfillmentService + + 48 + + + 8 + + + 609.51 + + + 48.56 + + + 27.63 +
App\Services\TaxCalculator + + 30 + + + 4 + + + 431.81 + + + 30.3 + + + 25.34 +
App\Services\ThemeSettingsService + + 23 + + + 9 + + + 153.73 + + + 37.84 + + + 36.61 +
App\Services\InventoryService + + 45 + + + 0 + + + 312 + + + 7.96 + + + 0 +
App\Services\NavigationService + + 17 + + + 5 + + + 99.91 + + + 95.92 + + + 33.66 +
App\Services\RefundService + + 39 + + + 1 + + + 479.27 + + + 32.84 + + + 12.13 +
App\Services\ProductService + + 119 + + + 10 + + + 1796.53 + + + 106.94 + + + 20.9 +
App\Services\ShippingCalculator + + 74 + + + 12 + + + 1220.05 + + + 53.25 + + + 27.35 +
App\Services\AnalyticsService + + 12 + + + 6 + + + 124 + + + 62 + + + 38.99 +
App\Services\CartService + + 130 + + + 0 + + + 1620.1 + + + 43.89 + + + 0 +
App\Services\PricingEngine + + 59 + + + 0 + + + 1072.41 + + + 64.84 + + + 0 +
App\Services\DiscountService + + 77 + + + 3 + + + 889.73 + + + 27.28 + + + 14.78 +
App\Services\SearchService + + 52 + + + 7 + + + 763.37 + + + 61.07 + + + 25.43 +
App\Services\VariantMatrixService + + 63 + + + 7 + + + 644.82 + + + 64.08 + + + 23.53 +
App\Concerns\ProfileValidationRules + + 16 + + + 15 + + + 63.4 + + + 27.74 + + + 44.04 +
App\Concerns\PasswordValidationRules + + 12 + + + 10 + + + 18.58 + + + 24.77 + + + 43.23 +
App\ValueObjects\PaymentResult + + 19 + + + 0 + + + 42 + + + 31.5 + + + 0 +
App\ValueObjects\DiscountResult + + 7 + + + 3 + + + 4.75 + + + 9.51 + + + 37.52 +
App\ValueObjects\PricingResult + + 11 + + + 15 + + + 102.8 + + + 102.8 + + + 46.17 +
App\ValueObjects\TaxLine + + 11 + + + 3 + + + 23.22 + + + 20.64 + + + 32.86 +
App\ValueObjects\RefundResult + + 11 + + + 0 + + + 19.65 + + + 19.65 + + + 0 +
+
+
+
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/report/oop.html b/report/oop.html new file mode 100644 index 00000000..c7327a83 --- /dev/null +++ b/report/oop.html @@ -0,0 +1,4125 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + + + +
+
+
+
classes
+
+ 142 (100 %) +
+
+
+
+
+
interfaces
+
1 (1 %) +
+
+
+
+
+
average LCOM
+
2.04
+
+
+
+
+
logical lines of code by class
+
29
+
+
+
+
+
logical lines of code by method
+
10
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassLCOMVolumeClass cycl.Max method cycl.BugsDifficulty
App\Auth\CustomerUserProvider + + 3 + + + 797.01 + + + 20 + + + 8 + + + 0.27 + + + 19.88 +
App\Providers\AppServiceProvider + + 2 + + + 104 + + + 2 + + + 2 + + + 0.03 + + + 1.71 +
App\Providers\FortifyServiceProvider + + 2 + + + 116.76 + + + 1 + + + 1 + + + 0.04 + + + 2.3 +
App\Models\OrderLine + + 3 + + + 88.81 + + + 1 + + + 1 + + + 0.03 + + + 0.79 +
App\Models\WebhookSubscription + + 2 + + + 44.97 + + + 1 + + + 1 + + + 0.01 + + + 0.55 +
App\Models\ThemeFile + + 2 + + + 30 + + + 1 + + + 1 + + + 0.01 + + + 0.57 +
App\Models\ProductOption + + 2 + + + 16.25 + + + 1 + + + 1 + + + 0.01 + + + 0.63 +
App\Models\NavigationItem + + 3 + + + 120 + + + 2 + + + 2 + + + 0.04 + + + 2.65 +
App\Models\Refund + + 2 + + + 36 + + + 1 + + + 1 + + + 0.01 + + + 0.64 +
App\Models\InventoryItem + + 3 + + + 50.72 + + + 1 + + + 1 + + + 0.02 + + + 1.71 +
App\Models\NavigationMenu + + 1 + + + 19.65 + + + 1 + + + 1 + + + 0.01 + + + 0.5 +
App\Models\App + + 2 + + + 34.87 + + + 1 + + + 1 + + + 0.01 + + + 0.56 +
App\Models\CartLine + + 1 + + + 38.04 + + + 1 + + + 1 + + + 0.01 + + + 0.63 +
App\Models\AppInstallation + + 3 + + + 55.35 + + + 1 + + + 1 + + + 0.02 + + + 0.65 +
App\Models\Cart + + 3 + + + 64.53 + + + 1 + + + 1 + + + 0.02 + + + 2.17 +
App\Models\Discount + + 2 + + + 222.97 + + + 8 + + + 8 + + + 0.07 + + + 5.81 +
App\Models\Product + + 3 + + + 97.67 + + + 1 + + + 1 + + + 0.03 + + + 0.71 +
App\Models\Order + + 3 + + + 322.02 + + + 5 + + + 5 + + + 0.11 + + + 5.76 +
App\Models\Store + + 5 + + + 65.73 + + + 1 + + + 1 + + + 0.02 + + + 0.7 +
App\Models\StoreDomain + + 2 + + + 33 + + + 1 + + + 1 + + + 0.01 + + + 0.64 +
App\Models\Theme + + 3 + + + 39 + + + 1 + + + 1 + + + 0.01 + + + 0.71 +
App\Models\ProductMedia + + 2 + + + 59.21 + + + 1 + + + 1 + + + 0.02 + + + 0.58 +
App\Models\User + + 3 + + + 267.19 + + + 4 + + + 4 + + + 0.09 + + + 3.36 +
App\Models\WebhookDelivery + + 2 + + + 57.36 + + + 1 + + + 1 + + + 0.02 + + + 0.64 +
App\Models\Fulfillment + + 3 + + + 56.47 + + + 1 + + + 1 + + + 0.02 + + + 0.78 +
App\Models\ThemeSettings + + 2 + + + 28.07 + + + 1 + + + 1 + + + 0.01 + + + 0.67 +
App\Models\Checkout + + 2 + + + 110.36 + + + 1 + + + 1 + + + 0.04 + + + 0.78 +
App\Models\Payment + + 3 + + + 58.81 + + + 1 + + + 1 + + + 0.02 + + + 0.7 +
App\Models\AnalyticsDaily + + 2 + + + 107.31 + + + 1 + + + 1 + + + 0.04 + + + 1.08 +
App\Models\Customer + + 4 + + + 89.62 + + + 1 + + + 1 + + + 0.03 + + + 0.82 +
App\Models\ProductVariant + + 4 + + + 110.36 + + + 1 + + + 1 + + + 0.04 + + + 0.72 +
App\Models\ProductOptionValue + + 1 + + + 13.93 + + + 1 + + + 1 + + + 0 + + + 0.63 +
App\Models\TaxSettings + + 2 + + + 55.35 + + + 1 + + + 1 + + + 0.02 + + + 0.7 +
App\Models\ShippingZone + + 2 + + + 36 + + + 1 + + + 1 + + + 0.01 + + + 0.71 +
App\Models\Collection + + 2 + + + 43.19 + + + 1 + + + 1 + + + 0.01 + + + 0.61 +
App\Models\Page + + 1 + + + 30 + + + 1 + + + 1 + + + 0.01 + + + 0.64 +
App\Models\ShippingRate + + 2 + + + 44.38 + + + 1 + + + 1 + + + 0.01 + + + 0.75 +
App\Models\StoreSettings + + 2 + + + 23.26 + + + 1 + + + 1 + + + 0.01 + + + 0.7 +
App\Models\CustomerAddress + + 2 + + + 33 + + + 1 + + + 1 + + + 0.01 + + + 0.64 +
App\Models\StoreUser + + 2 + + + 25.85 + + + 2 + + + 2 + + + 0.01 + + + 2 +
App\Models\FulfillmentLine + + 1 + + + 16.25 + + + 1 + + + 1 + + + 0.01 + + + 0.63 +
App\Models\AnalyticsEvent + + 1 + + + 44.97 + + + 1 + + + 1 + + + 0.01 + + + 0.6 +
App\Models\Scopes\StoreScope + + 1 + + + 41.21 + + + 2 + + + 2 + + + 0.01 + + + 3.6 +
App\Models\Organization + + 1 + + + 8 + + + 1 + + + 1 + + + 0 + + + 0.5 +
App\Models\Concerns\BelongsToStore + + 2 + + + 44.38 + + + 3 + + + 3 + + + 0.01 + + + 5 +
App\Exceptions\InsufficientInventoryException + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 +
App\Exceptions\FulfillmentGuardException + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 +
App\Exceptions\InvalidDiscountException + + 7 + + + 106.27 + + + 2 + + + 2 + + + 0.04 + + + 1.27 +
App\Exceptions\PaymentFailedException + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 +
App\Policies\StorePolicy + + 3 + + + 56.15 + + + 1 + + + 1 + + + 0.02 + + + 9.33 +
App\Policies\Concerns\ChecksStoreRole + + 1 + + + 76.11 + + + 3 + + + 2 + + + 0.03 + + + 5.83 +
App\Livewire\Settings\TwoFactor + + 1 + + + 470.65 + + + 9 + + + 3 + + + 0.16 + + + 6.92 +
App\Livewire\Settings\DeleteUserForm + + 1 + + + 18.58 + + + 1 + + + 1 + + + 0.01 + + + 0 +
App\Livewire\Settings\TwoFactor\RecoveryCodes + + 1 + + + 60.23 + + + 4 + + + 4 + + + 0.02 + + + 5.2 +
App\Livewire\Settings\Password + + 1 + + + 86.37 + + + 2 + + + 2 + + + 0.03 + + + 3 +
App\Livewire\Settings\Profile + + 5 + + + 148.49 + + + 6 + + + 3 + + + 0.05 + + + 6.94 +
App\Livewire\Settings\Appearance + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 +
App\Livewire\Storefront\Products\Show + + 1 + + + 416.15 + + + 5 + + + 3 + + + 0.14 + + + 10.78 +
App\Livewire\Storefront\Home + + 2 + + + 95.18 + + + 5 + + + 3 + + + 0.03 + + + 2.8 +
App\Livewire\Storefront\Checkout\Show + + 1 + + + 1932.88 + + + 25 + + + 9 + + + 0.64 + + + 18.4 +
App\Livewire\Storefront\Checkout\Confirmation + + 1 + + + 46.51 + + + 1 + + + 1 + + + 0.02 + + + 2.36 +
App\Livewire\Storefront\Search\Index + + 2 + + + 78.87 + + + 2 + + + 2 + + + 0.03 + + + 3 +
App\Livewire\Storefront\CartDrawer + + 2 + + + 132.83 + + + 3 + + + 3 + + + 0.04 + + + 6.5 +
App\Livewire\Storefront\Cart\Show + + 2 + + + 284.98 + + + 6 + + + 3 + + + 0.09 + + + 10.28 +
App\Livewire\Storefront\Account\Dashboard + + 2 + + + 56.47 + + + 1 + + + 1 + + + 0.02 + + + 1.75 +
App\Livewire\Storefront\Account\Auth\Login + + 2 + + + 169.92 + + + 4 + + + 3 + + + 0.06 + + + 5.71 +
App\Livewire\Storefront\Account\Auth\Register + + 2 + + + 197.65 + + + 2 + + + 2 + + + 0.07 + + + 3.42 +
App\Livewire\Storefront\Account\Addresses\Index + + 5 + + + 518.06 + + + 2 + + + 2 + + + 0.17 + + + 4.31 +
App\Livewire\Storefront\Account\Orders\Index + + 2 + + + 49.83 + + + 1 + + + 1 + + + 0.02 + + + 1.5 +
App\Livewire\Storefront\Account\Orders\Show + + 1 + + + 70.31 + + + 1 + + + 1 + + + 0.02 + + + 2.25 +
App\Livewire\Storefront\Collections\Index + + 2 + + + 34.87 + + + 1 + + + 1 + + + 0.01 + + + 1.29 +
App\Livewire\Storefront\Collections\Show + + 2 + + + 177.2 + + + 3 + + + 3 + + + 0.06 + + + 4.13 +
App\Livewire\Storefront\Pages\Show + + 1 + + + 50.72 + + + 1 + + + 1 + + + 0.02 + + + 1.86 +
App\Livewire\Storefront\Concerns\EnsuresStore + + 1 + + + 33.69 + + + 3 + + + 3 + + + 0.01 + + + 4.67 +
App\Livewire\Admin\Customers\Index + + 2 + + + 152.93 + + + 1 + + + 1 + + + 0.05 + + + 3.87 +
App\Livewire\Admin\Customers\Show + + 1 + + + 148.68 + + + 2 + + + 2 + + + 0.05 + + + 3.87 +
App\Livewire\Admin\Settings\Taxes + + 2 + + + 293.25 + + + 5 + + + 5 + + + 0.1 + + + 2.88 +
App\Livewire\Admin\Settings\Index + + 2 + + + 200.67 + + + 1 + + + 1 + + + 0.07 + + + 1.9 +
App\Livewire\Admin\Settings\Shipping + + 4 + + + 562.32 + + + 2 + + + 2 + + + 0.19 + + + 4.35 +
App\Livewire\Admin\Dashboard + + 3 + + + 240.37 + + + 2 + + + 2 + + + 0.08 + + + 3.25 +
App\Livewire\Admin\Products\Index + + 2 + + + 242.03 + + + 3 + + + 2 + + + 0.08 + + + 9 +
App\Livewire\Admin\Products\Form + + 2 + + + 1005.38 + + + 20 + + + 11 + + + 0.34 + + + 11.9 +
App\Livewire\Admin\Auth\Login + + 2 + + + 294.41 + + + 4 + + + 4 + + + 0.1 + + + 5.63 +
App\Livewire\Admin\Navigation\Index + + 5 + + + 860.77 + + + 7 + + + 6 + + + 0.29 + + + 9.64 +
App\Livewire\Admin\Discounts\Index + + 2 + + + 96.79 + + + 1 + + + 1 + + + 0.03 + + + 3.83 +
App\Livewire\Admin\Discounts\Form + + 2 + + + 729.11 + + + 12 + + + 8 + + + 0.24 + + + 10.21 +
App\Livewire\Admin\Orders\Index + + 2 + + + 272.32 + + + 1 + + + 1 + + + 0.09 + + + 6.12 +
App\Livewire\Admin\Orders\Show + + 2 + + + 846.19 + + + 13 + + + 6 + + + 0.28 + + + 13.89 +
App\Livewire\Admin\Collections\Index + + 2 + + + 97.67 + + + 1 + + + 1 + + + 0.03 + + + 3.64 +
App\Livewire\Admin\Collections\Form + + 1 + + + 972.06 + + + 14 + + + 8 + + + 0.32 + + + 14.92 +
App\Livewire\Admin\Pages\Index + + 3 + + + 135.93 + + + 1 + + + 1 + + + 0.05 + + + 3.47 +
App\Livewire\Admin\Pages\Form + + 2 + + + 641.02 + + + 13 + + + 10 + + + 0.21 + + + 12.31 +
App\Livewire\Admin\Apps\Index + + 3 + + + 325.53 + + + 1 + + + 1 + + + 0.11 + + + 5.9 +
App\Livewire\Admin\Themes\Index + + 4 + + + 279.68 + + + 2 + + + 2 + + + 0.09 + + + 6.71 +
App\Livewire\Admin\Analytics\Index + + 1 + + + 301.19 + + + 3 + + + 3 + + + 0.1 + + + 5.42 +
App\Livewire\Admin\Developers\Index + + 4 + + + 400.08 + + + 1 + + + 1 + + + 0.13 + + + 2.03 +
App\Livewire\Actions\Logout + + 1 + + + 4.75 + + + 1 + + + 1 + + + 0 + + + 0.5 +
App\Support\HandleGenerator + + 2 + + + 248.8 + + + 4 + + + 3 + + + 0.08 + + + 8.75 +
App\Support\CartSession + + 3 + + + 181.32 + + + 7 + + + 4 + + + 0.06 + + + 15.17 +
App\Http\Middleware\ResolveStore + + 1 + + + 390.14 + + + 8 + + + 5 + + + 0.13 + + + 6.19 +
App\Http\Controllers\Controller + + 0 + + + 0 + + + 1 + + + 0 + + + 0 + + + 0 +
App\Actions\Fortify\ResetUserPassword + + 1 + + + 18 + + + 1 + + + 1 + + + 0.01 + + + 0 +
App\Actions\Fortify\CreateNewUser + + 1 + + + 38.77 + + + 1 + + + 1 + + + 0.01 + + + 1.4 +
App\Jobs\ExpireAbandonedCheckouts + + 1 + + + 20.9 + + + 1 + + + 1 + + + 0.01 + + + 0 +
App\Jobs\CleanupAbandonedCarts + + 1 + + + 10 + + + 1 + + + 1 + + + 0 + + + 0 +
App\Jobs\AggregateAnalytics + + 2 + + + 532.19 + + + 3 + + + 3 + + + 0.18 + + + 4.67 +
App\Jobs\CancelUnpaidBankTransferOrders + + 1 + + + 33 + + + 1 + + + 1 + + + 0.01 + + + 0 +
App\Jobs\ProcessMediaUpload + + 2 + + + 23.26 + + + 1 + + + 1 + + + 0.01 + + + 0.8 +
App\Jobs\DeliverWebhook + + 2 + + + 618.62 + + + 8 + + + 6 + + + 0.21 + + + 11.39 +
App\Events\OrderRefunded + + 1 + + + 2 + + + 1 + + + 1 + + + 0 + + + 0 +
App\Events\OrderCancelled + + 1 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 +
App\Events\OrderCreated + + 1 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 +
App\Events\OrderPaid + + 1 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 +
App\Events\FulfillmentDelivered + + 1 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 +
App\Events\OrderFulfilled + + 1 + + + 0 + + + 1 + + + 1 + + + 0 + + + 0 +
App\Observers\ProductObserver + + 2 + + + 15.85 + + + 1 + + + 1 + + + 0.01 + + + 0 +
App\Listeners\DispatchOrderWebhooks + + 2 + + + 93.21 + + + 1 + + + 1 + + + 0.03 + + + 0 +
App\Services\WebhookService + + 2 + + + 130.8 + + + 2 + + + 2 + + + 0.04 + + + 3 +
App\Services\OrderService + + 3 + + + 1476.23 + + + 25 + + + 12 + + + 0.49 + + + 14.31 +
App\Services\Payments\MockPaymentProvider + + 2 + + + 335.2 + + + 5 + + + 4 + + + 0.11 + + + 4.4 +
App\Services\CheckoutService + + 3 + + + 1835.38 + + + 30 + + + 8 + + + 0.61 + + + 15.28 +
App\Services\FulfillmentService + + 3 + + + 609.51 + + + 14 + + + 7 + + + 0.2 + + + 12.55 +
App\Services\TaxCalculator + + 2 + + + 431.81 + + + 10 + + + 6 + + + 0.14 + + + 14.25 +
App\Services\ThemeSettingsService + + 2 + + + 153.73 + + + 3 + + + 3 + + + 0.05 + + + 4.06 +
App\Services\InventoryService + + 5 + + + 312 + + + 4 + + + 4 + + + 0.1 + + + 39.21 +
App\Services\NavigationService + + 2 + + + 99.91 + + + 1 + + + 1 + + + 0.03 + + + 1.04 +
App\Services\RefundService + + 2 + + + 479.27 + + + 10 + + + 10 + + + 0.16 + + + 14.6 +
App\Services\ProductService + + 4 + + + 1796.53 + + + 33 + + + 16 + + + 0.6 + + + 16.8 +
App\Services\ShippingCalculator + + 2 + + + 1220.05 + + + 36 + + + 12 + + + 0.41 + + + 22.91 +
App\Services\AnalyticsService + + 2 + + + 124 + + + 2 + + + 2 + + + 0.04 + + + 2 +
App\Services\CartService + + 3 + + + 1620.1 + + + 20 + + + 10 + + + 0.54 + + + 36.91 +
App\Services\PricingEngine + + 2 + + + 1072.41 + + + 16 + + + 16 + + + 0.36 + + + 16.54 +
App\Services\DiscountService + + 3 + + + 889.73 + + + 21 + + + 15 + + + 0.3 + + + 32.62 +
App\Services\SearchService + + 2 + + + 763.37 + + + 15 + + + 7 + + + 0.25 + + + 12.5 +
App\Services\VariantMatrixService + + 1 + + + 644.82 + + + 13 + + + 10 + + + 0.21 + + + 10.06 +
App\Concerns\ProfileValidationRules + + 1 + + + 63.4 + + + 2 + + + 2 + + + 0.02 + + + 2.29 +
App\Concerns\PasswordValidationRules + + 2 + + + 18.58 + + + 1 + + + 1 + + + 0.01 + + + 0.75 +
App\ValueObjects\PaymentResult + + 2 + + + 42 + + + 1 + + + 1 + + + 0.01 + + + 1.33 +
App\ValueObjects\DiscountResult + + 1 + + + 4.75 + + + 1 + + + 1 + + + 0 + + + 0 +
App\ValueObjects\PricingResult + + 2 + + + 102.8 + + + 1 + + + 1 + + + 0.03 + + + 1 +
App\ValueObjects\TaxLine + + 2 + + + 23.22 + + + 1 + + + 1 + + + 0.01 + + + 1.13 +
App\ValueObjects\RefundResult + + 2 + + + 19.65 + + + 1 + + + 1 + + + 0.01 + + + 1 +
+
+
+
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + diff --git a/report/package_relations.html b/report/package_relations.html new file mode 100644 index 00000000..5f983ac9 --- /dev/null +++ b/report/package_relations.html @@ -0,0 +1,1016 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + + + +
+
+
+

Package relations

+
+
+
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/report/packages.html b/report/packages.html new file mode 100644 index 00000000..a3d4299c --- /dev/null +++ b/report/packages.html @@ -0,0 +1,1301 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + +
+
+
+

Packages

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameClassesAbstractionInstabilityDistanceOutgoing class dep.Outgoing package dep.Incoming class dep.Incoming package dep.
App\Auth100.8750.1257511
App\Contracts110.6250.6255332
App\Providers201012700
App\Models4000.120.881067336
App\Models\Scopes100.750.253111
App\Models\Concerns11113300
App\Exceptions400.20.81141
App\Policies10102100
App\Policies\Concerns11112200
App\Livewire\Settings50109500
App\Livewire\Settings\TwoFactor10102200
App\Livewire\Storefront\Products10104400
App\Livewire\Storefront20107500
App\Livewire\Storefront\Checkout20106500
App\Livewire\Storefront\Search10103300
App\Livewire\Storefront\Cart10103300
App\Livewire\Storefront\Account10104400
App\Livewire\Storefront\Account\Auth20106500
App\Livewire\Storefront\Account\Addresses10104400
App\Livewire\Storefront\Account\Orders20104400
App\Livewire\Storefront\Collections20103300
App\Livewire\Storefront\Pages10103300
App\Livewire\Storefront\Concerns11111100
App\Livewire\Admin\Customers20103300
App\Livewire\Admin\Settings30105300
App\Livewire\Admin10105500
App\Livewire\Admin\Products20105500
App\Livewire\Admin\Auth10106400
App\Livewire\Admin\Navigation10105400
App\Livewire\Admin\Discounts20103300
App\Livewire\Admin\Orders20106400
App\Livewire\Admin\Collections20105400
App\Livewire\Admin\Pages20104400
App\Livewire\Admin\Apps10104300
App\Livewire\Admin\Themes10104400
App\Livewire\Admin\Analytics10103300
App\Livewire\Admin\Developers10105500
App\Livewire\Actions100.6670.3332111
App\Support200.4170.5835377
App\Http\Middleware10109600
App\Http\Controllers110000
App\Actions\Fortify20104300
App\Jobs600.9470.05318711
App\Events600.3750.6253152
App\Observers10102200
App\Listeners10105300
App\Services1700.8690.131531386
App\Services\Payments10107500
App\Concerns21112200
App\ValueObjects500.2860.7142153
+
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + diff --git a/report/panel.html b/report/panel.html new file mode 100644 index 00000000..f97f7380 --- /dev/null +++ b/report/panel.html @@ -0,0 +1,243 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + +
+
+
+
4961
+
lines of code
+
+
+
+
+
+ 142 (100 %) + +
+
classes
+
+
+
+
+
1 (1 %) + +
+
interfaces
+
+
+ + +
+ +
+ +
+
+
3
+
methods by class
+
+
+
+
+
29
+
logical lines of code by class
+
+
+ + +
+
+
10
+
logical lines of code by method
+
+
+ +
+
+
2.04
+
average LCOM
+
+
+
+ +
+
+
+
Top 10 ClassRank
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassClassRank
App\Models\Order 81.02 + 44.71 + 0.1
App\Models\Store 97.12 + 56.27 + 0.06
App\Models\Cart 96 + 57.91 + 0.04
App\Models\Product 95.92 + 55.07 + 0.04
App\Models\WebhookSubscription 100.98 + 62.64 + 0.02
App\Models\User 92.09 + 49.64 + 0.02
App\Models\Fulfillment 99.17 + 59.7 + 0.02
App\Models\Checkout 99.55 + 60.56 + 0.02
App\Support\CartSession 49.84 + 49.84 + 0.02
App\Models\NavigationItem 88.64 + 55.06 + 0.01
+ +
+ +
+
+
+
+ 4.27
+
Average cyclomatic complexity by class
+
+
+
+
+
+

Maintainability / complexity

+
+
+
+
+
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + + diff --git a/report/relations.html b/report/relations.html new file mode 100644 index 00000000..17f23899 --- /dev/null +++ b/report/relations.html @@ -0,0 +1,1882 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + + + +
+
+
+

Object relations

+
+
+
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/report/violations.html b/report/violations.html new file mode 100644 index 00000000..a77a293b --- /dev/null +++ b/report/violations.html @@ -0,0 +1,999 @@ + + + + + PhpMetrics report + + + + + + + + + + +
+ +
+ + + + +
+
+
+ Created at 2026-04-13 05:14:05 , with PHPMetrics v2.9.1 (Jean-François Lépine). +
+ + + +
+
+
+
Violations
+
26
+
+
+
+
+
Information
+
0
+
+
+
+
+
Warnings
+
18
+
+
+
+
+
Errors
+
8
+
+
+
+
+
Criticals
+
0
+
+
+
+ +
+
+
+

Class Violations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassViolations
+ + App\Livewire\Storefront\Checkout\Show + +
+
+
+ Probably bugged warning + +
+
This component contains in theory 0.64 bugs.
+
+ * Calculation is based on number of operators, operands, cyclomatic complexity
+ * See more details at https://en.wikipedia.org/wiki/Halstead_complexity_measures
+ * testsuites has dependency to this class.
+
+ Maybe you should check your unit tests for this class.
+ +
+
+
+ Probably bugged +
+ + App\Livewire\Admin\Products\Form + +
+
+
+ Too complex method code error + +
+
This class looks really complex.
+
+ * Algorithms are complex (Max cyclomatic complexity of class methods is 11)
+
+ Maybe you should delegate some code to other objects or split complex method.
+ +
+
+
+ Too complex method code +
+ + App\Livewire\Admin\Navigation\Index + +
+
+
+ Blob / God object error + +
+
A blob object (or "god class") does not follow the Single responsibility principle.
+
+ * object has lot of public methods (8, excluding getters and setters)
+ * object has a high Lack of cohesion of methods (LCOM=5)
+ * object knows everything (and use lot of external classes)
+
+ Maybe you should reducing the number of methods splitting this object in many sub objects.
+ +
+
+
+ Blob / God object +
+ + App\Services\OrderService + +
+
+
+ Too complex method code error + +
+
This class looks really complex.
+
+ * Algorithms are complex (Max cyclomatic complexity of class methods is 12)
+
+ Maybe you should delegate some code to other objects or split complex method.
+ +
+
+
+ Probably bugged warning + +
+
This component contains in theory 0.49 bugs.
+
+ * Calculation is based on number of operators, operands, cyclomatic complexity
+ * See more details at https://en.wikipedia.org/wiki/Halstead_complexity_measures
+ * testsuites has dependency to this class.
+
+ Maybe you should check your unit tests for this class.
+ +
+
+
+ Too complex method code + Probably bugged +
+ + App\Services\CheckoutService + +
+
+
+ Blob / God object error + +
+
A blob object (or "god class") does not follow the Single responsibility principle.
+
+ * object has lot of public methods (9, excluding getters and setters)
+ * object has a high Lack of cohesion of methods (LCOM=3)
+ * object knows everything (and use lot of external classes)
+
+ Maybe you should reducing the number of methods splitting this object in many sub objects.
+ +
+
+
+ Probably bugged warning + +
+
This component contains in theory 0.61 bugs.
+
+ * Calculation is based on number of operators, operands, cyclomatic complexity
+ * See more details at https://en.wikipedia.org/wiki/Halstead_complexity_measures
+ * testsuites has dependency to this class.
+
+ Maybe you should check your unit tests for this class.
+ +
+
+
+ Blob / God object + Probably bugged +
+ + App\Services\ProductService + +
+
+
+ Too complex method code error + +
+
This class looks really complex.
+
+ * Algorithms are complex (Max cyclomatic complexity of class methods is 16)
+
+ Maybe you should delegate some code to other objects or split complex method.
+ +
+
+
+ Probably bugged warning + +
+
This component contains in theory 0.6 bugs.
+
+ * Calculation is based on number of operators, operands, cyclomatic complexity
+ * See more details at https://en.wikipedia.org/wiki/Halstead_complexity_measures
+ * testsuites has dependency to this class.
+
+ Maybe you should check your unit tests for this class.
+ +
+
+
+ Too complex method code + Probably bugged +
+ + App\Services\ShippingCalculator + +
+
+
+ Too complex method code error + +
+
This class looks really complex.
+
+ * Algorithms are complex (Max cyclomatic complexity of class methods is 12)
+
+ Maybe you should delegate some code to other objects or split complex method.
+ +
+
+
+ Probably bugged warning + +
+
This component contains in theory 0.41 bugs.
+
+ * Calculation is based on number of operators, operands, cyclomatic complexity
+ * See more details at https://en.wikipedia.org/wiki/Halstead_complexity_measures
+ * testsuites has dependency to this class.
+
+ Maybe you should check your unit tests for this class.
+ +
+
+
+ Too complex method code + Probably bugged +
+ + App\Services\CartService + +
+
+
+ Probably bugged warning + +
+
This component contains in theory 0.54 bugs.
+
+ * Calculation is based on number of operators, operands, cyclomatic complexity
+ * See more details at https://en.wikipedia.org/wiki/Halstead_complexity_measures
+ * testsuites has dependency to this class.
+
+ Maybe you should check your unit tests for this class.
+ +
+
+
+ Probably bugged +
+ + App\Services\PricingEngine + +
+
+
+ Too complex method code error + +
+
This class looks really complex.
+
+ * Algorithms are complex (Max cyclomatic complexity of class methods is 16)
+
+ Maybe you should delegate some code to other objects or split complex method.
+ +
+
+
+ Probably bugged warning + +
+
This component contains in theory 0.36 bugs.
+
+ * Calculation is based on number of operators, operands, cyclomatic complexity
+ * See more details at https://en.wikipedia.org/wiki/Halstead_complexity_measures
+ * testsuites has dependency to this class.
+
+ Maybe you should check your unit tests for this class.
+ +
+
+
+ Too complex method code + Probably bugged +
+ + App\Services\DiscountService + +
+
+
+ Too complex method code error + +
+
This class looks really complex.
+
+ * Algorithms are complex (Max cyclomatic complexity of class methods is 15)
+
+ Maybe you should delegate some code to other objects or split complex method.
+ +
+
+
+ Too complex method code +
+
+
+
+
+
+
+

Package Violations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PackageViolations
+ + App\Contracts + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Models + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Models\Concerns + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Exceptions + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Policies\Concerns + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Livewire\Storefront\Concerns + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Support + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Events + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\Services + +
+
+
+ Stable Dependencies Principle warning + +
+
Packages should depend in the direction of stability.
+
+ This package is more stable (0.869) than 1 package(s) that it depends on.
+ The packages that are more stable are
+
+ * App\Jobs (0.947)
+ +
+
+
+ Stable Dependencies Principle +
+ + App\Concerns + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+ + App\ValueObjects + +
+
+
+ Stable Abstractions Principle warning + +
+
Packages should be either abstract and stable or concrete and instable.
+
+ This package is instable and abstract.
+ +
+
+
+ Stable Abstractions Principle +
+
+
+
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + diff --git a/review-01-storefront-home.png b/review-01-storefront-home.png new file mode 100644 index 0000000000000000000000000000000000000000..56f40ab45531edd7bf05de49e0c7477a55314943 GIT binary patch literal 58142 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 literal 0 HcmV?d00001 diff --git a/review-05-admin-order-detail.png b/review-05-admin-order-detail.png new file mode 100644 index 0000000000000000000000000000000000000000..a06bda0f9170b1fe277dd79628066b41c3ae9fbf GIT binary patch literal 69981 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 literal 0 HcmV?d00001 diff --git a/review-06-admin-shipping.png b/review-06-admin-shipping.png new file mode 100644 index 0000000000000000000000000000000000000000..c6f508722159b59853cdd7602e579095bea85e06 GIT binary patch literal 50137 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 T&2?jo=n+}A&NjStKF59oV~~qh literal 0 HcmV?d00001 diff --git a/review-07-order-confirmation.png b/review-07-order-confirmation.png new file mode 100644 index 0000000000000000000000000000000000000000..d0aab3f2e6b9659d62aabf3e383254f7e3a1c173 GIT binary patch literal 50872 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

ID_UHPi^GLovo!a%Y2 zFBK~xCp&ub%^iO=M6sx*DoyR)x34yc zQdYV3Isa|Xh4H5mN!_NoBm_`+hsktSnOk7D_O@q=+|7U+4Wu%OX6j8@LFm^1P}7~r z2A`z%mPGOIyCP9|qCHbfLRn7yE+QOwqIa^U{+#5~h1AN_n*Re?#x4E_ZT`Q9F|Rks zMsobC$wWkMe=5*w+5asAQH5K7^zMIu9Xhj{>pwp`b@lMB|0{p=!)6B&kpw4t5uqp2 zGu}G}D&oz2@6X@A=%~cnAO-=k`;Ae&coTn1l0Q0bfOCgqb9mLw@4*8t{El+0Be5zF zneX;yH=cEX*LXHHKGOZw1+>`A(*gK=<&!Lg&$;^Vb11G6V=0oTgr@K@Pdj`?R|6=p zxg-P$Yjv9Y1ozE=t5lqvd9QyQQz2XoB{??JoiFJsDKci4ErUI&#Q*(1^S>)SuI_T7 zhC`d=-{+V8`+SC!2cJZO&-f9(_XL611XSp~J=Sjj=s4rQpFP#Yu_oL>V5#fT|NdFI zgsXfSH}A|v_3xAF{&S@_%(?#Eh0I771Af62bm8-A8!qY&v;Th%1kpQjW1asz;@|rT zS48-8=nUcG@aOm8|No=^O)GJk;jK~4Log>@#S{R7H^!fIO6-PFPvxYWVg>5~;3SQK zl11%??i>#}DKNV}S8ZGQcWJsOx$!M9V6s%Gho`4`bG-M$cq>&>+?_jjR_Dk1lGQWS zfVJ=M0egA(j|vi`sl2_tb2MX$9u@^UN(WJ}pcGr3=@oRI3{&CvR3o>V=`B1DbhJZ{9H)TZ&$fEA z>)*Z%@FDEE;t%JTvD5x(0heE0F!@a4`K`V_OD#jiw&KqAno*U0N-xP9xz&}STAR}SY@{~Scd5sGX&l(; zDeer&`!TJs%S2nsSaU)sy6M)&DxdB4)@H)Oj!Nt9Tv{=o3K5-jr9_aS(vDK636;=O zBGawi2A(q`p<-l(h1e-GgVlp1>nkf88}rReuh1&D!RWAk2o$7Cdta_jCm5!HRYyAh z?SUf(%^lzjw=VC@(RBr8;>gd|D|KjlCA~B*Mdz)S ze2sLuvnxrrz#?&xD>+3b+`)%B*RVR*v@t=OR%bEju%ep{!&?G8L-BFAwFeDN)#~dMD)wc z`y=S}hf=<;n}0fm7S!p73{>|$2_ovR2g6?5TA!ObE!MH*I9iu7Qg+Gx?>EL60b35; zqHK{e(|RgX8i2rR`;mwB6~Jk&?=$Ill{0MAYO?WWC?$Tt8E;4@b9KHP!=W#A?RSM!UTRCRGTzX8F`QPP zh4cUkiCSh-`lm3pJOl4fL6jUb%#T`9i(H>9`1q||caB`vzw*%dAWybtPWBmQ<}bvL zMK1w%DKi*Fj-T%`uQ|0j8fi%Jr=&UkSW{E$LPk6!2RtmryxZ^Y>@een5I{_y^V*V8 z*m?350YuGF^&jL7=4Lh5~w)-nhZ4A7Z7Os$P|LI(VKqSQ5vDjU!Yaf0@ zC?(cqShb=XQWXUOfCMyAI6jCb4}DoY5_Tbej%N#9iK&C)T1U1>cC7L8?Dtvct~^8Av zn^oX^kYLiWoDMyLmSvvcO{Ed^idYP#c`C2^6b%iH##wUryd6YtEQCth+6Ai9aNL2G zsTxw8(B`Z|-1VuhoH(A~&GlvQvYyp%ocIsw%5+DDYT{MD=?~z`r~u3z>5@8yR;CU# zl1z0@dTTSR-!BH`h;17NWBRLJLZoI!Y>!$+pGFBKL{e55s zO+`AIH)Gvd{GsFP6af!tL9@=Wkx6`9-H!+qoa22$9TcH->LX{bk(B?Q{a&u~fSzQ^ zeSM)_v9n~f#Ed~Qh@$DBGfJ&+WCI7DE}hQ`fuqG8RE!f!Ii!_tsiq~{U*?>(E}mDw z5;pOo@9oCM256Cq-!L}5YZWmI4*psRtFdZJ#3gr!a<9f{zFqssNDBP|sCRh4U{>{1_n;9udU_k7AvG}g8pc>e-A_hCl)q3pnl`V4Rpwvli5zk zR=W*-QS8}cwF^E1{PiR!?&WIoTb%6VS#}yv?-O)udy`dNy^X?HAVGRjEW6HOA>v0{ z%B^!)gQD=Z?eBLb`<;Vg={jbJEC~n(-H?h8fzG@@r}h31VUed~YYa^YR*R zhnCqYR^}|lX-AAKF>b08b(g!^mMaC;pyuU%yux`!w!&dFXkOp4&XwZ4c5@$ej(pSl zBDX)rm1QWW58JOaRJhOoYKpTCMVru}TS04?8u0)Z_tuUbIVP#R<|5%02)6D}MRiRu z_3BZZpqaknDbK{z0{dyj5~J>JGqndBK_FUFLXBBkZYA8+3~0&G6&Mq8Nn@HlCF0QmJ^Ir%Gu~^D zjtRZUgPgW#7|Z$N8#YZnc?ow0P(*L?uPO#wec?eS^&A~O8xA@;I-X2s8Fwk{*BRoV zBUBuE7ViCydd)cgTM!#jaRmf}J;$o9J!6oz>dLkZEi*Juw)jj&_M?B~W^0%(A8 z99!PuJliX{!N{7@TVkKAe?~b;g4i2%>n6V>6ufemnM}1$#5Is=a9+$G;-F^c{Pd!H zbV8ZN#I^0!Pu}e7y!Yn#h=}5)K+Xb+oQ_RAyQ3A-WY}nwZ~y0qq^BEd-uvzP&As%d z^w8{zq?`+E{ZaukwBO#eXSvmt9W`%p@roQ2aSFL$t9P^c~UJhZB;8|D)7iTIg8M;h9Ae9?L`)gSWDi}zZ3k{TWp(9 zZzIvvmya{_>-3;|B5I72Pr;MKLqCBqZ-!3VMDtlkEb2kwWrmEotu3E00EeL``8;~3 zPGP2O98_Xq!#ytOdD_c8G z%66dQ_+$l)hPVqx_e6wleS2-U)@t_@=%{@rc-TSU@w^50vD}k45VXrL06Qj%>ge{| zotLCZJQY-Jfg6_( zu)Fk}{Ih%lq%7ES2hm}9L9v^?+NgVRQZqhAj{qKRpp4Z+BrU$JzLM0!(7{ywVDV*? zQh7&b)G9$zT;`jatqW)4Shhwts`WU6(pR{PdOn-(xj68vEy|<|-@3bWQ)rI8PxRY^ z^1VeiF~^q{7dsCw+f}DETZ=%nKWY_llvUjaqV>$WYk|>>(k&nmG#{=S;2Zb(Mu8c| ztuYVQ3l2WdmL02snR=fqm0o(ooL%V5a>&`{d*t}onE4@Vo`;=ZorXX7eLtwrKccp8 zs@ftKO;I@VQY)b{_}fboN;L7}v)%Rf{G!>~P8B&LpIsqoqCIZGF0kz{A#Zx_olx+H zX%RR?RCyDo%eUe&W(AceHy=VywGopS#IABFu4;1C6dZIOr3bxO`VDCdnDn-L*StNc*0^`W!2Az2?`q?L4i>y&jY6_2JkFE%VS$_LgzB zmii_ET?@N@{mJgTwbA99qqTc=eO-6EPy@XG6oE6m+vagXJ-ffJoPkrC_Sr?|*4LM= zPpP6{0>z)dw@5f#dhce-)!eW2yfw|)?(5E;zwe1naC13m(jo8xkD;NVr&evT3uo!6 z`L|v9*bp#4fmX9e-4wdYoTnCCw6U z{!P2x-Cd>mr^cwK=sTvY^#=I()L&gEU$%Px{ynkujBMX{05#F!LV`v5Tdc^Y@0XXk z5^#g)+IX_-fO~D}*mvxcJpJ-6lIj4}S(8QsI1q0 z7dti0)MAvcG!P6D8bM=%wF_Q{9v$uVf>eDx+l$FC=mpe|T`f;~+$7bZLFIZ%(}K-kU>T!mEuWNPv-d}#dG3hFn3fH3Z%nXZG> zSnRJDdy(8KG_7mfTd8={i)Kt%V|q#dA`@Bj5ACk`cYOdqeg5d zY8)(_GBgco@@5cTZYLqSTyO=Y{N1*lXk8;*jQQ4=iT71L(JzfZU!t8)D3ExbNOF?D zVfM46SySxsr&}_lqdS-QICW~Ox3`3|>y19HzW)a5ctX^x#Pfdj=2Fc3(W)8kSWlhr z#kPas9eQSJL z0Ga1R3Ci~xjhF*Ah3hOeE}x}IWcoyq&*urmD>l0`wO+1P{uMglZ#Zdy1|<1xM9 zsduVQ^1rlzxk~dx+kiERzi!v92yNuS*M#gO!9vi?HbSx6durJLHP&GQ%mLjX;9$D+}Jk#5`Y8(Mu zJL-1e_Vc7H6xP0ExQEkSIWdkwufAl1gByLnJG#NrNes8GpYd)P2rt09@0wx!oX82H zJLVE}|85)xjTe*xvz1?sF`%VefXahzBcyRp27$&xK@BQy7X$`Cwo{0`*eXg-x_^I* za;1j&@kZY!U7&9;&liQ8Sk{@p0GOryI!DBOHu9?0WLQEWEGlJboUgpOss8(oMQaj_ z%l=Bw?%ctAbH(-Cqhmww92Q2=*>Z~qeT1RAz@&&pYo1SbjBm})9?Nf!*#7xkQSN$c zv8`dbp7K3eu`6h;YC2qS4|y_qGwp^yAPUfB_6ME9BUh)eu5@d>HF?I!M)4Exk_9S6 zXUM&H^q*PC2vDtt#nv}ioaP3+AuvJY=TBzS%w<=4`A&GQLN!B$QIZKHs=vy+Twf4w zV_h|U|I)VJp7u8;Ql4t|U+?WX2=JAm_zONmIIoD?@5^74H&3&gubs0B?%9zfDjTFz zV7w-ixrNSIz42FeeRYuddo1;CqRU~I{~f26r*;wS+S4(iPOMeM_w}#r>*O;2_**ND zQmCo>%^$$DhV+of>x)y=llp?QPRHhac1WgE2Rac*)>4Y+@@%84U7mKf7FYXXp7QG( zS4K~Rsu#%?34colRaCoRly;&c;Z(mvMyU2?mQkaUZu%q z`3SuD#)nhta$<-*c6@ zB*(e6$O*n3zU<;)OsZC$yKs6Vq>kR<=tW=8YK=yE$)InpvN?A9ov*-KWYvEAY7g%H zLyj~K*C9W4Ft0BOV!r9B?XRShRo9xDyS&L2deDJZ4#TZL~(xtWN7lsP)*wcXvY?%oMc~HX?($T)~sR7Yb)Dp^)GfH>9&mw ziZ-a_pzpT#l=L_s>qRz$3O6mvwS|d!z$Ah3;?*NPoHqw~!{bYfdLLj^m{co`Ue!p*P1+4XILnNJnT z{t}mEL~F}dbPQ()Tnrm@#34P8Hw_$G#~++X&|m2q&Keb`fnK$bcjU=bR?XZBU`c}X z;E@~eA(EgEb1^RAh^e2w;G@LD!?=r0kcT)=J^lp^*7hFQdLaA1PW>GH^3(+6h-0&f zxzz&NE8s$$r@ng#%4t3d#2)ckI@^@1h+1@Y;LEiYIndjQKXtsFuX)8@yDKKF_1mF6 zhl0DzA&=sJj2FUdeo5OvOxxOA$ClND88kot2$fEhU?0U( z5sbgXv?O$*mpbm826_bI`O`3SzEv7HdB&et(7Lx^0_C2dVy8)(SE7aKLIGbwI_tVo z{6SmWxhn5%|qPS0^VaV&VT`i^5wRPm1^BQ>#Bpq%SpnG&Es{64@OC%&D5CM2Zrt!sN0sq6hDd9Un@M+-U5F{hQ zpGyR7o$%-X+>bu?Ve9RLW^RY<&XXrk0-7Blfk3I%Jw-u7!KF9E*d!~`^? z-%mlTN4t3l5h+?BrwcU@F*M(rJmh=XnXZ{PfQls-81e^CCs_yxVgk7#vJ*4cpfn+9 z)q=;m5c1ig4!8@4%&JgS(WALzdZ_qw$bopW83b>Ps@5(%Nx;6g7{Gt;`|y zn5^!4q@mB7OV@1)o4clY3ugX27!BJyhXeqG3a|s+=C1(7guI?43vRc=f3WgW>|-jb zQKdDr3B&g_{)fVkaOf0_e|dVg>u8&%VuHZfkYZv3!F>l>TXtQ+21k#`TB@-f!!mqU zy>+w?NH#oy0JDw}#IH+2J47v7UM5Kfvz~o%(f$X%KjHM1owVHgl^&fQf1HLQgj++gvL8P*+G+>z5a=y9qNJCOgH92L7(OJAR^VG;<6Zu2j|KAGz% z8UQrQ=mp+?K2gtAtn}+}D8veby$@ zxM(N_(|f>VfU$L-gU)2?O`x;bw7k4qe{g%Ico4aYXfa=(di$6*kmR%>Gw_<@JZ5rF z>HTC!A>U+7p2=zzRJryQU;1+xsFH_GwNToWZ=UA7VoyLjd{h&R`mNo zO122%P6ZUu#R*clHGh*N)SoYuEwr}zD+M#_mlaSYYQD-V8ZC*HHx%){QD9Mc!@xmn z?v~cz#hh274|GH}BU@Z0wmHCgM>lqgZ=UvB+u>qJNK@lhl~`WOczJnE&xsNd4G#q! zBf9M7bU5j(`q&ZL!bv6hsDOvg<4I>lXeH1n-@xY+`qI&Q1KFXlzfBJ8` z?Z@LoGW))osrkPbI>B3cU9ZYZn`iRLSGl$~Q9q7RT9v>B7kY7M%5M+?yVOL)Nc^6- z+-|FJCHXU95A_vd*u<3+yOh$Fv(&SeaJ*)F@@pMF3e>#{iy@)8s5F(l%T1EVESV*@ z%>_hD7ww}_IVt1@D-)^{3I4Drg=F~FIl(kIU%*Kr*=TIdUfIV z$7xDLGS42S<rsK{s2qu!n*qrDqSgr-okGOdx7*1jY&4pSPT zHb-rV6Z_(h*+TbjYM!!KMGfsr)J&hsINRq&<`Q`C!1?bE3pPegK__*%7|*eyYNky3xXq&bjy zbv|{xv(z;&xj9k$0XK^`%fc^Ku{QG>hus-#bBWYh?=(9X=uZ3HcMxB1OH>-{v8%F| zNi7KMc+1}k!M|W4jP+rmeD(#=cOLcq-BOE-bl0|%uYSKsMD!pC5c%*qT9ff7>rHXO z9HTtdulAM$9hfl{|87p5z>DkadU1IG593v5@86iHxSx5w~C9 z5k{-lB)`#sV+*OwPCTP1RlME0rLWC9YE=?wDyjBxX_QPqPQ7r|_K9e*kWZIGp@&X- zmlfLUx$o_4ZEu#D61-FZ!b$ztqB>k0Z!2Xb);QQ~CPaMs3Dy3PysZAG0@-GTuJ5;k=fs;}BVjL1EGZBR64xHVa7@qCxqj^!J&bgcjp zS|Jjje_kZ!Ug&y}L42`*anV_$X$6PT>MV1G>f}wPJBd~K!(Jl`J7;{!JS>B5W!s^L zbunGYUIf<VeG0~tfQCw;4>`~8em(rQRnwH)u!9JYfW#|~t zg8BzocD~+z=1!^f`~J<26j|!!I-!}o8veNPqO*bUKBN0~^M37*zmX>m@)2h&@!;7@ zXfN0NdmxJv3b}Kx4^qKRoT@z!>iM`Iu6JGF{OAxnUtC`^_lQ5+YPgxQYi~F4i`(+k z>TkD#$x;j7%13BN6n$9RCZIaYSANVZD|vCiHy=FqDAu5*JtOlDQ5+mQuOF{*_8b*4qxAQ zG4_A1d@|2hHjV6bgUYh+zG(`kwg@&~3G_5=AGt4Lb!aNADx}Matfxx$hgj%6;4+LT z-HJQryZqfX{0=@HA+trtx%Q6hKVO?SpK@fo8!|;f8RhtEZE9@Hb)t>YQLor@`8MA( zSa6VmzU17i0eOJQbMto4o3zRO@|J${s1=dSS#8=*jy+yF=IQ5O9BC`CVv(6WU7v27@ z10G;QRp}jcaynEZo$gShR)K|by?tk(M$M{Ae1Mqq3RpQxL#J{<>8?>ofHJcg`!7 zH{S0Yy*QlU+|4xxn&v!|vx9blpn>g5b$z4+|JDK=mKqU#&*j`EwW*7BG&55HQ-$3I zs)1xNg$t0blL@|g zh14)kLj&8Oof_wrgG1=Vs_Mo4;=_77YUNI9Z*x&hvFj9ksSK%VUAtism>SG%T3)w~ zF}$3)U;_tWr}I*OQ?|vR;VO|OMSGkXHms*=5>OZ%gdf9E-O%eUsXpz;7Ubt@Tg5J~lVP6a>L*GrqC3CG@5q=JImdQY`})Q$KE~sN|0m=$ zet2^ACj?SVgeVx*A`$9jm*n_KsN3e8Wdyb(gbwlAtB_q(V~q3%jN1dP9fj}gjZLUN znTdppzGVQ}Pj;zdawx9FOcU?4hM#K4`72`n#BD6jHJd3>J50h00Y4#ycI9G&fcp8E z@WpXwfE4+CZ-OYDZdeLcx7wIaXo7GVH^seGOUNBSt*crIqRL`6k2O~mZ4 z9giT>bG|jP!E8-?YD&HQ62EL|4UYQvbxjuy#5sI@w+T`lz<TzoS73)6Cg3#7MP*Cctt89c0OiFX{H^^c18yLvHy7)~UJ;cMAEk_~z zS#Cz6h-cB#`b^bicdH|r>bOr2${fr#Pj9t?a0)Mr^3vuQc5UuY`=DEPy(yzV-=2T# zVvG zy(Lm|FaXe}T&*Rt0J>`eXe*QsYoQko=fCFyS{(7dvCu5F8d+7brW8fQ$WG{yha{wf z{!&!(dW$rUs(DNm?%|EB`oPi05PRtL`r6O>$UOKoP!=o)DoU%UgKWdCwt!H*)0eNEs4`do!h76 z#3DVEUCfgb+oT;vHD>#yvR*k%nc|rGmt`tC&B^U%5ol$4pQ4@93H1Fv!SPjb^G|M$ zQOsY-9EB6$y60%`i&;)7>)acRKd6OR>|5UU_qh)p-tuS24nG9ey_mG6s>L?{inP}? zsno8=A~j~%JoH9vpTPFM4{J)6_8{E4)jpN?9-&sSx+p(N+OZt}Onj5VuV(b50K$hb za(q^X{XOo>#ZFc{Yica>eR5UbNh`t1*49>ag}zD#v#I~&Q3)%T!}ApOd+7x+c4AT+ zf~TjjLlqJZTHCmne3zrqJ}%6wJl$1%ZU@nw>y5RwQc|sn23>d4Il}KR(uZ>zZV4Jp zz^Kh10g3+i=g@El+!i7?$v;0WA<^20+fvw}qY>tfnqxxmFE%uod^%4Qup1_#`56a{ zrk+^gK(zdlVY7gcyFnBg(WQe;Kco))Uq5g0zj+Si-#7;eND?wy$KL=n;7Pv?{ABj|!ZW@J%=l0dNvQr?&&xjv zk$Kq3FhR4gKcz=O0cR{jAg&H^2l%fEpwVBvBd`r_Lz;w(*rh6nNFz!Kr+(Q@_XkH9 zE1y6CC{t`Z*veqD;~-HI%fG>h(?{G_ej7n+*xcBFodWPOi(nmwh|8p(pBru_xK_xJ zGD$!C=)sD=BZH-YG&VcYyMxyb&u1co{~H#N_|>b~X`Nt~u(>Nr@~H@wejTyjoW-3h z>+54*pQj5MXFiPg-3STcY4By^yh zQjTMaksU=8zf(9D(RO4e zZLkn|;J-oDhICqiPcUm9Lr06g`8OEs-?9W<5)EN_X^HR!u+47^x|}}AYw?mc4N-b= zPYW4GwHYwjCbqnn66e{dCceE%Hhzg9nnBdlI;_|_+}p-D;bQavt9&%lC3dS!=;d|Z ztQy%RD0`gM8*TA7yClL@UtLd>lX(;Dq8%=RooE;iMIfWm*s5yvR}AFb&iY#&>8+@) zPF9T$u_h2cV(uNQyxEx5xbnkAd&lV?ZdPqs*Q+lJ)&3Y8OB;sFJt3vfxF%wNY5upz zHgD=EcY(1I6w!KnT6t&|TlrN@kH@KdQCFE15#YIV{WP+VK}W*wdrd!q`$0(SjJKXO z`tTt2o1!&tUL;%oAnWmDNi9?F>@*JP0UxVQ>6$Xp%v~aveK=e} zi_fUCgh^`vS69Qw;GdcqDw0fDZK?9*h(ch+taI+&zfZZ#Qc)!^^q0!o2zNIO9knZC zt8HnDdDG%qnMd_myWP3tHmwG$HUa+mkZd&cJ8MQ2*Qt8-l|J_;u4w%O^(YCkeKZK$ zoU^--xiaH^lsm8S-HVPgr>lc=ZORq<9r#K`a%)ojT*|5+8 zJ;$#Nn3h~9HmLMSjTX6aZeo=A+8dgly7IZIskYS0(=+9hc}cMk@x}(;%nBBGB|3eW zk6|WibAX875Yc*VerburuxbM%qsyoR)>bgt&l9=z>^C+Lgn`u!C4b?p5UK@gbkx;N zh2}V60R#YuP1SOrVV_dEwpbpHd(ay71Tmyu2fZR>a_+##LI|o92e9t8UT^orQ=IBf z#t7uO9E=vQt+MK5RTVY* zuzM!*h0*43>nm$U{K+VeZ5#nhBn8EZ-$r4&@QwE7b7fIXp542^HULVBr~*cD#u@QW z1lcre!Um@1M1}^~5D|n{99icNw;)0EhI2p19hvSm{!B9ecd5jFq>Loy)SrH)o>7kJ zlyAF&YzZ?Sa*n~5#vGfdn{Pk6a*e+vdTj$ z@4e7$f_b@643QFD(1}Bn%7lZ2i*iA@xIPhuN9gX$afytNFe1e;E`}m8(~~d7q)srR zKs5(v&o(OQB!>M&ZPA!Cn(a`h-8A#>Os$_pION)xI8qkL8H8zlf+-uzHvZs|hJiK= zd`%}-GoWyn*GW&taf(u-U9@>`IULP-?Q^H|?Xz=n5v^qt5-noe8zzFYVf#1Sk;pGlZES$&dM|pUSAeLz3-*%rj{iEE;@7m}EjzJkb(Z5Fp@y7Tmrk-lYb! zU+I;n*OyYD_(WBCViTo_#3D~D^;$lzru|Xxu8!a6l+z(TaY&$uGfhTt=x4@N5^^?r z9wve=e2bW(DKzU4Wiva+(*H)7%_;t8)y}m(#5c&vde30W;<-Z8o_+hQ({j;jAslsy zT7d%>-tLWTH_(HF2Bq|sS!GO?p5N#4OqT0gR<+T3eZd24^VBkOy(_}Dv((l^}z{S*wvqBD7M)jZKczUhZ4!A zsUS=z6dra~ifpDmy^1Px=-Tkp&49bF$2S7Iu9S;Ej8*20x2TMA5wl%pH-FzE1*-Q*2i;4vKqlAQnt~4Zwi0y$% zQfaTwbXgs=(AF7;PEZb@<+v|mQkUrWTXI5iZO^JEKG)A=+8nR%8!mj28WD{Y1g#*5 z$s&0K{eZnTG(|y_LGHzCoWbZxy`F`#!9?+WGi+KRp3lUf&ecy$`+LSTjiRuYdab@4 zb-QB>2N40@SA(8o!bzK(o8epI=FWKUz^|@x!cZD-fV5wB%g0B@9g)rs@*zwQ2y(da zd7fO-mo?(j?3>IhJ*jP-6`5B_f4=G8s;i(2do#^=jx(sj3~-8&9gArA`Z@rdH-1NvM+o>F4Go*D&YvBv39f!sO|3S2%3-!{}2ak;Z0DDzDm_8#ZIO3$rHx4 z3KmeUaMe9wX)@tfIH|}Vum6g%El5kFHf>e`aW2eIwf@B=N0^qe-cvA@&f`^QOyJJX z9fb;u?1rz}q$3Nh0K(2G<@o}@S!GX7{3P3K2C5Artg>Ouc%r^u@L0|K4-*-1#78gG zeMLM7<>solQv!?Hp0TUtbjRyk1%pQ~%8p#(Ls>&2*~|$Ya4jqUICg#{3s-3<%b`Mf z^HgulWQ@t8$DhwK)EirBWaM$#<4K;X$?q!Ene2EjNEstzP9vX5Rq`>%!}O44y5uPuD+?OuR3WdHq8Q zIpYa%<5tno`s8~BJoyM5g_9E8`I8^M@6UB=tpjr z5#CIJUIr9_nV+_M4T6H=EP*}8RpXOZQV}j$dIhZKI@L7;xCs`}nXVz+!S@vJT0zc3 zJFV{Cy?aPV-;NZpFY#E+G|2WA3u~jZp_05L6cvd>64|hy$bbsfI)t8N4_Bf~s4a0E z582eV%TDt$y+CTt`1uaN^S&#OCgvr@99*6y?H5m8lXe-f+@ED4*j^>uHe7MBT=DqW zt83K+AJ2Z4v?;38-V)*s!m+TITWw!ca#^{+phpm*t>@nWl$4`l$n!Vt5;lJ&G{9_S>Jxcc9K8+!+JtTh`s-UikV1mvXT14A(=xulhUp zE&o*l)^D;ZbDCf$3g9KQcj!PbFkaDQ#_VE0%km4_-LKgD6Qmq!nR!;&0Qs^DCvTmS zwh+iYN^9u#N3z;yGn>i0ODc^UZY>as331$wDy0RwTofrN}VA*1!h3h$LljQ6U0!Iiw!UKhz}D-w<* zzqR5Vx*GC+ZWvhw?Ke4PN=oELzd$};=MJqJF*d4W$Fz|4b5so-9T(9|4mt@kM4{ks z0~Fmkh-HY@eLxrz#?BYkyr3AWv8TMfA8^nrFv06@Amt8VT<*xGOzruXgl4pff9A3$ z&Y?AsEN26$UJDuQ0>tjmp8v40dZgycE*BkSO$F=in`-sAlh=AZ1HRNTPg!;Tm4nb~sMD%GOXI>CGW=HW{rv|I5xhbYBEfwX~Eun&*yl8hC?*O<& zn;YOh${ebN)P;3kr3(yws3ti6s$c!2Z^~_(FQqWg;yH2S1OS|~*|8vvi@;E)&b{3S zL*hxrV*W;$yG9y9frgp9U1H>)Wzd}YUzCz zTBY#i^_a`Tm!dxXfhG{OerLL=bHHunF+t461Uuf*wT6PqVNj8NR5Zwwfb3Z1qIspG zf+xX*)uVznEDhoChd1qhA7gBhBKfz_|7+i+=SI*hh?4HTeEAZ` zWbeLxx{9|Eg%$YST#))-T7d09fx#n$%#jS~*-0cp^q-foJXJ^#^~P((CiJ)=aU)>~ z_=>RwUc73i>p5bsF`)ivY2x}p(VcdXAz0PYmGTTkcuv?u(Ey3Chhl_^Wxa!F>mcE6 z8bpUnad3y=?h~Fk*m3=<14qwkDb50*)@dUh^Fc@E`t>i+y$P?c!=eKF=qO%&56o!7 zPIURBo%NA_AHq!6I*8;;X&8p&ZQ$pexCs6N-xR`b1OynMMl-xIAobqgFn~6A0j1~- zI3}2^;u}SV8HLb)T}5q>{ZoCtti2qtL=)tK)}f&x=)R>mBZ$}}2-wDFRGT%P7<*@m z3@aG0Cn8(uE7hyNPO6q*z#nRkfOS`c`viWW(s3+AyOQu$o5&l2w=Hn#6?=&Cj{Qfd za!|hgjL*6Y$-L6Axt@F`s30dU;z)qx0mQW-_z3bSkJp;DKB~hIZaL08Kn&q62Iw28 zDm*-=#C-Zi0_?SA;F8Kf3dtz>2_UOU-wvP$0LD71UFlKC3IwGj%Nwpv(1K900#GS1 z>e*PxkCFc^(07#JydHRsA|Pn$s--F~@bhS5_|t^(RLd(_lRm{1L=v9=c>#?C2eAaM zh5y#K3p|-Gk`Wchs}S*)jC-YbMgay-fDHRR1S6`#QAFU40L>7?K*v!sPWlU?^Z1lV zufxJ{(i(IWXG0leDlpHHY2Lz2Uj3C+(4cap4S(eDnf95vCQAfeTSq=a-i; z`>p)w7#C;-R=oz`>U;L1BgaYco0YJ(Y%3Qd9#ZbL3`AJ5UB{B;?%lhrdWfgujcLt3C#pqcXMP5MvtRUyGQs$ej(?K_;n=Cp) zxK9%jSH1Q1_07$SyHM)Z@ahjWK?VkiD{>Ie!)Z`q_z_hEr!Lf#TMfDQ0%}QrxvQdb zD-`?x#@w4gbN#;WzM8+yCDN!;WGH1wiDnIikSU2WRhlWKc~VG&kO&o(Arw+6(m=`> zMG?)?K#>Mb?AKG@-#UArb=KKypZ_}N?C<)m-)h3={eGV3zOVbbulsrsY_3>&%7&ys zUB9f=^|OCHV!ZXF4k-S7Q+4%HUX*{}dPPH1sZWqI(`COV%z)Ytl}8B?dKQAUpCZrC zoJHW#qxIEdXfW6I$&2JbR6t7)yY-e=;sA`fq!fDtovf6i$@K>hCNov5dE(J@?R{rB zF`69K*NKq=)#enx06m_WuDCc!=49Uj17+{x7eXBKN?we}I$u~)&{VPP+&qc;cYmE~ zA2GGqv*7`z)ID>pg0=5vDkqA}FlO&#%P7^Y5ywf7R1tt3ANutg>U)|)z zMJu%>`<@=H=GdG$7ZbDUPL>QQZiQRBe*HS_d&N(qZ|v`tIivWT}fy#?!TT8SHzbMXAryu0VlvPygJ)I#TR z3ea{-)c;~-bKG@yC`PL}~0jO9Th(!5qjz-KPLVX&%Qm3_Dr*5)Q zZGZeRGm1P|6VyQ|*V9*OM#M^0i4E)i>rS!WmvCW{80>1pIVQ6YwX+X`ivPCOsP0kH zOX5>+iD%`jnQ0~Eh?#*JsNq6+uvQW3Sogb7E%aXputKbzO;0f~O$-B06`$6XGcv+Q zlSD#dOOd<&6b9zn7B*w5oouxMJ`-1dj8jRXXjQZmd^?%N4>&KO*XJq7H?>NCn zFepXGTa-etz$^gt8(-aZoSx6ff}KN;MnvpbEj;Jra#%1)Swsr`i{1I$+wV+Q@@u|g zze%W-=@S6!=JmWqq6~^dkDrKmGW1nV%~+su(5YGek(_jb@8}dLQ6Z+DwB^TIaG=iG zH>+73$c?eT&PZJzVSRof$&7bKFW?fv_Vi7p+^7Dw@HzZtOGr0#P0sBg7t*HxSed55 zL_e{b*^Air8^6|i;{0P&_aOE@4lSrR;ieK)q zVs1TZZZh1!rWO4i_Hvu{U1s5VF|{`ps}Z8!J?c`^M;lCtvrHK8kmXIu8S{%K6vfF% z^O!@qtgUeD>Cmp7($9aQm7E7)W9y7r1m8@mT(K=fLIJr!&)T+ayTp|w#ng-jUPJri zZ1wl52jiRa3(<$gF?g{{{dgfMFZy_?yAC6gbL#AwVR?-S#Bs|Rw>oVMj1vQef0K5~ z0IN#oMdLZP(A%_^K4VDKKrS=sTzakddO!={`Vup00F{kHRQr#{v=II%qHM~1vvUty zZ@;r!#o_NRXt)9E893#r>GWYD%0Ym9>RCPbPk3d9D9+^M&i0~3K|P>Saqqlj7_T20 zl+~gv_I98{I`L4sh<^>vCG`bR(2#4qN*@5iSo`?UeuWJC+#KvY$R)`9hOJT545>4U zouFtx(lMif#g~l_Qu%D^j4h$Oj_*<`Hqj2hzX6)$$;S61Q{w*?5L3^dd@e$pQ}J~$ zSL%qMoc;Z+W*2X79cKj7!e2E?*eb9HLpWS#25Lp`h~DNB|44b&+!{PgV`f>7V)xNz93iUQ?*p5TQh zBj|}c+G#P=O?^#z%%z6Zh>w|`G7Q0pc@yz4$S=L&cK>#rq((~vYGc=FDdl(F3udfX zfr_Ms$ZJ!>4povgTj-=c^*DBrn}0RrxXvs$80Qsg+S6}mpZlRUg4B&UR{Y9{$@9U< z|4D()9SnLP8wpo)oKfR;x_@EFO7&c29&t-N1xHh&LR*44{mOagJJIivNjRO0|9ys!ax8A*<=Bzb7 z28O#lTwe~zy~BBmdiEM1AV^HeqYZTxV~i{$V5C+gOojxe1DJ5ZnO~gj9p?p-8Ez1G z=X-W<{iZIz(Lay(>4PevFhmiC-%N2UP{xGCQZ`xAt-`1y@XBHEHolL-QUqH!J z#70Vc`uQ-2NO%Dou3@l744SNPJyL%R*jP_iqlXLvn8@D}^^U2o3Lo^`>7hNj6&an> znI%ooc)7a*o2?H-thZS5p^w{2+rZZ`&I!?S#{^s$USeKad++2e{+09(R!~^Iv9c$7 zhsuB=$9oz0hDiO^#0q*@|FR>mt__p$?;*quh#b6Uzgt8Zsl5LDD2=26t@e6uzi?sM z!w;gj0~-D$GrhYgOFiD)k1nDFf*2HCBX%s$W}$tD(|K!^YnFFUp@3OU83O`x*8UNn zq;mAfnWA8hqYzi*?We8-?Fn~rSt%0OsyP(SoyCTd`XLU)*YwZ}v^JS3mk}du&9!`J!ytUH!F!l1zmZ;2)2yq;?Yi`7NJQ zoQJqwUHkU%TW?vOu!t&eJp+_J@r%C>IJU7j+vLZx2>DmFL_e(3&PVJHH9PR~)34g| zx|^F!zAWf{GOEk;AqLh{!>7+T{IVeNN5`|e52lXU%8W_l8+i#W0V@6&E=okU`S#E4 z4JA?vVP~dF-@kGCUS6IG-cX%%E_6!eOYUnNXeVLX<4@H|GW~yUPXBN994=}?%);9c z5CI)tU-Ic|2g!--`6u-=E5*te6A2uh?z4MibIs5 zwDe&jz4C{()6<)Oyr(UaD)B5x@861tVr?9wQcS*yGt(7}}^?_G~

gJjvYlR7&(Sl4ij{f(Qi3>^uktS3og^u6;&7BRKbibsHq)g7lP^ zJ)zdN%iLP!oaQ@Q7}kcJrqOO-rtXp-bCWiPaONZX+w|Ixa;2GbiOnH%)ox;}1W*nzhHmzhv3ymMsav9zATJDK3y$$?ZCRZKr@3v-fCQ;bfwaN@T8cso3= z=ZRVtjw+D4-n=&QpBW>M4$$+tOSvt^ycdhue!`{=8d|fCifd<8kr?vYQ%(PiT?hy%-*GTk=3ZhT=$W8RA{$^LwCoO5FJeWH;Zss~4DXv`;mOnBsaFj(`*N-#@FX z3rA}AEm!OGXq!4g;x_TmZH^oc1>60k7Dh3h!{%V=oTy!e#-E&GD;1^5h#JgUr+E}g zFxOhhhrrqc$E}_`H(1;HK%N;o5<%J1lcv%uSMsa&c6;JkGceEpFl`8&H~TXlynU9i)fR=alHVHGH$eX;Pfyy+);(t+Vp`y0T5lR>u2j>T&+qu6 zdpp(FZqhpbYUXx;R1>SoV}jC;u5?C3aaM>7#r_I zKSYg7`K}s#>CuEQZ1RWv_i9w3RczaK?G~hOP>WY_s4bW0<-}>htTCBlcV(@9EdE`M z+lPJ2-BVV_&hR*=Mj;;x|IE2T9b3Ps>&##CDkOT06h1@586U+8lV+H&4qbEh$;{Ed zLla9(Am^>IKez`kdm*z{lJgV*!93Wd2pXlz7@#9S)oA61E~ z;X$d49!0@`!rk773g|%TvW;WTD+8`y0{rr@ThRB3;#%Um`}7{W-VBBR0DWINw=)EP z2cCuV8Qv1Mh9gTINlu$b6|xoD0rNC#@nsU}5^dGIEdeVj%C9G!&=D?J%??BicL^u5u59jYd|qUw8)RFz zX@fOefB%31h`J_vesYS5HoO@k{7R_RTwld`se)kbt01|$q|o5`-Aonnk`KCm z>CDA5#fY1i4v0b&TGVq=KOh>$%{^oI+%TEvPyWZDhf%zJ<`Y@jmkamMKwf5lH$Ffd zKT(|D422q23RbHY-m_#z-U7n+6UCxc|0|*}6C-rJ^|>arq?S)e6H}61U^J#q3WrZ1 z=#n3zd&#<{LqYc&X>wwFa-Zp16rW~WYCL+6v^jNX`lL$s|HGJ_$Jl{>pa^+BlU*x% zYufOUR4=WQLHNSR6HqL-C{KQzt-UVqy(Z26?gf`0YdbGCyYP)I zNIZ2`8iWr>B{j;8xD`G&dB8ENx!mn>W;-$uy)mDAwy_2-PFnlMgc9RIuX!vev68Dj zs&LbiHc7BaJ=$`XdcWuKH9Kwvcl(#J35&*i>@qS58d^m>mGlW1@v6Y%I_nEh4-9Wt z{1gQ6{l^bGhwqprHZ+Ql9G}%AS}C5O2vRVDonq#th;vYDliADa@gkKN$#!U zu)W2fMl;V&zbM@S*$CrLE*i@}Ncf(BKzi6kQNiSy!+K4}pKvMIMSms|si>QT(^TC+ zpNHV-7Hdf?09iFN)Wq2@joBlBOq}=>I%{6&vAOAvIR?L?6Iz@P@}Maf%_S^>+x~n4 zff}Bbo?d(F^uay@pTM6ra()ZN-7BUM!Vv4~E0X4q`MB(07JIDoSlgp}rExzpXuO6f zhBIWb2y9ikOk9IQmjh<}E~k`kFm{PeR6Q*ZL&nCbfDNSxh;*XZLQ!td85vF06-` zqrWvi7;Bpa$EfT4TwK}L*nT_A;R2)Zb`sulf1rci0-B&Nfy()-fs$AyMel$DTLJM# z>;u+){rVL%;9TOK#hZy&I|)q>5i6B61{_}-v6k|JE?XF$peT0mXu>~cabWgQ{Wx9l z5vCA|4T4i9yEKx9V+NLr%Pl^&yDz_DTtMU{Zr?b)P}M4HdBp`GE);6s2M|a>stZDM zHnf>6HC+@O_3y~_2PW_%JbGoVefQuky)im`@2Q8X-{>3NA}f0wpTJL}nrK1QD5R5Y zL7h2fE8Sq-QMPSmK`5mMj9xgIcaVPyR0f@Zi;&4JVxGC=XRyC;!RxdoaFk($t~N>O z`5fOTuxqEgF8fyHsI6*%pfEzqi@TY4$rpNF?%7cyQk6=qhsf_6SEnt_B4a;B^?FrF zB`sbokFf#qEyme4GOuE!oTkU zC~H0_k|b+{Lv!iK)>BQ6dx)clGkbA_#It8=zJtAeS~saq&&q6RzRHmx9lZ6sN9z+D zA=9HreeujeZ?pV8EIA|PRHQA-o#1w9t;{~AmEf^a>^AQwaw}NUIs13zDBgJ7I?{`s z%M{8Z+u;8EWFVJ4pBKb0LOI z=#e#eJTlH1k$OJ18(SG-LzHU~vs@m2`b_3gGU))_4JK{q&kM(X_m5w>7LPZ$T6~pB zgxF<38*P~%KYxm@$Y~b<2c)d<0R*8a1^yK{)DIS)k#c}ZyawXan9a~zyh@$HgD3Bp z4We1#DyTKQ>6s0i+hZ=2+aFLO(Ou4aie>|Xed66uB;{mOq$MRChPb^nt)KIX^WH_h&;hjZvnt>3~tI>nZFWgJ=2~-YfG{hCf*QA1-> zcU~OW)$W;e06cf+b6x&2i^=tC&V!m{{_E_~f4$T7zk0voU%pE2I3j(~d;xLy#*^${ zqB@uWEJKxuJPkowLqkKX#o02lWd14Q&t~LT*Z_6U^y1{kRCy2`l|I$ zA!CGlpG!|ZZfT6ZJ6fFoZbT)o(?9>Ph@&L`-Xi2bi@$cv@@^pS$S9gj{yv+$4m+__ zfye(qCob0jdff(x!94%8&4eV-SRrWY^0{C}IRCVoFeFPK{dX8@*kMJ}NO-N(rT1Su?xjE-L!2Zq zk)Lt3X`#pBs+wMf$yC56 zoo4=-btvZN!+zc2XU_VMl7k7W%itNPTPnwOJU4GM1I%=?Y;#@eLwHPqYiTT;9<9u4 zKOnt`Pe^L1r3&sOrFi1r@WF#mz-Q7^oi6v6MhWM)RA?(8@VHh3wfRE=7EPVkcH+`X z5om~6ylsEPX7!IXzsYHLze8QaRC+=-u{8X5kR;(E_7=Cs#ZpI*-WIWygCZCk zW;9_p-*K)N|E%kviIpp}J{Rk7%?Gss&vgM9-fti`4nd^$L*j5b_B9hYF4VW`^dJE+ zpa7W>)}u*Kyg9FbNn%N^E6IjI3Xq?TyK5#$NE|zng{jN(A7FY_&%6kI+8ay=MP@ls z{&FT)F#|*cAncqsw^;1_6imlRXttv`3MGEju`pDdR*=X>Z~Xnv#q zmfT_2?MT$EV4j7!YuyJkz_?EK&)qOuJ3ZQWL*-cv%*L=Jg3FiCb1rIl)^k+1O9~k_ zO~!n0J?Yp1Q^Q}?{cf>;lsHZyUTQ|zM-78#JzP`i@L`U2@XrlTEY1JxmmRJ8lc((t z16{#Nxf!~{hB<*$fFn#QjuI}?d|FkZ=zogJ{>>`h7;vYlQ0LHfV0398f9tn_|4Rs7 z0pADK-F$6|;kphCi1ht8Hb~QdgTA#&u)({zu9JgQj?hX{$1rg@g)J(E#o1@A+O&Vb z$AK?(IYQNdxSC%6lXq|&JQ+O9@xST@+aPMOD^y|NJamu-1vUrH8Y2uz0YZ(jrr@Q9 z$fm*v%W?ie11awayBV|$A!+rwu>~B4PjqA_n~qH6qB_c+>x!?dE+fNJ94=%vzs;F6xCA#3;KEGMRc_96q9nHfVm>Cwpn=QSJcBbD~G>1%Ls)wS& z9YFC#+lqfTNZn}`r618{#k(SAQ3)-b3}9={HBBq_=`?7<##is(V{TT<;X047gCS2L zk+4W?n$dF(ED@cU(`Te!U_5C%@^XAa*%Z?}miK`R?7dW(;(WCL{OzuC`joX@aiF9p zjzw|QO{79pgdx?K?oAzKsS^KYF-MinH1prhWR|NS<)k=YXqv~^AO|Dyg__LhxX-P3 zSMuGIhFms9&>F~Om0i0+gmwuBnER8_L}Q3j`2cwZc}e-_tRo{tX@b(F56Gf2TP>n8sGJU2LRQ5P>IG17mtU)N@$?* zrOmtw${z>qse`0p&DE&ojzk44oI44=htZyH>#&m>`++`{^C(qpKsRbbcAIPtAal}U zh+v=(ih{$*?{_!avI8v29M=~tdoe`f?=@IVG)9x)4CK<&(*t)sD$gR;pd+-VT7f?x z_b)_}ddBv79;rZjAAOvw91@SA&nGR>?|HB}PKk+{hxSt_U!}|GN#6VMbg|a&#Y0c3 zPq^8~b!MH)3)7V2TfT<$wxKqW*AwrP>oW%U#T;<=aUUwtBV0_te(oi1$ol=^Hemc$ z=#S~Psje1mX~Z?lGDb(t<0|hBN-LkF$8I@D9`$mc)dKz?GC}H=Y*{>*%YU9ewcq=} zL$gQS(rJ)Zii|H5dR6F&2^_>oWSGcec84r#2Sc+SjoC8xfs_~sNGb|@+WIOl3lQCM z;MEx_4847*Ub`+%WR2&yRI_+J5`ttR2%9s%5J@SokC=T{4d)p5 zgF!R6{>53GREDY2D2gC@Vvg6bON2{YW&@{;0UmwpFR70|(`QFSesjyYH>?fkYXbo4 z)>9SVS*fd!kO%C-*zDc=jJ~ctsCZGjhxFRX^zMF(5!la?4tO#-P%lC2l$<}ak*?5B zd#P5Mt`F{1x9$Le0k!1F#Z+~5bqTjVlKCNz?w>wzg!yjG@WaK0KCOd6U4m(Lxin<( z;DzuA#EW?VX|Nm4W_EdVFt&) zFk|U&>0xywVd_Hm|GJ3T$Gc|uvN_(iIZ>34+Q8-C_gI{0L6{&i_hX5&2a)Y}ZB>}W zaeaUH?O5x~z=}cY(PJW#xN~BIl9WFUyPS<~6sSVe!%xN;zX4r8^UD{P-oKr;^Q&gy zCv*j`i2o%q#`)-tmeW*KUA&#kWUk`!(@s^{sv>gyzhYF)rAP&o!?-s}ZkGTMebx1H zT3)B+ol^*(bieS}i^Cig*Rw>FrxE6OlhFCBn$WAGJ?+E`)ii5Qj#Z&xhSeN1Fl+bp z*&tPhqqg0%RZ`-2_+xFQqx4S8*~t|Y2Am)d0uh$aGP{S&zdys_vp6H?4Bns zt}sHfPfo`aD?@yZoFdTAxa$Tihbu+WhR0wJj&v~5_ z!uk9Zvaa{h<~53+U&aLtdA-|bYnqsc5){YUBOb?uI&Fct++z%vdDl#+Jo`4w8-J!P z;gZbZY<2kZlglf&$LMPt19sr+#i&5AK0w$H`Ha9(GlULqmNUVPec+LJk$R+HWl7&~ z)slM7iFB4?IXK`@x9kEhE%-FvS_`1c-fQrT=jJihgmIj}UlFeY=K>%|_<0eZsa2@_ z6=RZT(=J)O=`tdYujju?)Ganv@p#L%t%TDHsdW)Tq5Sff$vd-dF5@`Ug38EM>c^`^yx8J_x`s14_ zXOL5m~WjUEt-z!nR!LO8PZ20vXhvxxxj^R zU{oQJxALWYOJ%)VzR&%}Cp@MhJ>IC8J_iaKAx?yCzcgTu=7TxQnJ|D%r3`X3HdAt1 ziWxZH0%7J$TcbAA7!S1jYQ!&@(XgK;n4N5$ zWeL;F#W=x%D>Vhj9-dz`Z9gIEfm>xttyu!~f!ISNGC17_&!EZ-$H?ml-(I{fh<^_X z`E*k6FBcu4iVQcsZ&|W6Sh^$eEue#JtI6ZU1Q-7RWG3#p=citR&EpSTFU@#s*yOI1 ztk&koJd56VO?7ezQxTS)KWB*9rzoE0Vc~tAw`%=A1nB=UQ&&8tUvoV#s!@%-l{6EE zQtC$Epok@zQ*|V$v`sow%&w0;5j$RA1J_&%{7suSaUXU)=xzPSdw`jz6Sxtc0EX1! z&HML{0eplx^dg1;={OIu@FbH883wvu@=!9SFXSB5nv2JPVn_f=`sY`z{RVFws4d_F;;j6pllaes}0L zz+_oJ;XzilX@d_S2w=W0w1(Gf(`%&s$z2Lc2fo8HOWo&BX4ajXmm;oyhMhC*yCo~# z=0dNiUY3OqKZh=}W*P$pUnONb7ttJZ3$JjCXaHHb&~hUZgcZ3R?Vq_$dB!Hw3a{kO z7*QOj!VGr(@e?WOn_5d^rcj|QNLn2c)|DN`{ZQ1C9;&8;4>Kej>}p`>`Y@6E-A8HC(vx8OmX!NuED+AYO^gZ)=Yfj(TfeO3bpDQWIq>yLmL1=r&9vN&DCtQfT}m2iwKlMh_iCB@Iolu^_-)YGRd(Nc!y!dK-Ple( zH2eD#dHQgG2NfrDL#K}%COll7_7pv~YtH1w>m^a?5-mqp=-<;G5`IT0?Y5jXCnQ{W zT9WKMyYAAG;isrX;udm6Rgh=e+IBl%~TXSE>BO`hUSK_%&J|0x=Cx89^%|`inZPRK%IlT6pU&thW_7>hM>x)vs2^dMbz^9R4j0jH z|40k_x;vMo4gT?5FwEjIWbxns)jh}08%p_`$x@_~AlRkyD1g6!?SKcUMVXN{SlY)= z-7h_><sV>l3dg>d*Y5F4 zrhbsg-$UpHzYk$#k!I#cmcL@YwI@4JTtN8V6Ai$>mhx4rBQ!rvy|nAdPhc)Ja#ic? zi~m((oob}s7LkABRA7<;v5EpV{|z#)5k<%^gDeL_n(%fuM-%qXWqh@1`#$q?-MK@ZZz!LzRfT{mifT=#$D zfF}kBXxlNZXg7B_kI!aR#*0SGKv4Lex=BG5SIy!4Mn2$e0R%>WtHrWqE#EcQLDS;w zNy+`8JG|2m@t1;Og%T~Uov`t96FjNKhTlKd?dQ_&74#jE1#Rm2c*ui?ip|Z<^rXxs z9#HksXk`>A>@Ux2dY#}KsPrfbfdKOk7u~%CG#7ptC8q#5R`fxo6&3ydsj-t~Fq~M4 zY5siATn6AcV-F_-dJa*Zgn>LwNa@SsCfblYJUp51R2i(6HKqx@vUI=I z^(b35fKF#?g%x3BmeodGx!mEeYH?N#17}|v8q{tI zZ}*RWRP0+p1_U$ZHfCvbho)k87SZAFmM^&rP$qU4tOn#H$P6WqxeSJ+gDM$&OJO4t z_8sTWtuiqYmy!G_#LXoCQ;7Ft)G{je+-4`Ju#*?#a zzShpGHgK8i?X#5O)xQ*;TsY>ky4=b!Cl?)VSnO;6qTF3bJ?;0lr^9US)L8#~TGz)u z`c3;ON0g3L+f34V`~4)=ytjTo@1z=MLR1INll3KJIO8`uY@H& z93-v~F(xQbw;Od(TO=+aCei}}bl|zjRAL~Mr2#dqOKkKT^(t-E-t%^jpi2Z<4*!qnL!ZvZ-+8Q=+v@%beX{xOWT&dVd1)DXYLw&0^-P@NSpiN2wx4 zKtP;6v-J=F?Uik#5B0g+ud9OhACa@Do^)Qde!8BQ;AGl$K49lKAq)xIR?p)xgL+>* z_IUNDed4`s5%0}nR;M=4zP~6tWhG>Q$c=KSWNB4)|?&Mn6EnwbI>P>% zZ5u|gVi2pnrfYB?6tr%SL39dR$)2sw=Q}pNOFzvw8Q-3$n4R)|Z3NwMgiF!ed+8A9 zxD_R4UQA&^f^FI-zLKbDl>%kA6m1ICQzS0795ykwLWNx!%_yny^rn%um1%QRR8}Vq zxU^%*1~N-0hrn9M+&98@U#4P7%Me0m#R?Hu^p*-+2-9EC3SCYQIrCV?S;&l%qu;eP zho3`@36gcLYcuaa|BFcRjiL*EO)q?V`4MK*e$w>Oto7KUp<<4oO(Se?g!H1W0-EM0 z@qJ8Jq<*E9wF#bVwo_rJ3CN@=k$j9##M&;4Y|H?DXfng$J1CC9~%{z<9S%Ff1M4wNIhq~6LQ6i zkvs&K_I4HH8lvHNa%#Juh4rAqkQBp;Jk#?pCT@aCp%W035lI2=`p_WF&r|l~*CP|r z06{z~8wX`7O1DwINzMhKsbt!I7#8F~;|Eo}NiU=ISgb3%1c?W=-gkjQD{+eiU2(4M^2Je-RvHBuc`#~50U<*KCa zhCmY7nLnsVNLtlSxP$aHv5cHBO$lZ z*V}xK;?x>{o9Z<}wR=EW(OP7+wZ%v_fRbZrwh|{_|MK1RlJ|OP@0eA*RjAl%$*d*a zPf~qAc~4mXA*@Vqw*H$F`o4}Jzz*4(LMHbU^rlaXh8yV2XADw1*Sjw3&F-MaF&Uh~ zi;P#${LH_RE(qqx5@L-T?fT$a#CFF6lmF&0T6!Ca^T)GTAwI#NC_a3H21G z)K=15V#Rr&YD7BqB=EiMklWFPWZWPAXun^@xhHk0lV2JFMGtEoqb9H@Y`&2Q#bYC%uzgZuaVWse0cKI zp^G*~i6jlZW8^{X+)oraK4FvPpE@*2wBJM_dLdvm!=fV>=!_=7&pi<3EX1tNm7$+= z*{DUd+%b&m!(*{CdMlcofHdfNTy%R#1Q2~bZoR`VCa>QEnQ|2B36kg7#O)lE0XN#O zdolZ`9g)1$*czxVCPlf;kR{pRyR%Qtpbs<--oEm!ZYs9=)j;+;I2(||Ci;G z+LxuSv{fCiEmbx!7d%bC^j1aP)E2vpLGPqBg@$sgYe}!WFuZI9u@I~@+Py$LnqJH) zXe+AO!LHBCPAP6bo5!ysbhr`sub<1`33MTi`u|=B^yeo&%f?Ci1Jog7NiVCbohJlA z*9tm-B+@$!sg3<(te91^0sne^;2Wy;rohQ=Sd@pD;XMoXal*__D{ zHH{4$KWNt+uvIVo9+Aq1OHNMq_4Q@%C|tiZZ20hK(l7B1Zg`ZU;u83p)+%yY4^&q% zkx1f==zAdZe!164VV^{M+gM_|jSKgZXSV$Qc=nsW=mR|OzRkOOH@nzwebu_s;2w6K z)j?v!8cWXo&yU(f8+5sZ4NUL?L9JWG?-0O(;+8Lt9|&))wnFndgq{Rrlpy%^j}0k5)LJQ&#Jd zH;r)9JoKQhgK2vS*R)5we{%n!!mXA|FXrs6jU0|(CT;`j}}UdUuxyF^fjXVOv% zDQm=bqY6i)?h~69u3WkDizht!fcGQk=r-uB-R&$pkysoZu~GSAA27lA@y41bC_h_X z4WFd%niOIYnW?9qk}=RVuxXu5rUAk1ON00JG zr_)L@LD9IEeobQyH%GGQbr9&8my#E^CfSjG52wurc>}x+x^XQ9ZL@@bDeOeCiegxmv!Q+qQ+5g!NC| zjt;Wk{xs)Tjp&1w{OCQ7LoG~A{h6eRzsD*Q&@caLYSV4p;A0DrC-qhtMnto9i2y}I zbJPY|*?q5DwQUn}EA0H=m+LdlKtWJIgKf}kCB_H7W8iYQ!WAh<(t-&SGYHOSN1tC* z?i65jvC=IsH#ZkDo)NThC(BZ1c4_f=#9qR-G2~X@)}~v71YA=)bnMus*CC6!nJYD^ zRKB;5dWYR@B>IO~j~(}Xcdz92-`HaJjdfP6lNFO3h1jA-M!sMV3Pn)Sgm0A=`2z+% z_n#@NT^%_0$RwY;ln6qEgSv=qFPyR>Hhu7^@#{L3oeib%5wmVssI5QlAHSVfwfa`q zV7rZPI~A#?tl3u-9Us4Z-^h#B%_DTLw;#gk0|;hkXA9f6U&ijXi$>O1X}6St|E+%j zfj@&hh543sOs?O@lGAy38E+{V^Du1VyV&A2y(AV!^>RK1tS-MwB$-#x;o9et+3cp% zr^SV&2NqjrN2I9D(F~mM_0drG#4FC+vZ!ky#vIhwJLcWn-l~mUmeb(^P?j-`&pwaV zJzVh8({96auZrFdm=9kdvl@-9(rl%*I>|tK{#6>+gC-T*7_NAIZ&-6((YEHsk(Vmf zDXXj{-=U-WqOYc)6{sCON@96@c@j#JDxXP0zpifBi)dodv~ajVbi7|GOk=0Xg1py~9@>oXK=42OMe z%06l`zoXi)By*|$u?Ru#fBN)kRtji-v&-h?nOABeHaT=I<=_bWsU>P1CC(T7WH!x> z3&F$D5C-ZvYt*47pyPP{dLsw0M_XDaO#r0MTqpN zo5rO3CNmxMsKeY7UsM;xwORf~ixWm`Q_t2oCMay^o^3kfGa$i1_ar4PJ<#pWkfaCF zLmk)P>N!t*>n_LFmgv`nxfgyTI}BW3sn$wDOU{4N)-Px6oj*cvsb9>>x=zhKsCd}L ztV(uUP*6}3s>G+wS{DmOv~)=tzlL$anX7&Fnosgj(*63#an1bS12lP2A2_(u$96Kc zgB9mZ{)3bK{V6z8n6SFLxkXOY^SUdWCDt2&`yPj_!h(W?m>BQVfds%2u>&K$vv-Ev zy0yr$6r$}-9*0GVHb{)~yR73+c83a5l~ZWcoby6wh?a-wdRxj!oVfDH^6 zra8A>p6RbFKI6sb{wZwUs#WwP{fJ5$BK3ne0GaAiH z6LQ=M)O?x0sz=?mNqcp8HgxglfaAZ0?QwT^XG|#|Af`wsd} zk{SL$Ve54Xi4$k6fu6{!ZdNFd9JzmG2@AvKO5|BP3|M`=O?Tb%40X;748c4kzsIul z=;K>A@+O6rc&@RwGZbz}hue2nT&nsCogX-iVfKDM*JX+NOWG4WL>+1UvS%oZyAzWLT@%GFy_4UGanuO9@ zTDmT+CPK7$KiS`;QZLkP%-^s{t?1-l*&kF%R>wDHOIuxljgJ_8)qCE9#Q$&s0md7+ zW9Fqw6`4_d#@eRZ$5$CbdYrmUX4Ox=I@i#*XqZ9+$jwqP0r0RlbsZd}@>b!H*%kEA-%lwueR{>av@llW;DB z25#2kKEERgvgQ6L6l3(P(^svAqU{<}Qm5jMW=%^38Z7DUuWd&MZ%|89 z4zU9Sy}!YJ%NL8+rNTsNy+-zMJNYM%(PqfR%11O34)$5idp&#H$MnVzux7pH=&H3g z$oITArGxVv_g?aExTk5~*hvr|$6J2YfW-FsGxF*;T56Px;|q%yJGT9PZ=bSilY(}^Ys#sx-aO~99Qk;?*8(a)=90N4q`K|Hor5zpVo5gN)HH;A9v{I!SiOl z`v52Ol(6nzG-0e{{;uv5`;2)`1b?V7nuouh<6%EhQc0a`blk?BGSN z_-<9{dNv_)rC#lwT6>Xb<6qSMemiFy+RzG8J*FcATRG!mR`fwBK>D1Wxw(||YffYf z$Fy&`;tx|7Gx|9c`d{9R$oyWg%Axc(J7~Xuu|Yr0C8Oz7Kni-|eo=gka}coQ;)X_% zj<|twJl82VeYgVnO9?>Tac;xK4H^&aHmyiMYj|y5_&L>xVG~30H*Ml4*A*lkcrkP0 z+{-Z&ejVu`A)zJN^Pj_UDsvK6zY}YAiM+h}^-7n)d-6vtHI?DMNn zf3I{|Nc!6U*kY1$qYb~RI=J<7R4?Ok&l#p^MuX4$Gv5-yfzs=Z!tlQS@NlWdoJPRd zBn>446Ih3ob7RPML@LUQ)q4keEFo6<`8=W7{Ec1g^zzP>4g&+Vzq#uUb2CeK`08~4 zf7<-(rZWdhUn(7*@Z#*g^*`CkTWWNk-o5sMv=kz-?B>>kB6ek6Tw~SEPDZEh!rV&X z7hgJ)>8F(_%+!Z&}E5l%Mu>*R1@i*?UmiAxF==!>buwUzBZ-)qt?(h;J2^3y*WV7V|XZj z4z;KHP(`l!a<#NW+#H*1E15R@Rq44;FC}Nczq569>Y;6`$Ph{n?H-rQl}(2-XFw09 zkCxizfyGJ(bU%qJm>)2{NP()Ru<5gj%R=V;n{P)iaET7G*k{~x%&`8u;*PHv+i1g| zKQU@qQ#Nn0bno6Si@jYnc%5ufftu0%e(E?pY&6qw?|#;fXt5FDWR%w283wawi^N@h zWIp20aT>SS-x_-}^n8Wy&Beznuhu#u_6qCU2kmgBs@0saEVyY!goMQMJN>Nmwak+2!mEOJq?+(haLO}0e2%Pn;WL~oyi`BT zh;E`lsUn7O_BYW`&3qSk)*)l;m3(O{+nyS=?B26m3-65eSLSF`k?_#&{pkO6nz6C5 zo+#~}J(xSEYH4)%%Byu3Yfp(C#)4}DO16Ew`gHp0wECN!9l4C9~R=IT+~eB^qK z3J?FxY1FQk7V+Ij*<4VxJm~19yC^|#XBwbw<(0Q*-k-~y7^}Qq6eDEPfwzDJ zzX4xgQ;z?x-Pu{yySwmt*!pZo2M&nk@7-#R^fq4h)gsB2c2pbUnujJQ!t>Le3FWir zeZ2BMk+A+qKG^oK_M|*fac*%6$+_JHMCo?ssNOzL&OlU9yj)h;mapYJj_)YXdncw) z_W5PGGnTzLI>PgH2tf6Ke?uyD`1j&H$pI^29Ju7s9dTsCx+=aWJIKg#&ak6sI^KsY zjtD`nkzU#qGc>E|z=CBBL+yY6qwczEd};T2r(8mHJ(IV$mN>uFxv=Jnok!xlgL9;> zk>{*8F|lEK^GXO%fm-Tfh3nbsIoFeDH(GbMiRzvf87b!;Sy_sEUtm+T?Q_%NJuSr< z)ZYK0Q}$R3U><<)aH$qW=s#-ZSIH!WctUM02hOZ3wJS5>dswea8{>?Zdn%iwa4 zln*vm^PZowkUA7J?|!FV>JO!DPOcg;V%eI1?7nnL@ypLXWx2#i#n3GEX&KA+J7-dJ z5)E&Jh);lNEuW(&$;Ss#V$ihv>3MF&J3+&A95ery{1fl)yW2}NSvnbKC)*hpoR>YW z96P=*UFo?GmstH{ZD(v;sBmm~GMRfhC4ZiwZ_EX%hixrCpRS@pOMZQk>=JcgymBYw z&F5s-Wc36b+!Ar}ye0S+;Pvq3yNIBit~;j%Xi0okZ6jg*(Y5xZzJ6)Dl`qQ69IV-=gyt$y3N(;DDdvfSJl-Fb-BQB z8^8Uh|8X$xop_b2aWefU{K2u2;Op$NPwa^!@%3-{F812b zE!n0#`)1MEEss^(oU0U_b>^k@I%-pmPfVm9wiz+e7a4kvjqT=@{*IPM61VpQHtJeN z$=%4BvkZpBcBoJM#*V4_=8q_}7w>U=?gA7_OG{Hz|8V=dvTPF|M{MjWFn2d1a$AWQ zPdaK=z=GN;ixy{lJ4V!PE-dNUhM2k$`e>-9?$6p366Tvv?laoGDNhj>Ze{*DWqn0a zNy*1+e~fauKaCyx=PHE}6LgNKFsPDIYB|3yIZu`EbcH+tnWyP8tglY&>b8;deHBIJ1BZqpsXn(cE9B00@)8v8{8bt2^0$@f51;V)-ZYpl z+mYSXhDg= zn|nhfix{#yT2<9v;?d+4gdd*lK#xYshMX(E)i;%8^$xdg{|3bsV)89dw|vW9{bj*u z^&u|bhn}>2Q!w_a-(!&OsFYvlNszabbATe^de_dQ(ntHw`RuhJ&d{sl)1Obi$uSu_ ze$4YppW^Vr8qsRut>**OHjnIm%2>A{>rs2@$59>?OQW%%61u^3xB?eBb!`T?e%cc-u7sK+0Qm&>Xn>dMp06s5V-75kLCXt4-1r#a7$9 zhV;1g(BalYij^dG<`#0LkVFc6e50Dz$@O=y`z{XiFCXgic*UhHOm{dsnWe7wfQQW~ zDFx?)Sfi{Rxc^|`^0Biewy(McOir~Q(-1sio{AubYLoLE2475laQ0H|ll$yi1ZdXf zjv6cY{9hR~fCl<`c(!UePym06OE*KlJ#svFIfFk{R#9Z#a-taD!S74O}{^G+Xf<= zxefa56%h@H=s4HHrJ=LLtf$M3&l`fH8huuZeHLb4Skp3Pr-tLxbO zgx8#|LSviewuyNMeH0|eoT3dJ(yatQO{^Qf?2Y%(1KvjCM3Fy2HmfWYS=U7}&PNGj}6Nv}50k-k~(@YMpr3=dyx z)>@*!`j9>6R**l|^q)PoTrH_eNoQ-{r2We{6Z>blQ(Wb|DBkTkwIfZlzcT_IdkL3A z(4Zk@{-?&qeP^9!-hSH9q+-v;o;9T0KSZTB0M0R_F*ECUlT*QT_gH&*9fd|oN}~DC ze-+zCa2C8rsNEv=_P!w>TQPF%%BZx!;NWj>iXADPX2p2~q=9-Iongs`rOB<_@1RcY z+2>BgqiPye!U|L47V+60k(c@?nf0>`3|0c1tz+)whbfIiD48ENMd5pq-Ib)e;dtn> z>__sf8dGTjxe<|M5yt~LFHw*X>laewMu)i>2>qYygCo`-`ZDhcfwGZMDTq&K#!XPa zg4z@lS;)s*2Yeg|n4f{jsN9j5+pc`j2;K9ed>wwWYL&wtvz6 z+sCcnu1Zgw6BsBcl5_A4R3-v8IvEXs6Bo@Lf}^nF5}Xsca1fUOKnxGOA%Er?>BH2E z=BFlg2h@QeTiCGsTs6QC)=xInCgnJ6M!ZI{>aD?&MbWu}M9Azm{>=#i<}S9+mg)1- zQQPVJGVNB^+v~A(@YIjv$qZREj6$?iQr%b|?W z)Iiq%1Q1poF+xzV=OBq{+p9_p-r{)H>ErD|)XcPCzTWISDh!n05+mvOV$OH6fg8-+ zx!PE{8@gW}%!Tf}N)08Z^PN(0{nN6O8*gkLbjtN$Ma)QHf2R;GC=_xprDaLtpzPSmfD1mXwHDDw>LTXi^A{?>F<)BrT38 za2?;;1Zc_9oKe>2PhjhmH$vMG)dM?(Jm6?t*y%v%LFa>FH226@xvXLDE%f#ED--LR z;#W1qN|a5{RXG?xaEMErXNx;ZEX?TTuzmkQ0f7hh742#>xjzH(rv#@aAu-G6k7?*3 zNr6-)2FnXPDKXhV08@$Wfr8GJkWdoj(f>|g=yGc`v9ztk_Aha))!mI|X7a7JuQFSV z0EA+cuH>e5xRD{0<*JTvAd}TP1vR zzE=l#B&_lp z+Mh-iWVObv>gsCo5&YDtmcXQ`VSa-cu2lH)IBjt>FL}ogDklkE=gCZ)| zc+1<1i5fl~ZvbU6!;O^Iq4Or(8!QYOx zb7p<^$?V^WzwW77EPaWkjZNsWW5>+@TYKjj74?;d@kw+OD+_`igG7QT2@HzpssRN{ z#DGY37>X2U0;3cQVKJrToLn_^>Sg`U$(^{$Q0hQ3Ch`(C1I5CmsC zjT@)ahaQU`sfzv8%QnsK^-eNf(KqXjH~imz?iw8(J!Z}Os$D1?aN)oIx~FF`;*Hux zvUC!P(C>U@x-nPBK+0>$2<9=kP|pS+B6L?%ea2fP)y^`ru(5eU8w(9I3hDp+1-Az~Ua5iKY z1lAx5i_oipw<>8Z(}Z5^o0=Kt@;ghsJecD%&$nwsr7O1Fh?Y$xL{xALnpb&}y9WfE ztvcc@m&*Y!3a0uuvE%>Y1geoGk;J;T(;jy7mNs!wYL!(;tE9Jyyw~vrt znbsti7}%WC>0vEb_r$%IxTo1WFz`^t{YAuv095)1b08DUS4=Hm7Q3|Mm33s`Km@^% zk2oKNLIL6y5 z6ER}`U9Fi442+|*7Wv`_`{Ydo)u?{QnI4!h^bb|>37?=NT$XTW^G!FOtU2j+D{JA5 zs85o7ocZE{eknP#(0^@cy62*3SRz_=s?`vfpYsor{L6z0S!~lX`UL-VW~5NCqPVcJ zw8TD3B=VLH{n_kKUcqku6ML*^4+h0I+G!&nemVEWw#3~0{6p}2{OGs_H&h2?twugl z;eGx7cHzAhoIHi%XXC@hD(B2kTUj0@tJGFWgAy6%2_mUM<)#~!tq z^bJyr{rmUld;KV87(A0PtfkUO9DufM%H{m1I!7O6bCR^sSssqT66h@;so@6mkAC2I z#@QcTtCB`W#)Z zqI<+{6SXt+1L@ElUJvO~JIvVphhKh@KF1b|Nap*1T+bz9mpD^!7$|MTd}u6k%E~tH zb-#J;lsqo0j_>>kS;Jv3!-fKR8iavz7mkq9$TTsR0L+ocTU8!0{+C1QYRl~78koXT zQvOr%gtEl%V31FV7)^jFYzL4&{Dig4IAG>j*Sp$~(LRn)v(eb}8Rbv+KG<17F74}Z ziHbpii_4m(Jt)4U_k!jLB7mRx803cs`8yUT(OLwW%{xacLx-o*2J|RICu?#R_816V zO+0_4SFl(r*N9!c&RiPxa~K<6N1b)d7ap@z&;$29s&CrOiZT3qJNTVICSKf0+Rg(;2cNr4!O~Y z5K!4Kgd^L0yJRBgiAd>nzV$pn#!p`SE-$$4xno~nUu1W2tyAuWuE6u?frES8Z9z#_ zE~rOC^`Y?EkQ=sxfaqJVneC1$ua{@nETj#}1&oM5J8<*L)d?PT94pP_KRa}Ll7^uz zymjKW-jI-Fns!KT>$e&i8HLUmUAb&Zk#*46rWpT|Yo%O9gXy>e8AWB!GuIgvEsT@g zJ1#>=!Zfo50b^dxB^;e#=UvrDPB3#_7fDJ@pZGjU3wG>p0)SzW~qwq*&p5!Q;8DZ9s5fnGGxml)U$GC?YX@# zKhw0o`E{NsSQVN=_~_o&d9t3s;5fT}N1x$OZLO^Zs)Uhqj#Yh17=rv{=y>&RuM{4Gfv^}m$y{f8{+zb!fa<5AgN{|6w$AM5z<>!=$< oLo+z|QRFKzeE6C$^ur{x8ZT{kH@G2g{E@ggxi2kTvU=yg0LK+iHvj+t literal 0 HcmV?d00001 From d6abaa9b7db14a56081844f3fa99dde115d95319 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 17 Apr 2026 21:36:04 +0200 Subject: [PATCH 14/15] Add sonar-project.properties to suppress S5332 false positives in tests Co-Authored-By: Claude Sonnet 4.6 --- sonar-project.properties | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..fb9e8a68 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,9 @@ +sonar.sources=. +sonar.tests=tests +sonar.exclusions=vendor/**,node_modules/**,storage/**,bootstrap/cache/** +sonar.test.exclusions=vendor/**,node_modules/** + +# Suppress S5332 (clear-text protocol) in test files - http:// URLs are intentional test fixtures +sonar.issue.ignore.multicriteria=e1 +sonar.issue.ignore.multicriteria.e1.ruleKey=php:S5332 +sonar.issue.ignore.multicriteria.e1.resourceKey=tests/** From 4bc4f31c4c6a79a33aae506e99f2565357622157 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 17 Apr 2026 21:45:44 +0200 Subject: [PATCH 15/15] Revert "Add sonar-project.properties" - exclusions configured in SonarCloud UI instead --- sonar-project.properties | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index fb9e8a68..00000000 --- a/sonar-project.properties +++ /dev/null @@ -1,9 +0,0 @@ -sonar.sources=. -sonar.tests=tests -sonar.exclusions=vendor/**,node_modules/**,storage/**,bootstrap/cache/** -sonar.test.exclusions=vendor/**,node_modules/** - -# Suppress S5332 (clear-text protocol) in test files - http:// URLs are intentional test fixtures -sonar.issue.ignore.multicriteria=e1 -sonar.issue.ignore.multicriteria.e1.ruleKey=php:S5332 -sonar.issue.ignore.multicriteria.e1.resourceKey=tests/**