From 45171f9e54b220969d2bc82e71bc9537d4378733 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 20:24:21 +0100 Subject: [PATCH 01/17] Prompt --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..f734daa1 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +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. From 4d741f5bc8c5fa82b4dd0627e2e820f467fff71a Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 21:09:48 +0100 Subject: [PATCH 02/17] Prompt --- .claude/settings.local.json | 16 ++++++++-------- README.md | 2 ++ 2 files changed, 10 insertions(+), 8 deletions(-) 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/README.md b/README.md index f734daa1..79dd8737 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,5 @@ 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. From 4bb413344dbca92db52802e98eaceeb38511069b Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 21:27:09 +0100 Subject: [PATCH 03/17] Phase 1: Foundation - Multi-tenant architecture, auth, and authorization Implements the complete Phase 1 foundation layer: - Config: SQLite WAL mode, customer auth guard/provider, rate limiters - Migrations: organizations, stores, store_domains, store_users, store_settings, customers - Models: Organization, Store, StoreDomain, StoreUser (pivot), StoreSettings, Customer - Enums: StoreStatus, StoreUserRole, StoreDomainType - Middleware: ResolveStore (hostname-based storefront, session-based admin) - Tenant isolation: BelongsToStore trait + StoreScope global scope - Auth: Admin login/logout (Livewire), Customer login/register (Livewire, store-scoped) - CustomerUserProvider: Injects store_id into credential queries - Authorization: 10 policies with ChecksStoreRole trait and permission matrix - Test helpers: createStoreContext(), actingAsAdmin(), actingAsCustomer() - 31 tests covering tenant resolution, store isolation, admin auth, customer auth Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Auth/CustomerUserProvider.php | 34 ++++ 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/NavigationItemType.php | 11 ++ app/Enums/PageStatus.php | 10 ++ app/Enums/ProductStatus.php | 10 ++ app/Enums/StoreDomainType.php | 10 ++ app/Enums/StoreStatus.php | 9 + app/Enums/StoreUserRole.php | 11 ++ app/Enums/ThemeStatus.php | 9 + app/Enums/VariantStatus.php | 9 + app/Http/Middleware/ResolveStore.php | 87 +++++++++ app/Livewire/Admin/Auth/Login.php | 67 +++++++ app/Livewire/Admin/Auth/Logout.php | 25 +++ .../Storefront/Account/Auth/Login.php | 67 +++++++ .../Storefront/Account/Auth/Register.php | 68 ++++++++ app/Models/Concerns/BelongsToStore.php | 19 ++ app/Models/Customer.php | 38 ++++ app/Models/Organization.php | 22 +++ app/Models/Scopes/StoreScope.php | 17 ++ app/Models/Store.php | 60 +++++++ app/Models/StoreDomain.php | 37 ++++ app/Models/StoreSettings.php | 36 ++++ app/Models/StoreUser.php | 29 +++ app/Models/User.php | 44 +++-- app/Policies/CollectionPolicy.php | 41 +++++ app/Policies/CustomerPolicy.php | 29 +++ app/Policies/DiscountPolicy.php | 41 +++++ app/Policies/FulfillmentPolicy.php | 27 +++ app/Policies/OrderPolicy.php | 44 +++++ app/Policies/PagePolicy.php | 41 +++++ app/Policies/ProductPolicy.php | 51 ++++++ app/Policies/RefundPolicy.php | 17 ++ app/Policies/StorePolicy.php | 28 +++ app/Policies/ThemePolicy.php | 46 +++++ app/Providers/AppServiceProvider.php | 20 +++ app/Traits/ChecksStoreRole.php | 58 ++++++ bootstrap/app.php | 5 +- config/auth.php | 20 ++- config/database.php | 6 +- database/factories/CustomerFactory.php | 34 ++++ database/factories/OrganizationFactory.php | 20 +++ database/factories/StoreDomainFactory.php | 39 +++++ database/factories/StoreFactory.php | 37 ++++ database/factories/StoreSettingsFactory.php | 21 +++ database/factories/UserFactory.php | 1 + ...3_20_000001_create_organizations_table.php | 25 +++ .../2026_03_20_000002_create_stores_table.php | 31 ++++ ...3_20_000003_create_store_domains_table.php | 29 +++ ...00004_add_store_columns_to_users_table.php | 23 +++ ..._03_20_000005_create_store_users_table.php | 27 +++ ..._20_000006_create_store_settings_table.php | 22 +++ ...26_03_20_000007_create_customers_table.php | 39 +++++ .../views/livewire/admin/auth/login.blade.php | 30 ++++ .../livewire/admin/auth/logout.blade.php | 3 + .../storefront/account/auth/login.blade.php | 30 ++++ .../account/auth/register.blade.php | 46 +++++ routes/web.php | 26 +++ tests/Feature/Auth/AdminAuthTest.php | 111 ++++++++++++ tests/Feature/Auth/CustomerAuthTest.php | 165 ++++++++++++++++++ tests/Feature/Tenancy/StoreIsolationTest.php | 102 +++++++++++ .../Feature/Tenancy/TenantResolutionTest.php | 46 +++++ tests/Pest.php | 70 ++++++-- 66 files changed, 2182 insertions(+), 45 deletions(-) create mode 100644 app/Auth/CustomerUserProvider.php 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/NavigationItemType.php create mode 100644 app/Enums/PageStatus.php create mode 100644 app/Enums/ProductStatus.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/Enums/ThemeStatus.php create mode 100644 app/Enums/VariantStatus.php create mode 100644 app/Http/Middleware/ResolveStore.php create mode 100644 app/Livewire/Admin/Auth/Login.php create mode 100644 app/Livewire/Admin/Auth/Logout.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/Models/Concerns/BelongsToStore.php create mode 100644 app/Models/Customer.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/CollectionPolicy.php create mode 100644 app/Policies/CustomerPolicy.php create mode 100644 app/Policies/DiscountPolicy.php create mode 100644 app/Policies/FulfillmentPolicy.php create mode 100644 app/Policies/OrderPolicy.php create mode 100644 app/Policies/PagePolicy.php create mode 100644 app/Policies/ProductPolicy.php create mode 100644 app/Policies/RefundPolicy.php create mode 100644 app/Policies/StorePolicy.php create mode 100644 app/Policies/ThemePolicy.php create mode 100644 app/Traits/ChecksStoreRole.php create mode 100644 database/factories/CustomerFactory.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_03_20_000001_create_organizations_table.php create mode 100644 database/migrations/2026_03_20_000002_create_stores_table.php create mode 100644 database/migrations/2026_03_20_000003_create_store_domains_table.php create mode 100644 database/migrations/2026_03_20_000004_add_store_columns_to_users_table.php create mode 100644 database/migrations/2026_03_20_000005_create_store_users_table.php create mode 100644 database/migrations/2026_03_20_000006_create_store_settings_table.php create mode 100644 database/migrations/2026_03_20_000007_create_customers_table.php create mode 100644 resources/views/livewire/admin/auth/login.blade.php create mode 100644 resources/views/livewire/admin/auth/logout.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 tests/Feature/Auth/AdminAuthTest.php create mode 100644 tests/Feature/Auth/CustomerAuthTest.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..f3641576 --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,34 @@ +injectStoreId($credentials); + + return parent::retrieveByCredentials($credentials); + } + + public function validateCredentials(Authenticatable $user, array $credentials): bool + { + return parent::validateCredentials($user, $credentials); + } + + /** + * @param array $credentials + * @return array + */ + protected function injectStoreId(array $credentials): array + { + if (! isset($credentials['store_id']) && app()->bound('current_store')) { + $credentials['store_id'] = app('current_store')->id; + } + + return $credentials; + } +} 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 @@ +resolveForAdmin($request, $next); + } + + return $this->resolveForStorefront($request, $next); + } + + protected function resolveForStorefront(Request $request, Closure $next): Response + { + $hostname = $request->getHost(); + + $storeId = Cache::remember( + "store_domain:{$hostname}", + 300, + function () use ($hostname) { + $domain = StoreDomain::query() + ->where('hostname', $hostname) + ->first(); + + return $domain?->store_id; + } + ); + + if (! $storeId) { + abort(404); + } + + $store = Store::query()->find($storeId); + + if (! $store) { + abort(404); + } + + if ($store->status === StoreStatus::Suspended) { + abort(503); + } + + app()->instance('current_store', $store); + + return $next($request); + } + + protected function resolveForAdmin(Request $request, Closure $next): Response + { + $storeId = $request->session()->get('current_store_id'); + + if (! $storeId) { + abort(404); + } + + $store = Store::query()->find($storeId); + + if (! $store) { + abort(404); + } + + if ($request->user()) { + $hasAccess = $request->user()->stores() + ->where('stores.id', $store->id) + ->exists(); + + if (! $hasAccess) { + abort(403); + } + } + + app()->instance('current_store', $store); + + return $next($request); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..42c379da --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,67 @@ + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + public function authenticate(): mixed + { + $this->validate(); + + $throttleKey = 'login:'.$this->getIp(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + + throw ValidationException::withMessages([ + 'email' => __('Too many attempts. Try again in :seconds seconds.', ['seconds' => $seconds]), + ]); + } + + if (! Auth::guard('web')->attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { + RateLimiter::hit($throttleKey, 60); + + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials.'), + ]); + } + + RateLimiter::clear($throttleKey); + + session()->regenerate(); + + return redirect()->intended('/admin'); + } + + public function render(): mixed + { + return view('livewire.admin.auth.login'); + } + + protected function getIp(): string + { + return request()->ip() ?? '127.0.0.1'; + } +} diff --git a/app/Livewire/Admin/Auth/Logout.php b/app/Livewire/Admin/Auth/Logout.php new file mode 100644 index 00000000..9624995d --- /dev/null +++ b/app/Livewire/Admin/Auth/Logout.php @@ -0,0 +1,25 @@ +logout(); + + Session::invalidate(); + Session::regenerateToken(); + + return redirect('/admin/login'); + } + + public function render(): mixed + { + return view('livewire.admin.auth.logout'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..e69b0101 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,67 @@ + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + public function authenticate(): mixed + { + $this->validate(); + + $throttleKey = 'login:'.$this->getIp(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + + throw ValidationException::withMessages([ + 'email' => __('Too many attempts. Try again in :seconds seconds.', ['seconds' => $seconds]), + ]); + } + + if (! Auth::guard('customer')->attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { + RateLimiter::hit($throttleKey, 60); + + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials.'), + ]); + } + + RateLimiter::clear($throttleKey); + + session()->regenerate(); + + return redirect()->intended('/account'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.login'); + } + + protected function getIp(): string + { + return request()->ip() ?? '127.0.0.1'; + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..352484f4 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,68 @@ + */ + public function rules(): array + { + $storeId = app()->bound('current_store') ? app('current_store')->id : null; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + "unique:customers,email,NULL,id,store_id,{$storeId}", + ], + 'password' => ['required', 'string', Password::defaults(), 'confirmed'], + 'marketing_opt_in' => ['boolean'], + ]; + } + + public function register(): mixed + { + $this->validate(); + + $store = app('current_store'); + + $customer = Customer::query()->create([ + 'store_id' => $store->id, + 'name' => $this->name, + 'email' => $this->email, + 'password' => $this->password, + 'marketing_opt_in' => $this->marketing_opt_in, + ]); + + Auth::guard('customer')->login($customer); + + session()->regenerate(); + + return redirect('/account'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.register'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..5a492ac5 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,19 @@ +store_id && app()->bound('current_store')) { + $model->store_id = app('current_store')->id; + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..8ea4832a --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,38 @@ + 'hashed', + 'marketing_opt_in' => 'boolean', + ]; + } + + 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..7adc1950 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,22 @@ +hasMany(Store::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..a9b71042 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,17 @@ +bound('current_store')) { + $builder->where($model->getTable().'.store_id', app('current_store')->id); + } + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..4a64045a --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,60 @@ + StoreStatus::class, + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..b04acc22 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,37 @@ + StoreDomainType::class, + 'is_primary' => 'boolean', + 'created_at' => 'datetime', + ]; + } + + 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..465bc533 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,36 @@ + 'array', + 'updated_at' => 'datetime', + ]; + } + + 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..bd30edbd --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,29 @@ + StoreUserRole::class, + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..38c88610 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,8 +2,9 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; 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; @@ -14,22 +15,14 @@ class User extends Authenticatable /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable, TwoFactorAuthenticatable; - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ 'name', 'email', 'password', + 'status', + 'last_login_at', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ protected $hidden = [ 'password', 'two_factor_secret', @@ -37,22 +30,35 @@ class User extends Authenticatable 'remember_token', ]; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'last_login_at' => 'datetime', ]; } - /** - * Get the user's initials - */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $pivot = $this->stores()->where('stores.id', $store->id)->first(); + + if (! $pivot) { + return null; + } + + $role = $pivot->pivot->role; + + return $role instanceof StoreUserRole ? $role : StoreUserRole::from($role); + } + public function initials(): string { return Str::of($this->name) diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..67bf4759 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,41 @@ +resolveStoreId(); + + return $storeId && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Model $collection): bool + { + return $this->isAnyRole($user, $collection->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Model $collection): bool + { + return $this->isOwnerAdminOrStaff($user, $collection->store_id); + } + + public function delete(User $user, Model $collection): bool + { + return $this->isOwnerOrAdmin($user, $collection->store_id); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..4096fc13 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,29 @@ +resolveStoreId(); + + return $storeId && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Customer $customer): bool + { + return $this->isAnyRole($user, $customer->store_id); + } + + public function update(User $user, Customer $customer): bool + { + return $this->isOwnerAdminOrStaff($user, $customer->store_id); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..b73c52a5 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,41 @@ +resolveStoreId(); + + return $storeId && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Model $discount): bool + { + return $this->isAnyRole($user, $discount->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Model $discount): bool + { + return $this->isOwnerAdminOrStaff($user, $discount->store_id); + } + + public function delete(User $user, Model $discount): bool + { + return $this->isOwnerOrAdmin($user, $discount->store_id); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..cbaa00e0 --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,27 @@ +isOwnerAdminOrStaff($user, $order->store_id); + } + + public function update(User $user, Model $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } + + public function cancel(User $user, Model $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..bf817709 --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,44 @@ +resolveStoreId(); + + return $storeId && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Model $order): bool + { + return $this->isAnyRole($user, $order->store_id); + } + + public function update(User $user, Model $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function cancel(User $user, Model $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } + + public function createFulfillment(User $user, Model $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function createRefund(User $user, Model $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..1649090d --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,41 @@ +resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function view(User $user, Model $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Model $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function delete(User $user, Model $page): bool + { + return $this->isOwnerOrAdmin($user, $page->store_id); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..7ae4d92d --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,51 @@ +resolveStoreId(); + + return $storeId && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Model $product): bool + { + return $this->isAnyRole($user, $product->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Model $product): bool + { + return $this->isOwnerAdminOrStaff($user, $product->store_id); + } + + public function delete(User $user, Model $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function archive(User $user, Model $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function restore(User $user, Model $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..b5dafdb9 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,17 @@ +isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..eb79584c --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,28 @@ +isOwnerOrAdmin($user, $store->id); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->id); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, $store->id, [StoreUserRole::Owner]); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..ca75ad21 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,46 @@ +resolveStoreId(); + + return $storeId && $this->isOwnerOrAdmin($user, $storeId); + } + + public function view(User $user, Model $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveStoreId(); + + return $storeId && $this->isOwnerOrAdmin($user, $storeId); + } + + public function update(User $user, Model $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function delete(User $user, Model $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function publish(User $user, Model $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..0d98cb7f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,13 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; +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,11 +28,27 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureRateLimiting(); + $this->configureAuthProviders(); } /** * Configure default behaviors for production-ready applications. */ + protected function configureRateLimiting(): void + { + RateLimiter::for('login', function ($request) { + return Limit::perMinute(5)->by($request->ip()); + }); + } + + protected function configureAuthProviders(): void + { + Auth::provider('customers', function ($app, array $config) { + return new CustomerUserProvider($app['hash'], $config['model']); + }); + } + protected function configureDefaults(): void { Date::use(CarbonImmutable::class); diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..260e4dfe --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,58 @@ +stores()->where('stores.id', $storeId)->first(); + + if (! $pivot) { + return null; + } + + $role = $pivot->pivot->role; + + return $role instanceof StoreUserRole ? $role : StoreUserRole::from($role); + } + + /** @param array $roles */ + protected function hasRole(User $user, int $storeId, array $roles): bool + { + $role = $this->getStoreRole($user, $storeId); + + if (! $role) { + return false; + } + + return in_array($role, $roles); + } + + protected function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + protected function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + protected function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } + + protected function resolveStoreId(): ?int + { + if (app()->bound('current_store')) { + return app('current_store')->id; + } + + return null; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..4458511c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'resolve.store' => ResolveStore::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..5d418caa 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' => 'eloquent', + '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/config/database.php b/config/database.php index df933e7f..ecfaacf9 100644 --- a/config/database.php +++ b/config/database.php @@ -37,9 +37,9 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'wal', + 'synchronous' => 'normal', 'transaction_mode' => 'DEFERRED', ], diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..fb6719a0 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,34 @@ + */ +class CustomerFactory extends Factory +{ + protected $model = Customer::class; + + protected static ?string $password; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password' => static::$password ??= Hash::make('password'), + 'name' => fake()->name(), + 'marketing_opt_in' => false, + ]; + } + + public function guest(): static + { + return $this->state(fn (array $attributes) => [ + 'password' => null, + ]); + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..1e859031 --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,20 @@ + */ +class OrganizationFactory extends Factory +{ + protected $model = Organization::class; + + 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..3f5849db --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,39 @@ + */ +class StoreDomainFactory extends Factory +{ + protected $model = StoreDomain::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + 'tls_mode' => 'managed', + ]; + } + + public function admin(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => StoreDomainType::Admin, + ]); + } + + public function api(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => StoreDomainType::Api, + ]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..33086358 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,37 @@ + */ +class StoreFactory extends Factory +{ + protected $model = Store::class; + + public function definition(): array + { + $name = fake()->unique()->company(); + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug($name), + 'status' => StoreStatus::Active, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => StoreStatus::Suspended, + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..62e05e01 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,21 @@ + */ +class StoreSettingsFactory extends Factory +{ + protected $model = StoreSettings::class; + + 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..83f01e75 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -28,6 +28,7 @@ public function definition(): array 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), + 'status' => 'active', 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, diff --git a/database/migrations/2026_03_20_000001_create_organizations_table.php b/database/migrations/2026_03_20_000001_create_organizations_table.php new file mode 100644 index 00000000..0817a566 --- /dev/null +++ b/database/migrations/2026_03_20_000001_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_03_20_000002_create_stores_table.php b/database/migrations/2026_03_20_000002_create_stores_table.php new file mode 100644 index 00000000..4d6ae3d3 --- /dev/null +++ b/database/migrations/2026_03_20_000002_create_stores_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle')->unique(); + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_03_20_000003_create_store_domains_table.php b/database/migrations/2026_03_20_000003_create_store_domains_table.php new file mode 100644 index 00000000..4f970b70 --- /dev/null +++ b/database/migrations/2026_03_20_000003_create_store_domains_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('hostname')->unique(); + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_03_20_000004_add_store_columns_to_users_table.php b/database/migrations/2026_03_20_000004_add_store_columns_to_users_table.php new file mode 100644 index 00000000..e04e2e6c --- /dev/null +++ b/database/migrations/2026_03_20_000004_add_store_columns_to_users_table.php @@ -0,0 +1,23 @@ +string('status')->default('active')->after('remember_token'); + $table->timestamp('last_login_at')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['status', 'last_login_at']); + }); + } +}; diff --git a/database/migrations/2026_03_20_000005_create_store_users_table.php b/database/migrations/2026_03_20_000005_create_store_users_table.php new file mode 100644 index 00000000..d3b0c818 --- /dev/null +++ b/database/migrations/2026_03_20_000005_create_store_users_table.php @@ -0,0 +1,27 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_03_20_000006_create_store_settings_table.php b/database/migrations/2026_03_20_000006_create_store_settings_table.php new file mode 100644 index 00000000..b742c433 --- /dev/null +++ b/database/migrations/2026_03_20_000006_create_store_settings_table.php @@ -0,0 +1,22 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2026_03_20_000007_create_customers_table.php b/database/migrations/2026_03_20_000007_create_customers_table.php new file mode 100644 index 00000000..8734038b --- /dev/null +++ b/database/migrations/2026_03_20_000007_create_customers_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('password')->nullable(); + $table->string('name')->nullable(); + $table->boolean('marketing_opt_in')->default(false); + $table->timestamps(); + + $table->unique(['store_id', 'email'], 'idx_customers_store_email'); + $table->index('store_id', 'idx_customers_store_id'); + }); + + Schema::create('customer_password_reset_tokens', function (Blueprint $table) { + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + Schema::dropIfExists('customers'); + } +}; 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..ba0e0302 --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,30 @@ +
+

{{ __('Admin Login') }}

+ +
+ + + + + + + + {{ __('Log in') }} + + +
diff --git a/resources/views/livewire/admin/auth/logout.blade.php b/resources/views/livewire/admin/auth/logout.blade.php new file mode 100644 index 00000000..1456a385 --- /dev/null +++ b/resources/views/livewire/admin/auth/logout.blade.php @@ -0,0 +1,3 @@ + + {{ __('Log out') }} + 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..c7b9fccd --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,30 @@ +
+

{{ __('Customer Login') }}

+ +
+ + + + + + + + {{ __('Log in') }} + + +
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..1f776edc --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,46 @@ +
+

{{ __('Create Account') }}

+ +
+ + + + + + + + + + + + {{ __('Create Account') }} + + +
diff --git a/routes/web.php b/routes/web.php index f755f111..ff6b25c4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,9 @@ name('dashboard'); require __DIR__.'/settings.php'; + +// Admin auth routes +Route::prefix('admin')->group(function () { + Route::get('login', AdminLogin::class) + ->middleware('guest') + ->name('admin.login'); + + Route::post('logout', [AdminLogout::class, 'logout']) + ->middleware('auth') + ->name('admin.logout'); +}); + +// Customer storefront auth routes +Route::middleware('resolve.store:storefront')->group(function () { + Route::get('account/login', CustomerLogin::class) + ->middleware('guest:customer') + ->name('customer.login'); + + Route::get('account/register', CustomerRegister::class) + ->middleware('guest:customer') + ->name('customer.register'); +}); diff --git a/tests/Feature/Auth/AdminAuthTest.php b/tests/Feature/Auth/AdminAuthTest.php new file mode 100644 index 00000000..4b11de2c --- /dev/null +++ b/tests/Feature/Auth/AdminAuthTest.php @@ -0,0 +1,111 @@ +get('/admin/login')->assertOk(); +}); + +it('authenticates an admin user with valid credentials', function () { + $context = createStoreContext(); + + Livewire::test(Login::class) + ->set('email', $context['user']->email) + ->set('password', 'password') + ->call('authenticate') + ->assertRedirect('/admin'); + + $this->assertAuthenticatedAs($context['user'], 'web'); +}); + +it('rejects invalid credentials', function () { + $context = createStoreContext(); + + Livewire::test(Login::class) + ->set('email', $context['user']->email) + ->set('password', 'wrong-password') + ->call('authenticate') + ->assertHasErrors('email'); + + $this->assertGuest('web'); +}); + +it('shows generic error message on failed login', function () { + createStoreContext(); + + Livewire::test(Login::class) + ->set('email', 'nonexistent@example.com') + ->set('password', 'password') + ->call('authenticate') + ->assertHasErrors('email'); +}); + +it('validates required fields', function () { + Livewire::test(Login::class) + ->set('email', '') + ->set('password', '') + ->call('authenticate') + ->assertHasErrors(['email', 'password']); +}); + +it('rate limits login attempts', function () { + $context = createStoreContext(); + + for ($i = 0; $i < 5; $i++) { + Livewire::test(Login::class) + ->set('email', $context['user']->email) + ->set('password', 'wrong-password') + ->call('authenticate'); + } + + Livewire::test(Login::class) + ->set('email', $context['user']->email) + ->set('password', 'wrong-password') + ->call('authenticate') + ->assertHasErrors('email'); +}); + +it('supports remember me functionality', function () { + $context = createStoreContext(); + + Livewire::test(Login::class) + ->set('email', $context['user']->email) + ->set('password', 'password') + ->set('remember', true) + ->call('authenticate') + ->assertRedirect('/admin'); + + $this->assertAuthenticatedAs($context['user'], 'web'); +}); + +it('regenerates session on login', function () { + $context = createStoreContext(); + + Livewire::test(Login::class) + ->set('email', $context['user']->email) + ->set('password', 'password') + ->call('authenticate'); + + $this->assertAuthenticatedAs($context['user'], 'web'); +}); + +it('logs out an admin user', function () { + $context = createStoreContext(); + $this->actingAs($context['user'], 'web'); + + Livewire::test(Logout::class) + ->call('logout') + ->assertRedirect('/admin/login'); + + $this->assertGuest('web'); +}); + +it('preserves existing user data after login', function () { + $context = createStoreContext(); + + expect($context['user']->name)->not->toBeEmpty() + ->and($context['user']->email)->not->toBeEmpty() + ->and($context['user']->status)->toBe('active'); +}); diff --git a/tests/Feature/Auth/CustomerAuthTest.php b/tests/Feature/Auth/CustomerAuthTest.php new file mode 100644 index 00000000..d48a8442 --- /dev/null +++ b/tests/Feature/Auth/CustomerAuthTest.php @@ -0,0 +1,165 @@ +hostname; + + $this->get("http://{$hostname}/account/login") + ->assertOk(); +}); + +it('authenticates a customer with valid credentials', function () { + $context = createStoreContext(); + + $customer = Customer::factory()->create([ + 'store_id' => $context['store']->id, + 'email' => 'customer@example.com', + 'password' => Hash::make('password'), + ]); + + Livewire::test(Login::class) + ->set('email', 'customer@example.com') + ->set('password', 'password') + ->call('authenticate') + ->assertRedirect('/account'); + + $this->assertAuthenticatedAs($customer, 'customer'); +}); + +it('rejects invalid customer credentials', function () { + $context = createStoreContext(); + + Customer::factory()->create([ + 'store_id' => $context['store']->id, + 'email' => 'customer@example.com', + 'password' => Hash::make('password'), + ]); + + Livewire::test(Login::class) + ->set('email', 'customer@example.com') + ->set('password', 'wrong-password') + ->call('authenticate') + ->assertHasErrors('email'); + + $this->assertGuest('customer'); +}); + +it('validates required fields on customer login', function () { + createStoreContext(); + + Livewire::test(Login::class) + ->set('email', '') + ->set('password', '') + ->call('authenticate') + ->assertHasErrors(['email', 'password']); +}); + +it('rate limits customer login attempts', function () { + $context = createStoreContext(); + + Customer::factory()->create([ + 'store_id' => $context['store']->id, + 'email' => 'customer@example.com', + 'password' => Hash::make('password'), + ]); + + $component = Livewire::test(Login::class); + + for ($i = 0; $i < 5; $i++) { + $component->set('email', 'customer@example.com') + ->set('password', 'wrong') + ->call('authenticate'); + } + + $component->set('email', 'customer@example.com') + ->set('password', 'wrong') + ->call('authenticate') + ->assertHasErrors('email'); +}); + +it('renders the customer registration page', function () { + $context = createStoreContext(); + $hostname = $context['domain']->hostname; + + $this->get("http://{$hostname}/account/register") + ->assertOk(); +}); + +it('registers a new customer', function () { + $context = createStoreContext(); + + Livewire::test(Register::class) + ->set('name', 'New Customer') + ->set('email', 'new@example.com') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->call('register') + ->assertRedirect('/account'); + + $this->assertDatabaseHas('customers', [ + 'store_id' => $context['store']->id, + 'email' => 'new@example.com', + 'name' => 'New Customer', + ]); +}); + +it('validates registration fields', function () { + createStoreContext(); + + Livewire::test(Register::class) + ->set('name', '') + ->set('email', '') + ->set('password', '') + ->set('password_confirmation', '') + ->call('register') + ->assertHasErrors(['name', 'email', 'password']); +}); + +it('prevents duplicate email registration per store', function () { + $context = createStoreContext(); + + Customer::factory()->create([ + 'store_id' => $context['store']->id, + 'email' => 'existing@example.com', + ]); + + Livewire::test(Register::class) + ->set('name', 'Another') + ->set('email', 'existing@example.com') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->call('register') + ->assertHasErrors('email'); +}); + +it('scopes customer auth to current store', function () { + $context = createStoreContext(); + + // Create customer in a different store + $otherStore = Store::factory()->create([ + 'organization_id' => $context['organization']->id, + ]); + + Customer::factory()->create([ + 'store_id' => $otherStore->id, + 'email' => 'customer@example.com', + 'password' => Hash::make('password'), + ]); + + // Try to log in from the main store - should fail because the customer + // belongs to a different store + Livewire::test(Login::class) + ->set('email', 'customer@example.com') + ->set('password', 'password') + ->call('authenticate') + ->assertHasErrors('email'); + + $this->assertGuest('customer'); +}); diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php new file mode 100644 index 00000000..90bb57ac --- /dev/null +++ b/tests/Feature/Tenancy/StoreIsolationTest.php @@ -0,0 +1,102 @@ +create([ + 'store_id' => $context['store']->id, + 'email' => 'john@example.com', + ]); + + // Create customer in different store + $otherStore = Store::factory()->create([ + 'organization_id' => $context['organization']->id, + ]); + $customer2 = Customer::factory()->create([ + 'store_id' => $otherStore->id, + 'email' => 'john@example.com', + ]); + + // With StoreScope, only the current store's customer should be returned + $customers = Customer::query()->get(); + + expect($customers)->toHaveCount(1) + ->and($customers->first()->id)->toBe($customer1->id); +}); + +it('auto-sets store_id on creating models with BelongsToStore trait', function () { + $context = createStoreContext(); + + $customer = Customer::query()->create([ + 'email' => 'auto@example.com', + 'name' => 'Auto Test', + 'password' => 'password', + ]); + + expect($customer->store_id)->toBe($context['store']->id); +}); + +it('allows same email in different stores', function () { + $context = createStoreContext(); + + Customer::factory()->create([ + 'store_id' => $context['store']->id, + 'email' => 'shared@example.com', + ]); + + $otherStore = Store::factory()->create([ + 'organization_id' => $context['organization']->id, + ]); + + // Remove the current_store binding to avoid scope interference + app()->forgetInstance('current_store'); + app()->instance('current_store', $otherStore); + + $otherCustomer = Customer::factory()->create([ + 'store_id' => $otherStore->id, + 'email' => 'shared@example.com', + ]); + + expect($otherCustomer->exists)->toBeTrue(); +}); + +it('does not apply store scope when current_store is not bound', function () { + $store1 = Store::factory()->create(); + $store2 = Store::factory()->create(); + + Customer::factory()->create(['store_id' => $store1->id]); + Customer::factory()->create(['store_id' => $store2->id]); + + // Remove current_store binding + app()->forgetInstance('current_store'); + + $customers = Customer::withoutGlobalScopes()->get(); + + expect($customers)->toHaveCount(2); +}); + +it('isolates store data across organization stores', function () { + $context = createStoreContext(); + + Customer::factory()->count(3)->create([ + 'store_id' => $context['store']->id, + ]); + + $otherStore = Store::factory()->create([ + 'organization_id' => $context['organization']->id, + ]); + + app()->forgetInstance('current_store'); + app()->instance('current_store', $otherStore); + + Customer::factory()->count(2)->create([ + 'store_id' => $otherStore->id, + ]); + + // Should only see 2 customers for the current store + expect(Customer::query()->count())->toBe(2); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..6d361715 --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,46 @@ +hostname; + + $response = $this->get("http://{$hostname}/account/login"); + + $response->assertOk(); +}); + +it('returns 404 for unknown hostname on storefront routes', function () { + $response = $this->get('http://unknown-store.example.com/account/login'); + + $response->assertNotFound(); +}); + +it('returns 503 for suspended store on storefront', function () { + $context = createStoreContext(); + $context['store']->update(['status' => StoreStatus::Suspended]); + + $response = $this->get("http://{$context['domain']->hostname}/account/login"); + + $response->assertServiceUnavailable(); +}); + +it('caches hostname-to-store mapping', function () { + $context = createStoreContext(); + $hostname = $context['domain']->hostname; + + $this->get("http://{$hostname}/account/login")->assertOk(); + $this->get("http://{$hostname}/account/login")->assertOk(); +}); + +it('renders the admin login page without store resolution', function () { + $this->get('/admin/login')->assertOk(); +}); + +it('binds current_store to the container after resolution', function () { + $context = createStoreContext(); + + expect(app()->bound('current_store'))->toBeTrue() + ->and(app('current_store')->id)->toBe($context['store']->id); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..e9d7cc57 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,29 +1,26 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); /* |-------------------------------------------------------------------------- | Expectations |-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| */ expect()->extend('toBeOne', function () { @@ -34,14 +31,51 @@ |-------------------------------------------------------------------------- | Functions |-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| */ -function something() +/** + * Create a full store context with Organization, Store, StoreDomain, and an Owner user. + * Binds 'current_store' in the container. + * + * @return array{organization: Organization, store: Store, domain: StoreDomain, user: User} + */ +function createStoreContext(): array +{ + $organization = Organization::factory()->create(); + + $store = Store::factory()->create([ + 'organization_id' => $organization->id, + ]); + + $domain = StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'test-store.example.com', + 'is_primary' => true, + ]); + + $user = User::factory()->create(); + $user->stores()->attach($store->id, ['role' => StoreUserRole::Owner->value]); + + app()->instance('current_store', $store); + + return compact('organization', 'store', 'domain', 'user'); +} + +/** + * Authenticate as an admin user and set the store in session. + */ +function actingAsAdmin(User $user, ?Store $store = null): \Illuminate\Testing\TestCase +{ + $store = $store ?? app('current_store'); + + return test()->actingAs($user, 'web') + ->withSession(['current_store_id' => $store->id]); +} + +/** + * Authenticate as a customer user using the customer guard. + */ +function actingAsCustomer(Customer $customer): \Illuminate\Testing\TestCase { - // .. + return test()->actingAs($customer, 'customer'); } From 3af3af93713287c32720a161c0b9ae2c15ecd76a Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 21:32:35 +0100 Subject: [PATCH 04/17] Phase 2: Catalog - Products, Variants, Inventory, Collections, Media Add complete catalog system with: - 9 migrations for products, options, variants, inventory, collections, and media tables - 7 models (Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia) with relationships - 7 enums (ProductStatus, VariantStatus, CollectionStatus, CollectionType, MediaType, MediaStatus, InventoryPolicy) - 7 factories with useful states - ProductService (CRUD + status transitions with state machine validation) - VariantMatrixService (cartesian product rebuild, orphan handling) - InventoryService (reserve/release/commit/restock with transactions) - HandleGenerator (unique slug generation per store) - ProcessMediaUpload job - Custom exceptions (InvalidProductTransitionException, InsufficientInventoryException) - 50 passing tests across 6 test files Co-Authored-By: Claude Opus 4.6 (1M context) --- .../InsufficientInventoryException.php | 7 + .../InvalidProductTransitionException.php | 7 + app/Jobs/ProcessMediaUpload.php | 64 ++++++ app/Models/Collection.php | 46 +++++ app/Models/InventoryItem.php | 49 +++++ app/Models/Product.php | 69 +++++++ app/Models/ProductMedia.php | 48 +++++ app/Models/ProductOption.php | 31 +++ app/Models/ProductOptionValue.php | 25 +++ app/Models/ProductVariant.php | 56 ++++++ app/Models/Store.php | 15 ++ app/Services/InventoryService.php | 60 ++++++ app/Services/ProductService.php | 161 +++++++++++++++ app/Services/VariantMatrixService.php | 145 ++++++++++++++ app/Support/HandleGenerator.php | 41 ++++ database/factories/CollectionFactory.php | 44 +++++ database/factories/InventoryItemFactory.php | 41 ++++ database/factories/ProductFactory.php | 46 +++++ database/factories/ProductMediaFactory.php | 47 +++++ database/factories/ProductOptionFactory.php | 22 +++ .../factories/ProductOptionValueFactory.php | 22 +++ database/factories/ProductVariantFactory.php | 45 +++++ ...026_03_20_000010_create_products_table.php | 37 ++++ ...20_000011_create_product_options_table.php | 26 +++ ...012_create_product_option_values_table.php | 26 +++ ...0_000013_create_product_variants_table.php | 38 ++++ ...014_create_variant_option_values_table.php | 24 +++ ...20_000015_create_inventory_items_table.php | 27 +++ ..._03_20_000016_create_collections_table.php | 31 +++ ...00017_create_collection_products_table.php | 26 +++ ...3_20_000018_create_product_media_table.php | 35 ++++ tests/Feature/Catalog/CollectionTest.php | 94 +++++++++ tests/Feature/Catalog/HandleGeneratorTest.php | 75 +++++++ tests/Feature/Catalog/InventoryTest.php | 140 +++++++++++++ tests/Feature/Catalog/MediaUploadTest.php | 80 ++++++++ tests/Feature/Catalog/ProductCrudTest.php | 152 ++++++++++++++ tests/Feature/Catalog/VariantTest.php | 185 ++++++++++++++++++ 37 files changed, 2087 insertions(+) create mode 100644 app/Exceptions/InsufficientInventoryException.php create mode 100644 app/Exceptions/InvalidProductTransitionException.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_03_20_000010_create_products_table.php create mode 100644 database/migrations/2026_03_20_000011_create_product_options_table.php create mode 100644 database/migrations/2026_03_20_000012_create_product_option_values_table.php create mode 100644 database/migrations/2026_03_20_000013_create_product_variants_table.php create mode 100644 database/migrations/2026_03_20_000014_create_variant_option_values_table.php create mode 100644 database/migrations/2026_03_20_000015_create_inventory_items_table.php create mode 100644 database/migrations/2026_03_20_000016_create_collections_table.php create mode 100644 database/migrations/2026_03_20_000017_create_collection_products_table.php create mode 100644 database/migrations/2026_03_20_000018_create_product_media_table.php create mode 100644 tests/Feature/Catalog/CollectionTest.php create mode 100644 tests/Feature/Catalog/HandleGeneratorTest.php create mode 100644 tests/Feature/Catalog/InventoryTest.php create mode 100644 tests/Feature/Catalog/MediaUploadTest.php create mode 100644 tests/Feature/Catalog/ProductCrudTest.php create mode 100644 tests/Feature/Catalog/VariantTest.php diff --git a/app/Exceptions/InsufficientInventoryException.php b/app/Exceptions/InsufficientInventoryException.php new file mode 100644 index 00000000..ffe0ae3e --- /dev/null +++ b/app/Exceptions/InsufficientInventoryException.php @@ -0,0 +1,7 @@ + */ + private const SIZES = [ + 'thumbnail' => ['width' => 150, 'height' => 150], + 'medium' => ['width' => 600, 'height' => 600], + 'large' => ['width' => 1200, 'height' => 1200], + ]; + + public function __construct( + public ProductMedia $media + ) {} + + public function handle(): void + { + try { + $disk = Storage::disk('public'); + $path = $this->media->storage_key; + + if (! $disk->exists($path)) { + $this->media->update(['status' => MediaStatus::Failed]); + + return; + } + + $fullPath = $disk->path($path); + $imageInfo = @getimagesize($fullPath); + + if ($imageInfo === false) { + $this->media->update(['status' => MediaStatus::Failed]); + + return; + } + + $this->media->update([ + 'width' => $imageInfo[0], + 'height' => $imageInfo[1], + 'mime_type' => $imageInfo['mime'], + 'byte_size' => $disk->size($path), + 'status' => MediaStatus::Ready, + ]); + } catch (\Throwable) { + $this->media->update(['status' => MediaStatus::Failed]); + } + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..7609e796 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,46 @@ + CollectionStatus::class, + 'type' => CollectionType::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position') + ->orderByPivot('position'); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..36766389 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,49 @@ + InventoryPolicy::class, + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function getAvailableAttribute(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..f6b7b941 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,69 @@ + ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + + public function options(): HasMany + { + return $this->hasMany(ProductOption::class); + } + + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position'); + } + + public function defaultVariant(): HasMany + { + return $this->hasMany(ProductVariant::class)->where('is_default', true); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..e7ddd4d3 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,48 @@ + MediaType::class, + 'status' => MediaStatus::class, + 'width' => 'integer', + 'height' => 'integer', + 'byte_size' => 'integer', + 'created_at' => 'datetime', + ]; + } + + 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..8b06bc0c --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,31 @@ +belongsTo(Product::class); + } + + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class)->orderBy('position'); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..70b7e9b3 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,25 @@ +belongsTo(ProductOption::class, 'product_option_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..96d3fa46 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,56 @@ + VariantStatus::class, + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index 4a64045a..2398c715 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -57,4 +57,19 @@ public function customers(): HasMany { return $this->hasMany(Customer::class); } + + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + public function collections(): HasMany + { + return $this->hasMany(Collection::class); + } + + public function inventoryItems(): HasMany + { + return $this->hasMany(InventoryItem::class); + } } diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..f84b98b1 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,60 @@ +policy === InventoryPolicy::Continue) { + return true; + } + + return $item->available >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item = InventoryItem::lockForUpdate()->find($item->id); + + if ($item->policy === InventoryPolicy::Deny && $item->available < $quantity) { + throw new InsufficientInventoryException( + "Insufficient inventory: requested {$quantity}, available {$item->available}." + ); + } + + $item->increment('quantity_reserved', $quantity); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item = InventoryItem::lockForUpdate()->find($item->id); + $item->decrement('quantity_reserved', $quantity); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item = InventoryItem::lockForUpdate()->find($item->id); + $item->decrement('quantity_on_hand', $quantity); + $item->decrement('quantity_reserved', $quantity); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item = InventoryItem::lockForUpdate()->find($item->id); + $item->increment('quantity_on_hand', $quantity); + }); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..88ffa364 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,161 @@ +handleGenerator->generate( + $data['title'], + 'products', + $store->id + ); + + $product = Product::create([ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $handle, + 'status' => ProductStatus::Draft, + 'description_html' => $data['description_html'] ?? null, + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + ]); + + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'price_amount' => $data['price_amount'] ?? 0, + 'currency' => $store->default_currency, + 'is_default' => true, + 'position' => 0, + ]); + + InventoryItem::create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); + + return $product->load('variants.inventoryItem'); + }); + } + + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data) { + if (isset($data['title']) && ! isset($data['handle'])) { + $data['handle'] = $this->handleGenerator->generate( + $data['title'], + 'products', + $product->store_id, + $product->id + ); + } + + $product->update($data); + + return $product->fresh(); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $currentStatus = $product->status; + + $this->validateTransition($product, $currentStatus, $newStatus); + + $product->update(['status' => $newStatus]); + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $product->update(['published_at' => now()]); + } + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft) { + throw new InvalidProductTransitionException( + 'Only draft products can be deleted.' + ); + } + + if ($this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException( + 'Cannot delete product with existing order references.' + ); + } + + $product->delete(); + } + + private function validateTransition(Product $product, ProductStatus $from, ProductStatus $to): void + { + $allowed = match ($from) { + ProductStatus::Draft => [ProductStatus::Active, ProductStatus::Archived], + ProductStatus::Active => [ProductStatus::Archived, ProductStatus::Draft], + ProductStatus::Archived => [ProductStatus::Active, ProductStatus::Draft], + }; + + if (! in_array($to, $allowed)) { + throw new InvalidProductTransitionException( + "Cannot transition from {$from->value} to {$to->value}." + ); + } + + if ($to === ProductStatus::Active) { + $hasVariantWithPrice = $product->variants() + ->where('price_amount', '>', 0) + ->exists(); + + if (! $hasVariantWithPrice) { + throw new InvalidProductTransitionException( + 'Product must have at least one variant with a price greater than zero to be activated.' + ); + } + + if (empty($product->title)) { + throw new InvalidProductTransitionException( + 'Product title must not be empty to be activated.' + ); + } + } + + if ($to === ProductStatus::Draft && in_array($from, [ProductStatus::Active, ProductStatus::Archived])) { + if ($this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException( + 'Cannot revert to draft because order lines reference this product.' + ); + } + } + } + + private function hasOrderReferences(Product $product): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + $variantIds = $product->variants()->pluck('id'); + + return DB::table('order_lines') + ->whereIn('variant_id', $variantIds) + ->exists(); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..05b01f41 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,145 @@ +load('options.values'); + $options = $product->options; + + if ($options->isEmpty()) { + $this->ensureDefaultVariant($product); + + return; + } + + $optionValues = $options->map(fn ($option) => $option->values->pluck('id')->all())->all(); + $desiredCombos = $this->cartesianProduct($optionValues); + + $existingVariants = $product->variants()->with('optionValues')->get(); + + $firstVariant = $existingVariants->first(); + $defaultPrice = $firstVariant ? $firstVariant->price_amount : 0; + $defaultCurrency = $firstVariant ? $firstVariant->currency : $product->store->default_currency ?? 'USD'; + + $matched = []; + + foreach ($desiredCombos as $position => $combo) { + $comboSet = collect($combo)->sort()->values()->all(); + + $existing = $existingVariants->first(function ($variant) use ($comboSet) { + $variantSet = $variant->optionValues->pluck('id')->sort()->values()->all(); + + return $variantSet === $comboSet; + }); + + if ($existing) { + $matched[] = $existing->id; + $existing->update(['position' => $position]); + } else { + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'price_amount' => $defaultPrice, + 'currency' => $defaultCurrency, + 'is_default' => false, + 'position' => $position, + 'status' => VariantStatus::Active, + ]); + + $variant->optionValues()->sync($combo); + + InventoryItem::create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); + + $matched[] = $variant->id; + } + } + + $orphaned = $existingVariants->whereNotIn('id', $matched); + + foreach ($orphaned as $variant) { + if ($this->variantHasOrderReferences($variant)) { + $variant->update(['status' => VariantStatus::Archived]); + } else { + $variant->delete(); + } + } + }); + } + + /** + * @param array> $arrays + * @return array> + */ + private function cartesianProduct(array $arrays): array + { + if (empty($arrays)) { + return [[]]; + } + + $result = [[]]; + + foreach ($arrays as $values) { + $newResult = []; + foreach ($result as $combo) { + foreach ($values as $value) { + $newResult[] = array_merge($combo, [$value]); + } + } + $result = $newResult; + } + + return $result; + } + + private function ensureDefaultVariant(Product $product): void + { + $hasDefault = $product->variants()->where('is_default', true)->exists(); + + if (! $hasDefault) { + $firstVariant = $product->variants()->first(); + $defaultPrice = $firstVariant ? $firstVariant->price_amount : 0; + $defaultCurrency = $firstVariant ? $firstVariant->currency : 'USD'; + + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'price_amount' => $defaultPrice, + 'currency' => $defaultCurrency, + 'is_default' => true, + 'position' => 0, + ]); + + InventoryItem::create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); + } + } + + private function variantHasOrderReferences(ProductVariant $variant): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + return DB::table('order_lines') + ->where('variant_id', $variant->id) + ->exists(); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..8a3a203e --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,41 @@ +handleExists($handle, $table, $storeId, $excludeId)) { + $suffix++; + $handle = "{$base}-{$suffix}"; + } + + return $handle; + } + + private function handleExists(string $handle, string $table, int $storeId, ?int $excludeId): bool + { + $query = DB::table($table) + ->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..f3b1e53a --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,44 @@ + */ +class CollectionFactory extends Factory +{ + protected $model = Collection::class; + + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'description_html' => '

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

', + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CollectionStatus::Draft, + ]); + } + + public function automated(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => CollectionType::Automated, + ]); + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..5ee074c4 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,41 @@ + */ +class InventoryItemFactory extends Factory +{ + protected $model = InventoryItem::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => fake()->numberBetween(0, 100), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } + + public function oversellable(): static + { + return $this->state(fn (array $attributes) => [ + 'policy' => InventoryPolicy::Continue, + ]); + } + + public function outOfStock(): static + { + return $this->state(fn (array $attributes) => [ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + ]); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..b9b02ba0 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,46 @@ + */ +class ProductFactory extends Factory +{ + protected $model = Product::class; + + public function definition(): array + { + $title = fake()->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'status' => ProductStatus::Draft, + 'description_html' => '

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

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Clothing', 'Electronics', 'Books', 'Home']), + 'tags' => [fake()->word(), fake()->word()], + ]; + } + + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Active, + 'published_at' => now(), + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ProductStatus::Archived, + ]); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..fbffe35f --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,47 @@ + */ +class ProductMediaFactory extends Factory +{ + protected $model = ProductMedia::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => MediaType::Image, + 'storage_key' => 'products/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(3), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => fake()->numberBetween(10000, 500000), + 'position' => 0, + 'status' => MediaStatus::Ready, + ]; + } + + public function processing(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => MediaStatus::Processing, + ]); + } + + public function video(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => MediaType::Video, + 'storage_key' => 'products/'.fake()->uuid().'.mp4', + 'mime_type' => 'video/mp4', + ]); + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..412d4349 --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,22 @@ + */ +class ProductOptionFactory extends Factory +{ + protected $model = ProductOption::class; + + 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..18701695 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,22 @@ + */ +class ProductOptionValueFactory extends Factory +{ + protected $model = ProductOptionValue::class; + + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => fake()->randomElement(['Small', 'Medium', 'Large', 'Red', 'Blue', 'Green']), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..3900b053 --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,45 @@ + */ +class ProductVariantFactory extends Factory +{ + protected $model = ProductVariant::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => fake()->unique()->bothify('SKU-####-??'), + 'barcode' => fake()->optional()->ean13(), + 'price_amount' => fake()->numberBetween(100, 99999), + 'compare_at_amount' => null, + 'currency' => 'USD', + 'weight_g' => fake()->optional()->numberBetween(50, 5000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => VariantStatus::Active, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => VariantStatus::Archived, + ]); + } +} diff --git a/database/migrations/2026_03_20_000010_create_products_table.php b/database/migrations/2026_03_20_000010_create_products_table.php new file mode 100644 index 00000000..6cfb2602 --- /dev/null +++ b/database/migrations/2026_03_20_000010_create_products_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->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->text('tags')->default('[]'); + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_03_20_000011_create_product_options_table.php b/database/migrations/2026_03_20_000011_create_product_options_table.php new file mode 100644 index 00000000..e30eadd2 --- /dev/null +++ b/database/migrations/2026_03_20_000011_create_product_options_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('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_03_20_000012_create_product_option_values_table.php b/database/migrations/2026_03_20_000012_create_product_option_values_table.php new file mode 100644 index 00000000..8693e567 --- /dev/null +++ b/database/migrations/2026_03_20_000012_create_product_option_values_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_option_id')->constrained('product_options')->cascadeOnDelete(); + $table->string('value'); + $table->unsignedInteger('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_03_20_000013_create_product_variants_table.php b/database/migrations/2026_03_20_000013_create_product_variants_table.php new file mode 100644 index 00000000..06f4238b --- /dev/null +++ b/database/migrations/2026_03_20_000013_create_product_variants_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->unsignedInteger('price_amount')->default(0); + $table->unsignedInteger('compare_at_amount')->nullable(); + $table->string('currency')->default('USD'); + $table->unsignedInteger('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->unsignedInteger('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_03_20_000014_create_variant_option_values_table.php b/database/migrations/2026_03_20_000014_create_variant_option_values_table.php new file mode 100644 index 00000000..867f7fc7 --- /dev/null +++ b/database/migrations/2026_03_20_000014_create_variant_option_values_table.php @@ -0,0 +1,24 @@ +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_03_20_000015_create_inventory_items_table.php b/database/migrations/2026_03_20_000015_create_inventory_items_table.php new file mode 100644 index 00000000..dcae7d96 --- /dev/null +++ b/database/migrations/2026_03_20_000015_create_inventory_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->unique()->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->string('policy')->default('deny'); + + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_03_20_000016_create_collections_table.php b/database/migrations/2026_03_20_000016_create_collections_table.php new file mode 100644 index 00000000..150dcfb7 --- /dev/null +++ b/database/migrations/2026_03_20_000016_create_collections_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained()->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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_03_20_000017_create_collection_products_table.php b/database/migrations/2026_03_20_000017_create_collection_products_table.php new file mode 100644 index 00000000..02494312 --- /dev/null +++ b/database/migrations/2026_03_20_000017_create_collection_products_table.php @@ -0,0 +1,26 @@ +foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('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_03_20_000018_create_product_media_table.php b/database/migrations/2026_03_20_000018_create_product_media_table.php new file mode 100644 index 00000000..3141a8a3 --- /dev/null +++ b/database/migrations/2026_03_20_000018_create_product_media_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('type')->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('byte_size')->nullable(); + $table->unsignedInteger('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/tests/Feature/Catalog/CollectionTest.php b/tests/Feature/Catalog/CollectionTest.php new file mode 100644 index 00000000..15392549 --- /dev/null +++ b/tests/Feature/Catalog/CollectionTest.php @@ -0,0 +1,94 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +it('creates a collection', function () { + $collection = Collection::create([ + 'store_id' => $this->store->id, + 'title' => 'Summer Sale', + 'handle' => 'summer-sale', + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]); + + expect($collection->title)->toBe('Summer Sale') + ->and($collection->handle)->toBe('summer-sale') + ->and($collection->type)->toBe(CollectionType::Manual) + ->and($collection->status)->toBe(CollectionStatus::Active); +}); + +it('attaches products to a collection with position', function () { + $collection = Collection::factory()->create(['store_id' => $this->store->id]); + $product1 = Product::factory()->create(['store_id' => $this->store->id]); + $product2 = Product::factory()->create(['store_id' => $this->store->id]); + + $collection->products()->attach($product1->id, ['position' => 0]); + $collection->products()->attach($product2->id, ['position' => 1]); + + $products = $collection->fresh()->products; + expect($products)->toHaveCount(2) + ->and($products->first()->pivot->position)->toBe(0) + ->and($products->last()->pivot->position)->toBe(1); +}); + +it('lists collections for a product', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + $collection1 = Collection::factory()->create(['store_id' => $this->store->id]); + $collection2 = Collection::factory()->create(['store_id' => $this->store->id]); + + $collection1->products()->attach($product->id); + $collection2->products()->attach($product->id); + + expect($product->fresh()->collections)->toHaveCount(2); +}); + +it('scopes collections to the current store', function () { + Collection::factory()->create(['store_id' => $this->store->id]); + $otherStore = Store::factory()->create(); + Collection::factory()->create(['store_id' => $otherStore->id]); + + expect(Collection::count())->toBe(1); +}); + +it('creates a collection using the factory', function () { + $collection = Collection::factory()->create(['store_id' => $this->store->id]); + + expect($collection)->toBeInstanceOf(Collection::class) + ->and($collection->store_id)->toBe($this->store->id); +}); + +it('creates a draft collection using factory state', function () { + $collection = Collection::factory()->draft()->create(['store_id' => $this->store->id]); + + expect($collection->status)->toBe(CollectionStatus::Draft); +}); + +it('supports automated collection type', function () { + $collection = Collection::factory()->automated()->create(['store_id' => $this->store->id]); + + expect($collection->type)->toBe(CollectionType::Automated); +}); + +it('orders products within a collection by position', function () { + $collection = Collection::factory()->create(['store_id' => $this->store->id]); + $product1 = Product::factory()->create(['store_id' => $this->store->id]); + $product2 = Product::factory()->create(['store_id' => $this->store->id]); + $product3 = Product::factory()->create(['store_id' => $this->store->id]); + + $collection->products()->attach($product3->id, ['position' => 2]); + $collection->products()->attach($product1->id, ['position' => 0]); + $collection->products()->attach($product2->id, ['position' => 1]); + + $ordered = $collection->fresh()->products; + expect($ordered->first()->id)->toBe($product1->id) + ->and($ordered->last()->id)->toBe($product3->id); +}); diff --git a/tests/Feature/Catalog/HandleGeneratorTest.php b/tests/Feature/Catalog/HandleGeneratorTest.php new file mode 100644 index 00000000..626730ce --- /dev/null +++ b/tests/Feature/Catalog/HandleGeneratorTest.php @@ -0,0 +1,75 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->generator = new HandleGenerator; +}); + +it('generates a slug from title', function () { + $handle = $this->generator->generate('My Cool Product', 'products', $this->store->id); + + expect($handle)->toBe('my-cool-product'); +}); + +it('appends a suffix on collision', function () { + Product::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'widget', + ]); + + $handle = $this->generator->generate('Widget', 'products', $this->store->id); + + expect($handle)->toBe('widget-1'); +}); + +it('increments suffix on multiple collisions', function () { + Product::factory()->create(['store_id' => $this->store->id, 'handle' => 'gadget']); + Product::factory()->create(['store_id' => $this->store->id, 'handle' => 'gadget-1']); + + $handle = $this->generator->generate('Gadget', 'products', $this->store->id); + + expect($handle)->toBe('gadget-2'); +}); + +it('excludes current record when regenerating', function () { + $product = Product::factory()->create([ + 'store_id' => $this->store->id, + 'handle' => 'existing-product', + ]); + + $handle = $this->generator->generate( + 'Existing Product', + 'products', + $this->store->id, + $product->id + ); + + expect($handle)->toBe('existing-product'); +}); + +it('handles empty title gracefully', function () { + $handle = $this->generator->generate('', 'products', $this->store->id); + + expect($handle)->toBe('item'); +}); + +it('handles special characters in title', function () { + $handle = $this->generator->generate('Product @#$% Special!', 'products', $this->store->id); + + expect($handle)->toBe('product-at-special'); +}); + +it('scopes uniqueness to store', function () { + $otherStore = Store::factory()->create(); + + Product::factory()->create(['store_id' => $otherStore->id, 'handle' => 'shared-name']); + + $handle = $this->generator->generate('Shared Name', 'products', $this->store->id); + + expect($handle)->toBe('shared-name'); +}); diff --git a/tests/Feature/Catalog/InventoryTest.php b/tests/Feature/Catalog/InventoryTest.php new file mode 100644 index 00000000..e894c428 --- /dev/null +++ b/tests/Feature/Catalog/InventoryTest.php @@ -0,0 +1,140 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = new InventoryService; +}); + +function createInventoryItem(Store $store, array $overrides = []): InventoryItem +{ + $variant = ProductVariant::factory()->create([ + 'product_id' => \App\Models\Product::factory()->create(['store_id' => $store->id])->id, + ]); + + return InventoryItem::create(array_merge([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ], $overrides)); +} + +it('checks availability returns true when stock is sufficient', function () { + $item = createInventoryItem($this->store, ['quantity_on_hand' => 10]); + + expect($this->service->checkAvailability($item, 5))->toBeTrue(); +}); + +it('checks availability returns false when stock is insufficient with deny policy', function () { + $item = createInventoryItem($this->store, ['quantity_on_hand' => 3]); + + expect($this->service->checkAvailability($item, 5))->toBeFalse(); +}); + +it('checks availability returns true with continue policy regardless of stock', function () { + $item = createInventoryItem($this->store, [ + 'quantity_on_hand' => 0, + 'policy' => InventoryPolicy::Continue, + ]); + + expect($this->service->checkAvailability($item, 5))->toBeTrue(); +}); + +it('reserves stock successfully', function () { + $item = createInventoryItem($this->store, ['quantity_on_hand' => 10]); + + $this->service->reserve($item, 3); + + $item->refresh(); + expect($item->quantity_reserved)->toBe(3) + ->and($item->quantity_on_hand)->toBe(10) + ->and($item->available)->toBe(7); +}); + +it('throws exception when reserving more than available with deny policy', function () { + $item = createInventoryItem($this->store, ['quantity_on_hand' => 2]); + + $this->service->reserve($item, 5); +})->throws(InsufficientInventoryException::class); + +it('allows reserving beyond stock with continue policy', function () { + $item = createInventoryItem($this->store, [ + 'quantity_on_hand' => 2, + 'policy' => InventoryPolicy::Continue, + ]); + + $this->service->reserve($item, 5); + + $item->refresh(); + expect($item->quantity_reserved)->toBe(5); +}); + +it('releases reserved stock', function () { + $item = createInventoryItem($this->store, [ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 5, + ]); + + $this->service->release($item, 3); + + $item->refresh(); + expect($item->quantity_reserved)->toBe(2) + ->and($item->available)->toBe(8); +}); + +it('commits stock reducing both on_hand and reserved', function () { + $item = createInventoryItem($this->store, [ + 'quantity_on_hand' => 10, + 'quantity_reserved' => 5, + ]); + + $this->service->commit($item, 3); + + $item->refresh(); + expect($item->quantity_on_hand)->toBe(7) + ->and($item->quantity_reserved)->toBe(2); +}); + +it('restocks by incrementing on_hand', function () { + $item = createInventoryItem($this->store, ['quantity_on_hand' => 5]); + + $this->service->restock($item, 10); + + $item->refresh(); + expect($item->quantity_on_hand)->toBe(15); +}); + +it('computes available quantity correctly', function () { + $item = createInventoryItem($this->store, [ + 'quantity_on_hand' => 20, + 'quantity_reserved' => 8, + ]); + + expect($item->available)->toBe(12); +}); + +it('handles full lifecycle: reserve, commit, restock', function () { + $item = createInventoryItem($this->store, ['quantity_on_hand' => 10]); + + $this->service->reserve($item, 3); + $item->refresh(); + expect($item->available)->toBe(7); + + $this->service->commit($item, 3); + $item->refresh(); + expect($item->quantity_on_hand)->toBe(7) + ->and($item->quantity_reserved)->toBe(0); + + $this->service->restock($item, 5); + $item->refresh(); + expect($item->quantity_on_hand)->toBe(12); +}); diff --git a/tests/Feature/Catalog/MediaUploadTest.php b/tests/Feature/Catalog/MediaUploadTest.php new file mode 100644 index 00000000..9fe3344f --- /dev/null +++ b/tests/Feature/Catalog/MediaUploadTest.php @@ -0,0 +1,80 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); +}); + +it('creates product media with processing status', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + + $media = ProductMedia::create([ + 'product_id' => $product->id, + 'type' => MediaType::Image, + 'storage_key' => 'products/test.jpg', + 'position' => 0, + 'status' => MediaStatus::Processing, + ]); + + expect($media->status)->toBe(MediaStatus::Processing) + ->and($media->type)->toBe(MediaType::Image); +}); + +it('lists media for a product ordered by position', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + + ProductMedia::factory()->create(['product_id' => $product->id, 'position' => 2]); + ProductMedia::factory()->create(['product_id' => $product->id, 'position' => 0]); + ProductMedia::factory()->create(['product_id' => $product->id, 'position' => 1]); + + $media = $product->media()->orderBy('position')->get(); + expect($media)->toHaveCount(3) + ->and($media->first()->position)->toBe(0) + ->and($media->last()->position)->toBe(2); +}); + +it('marks media as failed when file does not exist', function () { + Storage::fake('public'); + + $product = Product::factory()->create(['store_id' => $this->store->id]); + + $media = ProductMedia::create([ + 'product_id' => $product->id, + 'type' => MediaType::Image, + 'storage_key' => 'products/nonexistent.jpg', + 'position' => 0, + 'status' => MediaStatus::Processing, + ]); + + $job = new ProcessMediaUpload($media); + $job->handle(); + + expect($media->fresh()->status)->toBe(MediaStatus::Failed); +}); + +it('creates video media type', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + + $media = ProductMedia::factory()->video()->create(['product_id' => $product->id]); + + expect($media->type)->toBe(MediaType::Video) + ->and($media->mime_type)->toBe('video/mp4'); +}); + +it('uses the factory to create ready media', function () { + $product = Product::factory()->create(['store_id' => $this->store->id]); + + $media = ProductMedia::factory()->create(['product_id' => $product->id]); + + expect($media->status)->toBe(MediaStatus::Ready) + ->and($media->width)->toBe(1200) + ->and($media->height)->toBe(1200); +}); diff --git a/tests/Feature/Catalog/ProductCrudTest.php b/tests/Feature/Catalog/ProductCrudTest.php new file mode 100644 index 00000000..c06b3d4f --- /dev/null +++ b/tests/Feature/Catalog/ProductCrudTest.php @@ -0,0 +1,152 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->service = new ProductService(new HandleGenerator); +}); + +it('creates a product with a default variant', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Test Product', + 'price_amount' => 2999, + ]); + + expect($product)->toBeInstanceOf(Product::class) + ->and($product->title)->toBe('Test Product') + ->and($product->handle)->toBe('test-product') + ->and($product->status)->toBe(ProductStatus::Draft) + ->and($product->store_id)->toBe($this->store->id) + ->and($product->variants)->toHaveCount(1); + + $variant = $product->variants->first(); + expect($variant->is_default)->toBeTrue() + ->and($variant->price_amount)->toBe(2999) + ->and($variant->inventoryItem)->not->toBeNull(); +}); + +it('creates a product with custom handle', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Test Product', + 'handle' => 'custom-handle', + ]); + + expect($product->handle)->toBe('custom-handle'); +}); + +it('generates unique handles on collision', function () { + $this->service->create($this->store, ['title' => 'Widget']); + $product2 = $this->service->create($this->store, ['title' => 'Widget']); + + expect($product2->handle)->toBe('widget-1'); +}); + +it('updates a product', function () { + $product = $this->service->create($this->store, ['title' => 'Old Title']); + + $updated = $this->service->update($product, [ + 'title' => 'New Title', + 'vendor' => 'Acme Corp', + ]); + + expect($updated->title)->toBe('New Title') + ->and($updated->vendor)->toBe('Acme Corp') + ->and($updated->handle)->toBe('new-title'); +}); + +it('transitions from draft to active when preconditions are met', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Active Product', + 'price_amount' => 1000, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); + + expect($product->fresh()->status)->toBe(ProductStatus::Active) + ->and($product->fresh()->published_at)->not->toBeNull(); +}); + +it('blocks draft to active if no variant has price', function () { + $product = $this->service->create($this->store, [ + 'title' => 'No Price Product', + 'price_amount' => 0, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); +})->throws(\App\Exceptions\InvalidProductTransitionException::class); + +it('transitions from active to archived', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Test', + 'price_amount' => 1000, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); + $this->service->transitionStatus($product->fresh(), ProductStatus::Archived); + + expect($product->fresh()->status)->toBe(ProductStatus::Archived); +}); + +it('transitions from archived back to active', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Test', + 'price_amount' => 1000, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); + $this->service->transitionStatus($product->fresh(), ProductStatus::Archived); + $this->service->transitionStatus($product->fresh(), ProductStatus::Active); + + expect($product->fresh()->status)->toBe(ProductStatus::Active); +}); + +it('deletes a draft product with no order references', function () { + $product = $this->service->create($this->store, ['title' => 'To Delete']); + + $this->service->delete($product); + + expect(Product::find($product->id))->toBeNull(); +}); + +it('blocks deletion of non-draft products', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Test', + 'price_amount' => 1000, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); + + $this->service->delete($product->fresh()); +})->throws(\App\Exceptions\InvalidProductTransitionException::class); + +it('stores tags as json array', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Tagged Product', + 'tags' => ['summer', 'sale'], + ]); + + $fresh = $product->fresh(); + expect($fresh->tags)->toBe(['summer', 'sale']); +}); + +it('sets published_at only on first activation', function () { + $product = $this->service->create($this->store, [ + 'title' => 'Test', + 'price_amount' => 1000, + ]); + + $this->service->transitionStatus($product, ProductStatus::Active); + $firstPublishedAt = $product->fresh()->published_at; + + $this->service->transitionStatus($product->fresh(), ProductStatus::Archived); + $this->service->transitionStatus($product->fresh(), ProductStatus::Active); + + expect($product->fresh()->published_at->toDateTimeString()) + ->toBe($firstPublishedAt->toDateTimeString()); +}); diff --git a/tests/Feature/Catalog/VariantTest.php b/tests/Feature/Catalog/VariantTest.php new file mode 100644 index 00000000..c728ad12 --- /dev/null +++ b/tests/Feature/Catalog/VariantTest.php @@ -0,0 +1,185 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->matrixService = new VariantMatrixService; + $this->productService = new ProductService(new HandleGenerator); +}); + +it('creates a default variant when product has no options', function () { + $product = $this->productService->create($this->store, [ + 'title' => 'Simple Product', + 'price_amount' => 1500, + ]); + + $this->matrixService->rebuildMatrix($product); + + $variants = $product->fresh()->variants; + expect($variants)->toHaveCount(1) + ->and($variants->first()->is_default)->toBeTrue(); +}); + +it('builds variant matrix from options', function () { + $product = $this->productService->create($this->store, [ + 'title' => 'T-Shirt', + 'price_amount' => 2500, + ]); + + $sizeOption = ProductOption::create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'S', 'position' => 0]); + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'M', 'position' => 1]); + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'L', 'position' => 2]); + + $this->matrixService->rebuildMatrix($product); + + $variants = $product->fresh()->variants()->where('status', VariantStatus::Active)->get(); + expect($variants)->toHaveCount(3); + + foreach ($variants as $variant) { + expect($variant->inventoryItem)->not->toBeNull(); + } +}); + +it('builds cartesian product for multiple options', function () { + $product = $this->productService->create($this->store, [ + 'title' => 'T-Shirt', + 'price_amount' => 2500, + ]); + + $sizeOption = ProductOption::create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'S', 'position' => 0]); + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'M', 'position' => 1]); + + $colorOption = ProductOption::create([ + 'product_id' => $product->id, + 'name' => 'Color', + 'position' => 1, + ]); + ProductOptionValue::create(['product_option_id' => $colorOption->id, 'value' => 'Red', 'position' => 0]); + ProductOptionValue::create(['product_option_id' => $colorOption->id, 'value' => 'Blue', 'position' => 1]); + + $this->matrixService->rebuildMatrix($product); + + $variants = $product->fresh()->variants()->where('status', VariantStatus::Active)->get(); + expect($variants)->toHaveCount(4); +}); + +it('preserves existing variants when matrix is rebuilt', function () { + $product = $this->productService->create($this->store, [ + 'title' => 'Shoe', + 'price_amount' => 5000, + ]); + + $sizeOption = ProductOption::create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + $small = ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'S', 'position' => 0]); + $medium = ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'M', 'position' => 1]); + + $this->matrixService->rebuildMatrix($product); + + $firstVariant = $product->fresh()->variants()->orderBy('position')->first(); + $firstVariant->update(['sku' => 'SHOE-S', 'price_amount' => 7500]); + + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'L', 'position' => 2]); + + $this->matrixService->rebuildMatrix($product); + + $preserved = ProductVariant::find($firstVariant->id); + expect($preserved->sku)->toBe('SHOE-S') + ->and($preserved->price_amount)->toBe(7500); + + $allVariants = $product->fresh()->variants()->where('status', VariantStatus::Active)->get(); + expect($allVariants)->toHaveCount(3); +}); + +it('removes orphaned variants without order references', function () { + $product = $this->productService->create($this->store, [ + 'title' => 'Test', + 'price_amount' => 1000, + ]); + + $sizeOption = ProductOption::create([ + 'product_id' => $product->id, + 'name' => 'Size', + 'position' => 0, + ]); + $small = ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'S', 'position' => 0]); + $medium = ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'M', 'position' => 1]); + + $this->matrixService->rebuildMatrix($product); + + expect($product->fresh()->variants)->toHaveCount(2); + + $medium->delete(); + + $this->matrixService->rebuildMatrix($product); + + $activeVariants = $product->fresh()->variants()->where('status', VariantStatus::Active)->get(); + expect($activeVariants)->toHaveCount(1); +}); + +it('attaches option values to variants via pivot', function () { + $product = $this->productService->create($this->store, [ + 'title' => 'Hat', + 'price_amount' => 1500, + ]); + + $colorOption = ProductOption::create([ + 'product_id' => $product->id, + 'name' => 'Color', + 'position' => 0, + ]); + ProductOptionValue::create(['product_option_id' => $colorOption->id, 'value' => 'Red', 'position' => 0]); + ProductOptionValue::create(['product_option_id' => $colorOption->id, 'value' => 'Blue', 'position' => 1]); + + $this->matrixService->rebuildMatrix($product); + + $variants = $product->fresh()->variants()->with('optionValues')->where('status', VariantStatus::Active)->get(); + + foreach ($variants as $variant) { + expect($variant->optionValues)->toHaveCount(1); + } +}); + +it('creates inventory items for new variants', function () { + $product = $this->productService->create($this->store, [ + 'title' => 'Book', + 'price_amount' => 999, + ]); + + $sizeOption = ProductOption::create([ + 'product_id' => $product->id, + 'name' => 'Format', + 'position' => 0, + ]); + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'Paperback', 'position' => 0]); + ProductOptionValue::create(['product_option_id' => $sizeOption->id, 'value' => 'Hardcover', 'position' => 1]); + + $this->matrixService->rebuildMatrix($product); + + $inventoryCount = InventoryItem::where('store_id', $this->store->id)->count(); + expect($inventoryCount)->toBeGreaterThanOrEqual(2); +}); From 6bb21bbb10dad9789e22ec507844f65a51180a2a Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 21:36:39 +0100 Subject: [PATCH 05/17] Phase 3: Themes, Pages, Navigation, Storefront Layout Add complete theme system with storefront rendering infrastructure: - Migrations for themes, theme_files, theme_settings, pages, navigation_menus, navigation_items - Models with relationships, enums (ThemeStatus, PageStatus, NavigationItemType), and factories - ThemeSettingsService singleton for loading/caching active theme settings - NavigationService with buildTree and resolveUrl for dynamic navigation - Storefront Blade layout with header, footer, cart drawer, search modal, dark mode - Livewire components: Home, Collections (Index/Show), Products/Show, Cart/Show, CartDrawer, Search (Index/Modal), Pages/Show - Reusable Blade components: breadcrumbs, price, badge, product-card, quantity-selector, pagination, address-form, order-summary - Error pages (404, 503) - Storefront routes - Seeders for themes, pages, and navigation menus - 30 passing tests covering models, services, and storefront rendering Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Livewire/Storefront/Cart/Show.php | 16 ++ app/Livewire/Storefront/CartDrawer.php | 37 +++ app/Livewire/Storefront/Collections/Index.php | 16 ++ app/Livewire/Storefront/Collections/Show.php | 23 ++ app/Livewire/Storefront/Home.php | 20 ++ app/Livewire/Storefront/Pages/Show.php | 30 +++ app/Livewire/Storefront/Products/Show.php | 23 ++ app/Livewire/Storefront/Search/Index.php | 23 ++ app/Livewire/Storefront/Search/Modal.php | 31 +++ app/Models/NavigationItem.php | 38 +++ app/Models/NavigationMenu.php | 30 +++ app/Models/Page.php | 36 +++ app/Models/Theme.php | 47 ++++ app/Models/ThemeFile.php | 34 +++ app/Models/ThemeSettings.php | 47 ++++ app/Providers/AppServiceProvider.php | 3 +- app/Services/NavigationService.php | 78 ++++++ app/Services/ThemeSettingsService.php | 68 +++++ database/factories/NavigationItemFactory.php | 53 ++++ database/factories/NavigationMenuFactory.php | 25 ++ database/factories/PageFactory.php | 44 ++++ database/factories/ThemeFactory.php | 33 +++ database/factories/ThemeFileFactory.php | 27 ++ database/factories/ThemeSettingsFactory.php | 32 +++ .../2026_03_20_202542_create_themes_table.php | 29 +++ ..._03_20_202546_create_theme_files_table.php | 28 ++ ..._20_202546_create_theme_settings_table.php | 22 ++ ...0_202547_create_navigation_items_table.php | 29 +++ ...0_202547_create_navigation_menus_table.php | 27 ++ .../2026_03_20_202547_create_pages_table.php | 31 +++ database/seeders/NavigationSeeder.php | 136 ++++++++++ database/seeders/PageSeeder.php | 62 +++++ database/seeders/ThemeSeeder.php | 71 ++++++ .../storefront/address-form.blade.php | 86 +++++++ .../components/storefront/badge.blade.php | 14 + .../storefront/breadcrumbs.blade.php | 24 ++ .../storefront/order-summary.blade.php | 45 ++++ .../storefront/pagination.blade.php | 54 ++++ .../components/storefront/price.blade.php | 18 ++ .../storefront/product-card.blade.php | 51 ++++ .../storefront/quantity-selector.blade.php | 35 +++ .../views/layouts/storefront/app.blade.php | 241 ++++++++++++++++++ .../livewire/storefront/cart-drawer.blade.php | 70 +++++ .../livewire/storefront/cart/show.blade.php | 9 + .../storefront/collections/index.blade.php | 8 + .../storefront/collections/show.blade.php | 8 + .../views/livewire/storefront/home.blade.php | 73 ++++++ .../livewire/storefront/pages/show.blade.php | 16 ++ .../storefront/products/show.blade.php | 6 + .../storefront/search/index.blade.php | 17 ++ .../storefront/search/modal.blade.php | 49 ++++ .../views/storefront/errors/404.blade.php | 33 +++ .../views/storefront/errors/503.blade.php | 18 ++ routes/web.php | 12 +- .../Navigation/NavigationServiceTest.php | 158 ++++++++++++ tests/Feature/Pages/PageModelTest.php | 83 ++++++ tests/Feature/Storefront/PageDisplayTest.php | 52 ++++ tests/Feature/Themes/ThemeModelTest.php | 91 +++++++ .../Themes/ThemeSettingsServiceTest.php | 71 ++++++ 59 files changed, 2589 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Storefront/Cart/Show.php create mode 100644 app/Livewire/Storefront/CartDrawer.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/Home.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/Livewire/Storefront/Search/Modal.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_03_20_202542_create_themes_table.php create mode 100644 database/migrations/2026_03_20_202546_create_theme_files_table.php create mode 100644 database/migrations/2026_03_20_202546_create_theme_settings_table.php create mode 100644 database/migrations/2026_03_20_202547_create_navigation_items_table.php create mode 100644 database/migrations/2026_03_20_202547_create_navigation_menus_table.php create mode 100644 database/migrations/2026_03_20_202547_create_pages_table.php create mode 100644 database/seeders/NavigationSeeder.php create mode 100644 database/seeders/PageSeeder.php create mode 100644 database/seeders/ThemeSeeder.php create mode 100644 resources/views/components/storefront/address-form.blade.php create mode 100644 resources/views/components/storefront/badge.blade.php create mode 100644 resources/views/components/storefront/breadcrumbs.blade.php create mode 100644 resources/views/components/storefront/order-summary.blade.php create mode 100644 resources/views/components/storefront/pagination.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/components/storefront/quantity-selector.blade.php create mode 100644 resources/views/layouts/storefront/app.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/collections/index.blade.php create mode 100644 resources/views/livewire/storefront/collections/show.blade.php create mode 100644 resources/views/livewire/storefront/home.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 resources/views/livewire/storefront/search/modal.blade.php create mode 100644 resources/views/storefront/errors/404.blade.php create mode 100644 resources/views/storefront/errors/503.blade.php create mode 100644 tests/Feature/Navigation/NavigationServiceTest.php create mode 100644 tests/Feature/Pages/PageModelTest.php create mode 100644 tests/Feature/Storefront/PageDisplayTest.php create mode 100644 tests/Feature/Themes/ThemeModelTest.php create mode 100644 tests/Feature/Themes/ThemeSettingsServiceTest.php diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..1d428cf9 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,16 @@ +layout('layouts.storefront.app', [ + 'title' => 'Cart', + ]); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..e3ad2014 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,37 @@ +itemCount = $itemCount; + $this->open = true; + } + + #[On('open-cart-drawer')] + public function openDrawer(): void + { + $this->open = true; + } + + #[On('close-cart-drawer')] + public function closeDrawer(): void + { + $this->open = false; + } + + public function render(): \Illuminate\View\View + { + return view('livewire.storefront.cart-drawer'); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..eefd04f1 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,16 @@ +layout('layouts.storefront.app', [ + 'title' => 'Collections', + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..df8f7a9e --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,23 @@ +handle = $handle; + } + + public function render(): \Illuminate\View\View + { + return view('livewire.storefront.collections.show') + ->layout('layouts.storefront.app', [ + 'title' => 'Collection', + ]); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..0efc4ec9 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,20 @@ + $themeSettings->all(), + ])->layout('layouts.storefront.app', [ + 'title' => 'Home', + ]); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..6974b338 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,30 @@ +handle = $handle; + $this->page = Page::where('handle', $handle) + ->where('status', PageStatus::Published) + ->firstOrFail(); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.storefront.pages.show') + ->layout('layouts.storefront.app', [ + 'title' => $this->page->title, + ]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..b2d0f729 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,23 @@ +handle = $handle; + } + + public function render(): \Illuminate\View\View + { + return view('livewire.storefront.products.show') + ->layout('layouts.storefront.app', [ + 'title' => 'Product', + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..47fa9d0e --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,23 @@ +query = request()->query('q', ''); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.storefront.search.index') + ->layout('layouts.storefront.app', [ + 'title' => 'Search', + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..245f9ceb --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,31 @@ +open = true; + } + + #[On('close-search-modal')] + public function closeModal(): void + { + $this->open = false; + $this->query = ''; + } + + public function render(): \Illuminate\View\View + { + return view('livewire.storefront.search.modal'); + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..e0eb69b9 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,38 @@ + NavigationItemType::class, + 'resource_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..5c1ea637 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,30 @@ +belongsTo(Store::class); + } + + 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..5461944a --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,36 @@ + PageStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..fc1c8279 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,47 @@ + ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + 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..5eff6a46 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,34 @@ + 'integer', + ]; + } + + 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..3b1d9393 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,47 @@ + 'array', + 'updated_at' => 'datetime', + ]; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } + + /** + * @param array $default + */ + public function get(string $key, mixed $default = null): mixed + { + return data_get($this->settings_json, $key, $default); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0d98cb7f..cb9441dd 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\Support\Facades\Auth; @@ -19,7 +20,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..a188929d --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,78 @@ + + */ + public function buildTree(NavigationMenu $menu): array + { + $storeId = $menu->store_id; + + return Cache::remember( + "navigation_tree:{$storeId}:{$menu->id}", + 300, + function () use ($menu) { + return $menu->items->map(function (NavigationItem $item) { + return [ + 'id' => $item->id, + 'label' => $item->label, + 'url' => $this->resolveUrl($item), + 'type' => $item->type->value, + ]; + })->all(); + } + ); + } + + public function resolveUrl(NavigationItem $item): string + { + return match ($item->type) { + NavigationItemType::Link => $item->url ?? '/', + NavigationItemType::Page => $this->resolvePageUrl($item->resource_id), + NavigationItemType::Collection => $this->resolveCollectionUrl($item->resource_id), + NavigationItemType::Product => $this->resolveProductUrl($item->resource_id), + }; + } + + private function resolvePageUrl(?int $resourceId): string + { + if (! $resourceId) { + return '/'; + } + + $page = Page::withoutGlobalScopes()->find($resourceId); + + return $page ? "/pages/{$page->handle}" : '/'; + } + + private function resolveCollectionUrl(?int $resourceId): string + { + if (! $resourceId) { + return '/collections'; + } + + $collection = \App\Models\Collection::withoutGlobalScopes()->find($resourceId); + + return $collection ? "/collections/{$collection->handle}" : '/collections'; + } + + private function resolveProductUrl(?int $resourceId): string + { + if (! $resourceId) { + return '/'; + } + + $product = \App\Models\Product::withoutGlobalScopes()->find($resourceId); + + return $product ? "/products/{$product->handle}" : '/'; + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..26c27e07 --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,68 @@ +settings) { + return $this->settings; + } + + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + $this->settings = Cache::remember( + "theme_settings:{$store->id}", + 300, + function () use ($store) { + $theme = Theme::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', ThemeStatus::Published) + ->first(); + + if (! $theme) { + return null; + } + + return $theme->settings; + } + ); + + return $this->settings; + } + + public function get(string $key, mixed $default = null): mixed + { + $settings = $this->load(); + + if (! $settings) { + return $default; + } + + return $settings->get($key, $default); + } + + public function all(): array + { + $settings = $this->load(); + + return $settings ? ($settings->settings_json ?? []) : []; + } + + public function reset(): void + { + $this->settings = null; + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..db5d586a --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,53 @@ + */ +class NavigationItemFactory extends Factory +{ + protected $model = NavigationItem::class; + + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => NavigationItemType::Link, + 'label' => fake()->words(2, true), + 'url' => '/', + 'resource_id' => null, + 'position' => 0, + ]; + } + + public function page(int $pageId): static + { + return $this->state(fn (array $attributes) => [ + 'type' => NavigationItemType::Page, + 'url' => null, + 'resource_id' => $pageId, + ]); + } + + public function collection(int $collectionId): static + { + return $this->state(fn (array $attributes) => [ + 'type' => NavigationItemType::Collection, + 'url' => null, + 'resource_id' => $collectionId, + ]); + } + + public function product(int $productId): static + { + return $this->state(fn (array $attributes) => [ + 'type' => NavigationItemType::Product, + 'url' => null, + 'resource_id' => $productId, + ]); + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..4b5a96c1 --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,25 @@ + */ +class NavigationMenuFactory extends Factory +{ + protected $model = NavigationMenu::class; + + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title), + 'title' => $title, + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..2bec8a65 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,44 @@ + */ +class PageFactory extends Factory +{ + protected $model = Page::class; + + public function definition(): array + { + $title = fake()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title), + 'body_html' => '

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

'.fake()->paragraphs(3, true).'

', + 'status' => PageStatus::Published, + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PageStatus::Draft, + 'published_at' => null, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PageStatus::Archived, + ]); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..c6a8cf70 --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,33 @@ + */ +class ThemeFactory extends Factory +{ + protected $model = Theme::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => 'Default Theme', + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..234d5611 --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,27 @@ + */ +class ThemeFileFactory extends Factory +{ + protected $model = ThemeFile::class; + + public function definition(): array + { + $path = 'templates/'.fake()->word().'.blade.php'; + + return [ + 'theme_id' => Theme::factory(), + 'path' => $path, + 'storage_key' => 'themes/'.Str::random(32), + 'sha256' => hash('sha256', fake()->text()), + 'byte_size' => fake()->numberBetween(100, 50000), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..f10997fa --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,32 @@ + */ +class ThemeSettingsFactory extends Factory +{ + protected $model = ThemeSettings::class; + + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => [ + 'primary_color' => '#1a1a2e', + 'secondary_color' => '#e94560', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Welcome to our store', + 'hero_subheading' => 'Discover our curated collection', + 'hero_cta_text' => 'Shop Now', + 'hero_cta_link' => '/collections/new-arrivals', + 'show_announcement_bar' => true, + 'announcement_text' => 'Free shipping on orders over 50 EUR', + 'products_per_page' => 12, + ], + ]; + } +} diff --git a/database/migrations/2026_03_20_202542_create_themes_table.php b/database/migrations/2026_03_20_202542_create_themes_table.php new file mode 100644 index 00000000..4df7043d --- /dev/null +++ b/database/migrations/2026_03_20_202542_create_themes_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id')->constrained()->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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_03_20_202546_create_theme_files_table.php b/database/migrations/2026_03_20_202546_create_theme_files_table.php new file mode 100644 index 00000000..48c952c7 --- /dev/null +++ b/database/migrations/2026_03_20_202546_create_theme_files_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256'); + $table->unsignedInteger('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_03_20_202546_create_theme_settings_table.php b/database/migrations/2026_03_20_202546_create_theme_settings_table.php new file mode 100644 index 00000000..5a332eb2 --- /dev/null +++ b/database/migrations/2026_03_20_202546_create_theme_settings_table.php @@ -0,0 +1,22 @@ +foreignId('theme_id')->primary()->constrained()->cascadeOnDelete(); + $table->json('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_03_20_202547_create_navigation_items_table.php b/database/migrations/2026_03_20_202547_create_navigation_items_table.php new file mode 100644 index 00000000..14c03c4f --- /dev/null +++ b/database/migrations/2026_03_20_202547_create_navigation_items_table.php @@ -0,0 +1,29 @@ +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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_03_20_202547_create_navigation_menus_table.php b/database/migrations/2026_03_20_202547_create_navigation_menus_table.php new file mode 100644 index 00000000..7907d156 --- /dev/null +++ b/database/migrations/2026_03_20_202547_create_navigation_menus_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('store_id')->constrained()->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_03_20_202547_create_pages_table.php b/database/migrations/2026_03_20_202547_create_pages_table.php new file mode 100644 index 00000000..1aa2c395 --- /dev/null +++ b/database/migrations/2026_03_20_202547_create_pages_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..69d744f5 --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,136 @@ +seedFashionMenus(); + $this->seedElectronicsMenus(); + } + + private function seedFashionMenus(): void + { + $store = Store::where('handle', 'like', '%fashion%')->first(); + + if (! $store) { + $store = Store::first(); + } + + if (! $store) { + return; + } + + // Main menu + $mainMenu = NavigationMenu::create([ + 'store_id' => $store->id, + 'handle' => 'main-menu', + 'title' => 'Main Menu', + ]); + + NavigationItem::create([ + 'menu_id' => $mainMenu->id, + 'type' => NavigationItemType::Link, + 'label' => 'Home', + 'url' => '/', + 'position' => 0, + ]); + + // Collection items - these reference collections that will be created in Phase 2 + // For now use link type with paths + $collectionLinks = [ + ['label' => 'New Arrivals', 'url' => '/collections/new-arrivals', 'position' => 1], + ['label' => 'T-Shirts', 'url' => '/collections/t-shirts', 'position' => 2], + ['label' => 'Pants & Jeans', 'url' => '/collections/pants-jeans', 'position' => 3], + ['label' => 'Sale', 'url' => '/collections/sale', 'position' => 4], + ]; + + foreach ($collectionLinks as $link) { + NavigationItem::create([ + 'menu_id' => $mainMenu->id, + 'type' => NavigationItemType::Link, + 'label' => $link['label'], + 'url' => $link['url'], + 'position' => $link['position'], + ]); + } + + // Footer menu + $footerMenu = NavigationMenu::create([ + 'store_id' => $store->id, + 'handle' => 'footer-menu', + 'title' => 'Footer Menu', + ]); + + $pages = Page::where('store_id', $store->id)->get(); + $pageMap = $pages->keyBy('handle'); + + $footerLinks = [ + ['label' => 'About Us', 'handle' => 'about', 'position' => 0], + ['label' => 'FAQ', 'handle' => 'faq', 'position' => 1], + ['label' => 'Shipping & Returns', 'handle' => 'shipping-returns', 'position' => 2], + ['label' => 'Privacy Policy', 'handle' => 'privacy-policy', 'position' => 3], + ['label' => 'Terms of Service', 'handle' => 'terms', 'position' => 4], + ]; + + foreach ($footerLinks as $link) { + $page = $pageMap->get($link['handle']); + + if ($page) { + NavigationItem::create([ + 'menu_id' => $footerMenu->id, + 'type' => NavigationItemType::Page, + 'label' => $link['label'], + 'resource_id' => $page->id, + 'position' => $link['position'], + ]); + } + } + } + + private function seedElectronicsMenus(): void + { + $store = Store::where('handle', 'like', '%electronics%')->first(); + + if (! $store) { + return; + } + + $mainMenu = NavigationMenu::create([ + 'store_id' => $store->id, + 'handle' => 'main-menu', + 'title' => 'Main Menu', + ]); + + NavigationItem::create([ + 'menu_id' => $mainMenu->id, + 'type' => NavigationItemType::Link, + 'label' => 'Home', + 'url' => '/', + 'position' => 0, + ]); + + $collectionLinks = [ + ['label' => 'Featured', 'url' => '/collections/featured', 'position' => 1], + ['label' => 'Accessories', 'url' => '/collections/accessories', 'position' => 2], + ]; + + foreach ($collectionLinks as $link) { + NavigationItem::create([ + 'menu_id' => $mainMenu->id, + 'type' => NavigationItemType::Link, + 'label' => $link['label'], + 'url' => $link['url'], + 'position' => $link['position'], + ]); + } + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..5cb214fc --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,62 @@ +first(); + + if (! $fashionStore) { + $fashionStore = Store::first(); + } + + if (! $fashionStore) { + return; + } + + $publishedAt = now()->subMonths(3); + + $pages = [ + [ + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

Our Story

Acme Fashion was founded with a simple mission: to provide high-quality, modern essentials that make getting dressed effortless. We believe fashion should be accessible, sustainable, and designed to last.

From our studio in Berlin, we curate collections that blend timeless style with contemporary design, ensuring every piece in our catalog meets our exacting standards for quality and craftsmanship.

Our Values

We are committed to ethical sourcing and sustainable practices. Every material we use is carefully selected for its environmental impact. We partner with manufacturers who share our commitment to fair labor practices and responsible production.

Our Team

Based in Berlin, our team of designers and curators brings together decades of experience in fashion and retail. We are passionate about creating a shopping experience that is as enjoyable as the clothes themselves.

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

Frequently Asked Questions

How long does shipping take?

Standard shipping within Germany takes 2-4 business days. Express shipping is available for 1-2 business day delivery. EU orders typically arrive within 5-7 business days.

What is your return policy?

We accept returns within 30 days of purchase. Items must be unworn, unwashed, and in their original packaging with all tags attached.

Do you ship internationally?

Yes! We ship to all EU countries as well as the United States, United Kingdom, Canada, and Australia.

How can I track my order?

Once your order has been shipped, you will receive an email with a tracking number. You can use this number to track your package through our shipping partner\'s website.

', + ], + [ + 'title' => 'Shipping & Returns', + 'handle' => 'shipping-returns', + 'body_html' => '

Shipping Rates

  • Germany Standard (2-4 days): 4.99 EUR
  • Germany Express (1-2 days): 9.99 EUR
  • EU Standard (5-7 days): 8.99 EUR
  • International (7-14 days): 14.99 EUR

Returns

We offer a 30-day return policy on all items. Items must be in their original, unworn condition with all tags attached. Please note that the customer is responsible for return shipping costs unless the item is defective or we made an error with your order.

', + ], + [ + 'title' => 'Privacy Policy', + 'handle' => 'privacy-policy', + 'body_html' => '

Information We Collect

We collect information you provide directly to us, such as your name, email address, shipping address, and payment information when you make a purchase.

How We Use Your Information

We use the information we collect to process transactions, send you order confirmations and updates, and improve our services.

Cookies

We use cookies and similar technologies to enhance your browsing experience, analyze site traffic, and personalize content.

Contact

If you have any questions about our privacy practices, please contact us at privacy@acme-fashion.test.

', + ], + [ + 'title' => 'Terms of Service', + 'handle' => 'terms', + 'body_html' => '

Orders and Payments

All prices are displayed in EUR and include applicable taxes. We accept major credit cards and bank transfers.

Product Descriptions

We make every effort to display our products as accurately as possible. However, slight variations in color may occur due to differences in monitor settings.

Limitation of Liability

Acme Fashion shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of our services.

Governing Law

These terms shall be governed by the laws of the Federal Republic of Germany.

', + ], + ]; + + foreach ($pages as $pageData) { + Page::create(array_merge($pageData, [ + 'store_id' => $fashionStore->id, + 'status' => PageStatus::Published, + 'published_at' => $publishedAt, + ])); + } + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..92a8f732 --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,71 @@ + $store->id, + 'name' => 'Default Theme', + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ]); + + $settings = $this->getSettingsForStore($store); + + ThemeSettings::create([ + 'theme_id' => $theme->id, + 'settings_json' => $settings, + ]); + } + } + + /** + * @return array + */ + private function getSettingsForStore(Store $store): array + { + if (str_contains(strtolower($store->name), 'electronics')) { + return [ + 'primary_color' => '#0f172a', + 'secondary_color' => '#3b82f6', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Acme Electronics', + 'hero_subheading' => 'Premium tech for professionals', + 'hero_cta_text' => 'Shop Featured', + 'hero_cta_link' => '/collections/featured', + 'featured_collection_handles' => ['featured'], + 'footer_text' => date('Y').' Acme Electronics. All rights reserved.', + ]; + } + + return [ + 'primary_color' => '#1a1a2e', + 'secondary_color' => '#e94560', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Welcome to Acme Fashion', + 'hero_subheading' => 'Discover our curated collection of modern essentials', + 'hero_cta_text' => 'Shop New Arrivals', + 'hero_cta_link' => '/collections/new-arrivals', + 'featured_collection_handles' => ['new-arrivals', 't-shirts', 'sale'], + 'footer_text' => date('Y').' Acme Fashion. All rights reserved.', + 'show_announcement_bar' => true, + 'announcement_text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + ]; + } +} diff --git a/resources/views/components/storefront/address-form.blade.php b/resources/views/components/storefront/address-form.blade.php new file mode 100644 index 00000000..7824cdb6 --- /dev/null +++ b/resources/views/components/storefront/address-form.blade.php @@ -0,0 +1,86 @@ +@props(['address' => null, 'prefix' => '']) + +@php + $p = $prefix ? $prefix . '.' : ''; +@endphp + +
class(['grid grid-cols-1 gap-4 sm:grid-cols-2']) }}> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/resources/views/components/storefront/badge.blade.php b/resources/views/components/storefront/badge.blade.php new file mode 100644 index 00000000..d8319868 --- /dev/null +++ b/resources/views/components/storefront/badge.blade.php @@ -0,0 +1,14 @@ +@props(['text', 'variant' => 'default']) + +@php + $classes = match($variant) { + 'sale' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + 'sold-out' => 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + 'new' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + }; +@endphp + +class(['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', $classes]) }}> + {{ $text }} + diff --git a/resources/views/components/storefront/breadcrumbs.blade.php b/resources/views/components/storefront/breadcrumbs.blade.php new file mode 100644 index 00000000..22f0e05b --- /dev/null +++ b/resources/views/components/storefront/breadcrumbs.blade.php @@ -0,0 +1,24 @@ +@props(['items']) + + diff --git a/resources/views/components/storefront/order-summary.blade.php b/resources/views/components/storefront/order-summary.blade.php new file mode 100644 index 00000000..c28666b8 --- /dev/null +++ b/resources/views/components/storefront/order-summary.blade.php @@ -0,0 +1,45 @@ +@props(['checkout', 'showDiscountInput' => true]) + +@php + $currency = app()->bound('current_store') ? app('current_store')->default_currency : 'EUR'; +@endphp + +
class(['rounded-lg border border-gray-200 bg-gray-50 p-6 dark:border-gray-700 dark:bg-gray-800/50']) }}> +

Order Summary

+ + {{-- Line items --}} +
+ {{-- Items will be rendered from checkout data in Phase 4/5 --}} +
+ + {{-- Discount code --}} + @if($showDiscountInput) +
+
+ + +
+
+ @endif + + {{-- Totals --}} +
+
+ Subtotal + 0.00 {{ $currency }} +
+
+ Shipping + Calculated at next step +
+
+ Total + 0.00 {{ $currency }} +
+
+
diff --git a/resources/views/components/storefront/pagination.blade.php b/resources/views/components/storefront/pagination.blade.php new file mode 100644 index 00000000..5d1d76d9 --- /dev/null +++ b/resources/views/components/storefront/pagination.blade.php @@ -0,0 +1,54 @@ +@props(['paginator']) + +@if($paginator->hasPages()) + +@endif diff --git a/resources/views/components/storefront/price.blade.php b/resources/views/components/storefront/price.blade.php new file mode 100644 index 00000000..3dcf14f2 --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,18 @@ +@props(['amount', 'currency' => 'EUR', 'compareAtAmount' => null]) + +@php + $formatted = number_format($amount / 100, 2, '.', ',') . ' ' . $currency; + $hasCompare = $compareAtAmount && $compareAtAmount > $amount; +@endphp + +class(['inline-flex items-center gap-2']) }}> + @if($hasCompare) + {{ $formatted }} + + {{ number_format($compareAtAmount / 100, 2, '.', ',') }} {{ $currency }} + + + @else + {{ $formatted }} + @endif + 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..e12332b9 --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,51 @@ +@props(['product', 'headingLevel' => 'h3', 'showQuickAdd' => true]) + +@php + $defaultVariant = $product->variants->first(); + $primaryImage = $product->media->sortBy('position')->first(); + $price = $defaultVariant?->price ?? 0; + $compareAtPrice = $defaultVariant?->compare_at_price; + $currency = app()->bound('current_store') ? app('current_store')->default_currency : 'EUR'; + $inStock = $defaultVariant?->inventoryItem?->quantity > 0; +@endphp + + diff --git a/resources/views/components/storefront/quantity-selector.blade.php b/resources/views/components/storefront/quantity-selector.blade.php new file mode 100644 index 00000000..401ba905 --- /dev/null +++ b/resources/views/components/storefront/quantity-selector.blade.php @@ -0,0 +1,35 @@ +@props(['value' => 1, 'min' => 1, 'max' => null, 'wireModel', 'compact' => false]) + +@php + $size = $compact ? 'h-8 w-8 text-xs' : 'h-10 w-10 text-sm'; + $inputSize = $compact ? 'h-8 w-12 text-xs' : 'h-10 w-14 text-sm'; +@endphp + +
class(['inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600']) }}> + + + + + +
diff --git a/resources/views/layouts/storefront/app.blade.php b/resources/views/layouts/storefront/app.blade.php new file mode 100644 index 00000000..64132973 --- /dev/null +++ b/resources/views/layouts/storefront/app.blade.php @@ -0,0 +1,241 @@ + + + + + + + {{ ($title ?? '') ? $title . ' - ' : '' }}{{ config('app.name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + + {{-- Skip to content --}} + + Skip to main content + + + {{-- Announcement bar --}} + @php + $themeSettings = app(\App\Services\ThemeSettingsService::class); + @endphp + @if($themeSettings->get('show_announcement_bar')) +
+

+ @if($themeSettings->get('announcement_link')) + + {{ $themeSettings->get('announcement_text', '') }} + + @else + {{ $themeSettings->get('announcement_text', '') }} + @endif +

+ +
+ @endif + + {{-- Header --}} +
+
+
+ {{-- Mobile hamburger --}} + + + {{-- Logo --}} + + @if($themeSettings->get('logo_url')) + {{ config('app.name') }} + @else + {{ app()->bound('current_store') ? app('current_store')->name : config('app.name') }} + @endif + + + {{-- Desktop navigation --}} + + + {{-- Right icons --}} +
+ {{-- Search --}} + + + {{-- Cart --}} + + + {{-- Account --}} + +
+
+
+ + {{-- Mobile navigation drawer --}} +
+
+ + +
+
+ + {{-- Main content --}} +
+ {{ $slot }} +
+ + {{-- Footer --}} +
+
+
+ {{-- Footer navigation --}} + @php + $footerMenu = \App\Models\NavigationMenu::where('handle', 'footer-menu')->first(); + $footerItems = $footerMenu ? $navService->buildTree($footerMenu) : []; + @endphp + @if(count($footerItems) > 0) +
+

Links

+ +
+ @endif + + {{-- Store info --}} +
+

Store

+
+

{{ app()->bound('current_store') ? app('current_store')->name : config('app.name') }}

+
+
+
+ + {{-- Social links --}} + @if($themeSettings->get('social_links')) +
+ @foreach($themeSettings->get('social_links', []) as $platform => $url) + + {{ ucfirst($platform) }} + + @endforeach +
+ @endif + + {{-- Copyright --}} +
+

+ © {{ date('Y') }} {{ app()->bound('current_store') ? app('current_store')->name : config('app.name') }}. All rights reserved. +

+
+
+
+ + {{-- Cart drawer --}} + @livewire('storefront.cart-drawer') + + {{-- Search modal --}} + @livewire('storefront.search.modal') + + @livewireScripts + + 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..56bddb32 --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,70 @@ +
+ {{-- Cart drawer backdrop --}} +
+ {{-- Overlay --}} +
+ + {{-- Drawer --}} + +
+
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..a28bfa84 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,9 @@ +
+
+

Your Cart

+
+ {{-- Cart line items, quantity controls, discount input, totals will be populated once Phase 4 is complete --}} +

Your cart is empty.

+
+
+
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..5aede072 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,8 @@ +
+
+

Collections

+
+ {{-- Collection cards will be populated once Phase 2 models are available --}} +
+
+
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..ed7101fa --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,8 @@ +
+
+

Collection: {{ $handle }}

+
+ {{-- Product grid with filters will be populated once Phase 2 models are available --}} +
+
+
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..965af3d9 --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,73 @@ +
+ {{-- Hero banner --}} + @if($settings['hero_heading'] ?? null) +
+
+

+ {{ $settings['hero_heading'] }} +

+ @if($settings['hero_subheading'] ?? null) +

+ {{ $settings['hero_subheading'] }} +

+ @endif + @if($settings['hero_cta_text'] ?? null) + + @endif +
+
+ @endif + + {{-- Featured collections --}} +
+

Featured Collections

+
+ {{-- Collections will be loaded from database once Phase 2 is complete --}} +
+
+ + {{-- Featured products --}} +
+

Featured Products

+
+ {{-- Products will be loaded from database once Phase 2 is complete --}} +
+
+ + {{-- Newsletter signup --}} +
+
+

Stay in the loop

+

Subscribe for exclusive offers and updates.

+
+ + + +
+
+
+ + {{-- Rich text section --}} + @if($settings['rich_text_content'] ?? null) +
+
+ {!! $settings['rich_text_content'] !!} +
+
+ @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..8bf4ee32 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,16 @@ +
+
+ {{-- Breadcrumbs --}} + + +
+

{{ $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..4bcf1bec --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,6 @@ +
+
+

Product: {{ $handle }}

+ {{-- Product detail with variant selection, gallery, add-to-cart will be populated once Phase 2 models are available --}} +
+
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..6fc26719 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,17 @@ +
+
+

+ @if($query) + Search results for "{{ $query }}" + @else + Search + @endif +

+
+ {{-- Search results with filters will be populated once Phase 8 is complete --}} + @if($query) +

No results found for "{{ $query }}".

+ @endif +
+
+
diff --git a/resources/views/livewire/storefront/search/modal.blade.php b/resources/views/livewire/storefront/search/modal.blade.php new file mode 100644 index 00000000..b8e12d13 --- /dev/null +++ b/resources/views/livewire/storefront/search/modal.blade.php @@ -0,0 +1,49 @@ +
+ +
diff --git a/resources/views/storefront/errors/404.blade.php b/resources/views/storefront/errors/404.blade.php new file mode 100644 index 00000000..e286293a --- /dev/null +++ b/resources/views/storefront/errors/404.blade.php @@ -0,0 +1,33 @@ + + + + + + Page Not Found - {{ config('app.name') }} + @vite(['resources/css/app.css']) + + +
+

404

+

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+
+
+ + +
+ + Go to home page + +
+
+ + diff --git a/resources/views/storefront/errors/503.blade.php b/resources/views/storefront/errors/503.blade.php new file mode 100644 index 00000000..d52d6125 --- /dev/null +++ b/resources/views/storefront/errors/503.blade.php @@ -0,0 +1,18 @@ + + + + + + Maintenance - {{ config('app.name') }} + @vite(['resources/css/app.css']) + + +
+

{{ config('app.name') }}

+

We'll be back soon

+

+ We're currently performing maintenance. Please check back shortly. +

+
+ + diff --git a/routes/web.php b/routes/web.php index ff6b25c4..d1b0ba58 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,8 +27,18 @@ ->name('admin.logout'); }); -// Customer storefront auth routes +// Storefront routes Route::middleware('resolve.store:storefront')->group(function () { + // Public storefront pages + Route::get('/', \App\Livewire\Storefront\Home::class)->name('storefront.home'); + Route::get('/collections', \App\Livewire\Storefront\Collections\Index::class)->name('storefront.collections.index'); + Route::get('/collections/{handle}', \App\Livewire\Storefront\Collections\Show::class)->name('storefront.collections.show'); + Route::get('/products/{handle}', \App\Livewire\Storefront\Products\Show::class)->name('storefront.products.show'); + Route::get('/cart', \App\Livewire\Storefront\Cart\Show::class)->name('storefront.cart'); + Route::get('/search', \App\Livewire\Storefront\Search\Index::class)->name('storefront.search'); + Route::get('/pages/{handle}', \App\Livewire\Storefront\Pages\Show::class)->name('storefront.pages.show'); + + // Customer auth routes (no auth required) Route::get('account/login', CustomerLogin::class) ->middleware('guest:customer') ->name('customer.login'); diff --git a/tests/Feature/Navigation/NavigationServiceTest.php b/tests/Feature/Navigation/NavigationServiceTest.php new file mode 100644 index 00000000..8255f124 --- /dev/null +++ b/tests/Feature/Navigation/NavigationServiceTest.php @@ -0,0 +1,158 @@ +context = createStoreContext(); + $this->navigationService = new NavigationService; +}); + +it('builds a navigation tree from menu items', function () { + $menu = NavigationMenu::factory()->create([ + 'store_id' => $this->context['store']->id, + 'handle' => 'main-menu', + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'Home', + 'url' => '/', + 'position' => 0, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'About', + 'url' => '/about', + 'position' => 1, + ]); + + $tree = $this->navigationService->buildTree($menu); + + expect($tree)->toHaveCount(2) + ->and($tree[0]['label'])->toBe('Home') + ->and($tree[0]['url'])->toBe('/') + ->and($tree[1]['label'])->toBe('About') + ->and($tree[1]['url'])->toBe('/about'); +}); + +it('resolves link type URLs directly', function () { + $item = new NavigationItem([ + 'type' => NavigationItemType::Link, + 'url' => '/custom-page', + ]); + + $url = $this->navigationService->resolveUrl($item); + + expect($url)->toBe('/custom-page'); +}); + +it('resolves page type URLs via handle', function () { + $page = Page::factory()->create([ + 'store_id' => $this->context['store']->id, + 'handle' => 'about-us', + ]); + + $item = new NavigationItem([ + 'type' => NavigationItemType::Page, + 'resource_id' => $page->id, + ]); + + $url = $this->navigationService->resolveUrl($item); + + expect($url)->toBe('/pages/about-us'); +}); + +it('returns fallback URL for missing page resources', function () { + $item = new NavigationItem([ + 'type' => NavigationItemType::Page, + 'resource_id' => 99999, + ]); + + $url = $this->navigationService->resolveUrl($item); + + expect($url)->toBe('/'); +}); + +it('returns items ordered by position', function () { + $menu = NavigationMenu::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'Third', + 'position' => 2, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'First', + 'position' => 0, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'Second', + 'position' => 1, + ]); + + $tree = $this->navigationService->buildTree($menu); + + expect($tree[0]['label'])->toBe('First') + ->and($tree[1]['label'])->toBe('Second') + ->and($tree[2]['label'])->toBe('Third'); +}); + +it('creates navigation menu with factory', function () { + $menu = NavigationMenu::factory()->create([ + 'store_id' => $this->context['store']->id, + 'handle' => 'test-menu', + 'title' => 'Test Menu', + ]); + + expect($menu->handle)->toBe('test-menu') + ->and($menu->title)->toBe('Test Menu') + ->and($menu->store->id)->toBe($this->context['store']->id); +}); + +it('scopes navigation menus to current store', function () { + NavigationMenu::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + $otherStore = \App\Models\Store::factory()->create(); + NavigationMenu::factory()->create([ + 'store_id' => $otherStore->id, + ]); + + expect(NavigationMenu::count())->toBe(1); +}); + +it('has items relationship ordered by position', function () { + $menu = NavigationMenu::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'Second', + 'position' => 1, + ]); + + NavigationItem::factory()->create([ + 'menu_id' => $menu->id, + 'label' => 'First', + 'position' => 0, + ]); + + $items = $menu->items; + + expect($items)->toHaveCount(2) + ->and($items[0]->label)->toBe('First') + ->and($items[1]->label)->toBe('Second'); +}); diff --git a/tests/Feature/Pages/PageModelTest.php b/tests/Feature/Pages/PageModelTest.php new file mode 100644 index 00000000..da43a1ff --- /dev/null +++ b/tests/Feature/Pages/PageModelTest.php @@ -0,0 +1,83 @@ +context = createStoreContext(); +}); + +it('creates a page with factory', function () { + $page = Page::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($page)->toBeInstanceOf(Page::class) + ->and($page->store_id)->toBe($this->context['store']->id) + ->and($page->status)->toBe(PageStatus::Published); +}); + +it('creates a draft page', function () { + $page = Page::factory()->draft()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($page->status)->toBe(PageStatus::Draft) + ->and($page->published_at)->toBeNull(); +}); + +it('creates an archived page', function () { + $page = Page::factory()->archived()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($page->status)->toBe(PageStatus::Archived); +}); + +it('has a store relationship', function () { + $page = Page::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($page->store->id)->toBe($this->context['store']->id); +}); + +it('enforces unique handle per store', function () { + Page::factory()->create([ + 'store_id' => $this->context['store']->id, + 'handle' => 'about', + ]); + + Page::factory()->create([ + 'store_id' => $this->context['store']->id, + 'handle' => 'about', + ]); +})->throws(\Illuminate\Database\UniqueConstraintViolationException::class); + +it('allows same handle in different stores', function () { + Page::factory()->create([ + 'store_id' => $this->context['store']->id, + 'handle' => 'about', + ]); + + $otherStore = \App\Models\Store::factory()->create(); + $page = Page::factory()->create([ + 'store_id' => $otherStore->id, + 'handle' => 'about', + ]); + + expect($page->handle)->toBe('about'); +}); + +it('scopes pages to current store', function () { + Page::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + $otherStore = \App\Models\Store::factory()->create(); + Page::factory()->create([ + 'store_id' => $otherStore->id, + ]); + + expect(Page::count())->toBe(1); +}); diff --git a/tests/Feature/Storefront/PageDisplayTest.php b/tests/Feature/Storefront/PageDisplayTest.php new file mode 100644 index 00000000..884b1165 --- /dev/null +++ b/tests/Feature/Storefront/PageDisplayTest.php @@ -0,0 +1,52 @@ +context = createStoreContext(); + + // Create a published theme with settings for the layout + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => [ + 'hero_heading' => 'Test Store', + 'show_announcement_bar' => false, + ], + ]); +}); + +it('displays a published page', function () { + $page = Page::factory()->create([ + 'store_id' => $this->context['store']->id, + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

This is our about page.

', + 'status' => PageStatus::Published, + ]); + + Livewire::test(\App\Livewire\Storefront\Pages\Show::class, ['handle' => 'about']) + ->assertSee('About Us') + ->assertSee('This is our about page.') + ->assertStatus(200); +}); + +it('returns 404 for draft pages', function () { + Page::factory()->draft()->create([ + 'store_id' => $this->context['store']->id, + 'handle' => 'draft-page', + ]); + + Livewire::test(\App\Livewire\Storefront\Pages\Show::class, ['handle' => 'draft-page']); +})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); + +it('returns 404 for nonexistent pages', function () { + Livewire::test(\App\Livewire\Storefront\Pages\Show::class, ['handle' => 'nonexistent']); +})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class); diff --git a/tests/Feature/Themes/ThemeModelTest.php b/tests/Feature/Themes/ThemeModelTest.php new file mode 100644 index 00000000..6b5b8b54 --- /dev/null +++ b/tests/Feature/Themes/ThemeModelTest.php @@ -0,0 +1,91 @@ +context = createStoreContext(); +}); + +it('creates a theme with factory', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($theme)->toBeInstanceOf(Theme::class) + ->and($theme->store_id)->toBe($this->context['store']->id) + ->and($theme->status)->toBe(ThemeStatus::Published); +}); + +it('creates a draft theme', function () { + $theme = Theme::factory()->draft()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($theme->status)->toBe(ThemeStatus::Draft) + ->and($theme->published_at)->toBeNull(); +}); + +it('has a store relationship', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($theme->store->id)->toBe($this->context['store']->id); +}); + +it('has a files relationship', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + ThemeFile::factory()->create(['theme_id' => $theme->id]); + + expect($theme->files)->toHaveCount(1); +}); + +it('has a settings relationship', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + ThemeSettings::factory()->create(['theme_id' => $theme->id]); + + expect($theme->settings)->toBeInstanceOf(ThemeSettings::class); +}); + +it('scopes themes to current store', function () { + Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + // Create a theme for another store (without full context to avoid hostname conflict) + $otherStore = \App\Models\Store::factory()->create(); + Theme::factory()->create([ + 'store_id' => $otherStore->id, + ]); + + // With current store bound, should only see one theme + expect(Theme::count())->toBe(1); +}); + +it('stores theme settings as JSON', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + $settings = ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => [ + 'primary_color' => '#ff0000', + 'hero_heading' => 'Test', + ], + ]); + + expect($settings->settings_json)->toBeArray() + ->and($settings->get('primary_color'))->toBe('#ff0000') + ->and($settings->get('hero_heading'))->toBe('Test') + ->and($settings->get('nonexistent', 'default'))->toBe('default'); +}); diff --git a/tests/Feature/Themes/ThemeSettingsServiceTest.php b/tests/Feature/Themes/ThemeSettingsServiceTest.php new file mode 100644 index 00000000..fa3c27f6 --- /dev/null +++ b/tests/Feature/Themes/ThemeSettingsServiceTest.php @@ -0,0 +1,71 @@ +context = createStoreContext(); + $this->service = app(ThemeSettingsService::class); + $this->service->reset(); +}); + +it('loads settings for the active published theme', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => ['primary_color' => '#123456'], + ]); + + expect($this->service->get('primary_color'))->toBe('#123456'); +}); + +it('returns default value when setting is missing', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => [], + ]); + + expect($this->service->get('nonexistent', 'fallback'))->toBe('fallback'); +}); + +it('returns null when no store is bound', function () { + app()->forgetInstance('current_store'); + + $service = new ThemeSettingsService; + + expect($service->load())->toBeNull() + ->and($service->get('anything', 'default'))->toBe('default'); +}); + +it('returns null when no published theme exists', function () { + Theme::factory()->draft()->create([ + 'store_id' => $this->context['store']->id, + ]); + + expect($this->service->load())->toBeNull(); +}); + +it('returns all settings as array', function () { + $theme = Theme::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + ThemeSettings::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => ['key1' => 'value1', 'key2' => 'value2'], + ]); + + $all = $this->service->all(); + + expect($all)->toBeArray() + ->and($all['key1'])->toBe('value1') + ->and($all['key2'])->toBe('value2'); +}); From 7f7ffd3606b846ac19b4fba051a32a56e2a111e2 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 21:38:59 +0100 Subject: [PATCH 06/17] Phase 8: Search - FTS5 full-text search with autocomplete Add SQLite FTS5-based search system: - 3 migrations (search_settings, search_queries, products_fts virtual table) - SearchSettings and SearchQuery models - SearchService with search (FTS5 + store scoping + filters + sorting + pagination), autocomplete (prefix matching), syncProduct, and removeProduct - ProductObserver to auto-sync products into FTS5 index on create/update/delete - SQLite-compatible relevance ordering using CASE expressions - Search query logging for analytics - 18 passing tests (SearchTest + AutocompleteTest) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Models/Product.php | 3 + app/Models/SearchQuery.php | 36 ++++ app/Models/SearchSettings.php | 35 ++++ app/Observers/ProductObserver.php | 28 +++ app/Services/SearchService.php | 158 ++++++++++++++++ ...20_000030_create_search_settings_table.php | 23 +++ ..._20_000031_create_search_queries_table.php | 29 +++ ...03_20_000032_create_products_fts_table.php | 27 +++ tests/Feature/Search/AutocompleteTest.php | 103 ++++++++++ tests/Feature/Search/SearchTest.php | 177 ++++++++++++++++++ 10 files changed, 619 insertions(+) create mode 100644 app/Models/SearchQuery.php create mode 100644 app/Models/SearchSettings.php create mode 100644 app/Observers/ProductObserver.php create mode 100644 app/Services/SearchService.php create mode 100644 database/migrations/2026_03_20_000030_create_search_settings_table.php create mode 100644 database/migrations/2026_03_20_000031_create_search_queries_table.php create mode 100644 database/migrations/2026_03_20_000032_create_products_fts_table.php create mode 100644 tests/Feature/Search/AutocompleteTest.php create mode 100644 tests/Feature/Search/SearchTest.php diff --git a/app/Models/Product.php b/app/Models/Product.php index f6b7b941..7ce5080d 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -4,12 +4,15 @@ use App\Enums\ProductStatus; use App\Models\Concerns\BelongsToStore; +use App\Observers\ProductObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +#[ObservedBy(ProductObserver::class)] class Product extends Model { use BelongsToStore; diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..07cfcd0a --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,36 @@ + 'array', + 'results_count' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/SearchSettings.php b/app/Models/SearchSettings.php new file mode 100644 index 00000000..f14d2781 --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,35 @@ + 'array', + 'stop_words_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..d1e7b6bd --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,28 @@ +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/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..916e7c6b --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,158 @@ +buildFtsQuery($query); + + $productIds = DB::table('products_fts') + ->selectRaw('product_id, rank') + ->where('store_id', $store->id) + ->whereRaw('products_fts MATCH ?', [$ftsQuery]) + ->orderBy('rank') + ->pluck('product_id') + ->all(); + + $productsQuery = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->whereIn('id', $productIds); + + if (! empty($filters['vendor'])) { + $productsQuery->where('vendor', $filters['vendor']); + } + + if (! empty($filters['product_type'])) { + $productsQuery->where('product_type', $filters['product_type']); + } + + if (isset($filters['price_min']) || isset($filters['price_max'])) { + $productsQuery->whereHas('variants', function ($q) use ($filters) { + if (isset($filters['price_min'])) { + $q->where('price_amount', '>=', $filters['price_min']); + } + if (isset($filters['price_max'])) { + $q->where('price_amount', '<=', $filters['price_max']); + } + }); + } + + $sortField = $filters['sort'] ?? 'relevance'; + match ($sortField) { + 'price_asc' => $productsQuery->orderByRaw( + '(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) ASC' + ), + 'price_desc' => $productsQuery->orderByRaw( + '(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id) DESC' + ), + 'newest' => $productsQuery->orderByDesc('created_at'), + default => $this->orderByRelevance($productsQuery, $productIds), + }; + + $result = $productsQuery->paginate($perPage); + + SearchQuery::create([ + 'store_id' => $store->id, + 'query' => $query, + 'filters_json' => $filters ?: null, + 'results_count' => $result->total(), + 'created_at' => now(), + ]); + + return $result; + } + + public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection + { + $prefix = trim($prefix); + + if ($prefix === '') { + return collect(); + } + + $ftsPrefix = '"'.str_replace('"', '""', $prefix).'" *'; + + return DB::table('products_fts') + ->join('products', 'products.id', '=', 'products_fts.product_id') + ->where('products_fts.store_id', $store->id) + ->where('products.status', 'active') + ->whereRaw('products_fts MATCH ?', [$ftsPrefix]) + ->select('products.id', 'products.title', 'products.handle') + ->limit($limit) + ->get(); + } + + public function syncProduct(Product $product): void + { + $this->removeProduct($product->id); + + $description = $product->description_html + ? strip_tags($product->description_html) + : ''; + + $tags = is_array($product->tags) + ? implode(' ', $product->tags) + : ''; + + DB::table('products_fts')->insert([ + 'product_id' => $product->id, + 'store_id' => $product->store_id, + 'title' => $product->title, + 'description' => $description, + 'vendor' => $product->vendor ?? '', + 'product_type' => $product->product_type ?? '', + 'tags' => $tags, + ]); + } + + public function removeProduct(int $productId): void + { + DB::table('products_fts') + ->where('product_id', $productId) + ->delete(); + } + + private function buildFtsQuery(string $query): string + { + $words = preg_split('/\s+/', $query, -1, PREG_SPLIT_NO_EMPTY); + + $escaped = array_map(function ($word) { + return '"'.str_replace('"', '""', $word).'"'; + }, $words); + + return implode(' ', $escaped); + } + + /** + * Order results by FTS relevance using a CASE expression (SQLite-compatible). + */ + private function orderByRelevance(\Illuminate\Database\Eloquent\Builder $query, array $productIds): void + { + if (empty($productIds)) { + return; + } + + $cases = []; + foreach ($productIds as $index => $id) { + $cases[] = "WHEN {$id} THEN {$index}"; + } + + $query->orderByRaw('CASE id '.implode(' ', $cases).' ELSE '.count($productIds).' END'); + } +} diff --git a/database/migrations/2026_03_20_000030_create_search_settings_table.php b/database/migrations/2026_03_20_000030_create_search_settings_table.php new file mode 100644 index 00000000..81ccea05 --- /dev/null +++ b/database/migrations/2026_03_20_000030_create_search_settings_table.php @@ -0,0 +1,23 @@ +foreignId('store_id')->primary()->constrained()->cascadeOnDelete(); + $table->text('synonyms_json')->default('[]'); + $table->text('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/migrations/2026_03_20_000031_create_search_queries_table.php b/database/migrations/2026_03_20_000031_create_search_queries_table.php new file mode 100644 index 00000000..9a31f446 --- /dev/null +++ b/database/migrations/2026_03_20_000031_create_search_queries_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('query'); + $table->text('filters_json')->nullable(); + $table->unsignedInteger('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_search_queries_store_id'); + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index(['store_id', 'query'], 'idx_search_queries_store_query'); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_03_20_000032_create_products_fts_table.php b/database/migrations/2026_03_20_000032_create_products_fts_table.php new file mode 100644 index 00000000..f633eed7 --- /dev/null +++ b/database/migrations/2026_03_20_000032_create_products_fts_table.php @@ -0,0 +1,27 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->searchService = app(SearchService::class); +}); + +function createAutocompleteProduct(Store $store, array $overrides = []): Product +{ + $product = Product::withoutEvents(function () use ($store, $overrides) { + return Product::factory()->create(array_merge([ + 'store_id' => $store->id, + 'status' => ProductStatus::Active, + 'published_at' => now(), + ], $overrides)); + }); + + ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 1000, + 'is_default' => true, + ]); + + app(SearchService::class)->syncProduct($product); + + return $product; +} + +it('returns autocomplete results matching a prefix', function () { + createAutocompleteProduct($this->store, ['title' => 'Running Shoes']); + createAutocompleteProduct($this->store, ['title' => 'Running Shorts']); + createAutocompleteProduct($this->store, ['title' => 'Hiking Boots']); + + $results = $this->searchService->autocomplete($this->store, 'Runn'); + + expect($results)->toHaveCount(2); +}); + +it('returns empty collection for empty prefix', function () { + createAutocompleteProduct($this->store, ['title' => 'Something']); + + $results = $this->searchService->autocomplete($this->store, ''); + + expect($results)->toBeEmpty(); +}); + +it('limits autocomplete results', function () { + for ($i = 1; $i <= 10; $i++) { + createAutocompleteProduct($this->store, ['title' => "Blue Widget {$i}"]); + } + + $results = $this->searchService->autocomplete($this->store, 'Blue', 3); + + expect($results)->toHaveCount(3); +}); + +it('scopes autocomplete to the given store', function () { + $otherStore = Store::factory()->create(); + + createAutocompleteProduct($this->store, ['title' => 'Exclusive Shirt']); + createAutocompleteProduct($otherStore, ['title' => 'Exclusive Pants']); + + $results = $this->searchService->autocomplete($this->store, 'Exclusive'); + + expect($results)->toHaveCount(1) + ->and($results->first()->title)->toBe('Exclusive Shirt'); +}); + +it('only returns active products in autocomplete', function () { + createAutocompleteProduct($this->store, ['title' => 'Active Hat']); + + $draft = Product::withoutEvents(function () { + return Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Draft Hat', + 'status' => ProductStatus::Draft, + ]); + }); + $this->searchService->syncProduct($draft); + + $results = $this->searchService->autocomplete($this->store, 'Hat'); + + expect($results)->toHaveCount(1) + ->and($results->first()->title)->toBe('Active Hat'); +}); + +it('returns product id, title, and handle', function () { + $product = createAutocompleteProduct($this->store, ['title' => 'Test Item', 'handle' => 'test-item']); + + $results = $this->searchService->autocomplete($this->store, 'Test'); + + $first = $results->first(); + expect($first->id)->toBe($product->id) + ->and($first->title)->toBe('Test Item') + ->and($first->handle)->toBe('test-item'); +}); diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..ce524a12 --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,177 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->searchService = app(SearchService::class); +}); + +function createSearchableProduct(Store $store, array $overrides = []): Product +{ + $priceAmount = $overrides['price_amount'] ?? 2999; + unset($overrides['price_amount']); + + $product = Product::withoutEvents(function () use ($store, $overrides) { + return Product::factory()->create(array_merge([ + 'store_id' => $store->id, + 'status' => ProductStatus::Active, + 'published_at' => now(), + ], $overrides)); + }); + + ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => $priceAmount, + 'is_default' => true, + ]); + + app(SearchService::class)->syncProduct($product); + + return $product; +} + +it('searches products by title', function () { + createSearchableProduct($this->store, ['title' => 'Blue Running Shoes']); + createSearchableProduct($this->store, ['title' => 'Red Hiking Boots']); + + $results = $this->searchService->search($this->store, 'Running'); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->title)->toBe('Blue Running Shoes'); +}); + +it('searches products by vendor', function () { + createSearchableProduct($this->store, ['title' => 'Widget', 'vendor' => 'Acme Corp']); + createSearchableProduct($this->store, ['title' => 'Gadget', 'vendor' => 'Beta Inc']); + + $results = $this->searchService->search($this->store, 'Acme'); + + expect($results->total())->toBe(1); +}); + +it('searches products by tags', function () { + createSearchableProduct($this->store, ['title' => 'Summer Dress', 'tags' => ['summer', 'sale']]); + createSearchableProduct($this->store, ['title' => 'Winter Coat', 'tags' => ['winter']]); + + $results = $this->searchService->search($this->store, 'summer'); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->title)->toBe('Summer Dress'); +}); + +it('returns empty results for empty query', function () { + createSearchableProduct($this->store, ['title' => 'Test Product']); + + $results = $this->searchService->search($this->store, ''); + + expect($results->total())->toBe(0); +}); + +it('scopes search to the given store', function () { + $otherStore = Store::factory()->create(); + + createSearchableProduct($this->store, ['title' => 'My Widget']); + createSearchableProduct($otherStore, ['title' => 'Their Widget']); + + $results = $this->searchService->search($this->store, 'Widget'); + + expect($results->total())->toBe(1); +}); + +it('only returns active products', function () { + createSearchableProduct($this->store, ['title' => 'Active Lamp', 'status' => ProductStatus::Active]); + + $draftProduct = Product::withoutEvents(function () { + return Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Draft Lamp', + 'status' => ProductStatus::Draft, + ]); + }); + $this->searchService->syncProduct($draftProduct); + + $results = $this->searchService->search($this->store, 'Lamp'); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->title)->toBe('Active Lamp'); +}); + +it('filters search results by vendor', function () { + createSearchableProduct($this->store, ['title' => 'Phone A', 'vendor' => 'Apple']); + createSearchableProduct($this->store, ['title' => 'Phone B', 'vendor' => 'Samsung']); + + $results = $this->searchService->search($this->store, 'Phone', ['vendor' => 'Apple']); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->vendor)->toBe('Apple'); +}); + +it('filters search results by price range', function () { + createSearchableProduct($this->store, ['title' => 'Cheap Item', 'price_amount' => 500]); + createSearchableProduct($this->store, ['title' => 'Expensive Item', 'price_amount' => 50000]); + + $results = $this->searchService->search($this->store, 'Item', [ + 'price_min' => 1000, + 'price_max' => 60000, + ]); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->title)->toBe('Expensive Item'); +}); + +it('logs search queries', function () { + createSearchableProduct($this->store, ['title' => 'Test Widget']); + + $this->searchService->search($this->store, 'Widget'); + + $log = SearchQuery::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + expect($log)->not->toBeNull() + ->and($log->query)->toBe('Widget') + ->and($log->results_count)->toBe(1); +}); + +it('syncs product into FTS index', function () { + $product = Product::withoutEvents(function () { + return Product::factory()->create([ + 'store_id' => $this->store->id, + 'title' => 'Unique Sync Test Item', + 'status' => ProductStatus::Active, + 'published_at' => now(), + ]); + }); + + ProductVariant::factory()->create(['product_id' => $product->id, 'is_default' => true, 'price_amount' => 1000]); + + $this->searchService->syncProduct($product); + + $results = $this->searchService->search($this->store, 'Unique Sync Test'); + expect($results->total())->toBe(1); +}); + +it('removes product from FTS index', function () { + $product = createSearchableProduct($this->store, ['title' => 'Removable Product']); + + $this->searchService->removeProduct($product->id); + + $results = $this->searchService->search($this->store, 'Removable'); + expect($results->total())->toBe(0); +}); + +it('paginates search results', function () { + for ($i = 1; $i <= 5; $i++) { + createSearchableProduct($this->store, ['title' => "Paginated Widget {$i}"]); + } + + $results = $this->searchService->search($this->store, 'Widget', [], 2); + + expect($results->perPage())->toBe(2) + ->and($results->total())->toBe(5) + ->and($results->items())->toHaveCount(2); +}); From 3d40910c9c6f7bf853525ba73f19093bcfdfbcd5 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 22:00:30 +0100 Subject: [PATCH 07/17] Phase 5: Payments, Orders, Fulfillment - Mock payment provider with magic card numbers (4242=success, 0002=decline, 9995=insufficient) - OrderService: createFromCheckout with atomic order creation, inventory commit/reserve, payment recording - Bank transfer support with deferred capture and admin confirmation flow - Sequential order numbering per store starting at #1001 - Auto-fulfillment for digital products (requires_shipping=false) - RefundService: full/partial refunds with optional inventory restocking - FulfillmentService with payment guard, multi-shipment support, status transitions - CancelUnpaidBankTransferOrders scheduled job - 7 migrations, 8 models, 7 enums, 7 factories, 5 domain events - 41 tests with 104 assertions, all passing Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Contracts/PaymentProvider.php | 15 + 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/OrderCancelled.php | 13 + app/Events/OrderCreated.php | 13 + app/Events/OrderFulfilled.php | 13 + app/Events/OrderPaid.php | 13 + app/Events/OrderRefunded.php | 13 + app/Exceptions/FulfillmentGuardException.php | 7 + app/Exceptions/PaymentFailedException.php | 7 + app/Jobs/CancelUnpaidBankTransferOrders.php | 36 ++ app/Models/Customer.php | 16 + app/Models/CustomerAddress.php | 34 ++ app/Models/Fulfillment.php | 45 +++ app/Models/FulfillmentLine.php | 37 ++ app/Models/Order.php | 86 ++++ app/Models/OrderLine.php | 59 +++ app/Models/Payment.php | 43 ++ app/Models/Refund.php | 44 ++ app/Models/Store.php | 5 + app/Providers/AppServiceProvider.php | 3 + app/Services/FulfillmentService.php | 126 ++++++ app/Services/OrderService.php | 258 ++++++++++++ app/Services/Payment/MockPaymentProvider.php | 58 +++ app/Services/RefundService.php | 73 ++++ app/ValueObjects/PaymentResult.php | 13 + app/ValueObjects/RefundResult.php | 12 + database/factories/CustomerAddressFactory.php | 43 ++ database/factories/FulfillmentFactory.php | 45 +++ database/factories/FulfillmentLineFactory.php | 23 ++ database/factories/OrderFactory.php | 91 +++++ database/factories/OrderLineFactory.php | 32 ++ database/factories/PaymentFactory.php | 46 +++ database/factories/RefundFactory.php | 36 ++ ...000020_create_customer_addresses_table.php | 27 ++ .../2026_03_20_000021_create_orders_table.php | 46 +++ ..._03_20_000022_create_order_lines_table.php | 34 ++ ...026_03_20_000023_create_payments_table.php | 34 ++ ...2026_03_20_000024_create_refunds_table.php | 31 ++ ...03_20_000025_create_fulfillments_table.php | 31 ++ ..._000026_create_fulfillment_lines_table.php | 26 ++ routes/console.php | 8 + .../Fulfillment/FulfillmentServiceTest.php | 231 +++++++++++ tests/Feature/Order/OrderServiceTest.php | 376 ++++++++++++++++++ tests/Feature/Order/RefundServiceTest.php | 150 +++++++ .../Payment/MockPaymentProviderTest.php | 96 +++++ 51 files changed, 2524 insertions(+) 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/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/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/Payment/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/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_03_20_000020_create_customer_addresses_table.php create mode 100644 database/migrations/2026_03_20_000021_create_orders_table.php create mode 100644 database/migrations/2026_03_20_000022_create_order_lines_table.php create mode 100644 database/migrations/2026_03_20_000023_create_payments_table.php create mode 100644 database/migrations/2026_03_20_000024_create_refunds_table.php create mode 100644 database/migrations/2026_03_20_000025_create_fulfillments_table.php create mode 100644 database/migrations/2026_03_20_000026_create_fulfillment_lines_table.php create mode 100644 tests/Feature/Fulfillment/FulfillmentServiceTest.php create mode 100644 tests/Feature/Order/OrderServiceTest.php create mode 100644 tests/Feature/Order/RefundServiceTest.php create mode 100644 tests/Feature/Payment/MockPaymentProviderTest.php diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..a0ec2738 --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,15 @@ +where('payment_method', PaymentMethod::BankTransfer) + ->where('financial_status', FinancialStatus::Pending) + ->where('placed_at', '<', now()->subDays($this->getCancelDays())) + ->get(); + + foreach ($orders as $order) { + $orderService->cancel($order, 'Auto-cancelled: bank transfer payment not received.'); + } + } + + protected function getCancelDays(): int + { + return 7; + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 8ea4832a..97079ccb 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -5,6 +5,7 @@ use App\Models\Concerns\BelongsToStore; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; class Customer extends Authenticatable @@ -35,4 +36,19 @@ public function store(): BelongsTo { return $this->belongsTo(Store::class); } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + 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..cb4ba568 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,34 @@ + 'array', + 'is_default' => 'boolean', + ]; + } + + 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..7a861881 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,45 @@ + FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + 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..06d7632a --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,37 @@ + 'integer', + ]; + } + + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + 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..e66c9d5d --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,86 @@ + OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'payment_method' => PaymentMethod::class, + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'subtotal_amount' => 'integer', + 'discount_amount' => 'integer', + 'shipping_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'placed_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..0091ca34 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,59 @@ + 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + 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..8527c808 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,43 @@ + PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..613f3848 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,44 @@ + RefundStatus::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php index 2398c715..5efee28b 100644 --- a/app/Models/Store.php +++ b/app/Models/Store.php @@ -72,4 +72,9 @@ public function inventoryItems(): HasMany { return $this->hasMany(InventoryItem::class); } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cb9441dd..2216fe65 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\Payment\MockPaymentProvider; use App\Services\ThemeSettingsService; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; @@ -21,6 +23,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/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..68f3cf57 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,126 @@ +financial_status, $allowedStatuses)) { + throw new FulfillmentGuardException( + 'Fulfillment cannot be created until payment is confirmed.' + ); + } + + return DB::transaction(function () use ($order, $lines, $tracking) { + $order->load('lines.fulfillmentLines'); + + // Validate quantities + foreach ($lines as $orderLineId => $quantity) { + $orderLine = $order->lines->firstWhere('id', $orderLineId); + + if (! $orderLine) { + throw new \RuntimeException("Order line {$orderLineId} not found."); + } + + $fulfilledSoFar = $orderLine->fulfillmentLines->sum('quantity'); + $unfulfilled = $orderLine->quantity - $fulfilledSoFar; + + if ($quantity > $unfulfilled) { + throw new \RuntimeException( + "Requested quantity ({$quantity}) exceeds unfulfilled quantity ({$unfulfilled}) for order line {$orderLineId}." + ); + } + } + + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + 'created_at' => now(), + ]); + + foreach ($lines as $orderLineId => $quantity) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $orderLineId, + 'quantity' => $quantity, + ]); + } + + // Update order fulfillment status + $this->updateOrderFulfillmentStatus($order); + + return $fulfillment; + }); + } + + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) { + throw new \RuntimeException('Fulfillment is not in pending status.'); + } + + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $tracking['tracking_number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, + 'shipped_at' => now(), + ]); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) { + throw new \RuntimeException('Fulfillment is not in shipped status.'); + } + + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Delivered, + ]); + } + + protected function updateOrderFulfillmentStatus(Order $order): void + { + $order->load('lines'); + $allFulfilled = true; + + foreach ($order->lines as $line) { + $totalFulfilled = FulfillmentLine::where('order_line_id', $line->id)->sum('quantity'); + + if ($totalFulfilled < $line->quantity) { + $allFulfilled = false; + break; + } + } + + if ($allFulfilled) { + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ]); + + OrderFulfilled::dispatch($order->fresh()); + } else { + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Partial, + ]); + } + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..9036e1c0 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,258 @@ +paymentProvider->charge( + $checkout, + $checkout->payment_method, + $paymentDetails, + ); + + if (! $paymentResult->success && $paymentResult->status === 'failed') { + // Release reserved inventory on payment failure + $cart = $checkout->cart()->with('lines.variant.inventoryItem')->first(); + foreach ($cart->lines as $line) { + if ($line->variant && $line->variant->inventoryItem) { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + } + + throw new PaymentFailedException($paymentResult->error ?? 'Payment failed.'); + } + + $isBankTransfer = $checkout->payment_method === 'bank_transfer'; + + return DB::transaction(function () use ($checkout, $paymentResult, $isBankTransfer) { + + $order = Order::create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $checkout->customer_id, + 'order_number' => $this->generateOrderNumber($checkout->store), + 'payment_method' => $checkout->payment_method, + 'status' => $isBankTransfer ? OrderStatus::Pending : OrderStatus::Paid, + 'financial_status' => $isBankTransfer ? FinancialStatus::Pending : FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => $checkout->cart->currency ?? 'USD', + 'subtotal_amount' => $checkout->totals_json['subtotal'] ?? 0, + 'discount_amount' => $checkout->totals_json['discount'] ?? 0, + 'shipping_amount' => $checkout->totals_json['shipping'] ?? 0, + 'tax_amount' => $checkout->totals_json['tax'] ?? $checkout->totals_json['tax_total'] ?? 0, + 'total_amount' => $checkout->totals_json['total'] ?? 0, + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + // Create order lines from cart lines + $cart = $checkout->cart()->with('lines.variant.product', 'lines.variant.inventoryItem')->first(); + + foreach ($cart->lines as $cartLine) { + $variant = $cartLine->variant; + $product = $variant?->product; + + $order->lines()->create([ + 'product_id' => $product?->id, + 'variant_id' => $variant?->id, + 'title_snapshot' => $product?->title ?? 'Unknown Product', + 'sku_snapshot' => $variant?->sku, + 'quantity' => $cartLine->quantity, + 'unit_price_amount' => $cartLine->unit_price_amount, + 'total_amount' => $cartLine->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]); + + // Commit or keep reserved inventory depending on payment method + if ($variant && $variant->inventoryItem) { + if (! $isBankTransfer) { + $this->inventoryService->commit($variant->inventoryItem, $cartLine->quantity); + } + } + } + + // Create payment record + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $checkout->payment_method, + 'provider_payment_id' => $paymentResult->providerPaymentId, + 'status' => $isBankTransfer ? PaymentStatus::Pending : PaymentStatus::Captured, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => Crypt::encryptString(json_encode([ + 'success' => $paymentResult->success, + 'status' => $paymentResult->status, + 'provider_payment_id' => $paymentResult->providerPaymentId, + ])), + 'created_at' => now(), + ]); + + // Mark cart as converted + $cart->update(['status' => CartStatus::Converted]); + + // Mark checkout as completed + $checkout->update(['status' => CheckoutStatus::Completed]); + + // Auto-fulfill digital products for non-bank-transfer + if (! $isBankTransfer) { + $this->autoFulfillDigitalProducts($order); + } + + OrderCreated::dispatch($order); + + if (! $isBankTransfer) { + OrderPaid::dispatch($order); + } + + return $order; + }); + } + + public function generateOrderNumber(Store $store): string + { + $maxNumber = Order::withoutGlobalScopes() + ->where('store_id', $store->id) + ->max(DB::raw("CAST(REPLACE(order_number, '#', '') AS INTEGER)")); + + $nextNumber = $maxNumber ? $maxNumber + 1 : 1001; + + return '#'.$nextNumber; + } + + public function cancel(Order $order, string $reason = ''): void + { + if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw new \RuntimeException('Cannot cancel an order that has been partially or fully fulfilled.'); + } + + DB::transaction(function () use ($order) { + // Release inventory for each order line + $order->load('lines.variant.inventoryItem'); + + foreach ($order->lines as $line) { + if ($line->variant && $line->variant->inventoryItem) { + if ($order->financial_status === FinancialStatus::Pending) { + // Bank transfer: inventory was reserved, release it + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } else { + // Credit card / PayPal: inventory was committed, restock it + $this->inventoryService->restock($line->variant->inventoryItem, $line->quantity); + } + } + } + + $order->update([ + 'status' => OrderStatus::Cancelled, + 'financial_status' => $order->financial_status === FinancialStatus::Pending + ? FinancialStatus::Voided + : $order->financial_status, + ]); + + // Mark payment as failed if pending + $order->payments()->where('status', PaymentStatus::Pending)->update([ + 'status' => PaymentStatus::Failed->value, + ]); + }); + + OrderCancelled::dispatch($order->fresh()); + } + + public function confirmBankTransferPayment(Order $order): void + { + if ($order->payment_method !== PaymentMethod::BankTransfer) { + throw new \RuntimeException('Order is not a bank transfer order.'); + } + + if ($order->financial_status !== FinancialStatus::Pending) { + throw new \RuntimeException('Order payment is not pending.'); + } + + DB::transaction(function () use ($order) { + $order->update([ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + + $order->payments()->where('status', PaymentStatus::Pending)->update([ + 'status' => PaymentStatus::Captured->value, + ]); + + // Commit reserved inventory + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant && $line->variant->inventoryItem) { + $this->inventoryService->commit($line->variant->inventoryItem, $line->quantity); + } + } + + // Auto-fulfill digital products + $this->autoFulfillDigitalProducts($order); + }); + + OrderPaid::dispatch($order->fresh()); + } + + protected function autoFulfillDigitalProducts(Order $order): void + { + $order->load('lines.variant'); + + $allDigital = $order->lines->every(function ($line) { + return $line->variant && ! $line->variant->requires_shipping; + }); + + if (! $allDigital || $order->lines->isEmpty()) { + return; + } + + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now(), + 'created_at' => now(), + ]); + + foreach ($order->lines as $line) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + ]); + } + + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ]); + } +} diff --git a/app/Services/Payment/MockPaymentProvider.php b/app/Services/Payment/MockPaymentProvider.php new file mode 100644 index 00000000..537b74ba --- /dev/null +++ b/app/Services/Payment/MockPaymentProvider.php @@ -0,0 +1,58 @@ + 'card_declined', + '4000000000009995' => 'insufficient_funds', + ]; + + public function charge(Checkout $checkout, string $paymentMethod, array $details = []): PaymentResult + { + $referenceId = 'mock_'.Str::random(16); + + if ($paymentMethod === 'bank_transfer') { + return new PaymentResult( + success: true, + status: 'pending', + providerPaymentId: $referenceId, + ); + } + + if ($paymentMethod === 'credit_card') { + $cardNumber = str_replace(' ', '', $details['card_number'] ?? ''); + + if (isset(self::MAGIC_CARDS[$cardNumber])) { + return new PaymentResult( + success: false, + status: 'failed', + providerPaymentId: $referenceId, + error: self::MAGIC_CARDS[$cardNumber], + ); + } + } + + return new PaymentResult( + success: true, + status: 'captured', + providerPaymentId: $referenceId, + ); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return new RefundResult( + success: true, + providerRefundId: 'mock_refund_'.Str::random(16), + ); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..4ab3f561 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,73 @@ +refunds()->sum('amount'); + $refundable = $order->total_amount - $existingRefunds; + + if ($amount > $refundable) { + throw new \RuntimeException("Refund amount ({$amount}) exceeds refundable amount ({$refundable})."); + } + + $refundResult = $this->paymentProvider->refund($payment, $amount); + + $refund = Refund::create([ + 'order_id' => $order->id, + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $refundResult->success ? RefundStatus::Processed : RefundStatus::Failed, + 'provider_refund_id' => $refundResult->providerRefundId, + 'created_at' => now(), + ]); + + if ($refundResult->success) { + $totalRefunded = $existingRefunds + $amount; + + if ($totalRefunded >= $order->total_amount) { + $order->update([ + 'financial_status' => FinancialStatus::Refunded, + 'status' => OrderStatus::Refunded, + ]); + } else { + $order->update([ + 'financial_status' => FinancialStatus::PartiallyRefunded, + ]); + } + + if ($restock) { + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant && $line->variant->inventoryItem) { + $this->inventoryService->restock($line->variant->inventoryItem, $line->quantity); + } + } + } + + OrderRefunded::dispatch($order->fresh()); + } + + return $refund; + }); + } +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..3e3a1a08 --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,13 @@ + */ +class CustomerAddressFactory extends Factory +{ + protected $model = CustomerAddress::class; + + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work', 'Other']), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => fake()->optional()->company(), + 'address1' => fake()->streetAddress(), + 'address2' => fake()->optional()->secondaryAddress(), + 'city' => fake()->city(), + 'province' => fake()->stateAbbr(), + 'province_code' => fake()->stateAbbr(), + 'country' => 'United States', + 'country_code' => 'US', + 'zip' => fake()->postcode(), + 'phone' => fake()->optional()->phoneNumber(), + ], + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes) => [ + 'is_default' => true, + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..24b594b4 --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,45 @@ + */ +class FulfillmentFactory extends Factory +{ + protected $model = Fulfillment::class; + + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => null, + 'created_at' => now(), + ]; + } + + public function shipped(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Shipped, + 'tracking_company' => 'DHL', + 'tracking_number' => fake()->bothify('##########'), + 'shipped_at' => now(), + ]); + } + + public function delivered(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => FulfillmentShipmentStatus::Delivered, + 'shipped_at' => now()->subDay(), + ]); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..57bffc04 --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,23 @@ + */ +class FulfillmentLineFactory extends Factory +{ + protected $model = FulfillmentLine::class; + + 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..dedd8aa5 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,91 @@ + */ +class OrderFactory extends Factory +{ + protected $model = Order::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => Customer::factory(), + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 99999), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => 'USD', + 'subtotal_amount' => 5000, + 'discount_amount' => 0, + 'shipping_amount' => 799, + 'tax_amount' => 430, + 'total_amount' => 6229, + 'email' => fake()->safeEmail(), + 'billing_address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'province' => fake()->stateAbbr(), + 'country' => 'US', + 'zip' => fake()->postcode(), + ], + 'shipping_address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'province' => fake()->stateAbbr(), + 'country' => 'US', + 'zip' => fake()->postcode(), + ], + 'placed_at' => now(), + ]; + } + + public function paid(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } + + public function fulfilled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Fulfilled, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + ]); + } + + public function cancelled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => OrderStatus::Cancelled, + 'financial_status' => FinancialStatus::Voided, + ]); + } + + public function bankTransfer(): static + { + return $this->state(fn (array $attributes) => [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..3a0df93f --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,32 @@ + */ +class OrderLineFactory extends Factory +{ + protected $model = OrderLine::class; + + public function definition(): array + { + $quantity = fake()->numberBetween(1, 3); + $unitPrice = fake()->numberBetween(1000, 10000); + + return [ + 'order_id' => Order::factory(), + 'product_id' => null, + 'variant_id' => null, + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => strtoupper(fake()->bothify('???-####')), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $quantity * $unitPrice, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..98302eb7 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,46 @@ + */ +class PaymentFactory extends Factory +{ + protected $model = Payment::class; + + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => PaymentMethod::CreditCard, + 'provider_payment_id' => 'mock_'.Str::random(16), + 'status' => PaymentStatus::Captured, + 'amount' => 6229, + 'currency' => 'USD', + 'raw_json_encrypted' => null, + 'created_at' => now(), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PaymentStatus::Pending, + 'method' => PaymentMethod::BankTransfer, + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PaymentStatus::Failed, + ]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..a5ce770a --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,36 @@ + */ +class RefundFactory extends Factory +{ + protected $model = Refund::class; + + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => 1000, + 'reason' => fake()->optional()->sentence(), + 'status' => RefundStatus::Processed, + 'provider_refund_id' => 'mock_refund_'.Str::random(16), + 'created_at' => now(), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => RefundStatus::Pending, + ]); + } +} diff --git a/database/migrations/2026_03_20_000020_create_customer_addresses_table.php b/database/migrations/2026_03_20_000020_create_customer_addresses_table.php new file mode 100644 index 00000000..5f6338e7 --- /dev/null +++ b/database/migrations/2026_03_20_000020_create_customer_addresses_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->json('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_03_20_000021_create_orders_table.php b/database/migrations/2026_03_20_000021_create_orders_table.php new file mode 100644 index 00000000..340daeee --- /dev/null +++ b/database/migrations/2026_03_20_000021_create_orders_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->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')->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->json('billing_address_json')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->dateTime('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_03_20_000022_create_order_lines_table.php b/database/migrations/2026_03_20_000022_create_order_lines_table.php new file mode 100644 index 00000000..9e553357 --- /dev/null +++ b/database/migrations/2026_03_20_000022_create_order_lines_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->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->json('tax_lines_json')->default('[]'); + $table->json('discount_allocations_json')->default('[]'); + + $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_03_20_000023_create_payments_table.php b/database/migrations/2026_03_20_000023_create_payments_table.php new file mode 100644 index 00000000..ac140c43 --- /dev/null +++ b/database/migrations/2026_03_20_000023_create_payments_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('order_id')->constrained()->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')->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->dateTime('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_03_20_000024_create_refunds_table.php b/database/migrations/2026_03_20_000024_create_refunds_table.php new file mode 100644 index 00000000..06a9fe34 --- /dev/null +++ b/database/migrations/2026_03_20_000024_create_refunds_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->string('reason')->nullable(); + $table->string('status')->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->dateTime('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_03_20_000025_create_fulfillments_table.php b/database/migrations/2026_03_20_000025_create_fulfillments_table.php new file mode 100644 index 00000000..81bebd8a --- /dev/null +++ b/database/migrations/2026_03_20_000025_create_fulfillments_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->dateTime('shipped_at')->nullable(); + $table->dateTime('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_03_20_000026_create_fulfillment_lines_table.php b/database/migrations/2026_03_20_000026_create_fulfillment_lines_table.php new file mode 100644 index 00000000..493ec235 --- /dev/null +++ b/database/migrations/2026_03_20_000026_create_fulfillment_lines_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->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/routes/console.php b/routes/console.php index 3c9adf1a..79fd6066 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,16 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes(); +Schedule::job(new CleanupAbandonedCarts)->daily(); +Schedule::job(new CancelUnpaidBankTransferOrders)->daily(); diff --git a/tests/Feature/Fulfillment/FulfillmentServiceTest.php b/tests/Feature/Fulfillment/FulfillmentServiceTest.php new file mode 100644 index 00000000..83085766 --- /dev/null +++ b/tests/Feature/Fulfillment/FulfillmentServiceTest.php @@ -0,0 +1,231 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->fulfillmentService = app(FulfillmentService::class); +}); + +function createPaidOrderWithLines(Store $store, int $lineCount = 1, int $quantity = 2): Order +{ + $order = Order::factory()->paid()->create([ + 'store_id' => $store->id, + ]); + + for ($i = 0; $i < $lineCount; $i++) { + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'quantity' => $quantity, + 'unit_price_amount' => 2500, + 'total_amount' => 2500 * $quantity, + ]); + } + + return $order; +} + +it('creates a fulfillment for a paid order', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store); + $line = $order->lines->first(); + + $fulfillment = $this->fulfillmentService->create($order, [ + $line->id => $line->quantity, + ], [ + 'tracking_company' => 'DHL', + 'tracking_number' => '123456', + ]); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending) + ->and($fulfillment->tracking_company)->toBe('DHL') + ->and($fulfillment->tracking_number)->toBe('123456') + ->and($fulfillment->lines)->toHaveCount(1); +}); + +it('blocks fulfillment for pending payment', function () { + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'financial_status' => FinancialStatus::Pending, + ]); + + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 1]); + + expect(fn () => $this->fulfillmentService->create($order, [$line->id => 1])) + ->toThrow(FulfillmentGuardException::class); +}); + +it('blocks fulfillment for voided payment', function () { + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'financial_status' => FinancialStatus::Voided, + ]); + + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 1]); + + expect(fn () => $this->fulfillmentService->create($order, [$line->id => 1])) + ->toThrow(FulfillmentGuardException::class); +}); + +it('allows fulfillment for partially refunded order', function () { + Event::fake(); + + $order = Order::factory()->create([ + 'store_id' => $this->store->id, + 'financial_status' => FinancialStatus::PartiallyRefunded, + 'status' => OrderStatus::Paid, + ]); + + $line = OrderLine::factory()->create(['order_id' => $order->id, 'quantity' => 1]); + + $fulfillment = $this->fulfillmentService->create($order, [$line->id => 1]); + + expect($fulfillment)->not->toBeNull(); +}); + +it('sets order to fulfilled when all lines are fulfilled', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store, 1, 2); + $line = $order->lines->first(); + + $this->fulfillmentService->create($order, [ + $line->id => 2, + ]); + + $order->refresh(); + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->status)->toBe(OrderStatus::Fulfilled); + + Event::assertDispatched(OrderFulfilled::class); +}); + +it('sets order to partial when some lines are fulfilled', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store, 1, 4); + $line = $order->lines->first(); + + $this->fulfillmentService->create($order, [ + $line->id => 2, + ]); + + $order->refresh(); + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Partial); + + Event::assertNotDispatched(OrderFulfilled::class); +}); + +it('prevents over-fulfillment', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store, 1, 2); + $line = $order->lines->first(); + + expect(fn () => $this->fulfillmentService->create($order, [ + $line->id => 5, + ]))->toThrow(RuntimeException::class); +}); + +it('marks fulfillment as shipped', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store); + $line = $order->lines->first(); + + $fulfillment = $this->fulfillmentService->create($order, [ + $line->id => $line->quantity, + ]); + + $this->fulfillmentService->markAsShipped($fulfillment, [ + 'tracking_company' => 'UPS', + 'tracking_number' => 'TRACK123', + 'tracking_url' => 'https://ups.com/track/TRACK123', + ]); + + $fulfillment->refresh(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($fulfillment->tracking_company)->toBe('UPS') + ->and($fulfillment->shipped_at)->not->toBeNull(); +}); + +it('marks fulfillment as delivered', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store); + $line = $order->lines->first(); + + $fulfillment = $this->fulfillmentService->create($order, [ + $line->id => $line->quantity, + ]); + + $this->fulfillmentService->markAsShipped($fulfillment); + + $this->fulfillmentService->markAsDelivered($fulfillment); + + $fulfillment->refresh(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); +}); + +it('rejects shipping a non-pending fulfillment', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store); + $line = $order->lines->first(); + + $fulfillment = $this->fulfillmentService->create($order, [$line->id => $line->quantity]); + $this->fulfillmentService->markAsShipped($fulfillment); + + expect(fn () => $this->fulfillmentService->markAsShipped($fulfillment->fresh())) + ->toThrow(RuntimeException::class); +}); + +it('rejects delivering a non-shipped fulfillment', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store); + $line = $order->lines->first(); + + $fulfillment = $this->fulfillmentService->create($order, [$line->id => $line->quantity]); + + expect(fn () => $this->fulfillmentService->markAsDelivered($fulfillment)) + ->toThrow(RuntimeException::class); +}); + +it('fulfills multiple lines across multiple fulfillments', function () { + Event::fake(); + + $order = createPaidOrderWithLines($this->store, 2, 3); + $lines = $order->lines; + + // First fulfillment: partial fulfillment of both lines + $this->fulfillmentService->create($order, [ + $lines[0]->id => 2, + $lines[1]->id => 1, + ]); + + $order->refresh(); + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Partial); + + // Second fulfillment: complete remaining + $this->fulfillmentService->create($order->fresh(), [ + $lines[0]->id => 1, + $lines[1]->id => 2, + ]); + + $order->refresh(); + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->status)->toBe(OrderStatus::Fulfilled); +}); diff --git a/tests/Feature/Order/OrderServiceTest.php b/tests/Feature/Order/OrderServiceTest.php new file mode 100644 index 00000000..506e1d87 --- /dev/null +++ b/tests/Feature/Order/OrderServiceTest.php @@ -0,0 +1,376 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->orderService = app(OrderService::class); +}); + +function createCheckoutWithItems(Store $store, string $paymentMethod = 'credit_card', int $quantity = 1): Checkout +{ + $customer = Customer::factory()->create(['store_id' => $store->id]); + + $product = Product::withoutEvents(function () use ($store) { + return Product::factory()->create([ + 'store_id' => $store->id, + 'status' => ProductStatus::Active, + 'published_at' => now(), + ]); + }); + + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 2500, + 'is_default' => true, + 'status' => VariantStatus::Active, + ]); + + InventoryItem::factory()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => $quantity, + 'policy' => InventoryPolicy::Deny, + ]); + + $cart = Cart::factory()->create([ + 'store_id' => $store->id, + 'customer_id' => $customer->id, + 'currency' => 'USD', + ]); + + CartLine::create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500 * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500 * $quantity, + ]); + + return Checkout::factory()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => $customer->id, + 'status' => CheckoutStatus::PaymentPending, + 'payment_method' => $paymentMethod, + 'email' => 'test@example.com', + 'shipping_address_json' => ['first_name' => 'John', 'last_name' => 'Doe', 'city' => 'NYC'], + 'billing_address_json' => ['first_name' => 'John', 'last_name' => 'Doe', 'city' => 'NYC'], + 'totals_json' => [ + 'subtotal' => 2500 * $quantity, + 'discount' => 0, + 'shipping' => 500, + 'tax' => 200, + 'total' => (2500 * $quantity) + 700, + ], + ]); +} + +it('creates an order from checkout with credit card', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $order = $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + expect($order->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->fulfillment_status)->toBe(FulfillmentStatus::Unfulfilled) + ->and($order->payment_method)->toBe(PaymentMethod::CreditCard) + ->and($order->total_amount)->toBe(3200) + ->and($order->email)->toBe('test@example.com') + ->and($order->order_number)->toStartWith('#'); + + Event::assertDispatched(OrderCreated::class); + Event::assertDispatched(OrderPaid::class); +}); + +it('creates order lines with snapshots', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $order = $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + expect($order->lines)->toHaveCount(1); + + $line = $order->lines->first(); + expect($line->title_snapshot)->not->toBeEmpty() + ->and($line->quantity)->toBe(1) + ->and($line->unit_price_amount)->toBe(2500) + ->and($line->product_id)->not->toBeNull() + ->and($line->variant_id)->not->toBeNull(); +}); + +it('creates a payment record', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $order = $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + $payment = $order->payments->first(); + expect($payment)->not->toBeNull() + ->and($payment->provider)->toBe('mock') + ->and($payment->method)->toBe(PaymentMethod::CreditCard) + ->and($payment->status)->toBe(PaymentStatus::Captured) + ->and($payment->amount)->toBe(3200) + ->and($payment->provider_payment_id)->toStartWith('mock_'); +}); + +it('commits inventory on credit card order', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $order = $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + $variant = $order->lines->first()->variant; + $inventory = $variant->inventoryItem->fresh(); + + // Inventory was: on_hand=10, reserved=1 + // After commit: on_hand=9, reserved=0 + expect($inventory->quantity_on_hand)->toBe(9) + ->and($inventory->quantity_reserved)->toBe(0); +}); + +it('marks cart as converted', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $order = $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + $cart = $checkout->cart->fresh(); + expect($cart->status)->toBe(CartStatus::Converted); +}); + +it('marks checkout as completed', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4242424242424242', + ]); + + expect($checkout->fresh()->status)->toBe(CheckoutStatus::Completed); +}); + +it('creates bank transfer order with pending status', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store, 'bank_transfer'); + $order = $this->orderService->createFromCheckout($checkout); + + expect($order->status)->toBe(OrderStatus::Pending) + ->and($order->financial_status)->toBe(FinancialStatus::Pending) + ->and($order->payment_method)->toBe(PaymentMethod::BankTransfer); + + $payment = $order->payments->first(); + expect($payment->status)->toBe(PaymentStatus::Pending); + + Event::assertDispatched(OrderCreated::class); + Event::assertNotDispatched(OrderPaid::class); +}); + +it('keeps inventory reserved for bank transfer orders', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store, 'bank_transfer'); + $order = $this->orderService->createFromCheckout($checkout); + + $variant = $order->lines->first()->variant; + $inventory = $variant->inventoryItem->fresh(); + + // Inventory stays reserved, not committed + // on_hand=10 (unchanged), reserved=1 (unchanged) + expect($inventory->quantity_on_hand)->toBe(10) + ->and($inventory->quantity_reserved)->toBe(1); +}); + +it('throws exception for declined credit card', function () { + $checkout = createCheckoutWithItems($this->store); + + expect(fn () => $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4000000000000002', + ]))->toThrow(PaymentFailedException::class, 'card_declined'); +}); + +it('releases inventory on payment failure', function () { + $checkout = createCheckoutWithItems($this->store); + + try { + $this->orderService->createFromCheckout($checkout, [ + 'card_number' => '4000000000000002', + ]); + } catch (PaymentFailedException) { + // expected + } + + $variant = CartLine::where('cart_id', $checkout->cart_id)->first()->variant; + $inventory = $variant->inventoryItem->fresh(); + + // reserved was 1, should be released back to 0 + expect($inventory->quantity_reserved)->toBe(0); +}); + +it('generates sequential order numbers', function () { + Event::fake(); + + $checkout1 = createCheckoutWithItems($this->store); + $order1 = $this->orderService->createFromCheckout($checkout1, ['card_number' => '4242424242424242']); + + $checkout2 = createCheckoutWithItems($this->store); + $order2 = $this->orderService->createFromCheckout($checkout2, ['card_number' => '4242424242424242']); + + expect($order1->order_number)->toBe('#1001') + ->and($order2->order_number)->toBe('#1002'); +}); + +it('cancels an unfulfilled order', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $order = $this->orderService->createFromCheckout($checkout, ['card_number' => '4242424242424242']); + + $this->orderService->cancel($order, 'Customer requested cancellation'); + + $order->refresh(); + expect($order->status)->toBe(OrderStatus::Cancelled); + + Event::assertDispatched(OrderCancelled::class); +}); + +it('restocks inventory on cancellation of paid order', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store); + $order = $this->orderService->createFromCheckout($checkout, ['card_number' => '4242424242424242']); + + $variant = $order->lines->first()->variant; + $inventoryBefore = $variant->inventoryItem->fresh()->quantity_on_hand; + + $this->orderService->cancel($order, 'cancelled'); + + $inventoryAfter = $variant->inventoryItem->fresh()->quantity_on_hand; + // After cancellation, on_hand should be restocked (was 9 after commit, now 10) + expect($inventoryAfter)->toBe($inventoryBefore + 1); +}); + +it('confirms bank transfer payment', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store, 'bank_transfer'); + $order = $this->orderService->createFromCheckout($checkout); + + $this->orderService->confirmBankTransferPayment($order); + + $order->refresh(); + expect($order->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid); + + $payment = $order->payments->first(); + expect($payment->fresh()->status)->toBe(PaymentStatus::Captured); + + Event::assertDispatched(OrderPaid::class); +}); + +it('commits inventory on bank transfer confirmation', function () { + Event::fake(); + + $checkout = createCheckoutWithItems($this->store, 'bank_transfer'); + $order = $this->orderService->createFromCheckout($checkout); + + $variant = $order->lines->first()->variant; + expect($variant->inventoryItem->fresh()->quantity_on_hand)->toBe(10); + + $this->orderService->confirmBankTransferPayment($order); + + $inventory = $variant->inventoryItem->fresh(); + expect($inventory->quantity_on_hand)->toBe(9) + ->and($inventory->quantity_reserved)->toBe(0); +}); + +it('auto-fulfills digital products on credit card payment', function () { + Event::fake(); + + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + $product = Product::withoutEvents(fn () => Product::factory()->create([ + 'store_id' => $this->store->id, + 'status' => ProductStatus::Active, + 'published_at' => now(), + ])); + + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 1000, + 'is_default' => true, + 'requires_shipping' => false, + ]); + + InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 100, + 'quantity_reserved' => 1, + ]); + + $cart = Cart::factory()->create(['store_id' => $this->store->id, 'customer_id' => $customer->id]); + CartLine::create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'line_subtotal_amount' => 1000, + 'line_discount_amount' => 0, + 'line_total_amount' => 1000, + ]); + + $checkout = Checkout::factory()->create([ + 'store_id' => $this->store->id, + 'cart_id' => $cart->id, + 'customer_id' => $customer->id, + 'status' => CheckoutStatus::PaymentPending, + 'payment_method' => 'credit_card', + 'email' => 'digital@example.com', + 'totals_json' => ['subtotal' => 1000, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 1000], + ]); + + $order = $this->orderService->createFromCheckout($checkout, ['card_number' => '4242424242424242']); + + expect($order->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->status)->toBe(OrderStatus::Fulfilled) + ->and($order->fulfillments)->toHaveCount(1); +}); diff --git a/tests/Feature/Order/RefundServiceTest.php b/tests/Feature/Order/RefundServiceTest.php new file mode 100644 index 00000000..8c0fbefa --- /dev/null +++ b/tests/Feature/Order/RefundServiceTest.php @@ -0,0 +1,150 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->refundService = app(RefundService::class); +}); + +it('creates a full refund', function () { + Event::fake(); + + $order = Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'total_amount' => 5000, + ]); + + $payment = Payment::factory()->create([ + 'order_id' => $order->id, + 'amount' => 5000, + 'status' => PaymentStatus::Captured, + ]); + + $refund = $this->refundService->create($order, $payment, 5000, 'Customer request'); + + expect($refund->status)->toBe(RefundStatus::Processed) + ->and($refund->amount)->toBe(5000) + ->and($refund->reason)->toBe('Customer request') + ->and($refund->provider_refund_id)->toStartWith('mock_refund_'); + + $order->refresh(); + expect($order->financial_status)->toBe(FinancialStatus::Refunded) + ->and($order->status)->toBe(OrderStatus::Refunded); + + Event::assertDispatched(OrderRefunded::class); +}); + +it('creates a partial refund', function () { + Event::fake(); + + $order = Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'total_amount' => 5000, + ]); + + $payment = Payment::factory()->create([ + 'order_id' => $order->id, + 'amount' => 5000, + 'status' => PaymentStatus::Captured, + ]); + + $refund = $this->refundService->create($order, $payment, 2000); + + expect($refund->amount)->toBe(2000); + + $order->refresh(); + expect($order->financial_status)->toBe(FinancialStatus::PartiallyRefunded); + + Event::assertDispatched(OrderRefunded::class); +}); + +it('rejects refund exceeding total amount', function () { + $order = Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'total_amount' => 5000, + ]); + + $payment = Payment::factory()->create([ + 'order_id' => $order->id, + 'amount' => 5000, + ]); + + expect(fn () => $this->refundService->create($order, $payment, 6000)) + ->toThrow(RuntimeException::class); +}); + +it('rejects refund exceeding remaining refundable amount', function () { + $order = Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'total_amount' => 5000, + ]); + + $payment = Payment::factory()->create([ + 'order_id' => $order->id, + 'amount' => 5000, + ]); + + // First refund of 3000 + Event::fake(); + $this->refundService->create($order, $payment, 3000); + + // Second refund of 3000 should fail (only 2000 remaining) + expect(fn () => $this->refundService->create($order->fresh(), $payment, 3000)) + ->toThrow(RuntimeException::class); +}); + +it('restocks inventory when restock flag is true', function () { + Event::fake(); + + $order = Order::factory()->paid()->create([ + 'store_id' => $this->store->id, + 'total_amount' => 5000, + ]); + + $product = \App\Models\Product::withoutEvents(fn () => \App\Models\Product::factory()->create([ + 'store_id' => $this->store->id, + 'status' => \App\Enums\ProductStatus::Active, + ])); + + $variant = \App\Models\ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 2500, + 'is_default' => true, + ]); + + $inventory = \App\Models\InventoryItem::factory()->create([ + 'store_id' => $this->store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 5, + 'quantity_reserved' => 0, + ]); + + $order->lines()->create([ + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => 'Test Product', + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + $payment = Payment::factory()->create([ + 'order_id' => $order->id, + 'amount' => 5000, + ]); + + $this->refundService->create($order, $payment, 5000, 'Restock test', true); + + expect($inventory->fresh()->quantity_on_hand)->toBe(7); +}); diff --git a/tests/Feature/Payment/MockPaymentProviderTest.php b/tests/Feature/Payment/MockPaymentProviderTest.php new file mode 100644 index 00000000..0fa7e68b --- /dev/null +++ b/tests/Feature/Payment/MockPaymentProviderTest.php @@ -0,0 +1,96 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->provider = new MockPaymentProvider; +}); + +it('charges credit card successfully with default card number', function () { + $checkout = Checkout::factory()->create(['store_id' => $this->store->id]); + + $result = $this->provider->charge($checkout, 'credit_card', [ + 'card_number' => '4242424242424242', + ]); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('captured') + ->and($result->providerPaymentId)->toStartWith('mock_'); +}); + +it('declines credit card with magic decline number', function () { + $checkout = Checkout::factory()->create(['store_id' => $this->store->id]); + + $result = $this->provider->charge($checkout, 'credit_card', [ + 'card_number' => '4000000000000002', + ]); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe('failed') + ->and($result->error)->toBe('card_declined'); +}); + +it('returns insufficient funds for magic number', function () { + $checkout = Checkout::factory()->create(['store_id' => $this->store->id]); + + $result = $this->provider->charge($checkout, 'credit_card', [ + 'card_number' => '4000000000009995', + ]); + + expect($result->success)->toBeFalse() + ->and($result->error)->toBe('insufficient_funds'); +}); + +it('accepts any other card number as success', function () { + $checkout = Checkout::factory()->create(['store_id' => $this->store->id]); + + $result = $this->provider->charge($checkout, 'credit_card', [ + 'card_number' => '5555555555554444', + ]); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('captured'); +}); + +it('always succeeds for paypal', function () { + $checkout = Checkout::factory()->create(['store_id' => $this->store->id]); + + $result = $this->provider->charge($checkout, 'paypal'); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('captured'); +}); + +it('returns pending for bank transfer', function () { + $checkout = Checkout::factory()->create(['store_id' => $this->store->id]); + + $result = $this->provider->charge($checkout, 'bank_transfer'); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe('pending'); +}); + +it('processes refunds successfully', function () { + $payment = Payment::factory()->create(); + + $result = $this->provider->refund($payment, 1000); + + expect($result->success)->toBeTrue() + ->and($result->providerRefundId)->toStartWith('mock_refund_'); +}); + +it('handles card numbers with spaces', function () { + $checkout = Checkout::factory()->create(['store_id' => $this->store->id]); + + $result = $this->provider->charge($checkout, 'credit_card', [ + 'card_number' => '4000 0000 0000 0002', + ]); + + expect($result->success)->toBeFalse() + ->and($result->error)->toBe('card_declined'); +}); From 5a17a2c5e5f32f4c01d9e41fe58bf45f8931eb33 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 22:10:48 +0100 Subject: [PATCH 08/17] Phase 9: Analytics - event ingestion, daily aggregation, and reporting - AnalyticsEvent model with client_event_id deduplication per store - AnalyticsService with track(), trackBatch(), and getDailyMetrics() - AggregateAnalytics job scheduled daily at 01:00, aggregates events into pre-computed daily metrics (orders, revenue, AOV, visits, funnel counts) - AnalyticsDaily model with store_id+date unique constraint - 16 tests covering event ingestion and aggregation Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Jobs/AggregateAnalytics.php | 81 +++++++ app/Models/AnalyticsDaily.php | 46 ++++ app/Models/AnalyticsEvent.php | 47 ++++ app/Services/AnalyticsService.php | 76 +++++++ database/factories/AnalyticsEventFactory.php | 67 ++++++ ...0_000033_create_analytics_events_table.php | 35 +++ ...20_000034_create_analytics_daily_table.php | 31 +++ routes/console.php | 2 + tests/Feature/Analytics/AggregationTest.php | 215 ++++++++++++++++++ .../Feature/Analytics/EventIngestionTest.php | 106 +++++++++ 10 files changed, 706 insertions(+) create mode 100644 app/Jobs/AggregateAnalytics.php create mode 100644 app/Models/AnalyticsDaily.php create mode 100644 app/Models/AnalyticsEvent.php create mode 100644 app/Services/AnalyticsService.php create mode 100644 database/factories/AnalyticsEventFactory.php create mode 100644 database/migrations/2026_03_20_000033_create_analytics_events_table.php create mode 100644 database/migrations/2026_03_20_000034_create_analytics_daily_table.php create mode 100644 tests/Feature/Analytics/AggregationTest.php create mode 100644 tests/Feature/Analytics/EventIngestionTest.php diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..3d426e4f --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,81 @@ +date ?? now()->subDay()->format('Y-m-d'); + + $startOfDay = $date.' 00:00:00'; + $endOfDay = $date.' 23:59:59'; + + $storeIds = AnalyticsEvent::withoutGlobalScopes() + ->where('created_at', '>=', $startOfDay) + ->where('created_at', '<=', $endOfDay) + ->distinct() + ->pluck('store_id'); + + foreach ($storeIds as $storeId) { + $this->aggregateForStore($storeId, $date, $startOfDay, $endOfDay); + } + } + + protected function aggregateForStore(int $storeId, string $date, string $startOfDay, string $endOfDay): void + { + $events = AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('created_at', '>=', $startOfDay) + ->where('created_at', '<=', $endOfDay) + ->get(); + + $completedEvents = $events->where('type', 'checkout_completed'); + $ordersCount = $completedEvents->count(); + + $revenueAmount = 0; + foreach ($completedEvents as $event) { + $revenueAmount += $event->properties_json['order_total'] ?? 0; + } + + $aovAmount = $ordersCount > 0 ? intdiv($revenueAmount, $ordersCount) : 0; + + $visitsCount = $events + ->where('type', 'page_view') + ->whereNotNull('session_id') + ->pluck('session_id') + ->unique() + ->count(); + + $addToCartCount = $events->where('type', 'add_to_cart')->count(); + $checkoutStartedCount = $events->where('type', 'checkout_started')->count(); + $checkoutCompletedCount = $ordersCount; + + AnalyticsDaily::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $storeId, 'date' => $date], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $aovAmount, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => $addToCartCount, + 'checkout_started_count' => $checkoutStartedCount, + 'checkout_completed_count' => $checkoutCompletedCount, + ], + ); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..c3e67154 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,46 @@ + 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..2366a133 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,47 @@ + 'array', + 'occurred_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..a1ac2958 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,76 @@ +where('store_id', $store->id) + ->where('client_event_id', $clientEventId) + ->exists(); + + if ($exists) { + return; + } + } + + AnalyticsEvent::create([ + 'store_id' => $store->id, + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $clientEventId, + 'occurred_at' => $occurredAt ? \Carbon\Carbon::parse($occurredAt) : now(), + 'created_at' => now(), + ]); + } + + public function trackBatch(Store $store, array $events): void + { + foreach ($events as $event) { + $this->track( + $store, + $event['type'] ?? '', + $event['properties'] ?? [], + $event['session_id'] ?? null, + null, + $event['client_event_id'] ?? null, + $event['occurred_at'] ?? null, + ); + } + } + + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', '>=', $startDate) + ->where('date', '<=', $endDate) + ->orderBy('date') + ->get(); + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..282c6f7c --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,67 @@ + */ +class AnalyticsEventFactory extends Factory +{ + protected $model = AnalyticsEvent::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => fake()->randomElement(['page_view', 'product_view', 'add_to_cart', 'checkout_started', 'checkout_completed']), + 'session_id' => Str::uuid()->toString(), + 'customer_id' => null, + 'properties_json' => [], + 'client_event_id' => Str::uuid()->toString(), + 'occurred_at' => now(), + 'created_at' => now(), + ]; + } + + public function pageView(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'page_view', + 'properties_json' => ['url' => '/'], + ]); + } + + public function productView(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'product_view', + 'properties_json' => ['product_id' => fake()->numberBetween(1, 100)], + ]); + } + + public function addToCart(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'add_to_cart', + 'properties_json' => ['product_id' => fake()->numberBetween(1, 100), 'variant_id' => fake()->numberBetween(1, 100)], + ]); + } + + public function checkoutStarted(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'checkout_started', + ]); + } + + public function checkoutCompleted(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'checkout_completed', + 'properties_json' => ['order_total' => fake()->numberBetween(1000, 50000)], + ]); + } +} diff --git a/database/migrations/2026_03_20_000033_create_analytics_events_table.php b/database/migrations/2026_03_20_000033_create_analytics_events_table.php new file mode 100644 index 00000000..b4703d6d --- /dev/null +++ b/database/migrations/2026_03_20_000033_create_analytics_events_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->json('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->dateTime('occurred_at')->nullable(); + $table->dateTime('created_at')->nullable(); + + $table->index('store_id', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_03_20_000034_create_analytics_daily_table.php b/database/migrations/2026_03_20_000034_create_analytics_daily_table.php new file mode 100644 index 00000000..17a140e2 --- /dev/null +++ b/database/migrations/2026_03_20_000034_create_analytics_daily_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('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->unique(['store_id', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/routes/console.php b/routes/console.php index 79fd6066..d93e7d7e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyFifteenMinutes(); Schedule::job(new CleanupAbandonedCarts)->daily(); Schedule::job(new CancelUnpaidBankTransferOrders)->daily(); +Schedule::job(new AggregateAnalytics)->dailyAt('01:00'); diff --git a/tests/Feature/Analytics/AggregationTest.php b/tests/Feature/Analytics/AggregationTest.php new file mode 100644 index 00000000..18b421dc --- /dev/null +++ b/tests/Feature/Analytics/AggregationTest.php @@ -0,0 +1,215 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->analyticsService = app(AnalyticsService::class); +}); + +it('aggregates events into daily metrics', function () { + $date = '2026-03-19'; + + // Create raw events for the date + AnalyticsEvent::factory()->count(3)->create([ + 'store_id' => $this->store->id, + 'type' => 'page_view', + 'session_id' => 'session-1', + 'created_at' => "{$date} 10:00:00", + ]); + + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'page_view', + 'session_id' => 'session-2', + 'created_at' => "{$date} 11:00:00", + ]); + + AnalyticsEvent::factory()->count(2)->create([ + 'store_id' => $this->store->id, + 'type' => 'add_to_cart', + 'created_at' => "{$date} 12:00:00", + ]); + + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'checkout_started', + 'created_at' => "{$date} 13:00:00", + ]); + + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'checkout_completed', + 'properties_json' => ['order_total' => 5000], + 'created_at' => "{$date} 14:00:00", + ]); + + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'checkout_completed', + 'properties_json' => ['order_total' => 3000], + 'created_at' => "{$date} 15:00:00", + ]); + + $job = new AggregateAnalytics($date); + $job->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $date) + ->first(); + + expect($daily)->not->toBeNull() + ->and($daily->orders_count)->toBe(2) + ->and($daily->revenue_amount)->toBe(8000) + ->and($daily->aov_amount)->toBe(4000) + ->and($daily->visits_count)->toBe(2) + ->and($daily->add_to_cart_count)->toBe(2) + ->and($daily->checkout_started_count)->toBe(1) + ->and($daily->checkout_completed_count)->toBe(2); +}); + +it('handles days with no events gracefully', function () { + $job = new AggregateAnalytics('2026-03-18'); + $job->handle(); + + $count = AnalyticsDaily::withoutGlobalScopes()->where('store_id', $this->store->id)->count(); + expect($count)->toBe(0); +}); + +it('updates existing daily record on re-aggregation', function () { + $date = '2026-03-19'; + + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'checkout_completed', + 'properties_json' => ['order_total' => 2000], + 'created_at' => "{$date} 10:00:00", + ]); + + $job = new AggregateAnalytics($date); + $job->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $date) + ->first(); + + expect($daily->orders_count)->toBe(1) + ->and($daily->revenue_amount)->toBe(2000); + + // Add another event and re-aggregate + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'checkout_completed', + 'properties_json' => ['order_total' => 3000], + 'created_at' => "{$date} 11:00:00", + ]); + + $job = new AggregateAnalytics($date); + $job->handle(); + + $updatedDaily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $date) + ->first(); + + expect($updatedDaily->orders_count)->toBe(2) + ->and($updatedDaily->revenue_amount)->toBe(5000) + ->and($updatedDaily->aov_amount)->toBe(2500); +}); + +it('scopes aggregation to each store separately', function () { + $otherStore = Store::factory()->create(); + $date = '2026-03-19'; + + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'checkout_completed', + 'properties_json' => ['order_total' => 1000], + 'created_at' => "{$date} 10:00:00", + ]); + + AnalyticsEvent::factory()->create([ + 'store_id' => $otherStore->id, + 'type' => 'checkout_completed', + 'properties_json' => ['order_total' => 9000], + 'created_at' => "{$date} 10:00:00", + ]); + + $job = new AggregateAnalytics($date); + $job->handle(); + + $daily1 = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $date) + ->first(); + + $daily2 = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $otherStore->id) + ->where('date', $date) + ->first(); + + expect($daily1->revenue_amount)->toBe(1000) + ->and($daily2->revenue_amount)->toBe(9000); +}); + +it('returns daily metrics for a date range', function () { + AnalyticsDaily::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'date' => '2026-03-17', + 'orders_count' => 5, + 'revenue_amount' => 25000, + 'aov_amount' => 5000, + 'visits_count' => 100, + 'add_to_cart_count' => 20, + 'checkout_started_count' => 10, + 'checkout_completed_count' => 5, + ]); + + AnalyticsDaily::withoutGlobalScopes()->create([ + 'store_id' => $this->store->id, + 'date' => '2026-03-18', + 'orders_count' => 3, + 'revenue_amount' => 15000, + 'aov_amount' => 5000, + 'visits_count' => 80, + 'add_to_cart_count' => 15, + 'checkout_started_count' => 8, + 'checkout_completed_count' => 3, + ]); + + $metrics = $this->analyticsService->getDailyMetrics($this->store, '2026-03-17', '2026-03-18'); + + expect($metrics)->toHaveCount(2) + ->and($metrics->first()->date)->toBe('2026-03-17') + ->and($metrics->last()->date)->toBe('2026-03-18'); +}); + +it('calculates zero aov when no orders exist', function () { + $date = '2026-03-19'; + + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->id, + 'type' => 'page_view', + 'session_id' => 'session-1', + 'created_at' => "{$date} 10:00:00", + ]); + + $job = new AggregateAnalytics($date); + $job->handle(); + + $daily = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->where('date', $date) + ->first(); + + expect($daily->orders_count)->toBe(0) + ->and($daily->aov_amount)->toBe(0) + ->and($daily->visits_count)->toBe(1); +}); diff --git a/tests/Feature/Analytics/EventIngestionTest.php b/tests/Feature/Analytics/EventIngestionTest.php new file mode 100644 index 00000000..4647f560 --- /dev/null +++ b/tests/Feature/Analytics/EventIngestionTest.php @@ -0,0 +1,106 @@ +store = Store::factory()->create(); + app()->instance('current_store', $this->store); + $this->analyticsService = app(AnalyticsService::class); +}); + +it('tracks a page view event', function () { + $this->analyticsService->track($this->store, 'page_view', ['url' => '/products'], 'session-1'); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + expect($event)->not->toBeNull() + ->and($event->type)->toBe('page_view') + ->and($event->session_id)->toBe('session-1') + ->and($event->properties_json)->toBe(['url' => '/products']); +}); + +it('tracks a product view event', function () { + $this->analyticsService->track($this->store, 'product_view', ['product_id' => 42], 'session-1'); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + expect($event->type)->toBe('product_view') + ->and($event->properties_json['product_id'])->toBe(42); +}); + +it('tracks an add to cart event', function () { + $this->analyticsService->track($this->store, 'add_to_cart', ['product_id' => 5, 'variant_id' => 10], 'session-1'); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + expect($event->type)->toBe('add_to_cart'); +}); + +it('tracks checkout completed event', function () { + $this->analyticsService->track($this->store, 'checkout_completed', ['order_total' => 5000], 'session-1'); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + expect($event->type)->toBe('checkout_completed') + ->and($event->properties_json['order_total'])->toBe(5000); +}); + +it('ignores invalid event types', function () { + $this->analyticsService->track($this->store, 'invalid_type', [], 'session-1'); + + $count = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->count(); + expect($count)->toBe(0); +}); + +it('deduplicates events by client_event_id', function () { + $this->analyticsService->track( + $this->store, 'page_view', [], 'session-1', null, 'event-123' + ); + $this->analyticsService->track( + $this->store, 'page_view', [], 'session-1', null, 'event-123' + ); + + $count = AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $this->store->id) + ->count(); + expect($count)->toBe(1); +}); + +it('allows same client_event_id in different stores', function () { + $otherStore = Store::factory()->create(); + + $this->analyticsService->track($this->store, 'page_view', [], 'session-1', null, 'event-abc'); + $this->analyticsService->track($otherStore, 'page_view', [], 'session-2', null, 'event-abc'); + + $count1 = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->count(); + $count2 = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $otherStore->id)->count(); + + expect($count1)->toBe(1) + ->and($count2)->toBe(1); +}); + +it('tracks events with customer id', function () { + $customer = Customer::factory()->create(['store_id' => $this->store->id]); + + $this->analyticsService->track($this->store, 'page_view', [], 'session-1', $customer->id); + + $event = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->first(); + expect($event->customer_id)->toBe($customer->id); +}); + +it('processes a batch of events', function () { + $this->analyticsService->trackBatch($this->store, [ + ['type' => 'page_view', 'session_id' => 's1', 'client_event_id' => 'e1', 'occurred_at' => '2026-03-20T10:00:00Z'], + ['type' => 'product_view', 'session_id' => 's1', 'client_event_id' => 'e2', 'occurred_at' => '2026-03-20T10:01:00Z'], + ['type' => 'add_to_cart', 'session_id' => 's1', 'client_event_id' => 'e3', 'occurred_at' => '2026-03-20T10:02:00Z'], + ]); + + $count = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->store->id)->count(); + expect($count)->toBe(3); +}); + +it('uses factory to create events', function () { + $event = AnalyticsEvent::factory()->pageView()->create(['store_id' => $this->store->id]); + + expect($event->type)->toBe('page_view') + ->and($event->store_id)->toBe($this->store->id); +}); From 11e9d0dbfe2a6327930e63ea380ac0b0d6dab791 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 22:18:44 +0100 Subject: [PATCH 09/17] Phase 6: Customer Accounts - dashboard, orders, addresses - Account dashboard with recent orders and logout - Order history (paginated) and order detail with line items, summary totals, fulfillment timeline, and address display - Address book with full CRUD, default address management, and customer isolation (prevents cross-customer access) - Customer auth middleware redirects guests to /account/login - 19 tests covering account pages, order access control, address CRUD, validation, and default toggling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Storefront/Account/Addresses/Index.php | 193 ++++++++++++++++++ app/Livewire/Storefront/Account/Dashboard.php | 37 ++++ .../Storefront/Account/Orders/Index.php | 28 +++ .../Storefront/Account/Orders/Show.php | 34 +++ bootstrap/app.php | 8 + .../account/addresses/index.blade.php | 121 +++++++++++ .../storefront/account/dashboard.blade.php | 88 ++++++++ .../storefront/account/orders/index.blade.php | 79 +++++++ .../storefront/account/orders/show.blade.php | 166 +++++++++++++++ routes/web.php | 17 +- .../Feature/Account/AddressManagementTest.php | 172 ++++++++++++++++ tests/Feature/Account/CustomerAccountTest.php | 160 +++++++++++++++ 12 files changed, 1099 insertions(+), 4 deletions(-) create mode 100644 app/Livewire/Storefront/Account/Addresses/Index.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 resources/views/livewire/storefront/account/addresses/index.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 tests/Feature/Account/AddressManagementTest.php create mode 100644 tests/Feature/Account/CustomerAccountTest.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..71e086c4 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,193 @@ + */ + public function rules(): array + { + return [ + 'label' => ['required', 'string', 'max:50'], + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'company' => ['nullable', 'string', 'max:255'], + 'address1' => ['required', 'string', 'max:255'], + 'address2' => ['nullable', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:255'], + 'province' => ['nullable', 'string', 'max:255'], + 'country' => ['required', 'string', 'max:2'], + 'zip' => ['required', 'string', 'max:20'], + 'phone' => ['nullable', 'string', 'max:30'], + 'is_default' => ['boolean'], + ]; + } + + public function openCreateForm(): void + { + $this->resetForm(); + $this->showForm = true; + } + + public function editAddress(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + $address = CustomerAddress::where('customer_id', $customer->id) + ->findOrFail($addressId); + + $this->editingAddressId = $address->id; + $this->label = $address->label ?? ''; + $this->first_name = $address->address_json['first_name'] ?? ''; + $this->last_name = $address->address_json['last_name'] ?? ''; + $this->company = $address->address_json['company'] ?? ''; + $this->address1 = $address->address_json['address1'] ?? ''; + $this->address2 = $address->address_json['address2'] ?? ''; + $this->city = $address->address_json['city'] ?? ''; + $this->province = $address->address_json['province'] ?? ''; + $this->country = $address->address_json['country_code'] ?? 'US'; + $this->zip = $address->address_json['zip'] ?? ''; + $this->phone = $address->address_json['phone'] ?? ''; + $this->is_default = $address->is_default; + $this->showForm = true; + } + + public function saveAddress(): void + { + $this->validate(); + + $customer = Auth::guard('customer')->user(); + + $addressJson = [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'company' => $this->company ?: null, + 'address1' => $this->address1, + 'address2' => $this->address2 ?: null, + 'city' => $this->city, + 'province' => $this->province ?: null, + 'country_code' => $this->country, + 'zip' => $this->zip, + 'phone' => $this->phone ?: null, + ]; + + if ($this->is_default) { + CustomerAddress::where('customer_id', $customer->id) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + if ($this->editingAddressId) { + $address = CustomerAddress::where('customer_id', $customer->id) + ->findOrFail($this->editingAddressId); + + $address->update([ + 'label' => $this->label, + 'address_json' => $addressJson, + 'is_default' => $this->is_default, + ]); + } else { + CustomerAddress::create([ + 'customer_id' => $customer->id, + 'label' => $this->label, + 'address_json' => $addressJson, + 'is_default' => $this->is_default, + ]); + } + + $this->resetForm(); + $this->showForm = false; + } + + public function deleteAddress(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + + CustomerAddress::where('customer_id', $customer->id) + ->where('id', $addressId) + ->delete(); + } + + public function setDefault(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + + CustomerAddress::where('customer_id', $customer->id) + ->where('is_default', true) + ->update(['is_default' => false]); + + CustomerAddress::where('customer_id', $customer->id) + ->where('id', $addressId) + ->update(['is_default' => true]); + } + + public function cancelForm(): void + { + $this->resetForm(); + $this->showForm = false; + } + + public function render(): mixed + { + $customer = Auth::guard('customer')->user(); + + $addresses = CustomerAddress::where('customer_id', $customer->id) + ->orderByDesc('is_default') + ->get(); + + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $addresses, + ])->layout('layouts.storefront.app', [ + 'title' => 'Addresses', + ]); + } + + protected function resetForm(): void + { + $this->editingAddressId = null; + $this->label = ''; + $this->first_name = ''; + $this->last_name = ''; + $this->company = ''; + $this->address1 = ''; + $this->address2 = ''; + $this->city = ''; + $this->province = ''; + $this->country = 'US'; + $this->zip = ''; + $this->phone = ''; + $this->is_default = false; + $this->resetValidation(); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..d71abb4f --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,37 @@ +logout(); + + session()->invalidate(); + session()->regenerateToken(); + + return redirect()->route('customer.login'); + } + + public function render(): mixed + { + $customer = Auth::guard('customer')->user(); + + $recentOrders = $customer->orders() + ->withoutGlobalScopes() + ->latest('placed_at') + ->limit(5) + ->get(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => $recentOrders, + ])->layout('layouts.storefront.app', [ + 'title' => 'My Account', + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..b3d8a5e0 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,28 @@ +user(); + + $orders = $customer->orders() + ->withoutGlobalScopes() + ->latest('placed_at') + ->paginate(10); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $orders, + ])->layout('layouts.storefront.app', [ + 'title' => 'Order History', + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..711afec3 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,34 @@ +orderNumber = $orderNumber; + } + + public function render(): mixed + { + $customer = Auth::guard('customer')->user(); + + $order = Order::withoutGlobalScopes() + ->where('customer_id', $customer->id) + ->where('order_number', $this->orderNumber) + ->with(['lines', 'payments', 'fulfillments.lines']) + ->firstOrFail(); + + return view('livewire.storefront.account.orders.show', [ + 'order' => $order, + ])->layout('layouts.storefront.app', [ + 'title' => "Order {$order->order_number}", + ]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 4458511c..42429dc3 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,6 +15,14 @@ $middleware->alias([ 'resolve.store' => ResolveStore::class, ]); + + $middleware->redirectGuestsTo(function ($request) { + if ($request->is('account', 'account/*')) { + return route('customer.login'); + } + + return route('login'); + }); }) ->withExceptions(function (Exceptions $exceptions): void { // 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..ad54f65e --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,121 @@ +
+
+

{{ __('Addresses') }}

+ @if(!$showForm) + + {{ __('Add Address') }} + + @endif +
+ + {{-- Navigation --}} + + + {{-- Address form --}} + @if($showForm) +
+

+ {{ $editingAddressId ? __('Edit Address') : __('New Address') }} +

+ +
+
+ +
+ + + + + + + +
+ +
+ +
+ +
+ + + + + + + +
+ +
+ +
+ + {{ $editingAddressId ? __('Update Address') : __('Save Address') }} + + + {{ __('Cancel') }} + +
+ +
+ @endif + + {{-- Address list --}} + @if($addresses->isEmpty() && !$showForm) +

{{ __('You have no saved addresses.') }}

+ @else +
+ @foreach($addresses as $address) +
+
+ + {{ $address->label }} + + @if($address->is_default) + + {{ __('Default') }} + + @endif +
+
+

{{ $address->address_json['first_name'] ?? '' }} {{ $address->address_json['last_name'] ?? '' }}

+

{{ $address->address_json['address1'] ?? '' }}

+ @if($address->address_json['address2'] ?? null) +

{{ $address->address_json['address2'] }}

+ @endif +

{{ $address->address_json['city'] ?? '' }}, {{ $address->address_json['province'] ?? '' }} {{ $address->address_json['zip'] ?? '' }}

+
+
+ + @if(!$address->is_default) + + @endif + +
+
+ @endforeach +
+ @endif +
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..a1a58e21 --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,88 @@ +
+
+

My Account

+ + {{ __('Log out') }} + +
+ +

+ {{ __('Welcome back, :name', ['name' => $customer->name]) }} +

+ + {{-- Navigation --}} + + + {{-- Recent orders --}} +
+

{{ __('Recent Orders') }}

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

{{ __('You have no orders yet.') }}

+ @else +
+ + + + + + + + + + + @foreach($recentOrders as $order) + + + + + + + @endforeach + +
{{ __('Order') }}{{ __('Date') }}{{ __('Status') }}{{ __('Total') }}
+ + {{ $order->order_number }} + + + {{ $order->placed_at?->format('M d, Y') }} + + + {{ ucfirst($order->status->value) }} + + + ${{ number_format($order->total_amount / 100, 2) }} +
+
+ + + @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..0054a8c7 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,79 @@ +
+

{{ __('Order History') }}

+ + {{-- Navigation --}} + + + @if($orders->isEmpty()) +

{{ __('You have no orders yet.') }}

+ @else +
+ + + + + + + + + + + + @foreach($orders as $order) + + + + + + + + @endforeach + +
{{ __('Order') }}{{ __('Date') }}{{ __('Payment') }}{{ __('Fulfillment') }}{{ __('Total') }}
+ + {{ $order->order_number }} + + + {{ $order->placed_at?->format('M d, Y') }} + + + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + + + {{ ucfirst($order->fulfillment_status->value) }} + + + ${{ number_format($order->total_amount / 100, 2) }} +
+
+ +
+ {{ $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..7e7e0979 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,166 @@ +
+ + +
+

+ {{ __('Order :number', ['number' => $order->order_number]) }} +

+ + {{ ucfirst($order->status->value) }} + +
+ +

+ {{ __('Placed on :date', ['date' => $order->placed_at?->format('F j, Y \a\t g:i A')]) }} +

+ + {{-- Order lines --}} +
+

{{ __('Items') }}

+
+ + + + + + + + + + + @foreach($order->lines as $line) + + + + + + + @endforeach + +
{{ __('Product') }}{{ __('SKU') }}{{ __('Qty') }}{{ __('Total') }}
+ {{ $line->title_snapshot }} + + {{ $line->sku_snapshot ?? '-' }} + + {{ $line->quantity }} + + ${{ number_format($line->total_amount / 100, 2) }} +
+
+
+ + {{-- Order summary --}} +
+
+
+
+
{{ __('Subtotal') }}
+
${{ number_format($order->subtotal_amount / 100, 2) }}
+
+ @if($order->discount_amount > 0) +
+
{{ __('Discount') }}
+
-${{ number_format($order->discount_amount / 100, 2) }}
+
+ @endif +
+
{{ __('Shipping') }}
+
${{ number_format($order->shipping_amount / 100, 2) }}
+
+
+
{{ __('Tax') }}
+
${{ number_format($order->tax_amount / 100, 2) }}
+
+
+
{{ __('Total') }}
+
${{ number_format($order->total_amount / 100, 2) }}
+
+
+
+
+ + {{-- Fulfillment timeline --}} + @if($order->fulfillments->isNotEmpty()) +
+

{{ __('Fulfillment') }}

+
+ @foreach($order->fulfillments as $fulfillment) +
+
+ + {{ __('Fulfillment') }} #{{ $loop->iteration }} + + + {{ ucfirst($fulfillment->status->value) }} + +
+ @if($fulfillment->tracking_number) +

+ {{ __('Tracking') }}: {{ $fulfillment->tracking_number }} + @if($fulfillment->tracking_url) + ({{ __('Track') }}) + @endif +

+ @endif + @if($fulfillment->shipped_at) +

+ {{ __('Shipped') }}: {{ $fulfillment->shipped_at->format('M d, Y') }} +

+ @endif +
+ @endforeach +
+
+ @endif + + {{-- Addresses --}} +
+ @if($order->shipping_address_json) +
+

{{ __('Shipping Address') }}

+
+

{{ $order->shipping_address_json['first_name'] ?? '' }} {{ $order->shipping_address_json['last_name'] ?? '' }}

+

{{ $order->shipping_address_json['address1'] ?? '' }}

+ @if($order->shipping_address_json['address2'] ?? null) +

{{ $order->shipping_address_json['address2'] }}

+ @endif +

{{ $order->shipping_address_json['city'] ?? '' }}, {{ $order->shipping_address_json['province'] ?? '' }} {{ $order->shipping_address_json['zip'] ?? '' }}

+

{{ $order->shipping_address_json['country'] ?? '' }}

+
+
+ @endif + + @if($order->billing_address_json) +
+

{{ __('Billing Address') }}

+
+

{{ $order->billing_address_json['first_name'] ?? '' }} {{ $order->billing_address_json['last_name'] ?? '' }}

+

{{ $order->billing_address_json['address1'] ?? '' }}

+ @if($order->billing_address_json['address2'] ?? null) +

{{ $order->billing_address_json['address2'] }}

+ @endif +

{{ $order->billing_address_json['city'] ?? '' }}, {{ $order->billing_address_json['province'] ?? '' }} {{ $order->billing_address_json['zip'] ?? '' }}

+

{{ $order->billing_address_json['country'] ?? '' }}

+
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index d1b0ba58..9d047d75 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,14 +2,14 @@ use App\Livewire\Admin\Auth\Login as AdminLogin; use App\Livewire\Admin\Auth\Logout as AdminLogout; +use App\Livewire\Storefront\Account\Addresses\Index as AddressesIndex; use App\Livewire\Storefront\Account\Auth\Login as CustomerLogin; use App\Livewire\Storefront\Account\Auth\Register as CustomerRegister; +use App\Livewire\Storefront\Account\Dashboard as CustomerDashboard; +use App\Livewire\Storefront\Account\Orders\Index as OrdersIndex; +use App\Livewire\Storefront\Account\Orders\Show as OrderShow; use Illuminate\Support\Facades\Route; -Route::get('/', function () { - return view('welcome'); -})->name('home'); - Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) ->name('dashboard'); @@ -46,4 +46,13 @@ Route::get('account/register', CustomerRegister::class) ->middleware('guest:customer') ->name('customer.register'); + + // Customer account routes (auth required) + Route::middleware('auth:customer')->group(function () { + Route::get('account', CustomerDashboard::class)->name('customer.account'); + Route::get('account/orders', OrdersIndex::class)->name('customer.orders'); + Route::get('account/orders/{orderNumber}', OrderShow::class)->name('customer.orders.show'); + Route::get('account/addresses', AddressesIndex::class)->name('customer.addresses'); + Route::post('account/logout', [CustomerDashboard::class, 'logout'])->name('customer.logout'); + }); }); diff --git a/tests/Feature/Account/AddressManagementTest.php b/tests/Feature/Account/AddressManagementTest.php new file mode 100644 index 00000000..074ebaf6 --- /dev/null +++ b/tests/Feature/Account/AddressManagementTest.php @@ -0,0 +1,172 @@ +context = createStoreContext(); + $this->customer = Customer::factory()->create([ + 'store_id' => $this->context['store']->id, + 'email' => 'customer@test.com', + 'password' => Hash::make('password'), + 'name' => 'Test Customer', + ]); +}); + +it('renders the addresses page', function () { + $hostname = $this->context['domain']->hostname; + + $this->actingAs($this->customer, 'customer') + ->get("http://{$hostname}/account/addresses") + ->assertOk() + ->assertSeeLivewire(AddressesIndex::class); +}); + +it('creates a new address', function () { + Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('openCreateForm') + ->set('label', 'Home') + ->set('first_name', 'John') + ->set('last_name', 'Doe') + ->set('address1', '123 Main St') + ->set('city', 'Springfield') + ->set('province', 'IL') + ->set('country', 'US') + ->set('zip', '62701') + ->call('saveAddress') + ->assertSet('showForm', false); + + $address = CustomerAddress::where('customer_id', $this->customer->id)->first(); + expect($address)->not->toBeNull() + ->and($address->label)->toBe('Home') + ->and($address->address_json['first_name'])->toBe('John') + ->and($address->address_json['city'])->toBe('Springfield'); +}); + +it('validates required address fields', function () { + Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('openCreateForm') + ->set('label', '') + ->set('first_name', '') + ->set('last_name', '') + ->set('address1', '') + ->set('city', '') + ->set('zip', '') + ->call('saveAddress') + ->assertHasErrors(['label', 'first_name', 'last_name', 'address1', 'city', 'zip']); +}); + +it('edits an existing address', function () { + $address = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + 'label' => 'Home', + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('editAddress', $address->id) + ->assertSet('editingAddressId', $address->id) + ->assertSet('label', 'Home') + ->set('label', 'Work') + ->set('first_name', 'Jane') + ->set('last_name', 'Smith') + ->set('address1', '456 Oak Ave') + ->set('city', 'Portland') + ->set('zip', '97201') + ->call('saveAddress'); + + $address->refresh(); + expect($address->label)->toBe('Work') + ->and($address->address_json['first_name'])->toBe('Jane') + ->and($address->address_json['city'])->toBe('Portland'); +}); + +it('deletes an address', function () { + $address = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('deleteAddress', $address->id); + + expect(CustomerAddress::find($address->id))->toBeNull(); +}); + +it('sets an address as default', function () { + $address1 = CustomerAddress::factory()->default()->create([ + 'customer_id' => $this->customer->id, + ]); + $address2 = CustomerAddress::factory()->create([ + 'customer_id' => $this->customer->id, + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('setDefault', $address2->id); + + $address1->refresh(); + $address2->refresh(); + + expect($address1->is_default)->toBeFalse() + ->and($address2->is_default)->toBeTrue(); +}); + +it('clears other defaults when creating a default address', function () { + $existing = CustomerAddress::factory()->default()->create([ + 'customer_id' => $this->customer->id, + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('openCreateForm') + ->set('label', 'Office') + ->set('first_name', 'John') + ->set('last_name', 'Doe') + ->set('address1', '789 Elm St') + ->set('city', 'Austin') + ->set('country', 'US') + ->set('zip', '73301') + ->set('is_default', true) + ->call('saveAddress'); + + $existing->refresh(); + expect($existing->is_default)->toBeFalse(); + + $newAddress = CustomerAddress::where('customer_id', $this->customer->id) + ->where('label', 'Office') + ->first(); + expect($newAddress->is_default)->toBeTrue(); +}); + +it('cancels the address form', function () { + Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('openCreateForm') + ->assertSet('showForm', true) + ->set('label', 'Test') + ->call('cancelForm') + ->assertSet('showForm', false) + ->assertSet('label', ''); +}); + +it('prevents managing addresses of other customers', function () { + $otherCustomer = Customer::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + $otherAddress = CustomerAddress::factory()->create([ + 'customer_id' => $otherCustomer->id, + ]); + + expect(fn () => Livewire::actingAs($this->customer, 'customer') + ->test(AddressesIndex::class) + ->call('editAddress', $otherAddress->id) + )->toThrow(ModelNotFoundException::class); +}); diff --git a/tests/Feature/Account/CustomerAccountTest.php b/tests/Feature/Account/CustomerAccountTest.php new file mode 100644 index 00000000..1ddc211f --- /dev/null +++ b/tests/Feature/Account/CustomerAccountTest.php @@ -0,0 +1,160 @@ +context = createStoreContext(); + $this->customer = Customer::factory()->create([ + 'store_id' => $this->context['store']->id, + 'email' => 'customer@test.com', + 'password' => Hash::make('password'), + 'name' => 'Test Customer', + ]); +}); + +it('redirects unauthenticated users to customer login', function () { + $hostname = $this->context['domain']->hostname; + + $this->get("http://{$hostname}/account") + ->assertRedirect(route('customer.login')); +}); + +it('renders the account dashboard for authenticated customers', function () { + $hostname = $this->context['domain']->hostname; + + $this->actingAs($this->customer, 'customer') + ->get("http://{$hostname}/account") + ->assertOk() + ->assertSeeLivewire(Dashboard::class); +}); + +it('shows recent orders on dashboard', function () { + $order = Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $this->customer->id, + 'order_number' => '#1001', + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(Dashboard::class) + ->assertSee('#1001'); +}); + +it('logs out a customer', function () { + Livewire::actingAs($this->customer, 'customer') + ->test(Dashboard::class) + ->call('logout') + ->assertRedirect(route('customer.login')); + + $this->assertGuest('customer'); +}); + +it('renders the order history page', function () { + $hostname = $this->context['domain']->hostname; + + $this->actingAs($this->customer, 'customer') + ->get("http://{$hostname}/account/orders") + ->assertOk() + ->assertSeeLivewire(OrdersIndex::class); +}); + +it('lists orders for the authenticated customer', function () { + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $this->customer->id, + 'order_number' => '#2001', + ]); + + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $this->customer->id, + 'order_number' => '#2002', + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(OrdersIndex::class) + ->assertSee('#2001') + ->assertSee('#2002'); +}); + +it('does not show orders from other customers', function () { + $otherCustomer = Customer::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $otherCustomer->id, + 'order_number' => '#9999', + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(OrdersIndex::class) + ->assertDontSee('#9999'); +}); + +it('renders order detail page', function () { + $order = Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $this->customer->id, + 'order_number' => '#3001', + 'total_amount' => 5000, + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'title_snapshot' => 'Test Product', + 'quantity' => 2, + 'total_amount' => 5000, + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(OrdersShow::class, ['orderNumber' => '#3001']) + ->assertSee('#3001') + ->assertSee('Test Product'); +}); + +it('blocks access to another customer order', function () { + $otherCustomer = Customer::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $otherCustomer->id, + 'order_number' => '#5001', + ]); + + expect(fn () => Livewire::actingAs($this->customer, 'customer') + ->test(OrdersShow::class, ['orderNumber' => '#5001']) + )->toThrow(ModelNotFoundException::class); +}); + +it('shows order summary totals', function () { + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $this->customer->id, + 'order_number' => '#4001', + 'subtotal_amount' => 4000, + 'discount_amount' => 500, + 'shipping_amount' => 799, + 'tax_amount' => 380, + 'total_amount' => 4679, + ]); + + Livewire::actingAs($this->customer, 'customer') + ->test(OrdersShow::class, ['orderNumber' => '#4001']) + ->assertSee('$40.00') + ->assertSee('$5.00') + ->assertSee('$7.99') + ->assertSee('$3.80') + ->assertSee('$46.79'); +}); From 1b312155f5722167f99e10398c956b4c03ddc840 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 22:31:18 +0100 Subject: [PATCH 10/17] Phase 7: Admin Panel - dashboard, products, orders, customers, and all management pages Full admin panel with Livewire components for all store management: - Dashboard with KPI tiles, date range filtering, orders chart, top products, conversion funnel - Products CRUD with search, status filtering, bulk actions, and form validation - Orders management with search, status/financial/fulfillment filters, detail view with fulfillment and refund actions - Customers listing with search, detail view with recent orders and addresses - Collections, discounts, pages, navigation, themes, analytics, and settings index pages - Admin layout shell with sidebar navigation and top bar with store switcher - 20 feature tests covering dashboard, product management, and order management - Fix ProductStatus enum handling in views and components (use ->value for string operations) - Fix route('home') references to url('/') for multi-tenant compatibility Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Livewire/Admin/Analytics/Index.php | 35 +++ app/Livewire/Admin/Collections/Index.php | 32 +++ app/Livewire/Admin/Customers/Index.php | 31 +++ app/Livewire/Admin/Customers/Show.php | 25 ++ app/Livewire/Admin/Dashboard.php | 232 ++++++++++++++++++ app/Livewire/Admin/Discounts/Index.php | 30 +++ app/Livewire/Admin/Layout/Sidebar.php | 13 + app/Livewire/Admin/Layout/TopBar.php | 47 ++++ app/Livewire/Admin/Navigation/Index.php | 16 ++ app/Livewire/Admin/Orders/Index.php | 60 +++++ app/Livewire/Admin/Orders/Show.php | 116 +++++++++ app/Livewire/Admin/Pages/Index.php | 19 ++ app/Livewire/Admin/Products/Form.php | 101 ++++++++ app/Livewire/Admin/Products/Index.php | 88 +++++++ app/Livewire/Admin/Settings/Index.php | 16 ++ app/Livewire/Admin/Themes/Index.php | 16 ++ resources/views/layouts/admin/app.blade.php | 54 ++++ resources/views/layouts/auth/card.blade.php | 2 +- resources/views/layouts/auth/simple.blade.php | 2 +- resources/views/layouts/auth/split.blade.php | 4 +- .../livewire/admin/analytics/index.blade.php | 65 +++++ .../admin/collections/index.blade.php | 35 +++ .../livewire/admin/customers/index.blade.php | 31 +++ .../livewire/admin/customers/show.blade.php | 39 +++ .../views/livewire/admin/dashboard.blade.php | 122 +++++++++ .../livewire/admin/discounts/index.blade.php | 43 ++++ .../livewire/admin/layout/sidebar.blade.php | 105 ++++++++ .../livewire/admin/layout/top-bar.blade.php | 40 +++ .../livewire/admin/navigation/index.blade.php | 25 ++ .../livewire/admin/orders/index.blade.php | 77 ++++++ .../livewire/admin/orders/show.blade.php | 176 +++++++++++++ .../admin/pages-admin/index.blade.php | 28 +++ .../livewire/admin/products/form.blade.php | 73 ++++++ .../livewire/admin/products/index.blade.php | 94 +++++++ .../livewire/admin/settings/index.blade.php | 31 +++ .../livewire/admin/themes/index.blade.php | 18 ++ routes/web.php | 28 +++ tests/Feature/Admin/DashboardTest.php | 57 +++++ tests/Feature/Admin/OrderManagementTest.php | 98 ++++++++ tests/Feature/Admin/ProductManagementTest.php | 137 +++++++++++ tests/Feature/Auth/AuthenticationTest.php | 4 +- tests/Feature/ExampleTest.php | 9 +- 42 files changed, 2265 insertions(+), 9 deletions(-) create mode 100644 app/Livewire/Admin/Analytics/Index.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/Index.php create mode 100644 app/Livewire/Admin/Layout/Sidebar.php create mode 100644 app/Livewire/Admin/Layout/TopBar.php create mode 100644 app/Livewire/Admin/Navigation/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/Pages/Index.php create mode 100644 app/Livewire/Admin/Products/Form.php create mode 100644 app/Livewire/Admin/Products/Index.php create mode 100644 app/Livewire/Admin/Settings/Index.php create mode 100644 app/Livewire/Admin/Themes/Index.php create mode 100644 resources/views/layouts/admin/app.blade.php create mode 100644 resources/views/livewire/admin/analytics/index.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/index.blade.php create mode 100644 resources/views/livewire/admin/layout/sidebar.blade.php create mode 100644 resources/views/livewire/admin/layout/top-bar.blade.php create mode 100644 resources/views/livewire/admin/navigation/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/pages-admin/index.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 resources/views/livewire/admin/settings/index.blade.php create mode 100644 resources/views/livewire/admin/themes/index.blade.php create mode 100644 tests/Feature/Admin/DashboardTest.php create mode 100644 tests/Feature/Admin/OrderManagementTest.php create mode 100644 tests/Feature/Admin/ProductManagementTest.php diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..bacd0d2f --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,35 @@ +bound('current_store') ? app('current_store') : null; + + $data = []; + if ($store) { + $startDate = match ($this->dateRange) { + 'last_7_days' => now()->subDays(6)->format('Y-m-d'), + 'last_90_days' => now()->subDays(89)->format('Y-m-d'), + default => now()->subDays(29)->format('Y-m-d'), + }; + + $data = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', '>=', $startDate) + ->where('date', '<=', now()->format('Y-m-d')) + ->orderBy('date') + ->get(); + } + + return view('livewire.admin.analytics.index', ['data' => $data]) + ->layout('layouts.admin.app', ['title' => 'Analytics']); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..a1a269fd --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,32 @@ +resetPage(); + } + + public function render(): mixed + { + $collections = Collection::query() + ->withCount('products') + ->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%")) + ->latest() + ->paginate(20); + + return view('livewire.admin.collections.index', [ + 'collections' => $collections, + ])->layout('layouts.admin.app', ['title' => 'Collections']); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..8a2cf144 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,31 @@ +resetPage(); + } + + public function render(): mixed + { + $customers = Customer::query() + ->withCount('orders') + ->when($this->search, fn ($q) => $q->where('name', 'like', "%{$this->search}%")->orWhere('email', 'like', "%{$this->search}%")) + ->latest() + ->paginate(20); + + return view('livewire.admin.customers.index', ['customers' => $customers]) + ->layout('layouts.admin.app', ['title' => 'Customers']); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..53bb8e70 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,25 @@ +customerId = $customerId; + } + + public function render(): mixed + { + $customer = Customer::with(['orders' => fn ($q) => $q->withoutGlobalScopes()->latest('placed_at')->limit(10), 'addresses']) + ->findOrFail($this->customerId); + + return view('livewire.admin.customers.show', ['customer' => $customer]) + ->layout('layouts.admin.app', ['title' => $customer->name]); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..33cbfd4d --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,232 @@ +bound('current_store') ? app('current_store') : null; + + $kpis = $this->loadKpis($store); + $chartData = $this->loadChart($store); + $topProducts = $this->loadTopProducts($store); + $funnelData = $this->loadFunnel($store); + + return view('livewire.admin.dashboard', [ + 'kpis' => $kpis, + 'chartData' => $chartData, + 'topProducts' => $topProducts, + 'funnelData' => $funnelData, + ])->layout('layouts.admin.app', [ + 'title' => 'Dashboard', + ]); + } + + /** + * @return array{start: string, end: string, prevStart: string, prevEnd: string} + */ + protected function getDateRange(): array + { + $end = now()->format('Y-m-d'); + + switch ($this->dateRange) { + case 'today': + $start = $end; + $prevEnd = now()->subDay()->format('Y-m-d'); + $prevStart = $prevEnd; + break; + case 'last_7_days': + $start = now()->subDays(6)->format('Y-m-d'); + $prevEnd = now()->subDays(7)->format('Y-m-d'); + $prevStart = now()->subDays(13)->format('Y-m-d'); + break; + case 'custom': + $start = $this->customStartDate ?: now()->subDays(29)->format('Y-m-d'); + $end = $this->customEndDate ?: now()->format('Y-m-d'); + $days = Carbon::parse($start)->diffInDays(Carbon::parse($end)); + $prevEnd = Carbon::parse($start)->subDay()->format('Y-m-d'); + $prevStart = Carbon::parse($prevEnd)->subDays($days)->format('Y-m-d'); + break; + default: // last_30_days + $start = now()->subDays(29)->format('Y-m-d'); + $prevEnd = now()->subDays(30)->format('Y-m-d'); + $prevStart = now()->subDays(59)->format('Y-m-d'); + break; + } + + return [ + 'start' => $start, + 'end' => $end, + 'prevStart' => $prevStart ?? $start, + 'prevEnd' => $prevEnd ?? $end, + ]; + } + + /** + * @return array + */ + protected function loadKpis(mixed $store): array + { + if (! $store) { + return $this->emptyKpis(); + } + + $range = $this->getDateRange(); + + $current = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', '>=', $range['start']) + ->where('date', '<=', $range['end']) + ->selectRaw('SUM(revenue_amount) as total_revenue, SUM(orders_count) as total_orders, SUM(visits_count) as total_visits') + ->first(); + + $previous = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', '>=', $range['prevStart']) + ->where('date', '<=', $range['prevEnd']) + ->selectRaw('SUM(revenue_amount) as total_revenue, SUM(orders_count) as total_orders, SUM(visits_count) as total_visits') + ->first(); + + $totalSales = (int) ($current->total_revenue ?? 0); + $ordersCount = (int) ($current->total_orders ?? 0); + $visitorsCount = (int) ($current->total_visits ?? 0); + $aov = $ordersCount > 0 ? intdiv($totalSales, $ordersCount) : 0; + + $prevSales = (int) ($previous->total_revenue ?? 0); + $prevOrders = (int) ($previous->total_orders ?? 0); + $prevVisitors = (int) ($previous->total_visits ?? 0); + + return [ + 'totalSales' => $totalSales, + 'ordersCount' => $ordersCount, + 'aov' => $aov, + 'visitorsCount' => $visitorsCount, + 'salesChange' => $this->percentChange($prevSales, $totalSales), + 'ordersChange' => $this->percentChange($prevOrders, $ordersCount), + 'aovChange' => $this->percentChange( + $prevOrders > 0 ? intdiv($prevSales, $prevOrders) : 0, + $aov + ), + 'visitorsChange' => $this->percentChange($prevVisitors, $visitorsCount), + ]; + } + + /** + * @return array + */ + protected function loadChart(mixed $store): array + { + if (! $store) { + return []; + } + + $range = $this->getDateRange(); + + return AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', '>=', $range['start']) + ->where('date', '<=', $range['end']) + ->orderBy('date') + ->get(['date', 'orders_count']) + ->map(fn ($row) => ['date' => $row->date, 'count' => $row->orders_count]) + ->toArray(); + } + + /** + * @return array + */ + protected function loadTopProducts(mixed $store): array + { + if (! $store) { + return []; + } + + $range = $this->getDateRange(); + + return OrderLine::query() + ->join('orders', 'order_lines.order_id', '=', 'orders.id') + ->where('orders.store_id', $store->id) + ->where('orders.placed_at', '>=', $range['start'].' 00:00:00') + ->where('orders.placed_at', '<=', $range['end'].' 23:59:59') + ->selectRaw('order_lines.title_snapshot as title, SUM(order_lines.quantity) as units_sold, SUM(order_lines.total_amount) as revenue') + ->groupBy('order_lines.title_snapshot') + ->orderByDesc('revenue') + ->limit(5) + ->get() + ->map(fn ($row) => [ + 'title' => $row->title, + 'units_sold' => (int) $row->units_sold, + 'revenue' => (int) $row->revenue, + ]) + ->toArray(); + } + + /** + * @return array + */ + protected function loadFunnel(mixed $store): array + { + if (! $store) { + return ['visits' => 0, 'add_to_cart' => 0, 'checkout_started' => 0, 'checkout_completed' => 0]; + } + + $range = $this->getDateRange(); + + $data = AnalyticsDaily::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('date', '>=', $range['start']) + ->where('date', '<=', $range['end']) + ->selectRaw('SUM(visits_count) as visits, SUM(add_to_cart_count) as add_to_cart, SUM(checkout_started_count) as checkout_started, SUM(checkout_completed_count) as checkout_completed') + ->first(); + + return [ + 'visits' => (int) ($data->visits ?? 0), + 'add_to_cart' => (int) ($data->add_to_cart ?? 0), + 'checkout_started' => (int) ($data->checkout_started ?? 0), + 'checkout_completed' => (int) ($data->checkout_completed ?? 0), + ]; + } + + protected function percentChange(int $previous, int $current): float + { + if ($previous === 0) { + return $current > 0 ? 100.0 : 0.0; + } + + return round((($current - $previous) / $previous) * 100, 1); + } + + /** + * @return array + */ + protected function emptyKpis(): array + { + return [ + 'totalSales' => 0, + 'ordersCount' => 0, + 'aov' => 0, + 'visitorsCount' => 0, + 'salesChange' => 0.0, + 'ordersChange' => 0.0, + 'aovChange' => 0.0, + 'visitorsChange' => 0.0, + ]; + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..d8451c92 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,30 @@ +resetPage(); + } + + public function render(): mixed + { + $discounts = Discount::query() + ->when($this->search, fn ($q) => $q->where('code', 'like', "%{$this->search}%")) + ->latest() + ->paginate(20); + + return view('livewire.admin.discounts.index', ['discounts' => $discounts]) + ->layout('layouts.admin.app', ['title' => 'Discounts']); + } +} diff --git a/app/Livewire/Admin/Layout/Sidebar.php b/app/Livewire/Admin/Layout/Sidebar.php new file mode 100644 index 00000000..a31dc3fa --- /dev/null +++ b/app/Livewire/Admin/Layout/Sidebar.php @@ -0,0 +1,13 @@ +stores()->where('stores.id', $storeId)->exists(); + + if (! $hasAccess) { + return null; + } + + session()->put('current_store_id', $storeId); + + return redirect()->route('admin.dashboard'); + } + + public function logout(): mixed + { + Auth::guard('web')->logout(); + + session()->invalidate(); + session()->regenerateToken(); + + return redirect()->route('admin.login'); + } + + public function render(): mixed + { + $user = Auth::user(); + $stores = $user ? $user->stores : collect(); + $currentStore = app()->bound('current_store') ? app('current_store') : null; + + return view('livewire.admin.layout.top-bar', [ + 'user' => $user, + 'stores' => $stores, + 'currentStore' => $currentStore, + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..e9ed64fa --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,16 @@ + NavigationMenu::query()->with('items')->get(), + ])->layout('layouts.admin.app', ['title' => 'Navigation']); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..979dd46a --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,60 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedFinancialFilter(): void + { + $this->resetPage(); + } + + public function updatedFulfillmentFilter(): void + { + $this->resetPage(); + } + + public function render(): mixed + { + $query = Order::query() + ->with('customer') + ->when($this->search, fn ($q) => $q->where(function ($q) { + $q->where('order_number', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%"); + })) + ->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter)) + ->when($this->financialFilter !== 'all', fn ($q) => $q->where('financial_status', $this->financialFilter)) + ->when($this->fulfillmentFilter !== 'all', fn ($q) => $q->where('fulfillment_status', $this->fulfillmentFilter)) + ->latest('placed_at'); + + return view('livewire.admin.orders.index', [ + 'orders' => $query->paginate(20), + ])->layout('layouts.admin.app', [ + 'title' => 'Orders', + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..5376ed4e --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,116 @@ +orderId = $orderId; + } + + public function cancelOrder(): void + { + $order = Order::findOrFail($this->orderId); + app(OrderService::class)->cancel($order); + $this->dispatch('toast', type: 'success', message: 'Order cancelled.'); + } + + public function confirmBankTransfer(): void + { + $order = Order::findOrFail($this->orderId); + app(OrderService::class)->confirmBankTransferPayment($order); + $this->dispatch('toast', type: 'success', message: 'Payment confirmed.'); + } + + public function processRefund(): void + { + $order = Order::with('payments')->findOrFail($this->orderId); + $payment = $order->payments()->where('status', 'captured')->first(); + + if (! $payment) { + $this->dispatch('toast', type: 'error', message: 'No captured payment found.'); + + return; + } + + try { + app(RefundService::class)->refund($order, $payment, $this->refundAmount, $this->refundReason, $this->refundRestock); + $this->refundAmount = 0; + $this->refundReason = ''; + $this->refundRestock = false; + $this->dispatch('toast', type: 'success', message: 'Refund processed.'); + } catch (\Exception $e) { + $this->dispatch('toast', type: 'error', message: $e->getMessage()); + } + } + + public function createFulfillment(): void + { + $order = Order::with('lines.fulfillmentLines')->findOrFail($this->orderId); + + $linesToFulfill = []; + foreach ($order->lines as $line) { + $fulfilled = $line->fulfillmentLines->sum('quantity'); + $remaining = $line->quantity - $fulfilled; + if ($remaining > 0) { + $linesToFulfill[$line->id] = $remaining; + } + } + + if (empty($linesToFulfill)) { + $this->dispatch('toast', type: 'error', message: 'All lines are already fulfilled.'); + + return; + } + + try { + $fulfillment = app(FulfillmentService::class)->createFulfillment( + $order, + $linesToFulfill, + $this->trackingNumber ?: null, + $this->trackingUrl ?: null, + $this->trackingCompany ?: null, + ); + $this->trackingNumber = ''; + $this->trackingUrl = ''; + $this->trackingCompany = ''; + $this->dispatch('toast', type: 'success', message: 'Fulfillment created.'); + } catch (FulfillmentGuardException $e) { + $this->dispatch('toast', type: 'error', message: $e->getMessage()); + } + } + + public function render(): mixed + { + $order = Order::with(['lines', 'payments', 'refunds', 'fulfillments.lines', 'customer']) + ->findOrFail($this->orderId); + + return view('livewire.admin.orders.show', [ + 'order' => $order, + ])->layout('layouts.admin.app', [ + 'title' => "Order {$order->order_number}", + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..e3582a61 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,19 @@ + Page::query()->latest()->paginate(20), + ])->layout('layouts.admin.app', ['title' => 'Pages']); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..747d7f87 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,101 @@ + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'in:draft,active,archived'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string'], + ]; + } + + public function mount(?int $productId = null): void + { + if ($productId) { + $product = Product::findOrFail($productId); + $this->productId = $product->id; + $this->title = $product->title; + $this->description = $product->description ?? ''; + $this->status = $product->status->value; + $this->vendor = $product->vendor ?? ''; + $this->product_type = $product->product_type ?? ''; + $this->tags = $product->tags ? implode(', ', $product->tags) : ''; + } + } + + public function save(): mixed + { + $this->validate(); + + $store = app('current_store'); + $tags = $this->tags ? array_map('trim', explode(',', $this->tags)) : []; + + if ($this->productId) { + $product = Product::findOrFail($this->productId); + $product->update([ + 'title' => $this->title, + 'description' => $this->description ?: null, + 'status' => $this->status, + 'vendor' => $this->vendor ?: null, + 'product_type' => $this->product_type ?: null, + 'tags' => $tags, + ]); + $this->dispatch('toast', type: 'success', message: 'Product updated.'); + } else { + $handle = app(HandleGenerator::class)->generate($this->title, 'products', $store->id); + $product = Product::create([ + 'store_id' => $store->id, + 'title' => $this->title, + 'handle' => $handle, + 'description' => $this->description ?: null, + 'status' => $this->status, + 'vendor' => $this->vendor ?: null, + 'product_type' => $this->product_type ?: null, + 'tags' => $tags, + ]); + $this->dispatch('toast', type: 'success', message: 'Product created.'); + + return redirect()->route('admin.products.edit', $product); + } + + return null; + } + + public function render(): mixed + { + $isEdit = (bool) $this->productId; + + return view('livewire.admin.products.form', [ + 'isEdit' => $isEdit, + 'product' => $isEdit ? Product::with(['variants', 'media'])->find($this->productId) : null, + ])->layout('layouts.admin.app', [ + 'title' => $isEdit ? "Edit {$this->title}" : 'New Product', + ]); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..cf06eaa4 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,88 @@ + */ + public array $selectedIds = []; + + public string $sortField = 'updated_at'; + + public string $sortDirection = 'desc'; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + } + + public function bulkSetActive(): void + { + Product::whereIn('id', $this->selectedIds)->update(['status' => 'active']); + $this->selectedIds = []; + $this->dispatch('toast', type: 'success', message: 'Products updated.'); + } + + public function bulkArchive(): void + { + Product::whereIn('id', $this->selectedIds)->update(['status' => 'archived']); + $this->selectedIds = []; + $this->dispatch('toast', type: 'success', message: 'Products archived.'); + } + + public function deleteProduct(int $id): void + { + $product = Product::findOrFail($id); + + if ($product->status !== ProductStatus::Draft) { + $this->dispatch('toast', type: 'error', message: 'Only draft products can be deleted.'); + + return; + } + + $product->delete(); + $this->dispatch('toast', type: 'success', message: 'Product deleted.'); + } + + public function render(): mixed + { + $query = Product::query() + ->withCount('variants') + ->with('media') + ->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%")) + ->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter)) + ->orderBy($this->sortField, $this->sortDirection); + + return view('livewire.admin.products.index', [ + 'products' => $query->paginate(20), + ])->layout('layouts.admin.app', [ + 'title' => 'Products', + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..fb5ea2c4 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,16 @@ +bound('current_store') ? app('current_store') : null; + + return view('livewire.admin.settings.index', ['store' => $store]) + ->layout('layouts.admin.app', ['title' => 'Settings']); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..b603193a --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,16 @@ + Theme::query()->latest()->get(), + ])->layout('layouts.admin.app', ['title' => 'Themes']); + } +} diff --git a/resources/views/layouts/admin/app.blade.php b/resources/views/layouts/admin/app.blade.php new file mode 100644 index 00000000..7c0548e6 --- /dev/null +++ b/resources/views/layouts/admin/app.blade.php @@ -0,0 +1,54 @@ + + + + + + {{ ($title ?? '') ? $title . ' - ' : '' }}Admin + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + @livewireStyles + + + + {{-- Sidebar --}} + @livewire('admin.layout.sidebar') + + {{-- Main wrapper --}} +
+ {{-- Top bar --}} + @livewire('admin.layout.top-bar') + + {{-- Toast notifications --}} +
+ +
+ + {{-- Main content --}} +
+ {{ $slot }} +
+
+ + @livewireScripts + + diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php index db947161..4006653d 100644 --- a/resources/views/layouts/auth/card.blade.php +++ b/resources/views/layouts/auth/card.blade.php @@ -6,7 +6,7 @@
- + diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php index 6e0d9093..1e5d412d 100644 --- a/resources/views/layouts/auth/simple.blade.php +++ b/resources/views/layouts/auth/simple.blade.php @@ -6,7 +6,7 @@
- + diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php index 4e9788bd..3eca641a 100644 --- a/resources/views/layouts/auth/split.blade.php +++ b/resources/views/layouts/auth/split.blade.php @@ -7,7 +7,7 @@
- + 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..76a7bfee --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,65 @@ +
+
+ Analytics + + + + + +
+ + @if($data->isEmpty()) +

No analytics data for this period.

+ @else + @php + $totalRevenue = $data->sum('revenue_amount'); + $totalOrders = $data->sum('orders_count'); + $totalVisits = $data->sum('visits_count'); + $avgAov = $totalOrders > 0 ? intdiv($totalRevenue, $totalOrders) : 0; + @endphp + +
+
+

Revenue

+

${{ number_format($totalRevenue / 100, 2) }}

+
+
+

Orders

+

{{ number_format($totalOrders) }}

+
+
+

AOV

+

${{ number_format($avgAov / 100, 2) }}

+
+
+

Visits

+

{{ number_format($totalVisits) }}

+
+
+ +
+ + + + + + + + + + + + @foreach($data as $row) + + + + + + + + @endforeach + +
DateRevenueOrdersVisitsAdd to Cart
{{ $row->date }}${{ number_format($row->revenue_amount / 100, 2) }}{{ $row->orders_count }}{{ $row->visits_count }}{{ $row->add_to_cart_count }}
+
+ @endif +
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..17b0c08a --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,35 @@ +
+
+ Collections +
+ +
+ +
+ +
+ + + + + + + + + + @forelse($collections as $collection) + + + + + + @empty + + @endforelse + +
TitleProductsStatus
{{ $collection->title }}{{ $collection->products_count }} + {{ ucfirst($collection->status) }} +
No collections found.
+
+
{{ $collections->links() }}
+
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..242867cf --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,31 @@ +
+ Customers +
+ +
+
+ + + + + + + + + + + @forelse($customers as $customer) + + + + + + + @empty + + @endforelse + +
NameEmailOrdersJoined
{{ $customer->name }}{{ $customer->email }}{{ $customer->orders_count }}{{ $customer->created_at?->format('M d, Y') }}
No customers found.
+
+
{{ $customers->links() }}
+
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..afcf64fe --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,39 @@ +
+ ← Customers + {{ $customer->name }} +

{{ $customer->email }}

+ +
+
+ Recent Orders + @if($customer->orders->isEmpty()) +

No orders.

+ @else +
+ @foreach($customer->orders as $order) +
+ {{ $order->order_number }} + ${{ number_format($order->total_amount / 100, 2) }} +
+ @endforeach +
+ @endif +
+ +
+ Addresses + @if($customer->addresses->isEmpty()) +

No addresses.

+ @else +
+ @foreach($customer->addresses as $address) +
+

{{ $address->label }}@if($address->is_default) Default@endif

+

{{ $address->address_json['address1'] ?? '' }}, {{ $address->address_json['city'] ?? '' }}

+
+ @endforeach +
+ @endif +
+
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..5e409735 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,122 @@ +
+
+ Dashboard + + + + + + +
+ + @if($dateRange === 'custom') +
+ + +
+ @endif + + {{-- KPI tiles --}} +
+ @php + $tiles = [ + ['label' => 'Total Sales', 'value' => '$' . number_format($kpis['totalSales'] / 100, 2), 'change' => $kpis['salesChange']], + ['label' => 'Orders', 'value' => number_format($kpis['ordersCount']), 'change' => $kpis['ordersChange']], + ['label' => 'Avg Order Value', 'value' => '$' . number_format($kpis['aov'] / 100, 2), 'change' => $kpis['aovChange']], + ['label' => 'Visitors', 'value' => number_format($kpis['visitorsCount']), 'change' => $kpis['visitorsChange']], + ]; + @endphp + + @foreach($tiles as $tile) +
+

{{ $tile['label'] }}

+

{{ $tile['value'] }}

+
+ @if($tile['change'] > 0) + +{{ $tile['change'] }}% + @elseif($tile['change'] < 0) + {{ $tile['change'] }}% + @else + 0% + @endif +
+
+ @endforeach +
+ + {{-- Orders chart --}} +
+ Orders over time + @if(empty($chartData)) +

No data for this period.

+ @else +
+ @php + $maxCount = max(array_column($chartData, 'count')); + $maxCount = $maxCount > 0 ? $maxCount : 1; + @endphp + @foreach($chartData as $point) +
+
+
+ @endforeach +
+ @endif +
+ +
+ {{-- Top products --}} +
+ Top products + @if(empty($topProducts)) +

No sales data for this period.

+ @else + + + + + + + + + + @foreach($topProducts as $product) + + + + + + @endforeach + +
ProductSoldRevenue
{{ $product['title'] }}{{ $product['units_sold'] }}${{ number_format($product['revenue'] / 100, 2) }}
+ @endif +
+ + {{-- Conversion funnel --}} +
+ Conversion funnel + @php + $maxFunnel = max($funnelData['visits'], 1); + $steps = [ + ['label' => 'Visits', 'value' => $funnelData['visits'], 'color' => 'bg-blue-200 dark:bg-blue-900'], + ['label' => 'Add to Cart', 'value' => $funnelData['add_to_cart'], 'color' => 'bg-blue-300 dark:bg-blue-800'], + ['label' => 'Checkout Started', 'value' => $funnelData['checkout_started'], 'color' => 'bg-blue-400 dark:bg-blue-700'], + ['label' => 'Completed', 'value' => $funnelData['checkout_completed'], 'color' => 'bg-blue-600 dark:bg-blue-500'], + ]; + @endphp +
+ @foreach($steps as $step) +
+ {{ $step['label'] }} +
+
+
+ {{ number_format($step['value']) }} +
+ @endforeach +
+
+
+
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..c773161f --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,43 @@ +
+ Discounts +
+ +
+
+ + + + + + + + + + + + @forelse($discounts as $discount) + + + + + + + + @empty + + @endforelse + +
CodeTypeValueStatusUses
{{ $discount->code }}{{ ucfirst(str_replace('_', ' ', $discount->type->value ?? $discount->type)) }} + @if(($discount->value_type->value ?? $discount->value_type) === 'percent') + {{ $discount->value }}% + @else + ${{ number_format($discount->value / 100, 2) }} + @endif + + + {{ ucfirst($discount->status->value ?? $discount->status) }} + + {{ $discount->times_used ?? 0 }}
No discounts found.
+
+
{{ $discounts->links() }}
+
diff --git a/resources/views/livewire/admin/layout/sidebar.blade.php b/resources/views/livewire/admin/layout/sidebar.blade.php new file mode 100644 index 00000000..63874116 --- /dev/null +++ b/resources/views/livewire/admin/layout/sidebar.blade.php @@ -0,0 +1,105 @@ +{{-- Mobile overlay --}} +
+
+ + {{-- Sidebar --}} + +
diff --git a/resources/views/livewire/admin/layout/top-bar.blade.php b/resources/views/livewire/admin/layout/top-bar.blade.php new file mode 100644 index 00000000..1870b4ee --- /dev/null +++ b/resources/views/livewire/admin/layout/top-bar.blade.php @@ -0,0 +1,40 @@ +
+ {{-- Left: hamburger + store selector --}} +
+ + + @if($stores->isNotEmpty()) + + + {{ $currentStore?->name ?? 'Select Store' }} + + + + @foreach($stores as $store) + + {{ $store->name }} + + @endforeach + + + @endif +
+ + {{-- Right: profile --}} +
+ @if($user) + + + + + Settings + + + + Log out + + + + @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..8605dfeb --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,25 @@ +
+ Navigation +
+ @forelse($menus as $menu) +
+ {{ $menu->title }} +

Handle: {{ $menu->handle }}

+ @if($menu->items->isNotEmpty()) +
    + @foreach($menu->items->sortBy('position') as $item) +
  • + {{ $item->label }} + {{ $item->link_type }} +
  • + @endforeach +
+ @else +

No items.

+ @endif +
+ @empty +

No navigation menus.

+ @endforelse +
+
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..9c32d968 --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,77 @@ +
+ Orders + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + @forelse($orders as $order) + + + + + + + + + @empty + + + + @endforelse + +
OrderDateCustomerPaymentFulfillmentTotal
+ + {{ $order->order_number }} + + {{ $order->placed_at?->format('M d, Y') }}{{ $order->customer?->name ?? $order->email }} + + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + + + {{ ucfirst($order->fulfillment_status->value) }} + + ${{ number_format($order->total_amount / 100, 2) }}
No orders found.
+
+ +
+ {{ $orders->links() }} +
+
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..52a1e7d5 --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,176 @@ +
+ ← Orders + +
+ Order {{ $order->order_number }} +
+ @if($order->status->value === 'pending' && $order->payment_method->value === 'bank_transfer' && $order->financial_status->value === 'pending') + Confirm Payment + @endif + @if($order->fulfillment_status->value !== 'fulfilled' && $order->status->value !== 'cancelled') + Cancel + @endif +
+
+ +
+ + {{ ucfirst($order->status->value) }} + + + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + + {{ ucfirst($order->fulfillment_status->value) }} + +
+ +
+ {{-- Left column --}} +
+ {{-- Line items --}} +
+ Items + + + + + + + + + + + + @foreach($order->lines as $line) + + + + + + + + @endforeach + +
ProductSKUQtyPriceTotal
{{ $line->title_snapshot }}{{ $line->sku_snapshot ?? '-' }}{{ $line->quantity }}${{ number_format($line->unit_price / 100, 2) }}${{ number_format($line->total_amount / 100, 2) }}
+ +
+
+
Subtotal
${{ number_format($order->subtotal_amount / 100, 2) }}
+ @if($order->discount_amount > 0) +
Discount
-${{ number_format($order->discount_amount / 100, 2) }}
+ @endif +
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 --}} +
+ Fulfillment + @if($order->fulfillments->isNotEmpty()) +
+ @foreach($order->fulfillments as $f) +
+
+ Fulfillment #{{ $loop->iteration }} + + {{ ucfirst($f->status->value) }} + +
+ @if($f->tracking_number) +

Tracking: {{ $f->tracking_number }}

+ @endif +
+ @endforeach +
+ @endif + + @if($order->fulfillment_status->value !== 'fulfilled' && in_array($order->financial_status->value, ['paid', 'partially_refunded'])) +
+

Create fulfillment

+
+ + + +
+ Fulfill remaining items +
+ @endif +
+ + {{-- Refunds --}} + @if($order->refunds->isNotEmpty()) +
+ Refunds +
+ @foreach($order->refunds as $refund) +
+
+ ${{ number_format($refund->amount / 100, 2) }} + @if($refund->reason) + {{ $refund->reason }} + @endif +
+ {{ ucfirst($refund->status->value) }} +
+ @endforeach +
+
+ @endif +
+ + {{-- Right column --}} +
+ {{-- Customer --}} +
+ Customer +
+ @if($order->customer) +

{{ $order->customer->name }}

+

{{ $order->customer->email }}

+ @else +

{{ $order->email }}

+ @endif +
+
+ + {{-- Payment --}} +
+ Payment +
+

{{ ucfirst(str_replace('_', ' ', $order->payment_method->value)) }}

+ @foreach($order->payments as $payment) +

{{ ucfirst($payment->status->value) }} - ${{ number_format($payment->amount / 100, 2) }}

+ @endforeach +
+ + @if(in_array($order->financial_status->value, ['paid', 'partially_refunded'])) +
+

Process refund

+
+ + + + Refund +
+
+ @endif +
+ + {{-- Addresses --}} + @if($order->shipping_address_json) +
+ Shipping address +
+

{{ $order->shipping_address_json['first_name'] ?? '' }} {{ $order->shipping_address_json['last_name'] ?? '' }}

+

{{ $order->shipping_address_json['address1'] ?? '' }}

+

{{ $order->shipping_address_json['city'] ?? '' }}, {{ $order->shipping_address_json['province'] ?? '' }} {{ $order->shipping_address_json['zip'] ?? '' }}

+
+
+ @endif +
+
+
diff --git a/resources/views/livewire/admin/pages-admin/index.blade.php b/resources/views/livewire/admin/pages-admin/index.blade.php new file mode 100644 index 00000000..526a97b4 --- /dev/null +++ b/resources/views/livewire/admin/pages-admin/index.blade.php @@ -0,0 +1,28 @@ +
+ Pages +
+ + + + + + + + + + @forelse($pages as $page) + + + + + + @empty + + @endforelse + +
TitleHandleStatus
{{ $page->title }}{{ $page->handle }} + {{ ucfirst($page->status) }} +
No pages found.
+
+
{{ $pages->links() }}
+
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..a24a374d --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,73 @@ +
+ + + {{ $isEdit ? 'Edit Product' : 'New Product' }} + +
+ {{-- Main content --}} +
+
+ +
+ +
+
+ + @if($isEdit && $product) + {{-- Variants summary --}} +
+ Variants + @if($product->variants->isEmpty()) +

No variants.

+ @else + + + + + + + + + + @foreach($product->variants as $variant) + + + + + + @endforeach + +
TitleSKUPrice
{{ $variant->title }}{{ $variant->sku ?? '-' }}${{ number_format($variant->price / 100, 2) }}
+ @endif +
+ @endif +
+ + {{-- Sidebar --}} +
+
+ + + + + +
+ +
+ +
+ +
+
+ +
+
+ + + {{ $isEdit ? 'Save changes' : 'Create 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..16937292 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,94 @@ +
+
+ Products + + Add product + +
+ + {{-- Filters --}} +
+
+ +
+ + + + + + +
+ + {{-- Bulk actions --}} + @if(count($selectedIds) > 0) +
+ {{ count($selectedIds) }} selected + Set Active + Archive +
+ @endif + + {{-- Products table --}} +
+ + + + + + + + + + + + + + @forelse($products as $product) + + + + + + + + + + @empty + + + + @endforelse + +
+ + + Title + @if($sortField === 'title') + {{ $sortDirection === 'asc' ? '↑' : '↓' }} + @endif + StatusVariantsVendor + Updated + @if($sortField === 'updated_at') + {{ $sortDirection === 'asc' ? '↑' : '↓' }} + @endif +
+ + + + {{ $product->title }} + + + + {{ ucfirst($product->status->value) }} + + {{ $product->variants_count }}{{ $product->vendor ?? '-' }}{{ $product->updated_at?->diffForHumans() }} + @if($product->status->value === 'draft') + + @endif +
No products found.
+
+ +
+ {{ $products->links() }} +
+
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..126dc7d1 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,31 @@ +
+ Settings + @if($store) +
+
+ General +
+
Store name
{{ $store->name }}
+
Handle
{{ $store->handle }}
+
Currency
{{ $store->default_currency }}
+
Timezone
{{ $store->timezone }}
+
+
+
+ Domains +
+ @foreach($store->domains as $domain) +
+ {{ $domain->hostname }} + @if($domain->is_primary) + Primary + @endif +
+ @endforeach +
+
+
+ @else +

No store selected.

+ @endif +
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..e14243d2 --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,18 @@ +
+ Themes +
+ @forelse($themes as $theme) +
+ {{ $theme->name }} +

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

+
+ + {{ ucfirst($theme->status) }} + +
+
+ @empty +

No themes.

+ @endforelse +
+
diff --git a/routes/web.php b/routes/web.php index 9d047d75..1bb4c0e4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,34 @@ ->name('admin.logout'); }); +// Admin panel routes (authenticated + store-scoped) +Route::prefix('admin') + ->middleware(['auth', 'resolve.store:admin']) + ->group(function () { + Route::get('/', \App\Livewire\Admin\Dashboard::class)->name('admin.dashboard'); + + Route::get('products', \App\Livewire\Admin\Products\Index::class)->name('admin.products.index'); + Route::get('products/create', \App\Livewire\Admin\Products\Form::class)->name('admin.products.create'); + Route::get('products/{productId}/edit', \App\Livewire\Admin\Products\Form::class)->name('admin.products.edit'); + + Route::get('orders', \App\Livewire\Admin\Orders\Index::class)->name('admin.orders.index'); + Route::get('orders/{orderId}', \App\Livewire\Admin\Orders\Show::class)->name('admin.orders.show'); + + Route::get('collections', \App\Livewire\Admin\Collections\Index::class)->name('admin.collections.index'); + + Route::get('customers', \App\Livewire\Admin\Customers\Index::class)->name('admin.customers.index'); + Route::get('customers/{customerId}', \App\Livewire\Admin\Customers\Show::class)->name('admin.customers.show'); + + Route::get('discounts', \App\Livewire\Admin\Discounts\Index::class)->name('admin.discounts.index'); + + Route::get('pages', \App\Livewire\Admin\Pages\Index::class)->name('admin.pages.index'); + Route::get('navigation', \App\Livewire\Admin\Navigation\Index::class)->name('admin.navigation.index'); + Route::get('themes', \App\Livewire\Admin\Themes\Index::class)->name('admin.themes.index'); + + Route::get('analytics', \App\Livewire\Admin\Analytics\Index::class)->name('admin.analytics.index'); + Route::get('settings', \App\Livewire\Admin\Settings\Index::class)->name('admin.settings.index'); + }); + // Storefront routes Route::middleware('resolve.store:storefront')->group(function () { // Public storefront pages diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..3e80165d --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,57 @@ +context = createStoreContext(); + session()->put('current_store_id', $this->context['store']->id); +}); + +it('renders the admin dashboard', function () { + $this->actingAs($this->context['user']) + ->get('/admin') + ->assertOk() + ->assertSeeLivewire(Dashboard::class); +}); + +it('requires authentication for the admin dashboard', function () { + $this->get('/admin') + ->assertRedirect(route('login')); +}); + +it('displays KPI tiles with analytics data', function () { + AnalyticsDaily::withoutGlobalScopes()->create([ + 'store_id' => $this->context['store']->id, + 'date' => now()->format('Y-m-d'), + 'orders_count' => 10, + 'revenue_amount' => 50000, + 'aov_amount' => 5000, + 'visits_count' => 200, + 'add_to_cart_count' => 50, + 'checkout_started_count' => 20, + 'checkout_completed_count' => 10, + ]); + + Livewire::actingAs($this->context['user']) + ->test(Dashboard::class) + ->assertSee('$500.00') + ->assertSee('200'); +}); + +it('supports date range filtering', function () { + Livewire::actingAs($this->context['user']) + ->test(Dashboard::class) + ->assertSet('dateRange', 'last_30_days') + ->set('dateRange', 'last_7_days') + ->assertSet('dateRange', 'last_7_days') + ->set('dateRange', 'today') + ->assertSet('dateRange', 'today'); +}); + +it('shows empty state when no data exists', function () { + Livewire::actingAs($this->context['user']) + ->test(Dashboard::class) + ->assertSee('$0.00'); +}); diff --git a/tests/Feature/Admin/OrderManagementTest.php b/tests/Feature/Admin/OrderManagementTest.php new file mode 100644 index 00000000..5eb49020 --- /dev/null +++ b/tests/Feature/Admin/OrderManagementTest.php @@ -0,0 +1,98 @@ +context = createStoreContext(); + session()->put('current_store_id', $this->context['store']->id); +}); + +it('renders the orders index', function () { + $this->actingAs($this->context['user']) + ->get('/admin/orders') + ->assertOk() + ->assertSeeLivewire(OrdersIndex::class); +}); + +it('lists orders for the store', function () { + $customer = Customer::factory()->create(['store_id' => $this->context['store']->id]); + + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '#1001', + ]); + + Livewire::actingAs($this->context['user']) + ->test(OrdersIndex::class) + ->assertSee('#1001'); +}); + +it('searches orders by number', function () { + $customer = Customer::factory()->create(['store_id' => $this->context['store']->id]); + + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '#2001', + ]); + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '#3001', + ]); + + Livewire::actingAs($this->context['user']) + ->test(OrdersIndex::class) + ->set('search', '#2001') + ->assertSee('#2001') + ->assertDontSee('#3001'); +}); + +it('renders the order detail page', function () { + $customer = Customer::factory()->create(['store_id' => $this->context['store']->id]); + + $order = Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '#4001', + 'total_amount' => 9999, + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'title_snapshot' => 'Widget X', + ]); + + Livewire::actingAs($this->context['user']) + ->test(OrdersShow::class, ['orderId' => $order->id]) + ->assertSee('#4001') + ->assertSee('Widget X') + ->assertSee('$99.99'); +}); + +it('filters orders by status', function () { + $customer = Customer::factory()->create(['store_id' => $this->context['store']->id]); + + Order::factory()->paid()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '#5001', + ]); + Order::factory()->cancelled()->create([ + 'store_id' => $this->context['store']->id, + 'customer_id' => $customer->id, + 'order_number' => '#5002', + ]); + + Livewire::actingAs($this->context['user']) + ->test(OrdersIndex::class) + ->set('statusFilter', 'paid') + ->assertSee('#5001') + ->assertDontSee('#5002'); +}); diff --git a/tests/Feature/Admin/ProductManagementTest.php b/tests/Feature/Admin/ProductManagementTest.php new file mode 100644 index 00000000..ac2b7237 --- /dev/null +++ b/tests/Feature/Admin/ProductManagementTest.php @@ -0,0 +1,137 @@ +context = createStoreContext(); + session()->put('current_store_id', $this->context['store']->id); +}); + +it('renders the products index', function () { + $this->actingAs($this->context['user']) + ->get('/admin/products') + ->assertOk() + ->assertSeeLivewire(ProductsIndex::class); +}); + +it('lists products for the store', function () { + Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'title' => 'Test Product Alpha', + ]); + + Livewire::actingAs($this->context['user']) + ->test(ProductsIndex::class) + ->assertSee('Test Product Alpha'); +}); + +it('searches products by title', function () { + Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'title' => 'Blue Widget', + ]); + Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'title' => 'Red Gadget', + ]); + + Livewire::actingAs($this->context['user']) + ->test(ProductsIndex::class) + ->set('search', 'Blue') + ->assertSee('Blue Widget') + ->assertDontSee('Red Gadget'); +}); + +it('filters products by status', function () { + Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'title' => 'Active Item', + 'status' => 'active', + ]); + Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'title' => 'Draft Item', + 'status' => 'draft', + ]); + + Livewire::actingAs($this->context['user']) + ->test(ProductsIndex::class) + ->set('statusFilter', 'active') + ->assertSee('Active Item') + ->assertDontSee('Draft Item'); +}); + +it('deletes a draft product', function () { + $product = Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'status' => 'draft', + ]); + + Livewire::actingAs($this->context['user']) + ->test(ProductsIndex::class) + ->call('deleteProduct', $product->id); + + expect(Product::find($product->id))->toBeNull(); +}); + +it('prevents deleting non-draft products', function () { + $product = Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'status' => 'active', + ]); + + Livewire::actingAs($this->context['user']) + ->test(ProductsIndex::class) + ->call('deleteProduct', $product->id); + + expect(Product::find($product->id))->not->toBeNull(); +}); + +it('renders the product create form', function () { + $this->actingAs($this->context['user']) + ->get('/admin/products/create') + ->assertOk() + ->assertSeeLivewire(ProductForm::class); +}); + +it('creates a new product', function () { + Livewire::actingAs($this->context['user']) + ->test(ProductForm::class) + ->set('title', 'New Test Product') + ->set('description', 'A great product') + ->set('vendor', 'TestVendor') + ->call('save') + ->assertRedirect(); + + $this->assertDatabaseHas('products', [ + 'store_id' => $this->context['store']->id, + 'title' => 'New Test Product', + 'vendor' => 'TestVendor', + ]); +}); + +it('edits an existing product', function () { + $product = Product::factory()->create([ + 'store_id' => $this->context['store']->id, + 'title' => 'Old Title', + ]); + + Livewire::actingAs($this->context['user']) + ->test(ProductForm::class, ['productId' => $product->id]) + ->assertSet('title', 'Old Title') + ->set('title', 'Updated Title') + ->call('save'); + + expect($product->fresh()->title)->toBe('Updated Title'); +}); + +it('validates required fields on product form', function () { + Livewire::actingAs($this->context['user']) + ->test(ProductForm::class) + ->set('title', '') + ->call('save') + ->assertHasErrors('title'); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd7..01b55d02 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -64,6 +64,6 @@ $response = $this->actingAs($user)->post(route('logout')); - $response->assertRedirect(route('home')); + $response->assertRedirect('/'); $this->assertGuest(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..57dd5e9c 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,10 @@ get('/'); +it('returns a successful response for the storefront home', function () { + $context = createStoreContext(); + $hostname = $context['domain']->hostname; - $response->assertStatus(200); + $response = $this->get("http://{$hostname}/"); + + $response->assertSuccessful(); }); From a7d3701a46a08dba3ddf551f2ba621a371a7ba16 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 22:37:36 +0100 Subject: [PATCH 11/17] Phase 10: Apps and Webhooks - app registry, OAuth, webhook delivery with circuit breaker - 6 migrations: apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries - 6 models with factories and enums for all status fields - WebhookService: dispatch to matching subscriptions, HMAC-SHA256 signing and verification - DeliverWebhook job: HTTP POST with signature headers, exponential backoff (6 attempts), circuit breaker pauses subscription after 5 consecutive failures - 11 tests covering signature generation/verification, delivery lifecycle, retries, circuit breaker, and subscription filtering Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Enums/AppInstallationStatus.php | 10 + app/Enums/AppStatus.php | 9 + app/Enums/WebhookDeliveryStatus.php | 10 + app/Enums/WebhookSubscriptionStatus.php | 10 + app/Jobs/DeliverWebhook.php | 96 ++++++++++ app/Models/App.php | 38 ++++ app/Models/AppInstallation.php | 52 +++++ app/Models/OauthClient.php | 34 ++++ app/Models/OauthToken.php | 33 ++++ app/Models/WebhookDelivery.php | 38 ++++ app/Models/WebhookSubscription.php | 46 +++++ app/Services/WebhookService.php | 46 +++++ database/factories/AppFactory.php | 28 +++ database/factories/AppInstallationFactory.php | 40 ++++ database/factories/OauthClientFactory.php | 24 +++ database/factories/OauthTokenFactory.php | 31 +++ database/factories/WebhookDeliveryFactory.php | 47 +++++ .../factories/WebhookSubscriptionFactory.php | 40 ++++ .../2026_03_20_300001_create_apps_table.php | 25 +++ ..._300002_create_app_installations_table.php | 30 +++ ...3_20_300003_create_oauth_clients_table.php | 27 +++ ...03_20_300004_create_oauth_tokens_table.php | 28 +++ ...005_create_webhook_subscriptions_table.php | 31 +++ ...300006_create_webhook_deliveries_table.php | 33 ++++ .../Feature/Webhooks/WebhookDeliveryTest.php | 179 ++++++++++++++++++ .../Feature/Webhooks/WebhookSignatureTest.php | 41 ++++ 26 files changed, 1026 insertions(+) create mode 100644 app/Enums/AppInstallationStatus.php create mode 100644 app/Enums/AppStatus.php create mode 100644 app/Enums/WebhookDeliveryStatus.php create mode 100644 app/Enums/WebhookSubscriptionStatus.php create mode 100644 app/Jobs/DeliverWebhook.php create mode 100644 app/Models/App.php create mode 100644 app/Models/AppInstallation.php create mode 100644 app/Models/OauthClient.php create mode 100644 app/Models/OauthToken.php create mode 100644 app/Models/WebhookDelivery.php create mode 100644 app/Models/WebhookSubscription.php create mode 100644 app/Services/WebhookService.php create mode 100644 database/factories/AppFactory.php create mode 100644 database/factories/AppInstallationFactory.php create mode 100644 database/factories/OauthClientFactory.php create mode 100644 database/factories/OauthTokenFactory.php create mode 100644 database/factories/WebhookDeliveryFactory.php create mode 100644 database/factories/WebhookSubscriptionFactory.php create mode 100644 database/migrations/2026_03_20_300001_create_apps_table.php create mode 100644 database/migrations/2026_03_20_300002_create_app_installations_table.php create mode 100644 database/migrations/2026_03_20_300003_create_oauth_clients_table.php create mode 100644 database/migrations/2026_03_20_300004_create_oauth_tokens_table.php create mode 100644 database/migrations/2026_03_20_300005_create_webhook_subscriptions_table.php create mode 100644 database/migrations/2026_03_20_300006_create_webhook_deliveries_table.php create mode 100644 tests/Feature/Webhooks/WebhookDeliveryTest.php create mode 100644 tests/Feature/Webhooks/WebhookSignatureTest.php diff --git a/app/Enums/AppInstallationStatus.php b/app/Enums/AppInstallationStatus.php new file mode 100644 index 00000000..d4a43b46 --- /dev/null +++ b/app/Enums/AppInstallationStatus.php @@ -0,0 +1,10 @@ + */ + public array $backoff = [60, 300, 1800, 7200, 43200]; + + public function __construct( + protected int $deliveryId, + protected array $payload, + ) {} + + public function handle(WebhookService $webhookService): void + { + $delivery = WebhookDelivery::findOrFail($this->deliveryId); + $subscription = $delivery->subscription; + + $jsonPayload = json_encode($this->payload); + $secret = $subscription->signing_secret_encrypted; + $signature = $webhookService->sign($jsonPayload, $secret); + + $delivery->increment('attempt_count'); + $delivery->update(['last_attempt_at' => now()]); + + try { + $response = Http::timeout(10) + ->withHeaders([ + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $subscription->event_type, + 'X-Platform-Delivery-Id' => $delivery->event_id, + 'X-Platform-Timestamp' => now()->toIso8601String(), + 'Content-Type' => 'application/json', + ]) + ->withBody($jsonPayload, 'application/json') + ->post($subscription->target_url); + + $delivery->update([ + 'response_code' => $response->status(), + 'response_body_snippet' => mb_substr($response->body(), 0, 500), + ]); + + if ($response->successful()) { + $delivery->update(['status' => WebhookDeliveryStatus::Success]); + $this->resetConsecutiveFailures($subscription); + + return; + } + + throw new \RuntimeException("Webhook delivery failed with status {$response->status()}"); + } catch (\Throwable $e) { + if ($delivery->attempt_count >= $this->tries) { + $delivery->update(['status' => WebhookDeliveryStatus::Failed]); + $this->checkCircuitBreaker($subscription); + + return; + } + + $this->release($this->backoff[$delivery->attempt_count - 1] ?? 43200); + } + } + + protected function checkCircuitBreaker(WebhookSubscription $subscription): void + { + $recentFailures = $subscription->deliveries() + ->latest('last_attempt_at') + ->limit(5) + ->get(); + + if ($recentFailures->count() >= 5 && $recentFailures->every(fn ($d) => $d->status === WebhookDeliveryStatus::Failed)) { + $subscription->update(['status' => WebhookSubscriptionStatus::Paused]); + } + } + + protected function resetConsecutiveFailures(WebhookSubscription $subscription): void + { + // Success resets the circuit breaker - no action needed since we check consecutive failures + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..cf5887db --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,38 @@ + */ + protected function casts(): array + { + return [ + 'status' => AppStatus::class, + ]; + } + + /** @return HasMany */ + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + /** @return HasMany */ + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..cf6124c9 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,52 @@ + */ + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'status' => AppInstallationStatus::class, + 'installed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + /** @return HasMany */ + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + /** @return HasMany */ + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 00000000..a572a201 --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,34 @@ + */ + protected function casts(): array + { + return [ + 'redirect_uris_json' => 'array', + 'client_secret_encrypted' => 'encrypted', + ]; + } + + /** @return BelongsTo */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } +} diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 00000000..d181eab3 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,33 @@ + */ + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..638c21de --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,38 @@ + */ + protected function casts(): array + { + return [ + 'status' => WebhookDeliveryStatus::class, + 'last_attempt_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..b608ecf4 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,46 @@ + */ + protected function casts(): array + { + return [ + 'status' => WebhookSubscriptionStatus::class, + 'signing_secret_encrypted' => 'encrypted', + ]; + } + + /** @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/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..76bea2f7 --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,46 @@ +where('store_id', $store->id) + ->where('event_type', $eventType) + ->where('status', WebhookSubscriptionStatus::Active) + ->get(); + + foreach ($subscriptions as $subscription) { + $delivery = WebhookDelivery::create([ + 'subscription_id' => $subscription->id, + 'event_id' => Str::uuid()->toString(), + 'attempt_count' => 0, + 'status' => WebhookDeliveryStatus::Pending, + ]); + + DeliverWebhook::dispatch($delivery->id, $payload); + } + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + $expected = $this->sign($payload, $secret); + + return hash_equals($expected, $signature); + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..ca55333e --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,28 @@ + */ +class AppFactory extends Factory +{ + protected $model = App::class; + + public function definition(): array + { + return [ + 'name' => fake()->company().' App', + 'status' => AppStatus::Active, + ]; + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => AppStatus::Disabled, + ]); + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..3ef4860c --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,40 @@ + */ +class AppInstallationFactory extends Factory +{ + protected $model = AppInstallation::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_id' => App::factory(), + 'scopes_json' => ['read_products', 'write_orders'], + 'status' => AppInstallationStatus::Active, + 'installed_at' => now(), + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => AppInstallationStatus::Suspended, + ]); + } + + public function uninstalled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => AppInstallationStatus::Uninstalled, + ]); + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..04789d1c --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,24 @@ + */ +class OauthClientFactory extends Factory +{ + protected $model = OauthClient::class; + + public function definition(): array + { + return [ + 'app_id' => App::factory(), + 'client_id' => Str::uuid()->toString(), + 'client_secret_encrypted' => Str::random(40), + 'redirect_uris_json' => ['https://example.com/callback'], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..5409cacb --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,31 @@ + */ +class OauthTokenFactory extends Factory +{ + protected $model = OauthToken::class; + + public function definition(): array + { + return [ + 'installation_id' => AppInstallation::factory(), + 'access_token_hash' => hash('sha256', Str::random(40)), + 'refresh_token_hash' => hash('sha256', Str::random(40)), + 'expires_at' => now()->addHour(), + ]; + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subHour(), + ]); + } +} diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..610c9f34 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,47 @@ + */ +class WebhookDeliveryFactory extends Factory +{ + protected $model = WebhookDelivery::class; + + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_id' => Str::uuid()->toString(), + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Pending, + 'last_attempt_at' => null, + 'response_code' => null, + 'response_body_snippet' => null, + ]; + } + + public function success(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookDeliveryStatus::Success, + 'response_code' => 200, + 'last_attempt_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookDeliveryStatus::Failed, + 'response_code' => 500, + 'last_attempt_at' => now(), + 'attempt_count' => 6, + ]); + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..f5a0fce8 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,40 @@ + */ +class WebhookSubscriptionFactory extends Factory +{ + protected $model = WebhookSubscription::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/webhooks', + 'signing_secret_encrypted' => 'test-secret', + 'status' => WebhookSubscriptionStatus::Active, + ]; + } + + public function paused(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookSubscriptionStatus::Paused, + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WebhookSubscriptionStatus::Disabled, + ]); + } +} diff --git a/database/migrations/2026_03_20_300001_create_apps_table.php b/database/migrations/2026_03_20_300001_create_apps_table.php new file mode 100644 index 00000000..c15a601f --- /dev/null +++ b/database/migrations/2026_03_20_300001_create_apps_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->index('status', 'idx_apps_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_03_20_300002_create_app_installations_table.php b/database/migrations/2026_03_20_300002_create_app_installations_table.php new file mode 100644 index 00000000..a61b22c1 --- /dev/null +++ b/database/migrations/2026_03_20_300002_create_app_installations_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->json('scopes_json')->default('[]'); + $table->string('status')->default('active'); + $table->dateTime('installed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + $table->index('app_id', 'idx_app_installations_app_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_03_20_300003_create_oauth_clients_table.php b/database/migrations/2026_03_20_300003_create_oauth_clients_table.php new file mode 100644 index 00000000..d49f74a6 --- /dev/null +++ b/database/migrations/2026_03_20_300003_create_oauth_clients_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->string('client_id')->unique(); + $table->text('client_secret_encrypted'); + $table->json('redirect_uris_json')->default('[]'); + $table->timestamps(); + + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_03_20_300004_create_oauth_tokens_table.php b/database/migrations/2026_03_20_300004_create_oauth_tokens_table.php new file mode 100644 index 00000000..e7b93ac8 --- /dev/null +++ b/database/migrations/2026_03_20_300004_create_oauth_tokens_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->string('access_token_hash')->unique(); + $table->string('refresh_token_hash')->nullable(); + $table->dateTime('expires_at'); + $table->timestamps(); + + $table->index('installation_id', 'idx_oauth_tokens_installation_id'); + $table->index('expires_at', 'idx_oauth_tokens_expires_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_03_20_300005_create_webhook_subscriptions_table.php b/database/migrations/2026_03_20_300005_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..7b4c9aa3 --- /dev/null +++ b/database/migrations/2026_03_20_300005_create_webhook_subscriptions_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->string('event_type'); + $table->text('target_url'); + $table->text('signing_secret_encrypted'); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->index('store_id', 'idx_webhook_subscriptions_store_id'); + $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event'); + $table->index('app_installation_id', 'idx_webhook_subscriptions_installation'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_03_20_300006_create_webhook_deliveries_table.php b/database/migrations/2026_03_20_300006_create_webhook_deliveries_table.php new file mode 100644 index 00000000..fe566988 --- /dev/null +++ b/database/migrations/2026_03_20_300006_create_webhook_deliveries_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->integer('attempt_count')->default(1); + $table->string('status')->default('pending'); + $table->dateTime('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + $table->timestamps(); + + $table->index('subscription_id', 'idx_webhook_deliveries_subscription_id'); + $table->index('event_id', 'idx_webhook_deliveries_event_id'); + $table->index('status', 'idx_webhook_deliveries_status'); + $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/tests/Feature/Webhooks/WebhookDeliveryTest.php b/tests/Feature/Webhooks/WebhookDeliveryTest.php new file mode 100644 index 00000000..626fd3cf --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDeliveryTest.php @@ -0,0 +1,179 @@ +context = createStoreContext(); +}); + +it('delivers a webhook to a subscribed URL', function () { + Http::fake([ + 'https://example.com/webhooks' => Http::response('OK', 200), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->context['store']->id, + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/webhooks', + 'signing_secret_encrypted' => 'test-secret', + ]); + + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->id, + ]); + + $job = new DeliverWebhook($delivery->id, ['event' => 'order.created']); + $job->handle(new WebhookService); + + Http::assertSent(function ($request) { + return $request->url() === 'https://example.com/webhooks' + && $request->hasHeader('X-Platform-Signature') + && $request->hasHeader('X-Platform-Event') + && $request->hasHeader('X-Platform-Delivery-Id') + && $request->hasHeader('X-Platform-Timestamp'); + }); + + expect($delivery->fresh()->status)->toBe(WebhookDeliveryStatus::Success); +}); + +it('signs the payload with HMAC', function () { + Http::fake([ + 'https://example.com/webhooks' => Http::response('OK', 200), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->context['store']->id, + 'signing_secret_encrypted' => 'my-secret', + ]); + + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->id, + ]); + + $payload = ['event' => 'order.created']; + $job = new DeliverWebhook($delivery->id, $payload); + $job->handle(new WebhookService); + + $expectedSignature = hash_hmac('sha256', json_encode($payload), 'my-secret'); + + Http::assertSent(function ($request) use ($expectedSignature) { + return $request->header('X-Platform-Signature')[0] === $expectedSignature; + }); +}); + +it('retries failed deliveries with exponential backoff', function () { + Http::fake([ + 'https://example.com/webhooks' => Http::response('Error', 500), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->id, + 'attempt_count' => 0, + ]); + + $job = new DeliverWebhook($delivery->id, ['event' => 'order.created']); + $job->handle(new WebhookService); + + $delivery->refresh(); + expect($delivery->attempt_count)->toBe(1) + ->and($delivery->response_code)->toBe(500) + ->and($delivery->status)->toBe(WebhookDeliveryStatus::Pending); +}); + +it('marks delivery as failed after max retries', function () { + Http::fake([ + 'https://example.com/webhooks' => Http::response('Error', 500), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->id, + 'attempt_count' => 5, + ]); + + $job = new DeliverWebhook($delivery->id, ['event' => 'order.created']); + $job->handle(new WebhookService); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDeliveryStatus::Failed) + ->and($delivery->attempt_count)->toBe(6); +}); + +it('pauses subscription after circuit breaker threshold', function () { + Http::fake([ + 'https://example.com/webhooks' => Http::response('Error', 500), + ]); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->context['store']->id, + ]); + + // Create 4 previously failed deliveries + for ($i = 0; $i < 4; $i++) { + WebhookDelivery::factory()->failed()->create([ + 'subscription_id' => $subscription->id, + ]); + } + + // The 5th delivery attempt that will also fail + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->id, + 'attempt_count' => 5, + ]); + + $job = new DeliverWebhook($delivery->id, ['event' => 'order.created']); + $job->handle(new WebhookService); + + $subscription->refresh(); + expect($subscription->status)->toBe(WebhookSubscriptionStatus::Paused); +}); + +it('dispatches webhooks for matching subscriptions', function () { + Queue::fake(); + + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->context['store']->id, + 'event_type' => 'order.created', + ]); + + // Non-matching subscription + WebhookSubscription::factory()->create([ + 'store_id' => $this->context['store']->id, + 'event_type' => 'product.updated', + ]); + + $service = new WebhookService; + $service->dispatch($this->context['store'], 'order.created', ['order_id' => 1]); + + Queue::assertPushed(DeliverWebhook::class, 1); + + expect(WebhookDelivery::where('subscription_id', $subscription->id)->count())->toBe(1); +}); + +it('skips paused subscriptions when dispatching', function () { + Queue::fake(); + + WebhookSubscription::factory()->paused()->create([ + 'store_id' => $this->context['store']->id, + 'event_type' => 'order.created', + ]); + + $service = new WebhookService; + $service->dispatch($this->context['store'], 'order.created', ['order_id' => 1]); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/Webhooks/WebhookSignatureTest.php b/tests/Feature/Webhooks/WebhookSignatureTest.php new file mode 100644 index 00000000..eace9296 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookSignatureTest.php @@ -0,0 +1,41 @@ +sign($payload, $secret); + + expect($signature)->toBe(hash_hmac('sha256', $payload, $secret)); +}); + +it('verifies a valid signature', function () { + $service = new WebhookService; + $payload = '{"event":"order.created"}'; + $secret = 'test-secret'; + + $signature = $service->sign($payload, $secret); + + expect($service->verify($payload, $signature, $secret))->toBeTrue(); +}); + +it('rejects a tampered payload', function () { + $service = new WebhookService; + $secret = 'test-secret'; + + $signature = $service->sign('{"event":"order.created"}', $secret); + + expect($service->verify('{"event":"order.updated"}', $signature, $secret))->toBeFalse(); +}); + +it('rejects an incorrect secret', function () { + $service = new WebhookService; + $payload = '{"event":"order.created"}'; + + $signature = $service->sign($payload, 'secret-a'); + + expect($service->verify($payload, $signature, 'secret-b'))->toBeFalse(); +}); From a5659fe8088960e8be3ae849e47e214d1f3194a5 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 22:45:44 +0100 Subject: [PATCH 12/17] Phase 11: Polish - error pages, structured logging, and comprehensive demo seeders - Styled 404 and 503 error pages with dark mode support - JSON structured logging channel in config/logging.php - Comprehensive seeder suite (14 seeders in dependency order): Organization, Store, StoreDomain, User, StoreSettings, TaxSettings, Shipping, Collection, Product (20 fashion + 5 electronics with full option/variant/inventory graphs), Discount (5 codes), Customer (10 with addresses), Theme, Page, Navigation - DatabaseSeeder orchestrates all seeders in correct FK order - migrate:fresh --seed runs successfully with full demo data Co-Authored-By: Claude Opus 4.6 (1M context) --- config/logging.php | 9 + database/seeders/CollectionSeeder.php | 49 +++ database/seeders/CustomerSeeder.php | 76 ++++ database/seeders/DatabaseSeeder.php | 25 +- database/seeders/DiscountSeeder.php | 53 +++ database/seeders/OrganizationSeeder.php | 17 + database/seeders/ProductSeeder.php | 522 +++++++++++++++++++++++ database/seeders/ShippingSeeder.php | 95 +++++ database/seeders/StoreDomainSeeder.php | 40 ++ database/seeders/StoreSeeder.php | 35 ++ database/seeders/StoreSettingsSeeder.php | 36 ++ database/seeders/TaxSettingsSeeder.php | 25 ++ database/seeders/UserSeeder.php | 47 ++ resources/views/errors/404.blade.php | 26 ++ resources/views/errors/503.blade.php | 20 + 15 files changed, 1065 insertions(+), 10 deletions(-) create mode 100644 database/seeders/CollectionSeeder.php create mode 100644 database/seeders/CustomerSeeder.php create mode 100644 database/seeders/DiscountSeeder.php create mode 100644 database/seeders/OrganizationSeeder.php create mode 100644 database/seeders/ProductSeeder.php create mode 100644 database/seeders/ShippingSeeder.php create mode 100644 database/seeders/StoreDomainSeeder.php create mode 100644 database/seeders/StoreSeeder.php create mode 100644 database/seeders/StoreSettingsSeeder.php create mode 100644 database/seeders/TaxSettingsSeeder.php create mode 100644 database/seeders/UserSeeder.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/503.blade.php diff --git a/config/logging.php b/config/logging.php index 9e998a49..ebf9a96a 100644 --- a/config/logging.php +++ b/config/logging.php @@ -123,6 +123,15 @@ 'handler' => NullHandler::class, ], + 'json' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/json.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + 'formatter' => \Monolog\Formatter\JsonFormatter::class, + ], + 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..8499fdef --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,49 @@ +firstOrFail(); + + app()->instance('current_store', $fashion); + + $fashionCollections = [ + ['title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description_html' => '

Discover the latest additions to our store.

'], + ['title' => 'T-Shirts', 'handle' => 't-shirts', 'description_html' => '

Premium cotton tees for every occasion.

'], + ['title' => 'Pants & Jeans', 'handle' => 'pants-jeans', 'description_html' => '

Find the perfect fit from our denim and trouser range.

'], + ['title' => 'Sale', 'handle' => 'sale', 'description_html' => '

Great deals on selected items.

'], + ]; + + foreach ($fashionCollections as $data) { + Collection::create(array_merge($data, [ + 'store_id' => $fashion->id, + 'type' => 'manual', + 'status' => 'active', + ])); + } + + $electronics = Store::where('handle', 'acme-electronics')->firstOrFail(); + + app()->instance('current_store', $electronics); + + $electronicsCollections = [ + ['title' => 'Featured', 'handle' => 'featured', 'description_html' => '

Our featured products.

'], + ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '

Essential accessories.

'], + ]; + + foreach ($electronicsCollections as $data) { + Collection::create(array_merge($data, [ + 'store_id' => $electronics->id, + 'type' => 'manual', + 'status' => 'active', + ])); + } + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..84e791a8 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,76 @@ +firstOrFail(); + app()->instance('current_store', $store); + + $customers = [ + ['email' => 'customer@acme.test', 'name' => 'John Doe', 'marketing_opt_in' => true], + ['email' => 'jane@example.com', 'name' => 'Jane Smith', 'marketing_opt_in' => false], + ['email' => 'michael@example.com', 'name' => 'Michael Brown', 'marketing_opt_in' => true], + ['email' => 'sarah@example.com', 'name' => 'Sarah Wilson', 'marketing_opt_in' => false], + ['email' => 'david@example.com', 'name' => 'David Lee', 'marketing_opt_in' => true], + ['email' => 'emma@example.com', 'name' => 'Emma Garcia', 'marketing_opt_in' => false], + ['email' => 'james@example.com', 'name' => 'James Taylor', 'marketing_opt_in' => false], + ['email' => 'lisa@example.com', 'name' => 'Lisa Anderson', 'marketing_opt_in' => true], + ['email' => 'robert@example.com', 'name' => 'Robert Martinez', 'marketing_opt_in' => false], + ['email' => 'anna@example.com', 'name' => 'Anna Thomas', 'marketing_opt_in' => true], + ]; + + foreach ($customers as $data) { + Customer::create(array_merge($data, [ + 'store_id' => $store->id, + 'password' => 'password', + ])); + } + + // Add addresses for customer 1 + $customer = Customer::where('email', 'customer@acme.test') + ->where('store_id', $store->id) + ->first(); + + if ($customer) { + CustomerAddress::create([ + 'customer_id' => $customer->id, + 'label' => 'Home', + 'is_default' => true, + 'address_json' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => 'Hauptstrasse 1', + 'city' => 'Berlin', + 'province' => 'Berlin', + 'zip' => '10115', + 'country' => 'DE', + 'phone' => '+49 30 12345678', + ], + ]); + + CustomerAddress::create([ + 'customer_id' => $customer->id, + 'label' => 'Office', + 'is_default' => false, + 'address_json' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => 'Friedrichstrasse 100', + 'city' => 'Berlin', + 'province' => 'Berlin', + 'zip' => '10117', + 'country' => 'DE', + 'phone' => '+49 30 87654321', + ], + ]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..a0de97ef 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,27 @@ 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([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..1ed1e635 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,53 @@ +firstOrFail(); + app()->instance('current_store', $store); + + $discounts = [ + [ + 'code' => 'WELCOME10', 'type' => 'code', 'value_type' => 'percent', + 'value_amount' => 10, 'starts_at' => '2025-01-01', 'ends_at' => '2027-12-31', + 'usage_limit' => null, 'usage_count' => 3, 'status' => 'active', + 'rules_json' => ['min_purchase_amount' => 2000], + ], + [ + 'code' => 'FLAT5', 'type' => 'code', 'value_type' => 'fixed', + 'value_amount' => 500, 'starts_at' => '2025-01-01', 'ends_at' => '2027-12-31', + 'usage_limit' => null, 'usage_count' => 0, 'status' => 'active', + 'rules_json' => [], + ], + [ + 'code' => 'FREESHIP', 'type' => 'code', 'value_type' => 'free_shipping', + 'value_amount' => 0, 'starts_at' => '2025-01-01', 'ends_at' => '2027-12-31', + 'usage_limit' => null, 'usage_count' => 1, 'status' => 'active', + 'rules_json' => [], + ], + [ + 'code' => 'EXPIRED20', 'type' => 'code', 'value_type' => 'percent', + 'value_amount' => 20, 'starts_at' => '2024-01-01', 'ends_at' => '2024-12-31', + 'usage_limit' => null, 'usage_count' => 0, 'status' => 'expired', + 'rules_json' => [], + ], + [ + 'code' => 'MAXED', 'type' => 'code', 'value_type' => 'percent', + 'value_amount' => 10, 'starts_at' => '2025-01-01', 'ends_at' => '2027-12-31', + 'usage_limit' => 5, 'usage_count' => 5, 'status' => 'active', + 'rules_json' => [], + ], + ]; + + foreach ($discounts as $data) { + Discount::create(array_merge($data, ['store_id' => $store->id])); + } + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..0f1f7424 --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,17 @@ + 'Acme Corp', + 'billing_email' => 'billing@acme.test', + ]); + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 00000000..7e5a1232 --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,522 @@ +seedFashionProducts(); + $this->seedElectronicsProducts(); + } + + private function seedFashionProducts(): void + { + $store = Store::where('handle', 'acme-fashion')->firstOrFail(); + app()->instance('current_store', $store); + + $collections = Collection::where('store_id', $store->id)->get()->keyBy('handle'); + + $products = $this->getFashionProductData(); + + foreach ($products as $data) { + $product = Product::create([ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $data['handle'], + 'status' => $data['status'], + 'description_html' => '

'.$data['description'].'

', + 'vendor' => $data['vendor'], + 'product_type' => $data['product_type'], + 'tags' => $data['tags'], + 'published_at' => $data['published_at'], + ]); + + // Attach to collections + foreach ($data['collections'] as $position => $handle) { + if ($collections->has($handle)) { + $product->collections()->attach($collections[$handle]->id, ['position' => $position]); + } + } + + // Create options and variants + $this->createOptionsAndVariants($product, $store, $data); + } + } + + private function seedElectronicsProducts(): void + { + $store = Store::where('handle', 'acme-electronics')->firstOrFail(); + app()->instance('current_store', $store); + + $collections = Collection::where('store_id', $store->id)->get()->keyBy('handle'); + + $products = [ + [ + 'title' => 'Pro Laptop 15', 'handle' => 'pro-laptop-15', 'vendor' => 'TechCorp', + 'product_type' => 'Laptops', 'tags' => ['featured'], 'description' => 'Powerful 15-inch laptop for professionals.', + 'options' => ['Storage' => ['256GB', '512GB', '1TB']], + 'prices' => [99999, 119999, 149999], 'weight_g' => 1800, 'inventory' => 10, + 'collections' => ['featured'], + ], + [ + 'title' => 'Wireless Headphones', 'handle' => 'wireless-headphones', 'vendor' => 'AudioMax', + 'product_type' => 'Audio', 'tags' => ['popular'], 'description' => 'Premium wireless headphones with noise cancellation.', + 'options' => ['Color' => ['Black', 'Silver']], + 'prices' => [14999, 14999], 'weight_g' => 250, 'inventory' => 25, + 'collections' => ['featured', 'accessories'], + ], + [ + 'title' => 'USB-C Cable 2m', 'handle' => 'usb-c-cable-2m', 'vendor' => 'CablePro', + 'product_type' => 'Cables', 'tags' => [], 'description' => 'High-quality USB-C cable, 2 meters.', + 'options' => [], 'prices' => [1299], 'weight_g' => 50, 'inventory' => 200, + 'collections' => ['accessories'], + ], + [ + 'title' => 'Mechanical Keyboard', 'handle' => 'mechanical-keyboard', 'vendor' => 'KeyTech', + 'product_type' => 'Peripherals', 'tags' => ['new'], 'description' => 'Full-size mechanical keyboard with RGB backlight.', + 'options' => ['Switch Type' => ['Red', 'Blue', 'Brown']], + 'prices' => [12999, 12999, 12999], 'weight_g' => 1100, 'inventory' => 15, + 'collections' => ['featured'], + ], + [ + 'title' => 'Monitor Stand', 'handle' => 'monitor-stand', 'vendor' => 'DeskGear', + 'product_type' => 'Accessories', 'tags' => [], 'description' => 'Ergonomic monitor stand with cable management.', + 'options' => [], 'prices' => [4999], 'weight_g' => 2500, 'inventory' => 30, + 'collections' => ['accessories'], + ], + ]; + + foreach ($products as $data) { + $product = Product::create([ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $data['handle'], + 'status' => 'active', + 'description_html' => '

'.$data['description'].'

', + 'vendor' => $data['vendor'], + 'product_type' => $data['product_type'], + 'tags' => $data['tags'], + 'published_at' => now(), + ]); + + foreach ($data['collections'] as $position => $handle) { + if ($collections->has($handle)) { + $product->collections()->attach($collections[$handle]->id, ['position' => $position]); + } + } + + $this->createElectronicsVariants($product, $store, $data); + } + } + + private function createOptionsAndVariants(Product $product, Store $store, array $data): void + { + $options = $data['options'] ?? []; + $optionValueIds = []; + + foreach ($options as $position => $optionData) { + $option = ProductOption::create([ + 'product_id' => $product->id, + 'name' => $optionData['name'], + 'position' => $position, + ]); + + $valueIds = []; + foreach ($optionData['values'] as $vPos => $value) { + $ov = ProductOptionValue::create([ + 'product_option_id' => $option->id, + 'value' => $value, + 'position' => $vPos, + ]); + $valueIds[$value] = $ov->id; + } + $optionValueIds[$optionData['name']] = $valueIds; + } + + // Build variant combinations + $combinations = $this->buildCombinations($options); + $price = $data['price']; + $compareAt = $data['compare_at'] ?? null; + $weight = $data['weight_g']; + $requiresShipping = $data['requires_shipping'] ?? true; + $inventory = $data['inventory']; + $inventoryPolicy = $data['inventory_policy'] ?? 'deny'; + $skuPrefix = $data['sku_prefix'] ?? 'ACME'; + + foreach ($combinations as $position => $combo) { + $skuParts = [$skuPrefix]; + $pivotValues = []; + + foreach ($combo as $optionName => $value) { + $skuParts[] = strtoupper(substr(preg_replace('/[^a-zA-Z0-9]/', '', $value), 0, 3)); + if (isset($optionValueIds[$optionName][$value])) { + $pivotValues[] = $optionValueIds[$optionName][$value]; + } + } + + $variantPrice = $price; + if (isset($data['variant_prices']) && isset($data['variant_prices'][$position])) { + $variantPrice = $data['variant_prices'][$position]; + } + + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'sku' => implode('-', $skuParts).'-'.($position + 1), + 'price_amount' => $variantPrice, + 'compare_at_amount' => $compareAt, + 'currency' => 'EUR', + 'weight_g' => $weight, + 'requires_shipping' => $requiresShipping, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => 'active', + ]); + + foreach ($pivotValues as $pvId) { + DB::table('variant_option_values')->insert([ + 'variant_id' => $variant->id, + 'product_option_value_id' => $pvId, + ]); + } + + InventoryItem::create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $inventory, + 'quantity_reserved' => 0, + 'policy' => $inventoryPolicy, + ]); + } + } + + private function createElectronicsVariants(Product $product, Store $store, array $data): void + { + $optionValueIds = []; + + foreach ($data['options'] as $optName => $values) { + $option = ProductOption::create([ + 'product_id' => $product->id, + 'name' => $optName, + 'position' => 0, + ]); + + $valueIds = []; + foreach ($values as $vPos => $value) { + $ov = ProductOptionValue::create([ + 'product_option_id' => $option->id, + 'value' => $value, + 'position' => $vPos, + ]); + $valueIds[$value] = $ov->id; + } + $optionValueIds[$optName] = $valueIds; + } + + if (empty($data['options'])) { + // Single default variant + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'sku' => 'ELEC-'.strtoupper(substr(preg_replace('/[^a-zA-Z0-9]/', '', $data['handle']), 0, 8)), + 'price_amount' => $data['prices'][0], + 'currency' => 'EUR', + 'weight_g' => $data['weight_g'], + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 0, + 'status' => 'active', + ]); + + InventoryItem::create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $data['inventory'], + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + return; + } + + $position = 0; + foreach ($data['options'] as $optName => $values) { + foreach ($values as $idx => $value) { + $variant = ProductVariant::create([ + 'product_id' => $product->id, + 'sku' => 'ELEC-'.strtoupper(substr(preg_replace('/[^a-zA-Z0-9]/', '', $value), 0, 6)).'-'.$position, + 'price_amount' => $data['prices'][$idx] ?? $data['prices'][0], + 'currency' => 'EUR', + 'weight_g' => $data['weight_g'], + 'requires_shipping' => true, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => 'active', + ]); + + if (isset($optionValueIds[$optName][$value])) { + DB::table('variant_option_values')->insert([ + 'variant_id' => $variant->id, + 'product_option_value_id' => $optionValueIds[$optName][$value], + ]); + } + + InventoryItem::create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $data['inventory'], + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $position++; + } + } + } + + /** @return array> */ + private function buildCombinations(array $options): array + { + if (empty($options)) { + return [[]]; + } + + $result = [[]]; + + foreach ($options as $optionData) { + $newResult = []; + foreach ($result as $combo) { + foreach ($optionData['values'] as $value) { + $newResult[] = array_merge($combo, [$optionData['name'] => $value]); + } + } + $result = $newResult; + } + + return $result; + } + + /** @return array> */ + private function getFashionProductData(): array + { + return [ + [ + 'title' => 'Classic Cotton T-Shirt', 'handle' => 'classic-cotton-t-shirt', + 'status' => 'active', 'vendor' => 'Acme Basics', 'product_type' => 'T-Shirts', + 'tags' => ['new', 'popular'], 'published_at' => now(), + 'description' => 'A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.', + 'collections' => ['new-arrivals', 't-shirts'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ['name' => 'Color', 'values' => ['White', 'Black', 'Navy']], + ], + 'price' => 2499, 'weight_g' => 200, 'inventory' => 15, 'sku_prefix' => 'ACME-CTSH', + ], + [ + 'title' => 'Premium Slim Fit Jeans', 'handle' => 'premium-slim-fit-jeans', + 'status' => 'active', 'vendor' => 'Acme Denim', 'product_type' => 'Pants', + 'tags' => ['new', 'sale'], 'published_at' => now(), + 'description' => 'Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.', + 'collections' => ['new-arrivals', 'pants-jeans', 'sale'], + 'options' => [ + ['name' => 'Size', 'values' => ['28', '30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Blue', 'Black']], + ], + 'price' => 7999, 'compare_at' => 9999, 'weight_g' => 800, 'inventory' => 8, + ], + [ + 'title' => 'Organic Hoodie', 'handle' => 'organic-hoodie', + 'status' => 'active', 'vendor' => 'Acme Basics', 'product_type' => 'Hoodies', + 'tags' => ['new', 'trending'], 'published_at' => now(), + 'description' => 'Made from 100% organic cotton. Warm, soft, and sustainably produced.', + 'collections' => ['new-arrivals'], + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], + 'price' => 5999, 'weight_g' => 500, 'inventory' => 20, + ], + [ + 'title' => 'Leather Belt', 'handle' => 'leather-belt', + 'status' => 'active', 'vendor' => 'Acme Accessories', 'product_type' => 'Accessories', + 'tags' => ['popular'], 'published_at' => now(), + 'description' => 'Genuine leather belt with brushed metal buckle. A wardrobe essential.', + 'collections' => [], + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Brown', 'Black']], + ], + 'price' => 3499, 'weight_g' => 150, 'inventory' => 25, + ], + [ + 'title' => 'Running Sneakers', 'handle' => 'running-sneakers', + 'status' => 'active', 'vendor' => 'Acme Sport', 'product_type' => 'Shoes', + 'tags' => ['trending'], 'published_at' => now(), + 'description' => 'Lightweight running sneakers with responsive cushioning and breathable mesh upper.', + 'collections' => ['new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], + ['name' => 'Color', 'values' => ['White', 'Black']], + ], + 'price' => 11999, 'weight_g' => 600, 'inventory' => 5, + ], + [ + 'title' => 'Graphic Print Tee', 'handle' => 'graphic-print-tee', + 'status' => 'active', 'vendor' => 'Acme Basics', 'product_type' => 'T-Shirts', + 'tags' => ['new'], 'published_at' => now(), + 'description' => 'Bold graphic print on soft cotton. Express yourself with this statement piece.', + 'collections' => ['t-shirts'], + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], + 'price' => 2999, 'weight_g' => 210, 'inventory' => 18, + ], + [ + 'title' => 'V-Neck Linen Tee', 'handle' => 'v-neck-linen-tee', + 'status' => 'active', 'vendor' => 'Acme Basics', 'product_type' => 'T-Shirts', + 'tags' => ['popular'], 'published_at' => now(), + 'description' => 'Lightweight linen blend v-neck. Perfect for warm summer days.', + 'collections' => ['t-shirts'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Beige', 'Olive', 'Sky Blue']], + ], + 'price' => 3499, 'weight_g' => 180, 'inventory' => 12, + ], + [ + 'title' => 'Striped Polo Shirt', 'handle' => 'striped-polo-shirt', + 'status' => 'active', 'vendor' => 'Acme Basics', 'product_type' => 'T-Shirts', + 'tags' => ['sale'], 'published_at' => now(), + 'description' => 'Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.', + 'collections' => ['t-shirts', 'sale'], + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], + 'price' => 2799, 'compare_at' => 3999, 'weight_g' => 250, 'inventory' => 10, + ], + [ + 'title' => 'Cargo Pants', 'handle' => 'cargo-pants', + 'status' => 'active', 'vendor' => 'Acme Workwear', 'product_type' => 'Pants', + 'tags' => ['popular'], 'published_at' => now(), + 'description' => 'Utility cargo pants with multiple pockets. Durable cotton twill construction.', + 'collections' => ['pants-jeans'], + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Khaki', 'Olive', 'Black']], + ], + 'price' => 5499, 'weight_g' => 700, 'inventory' => 14, + ], + [ + 'title' => 'Chino Shorts', 'handle' => 'chino-shorts', + 'status' => 'active', 'vendor' => 'Acme Basics', 'product_type' => 'Pants', + 'tags' => ['new', 'trending'], 'published_at' => now(), + 'description' => 'Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.', + 'collections' => ['pants-jeans', 'new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Navy', 'Sand']], + ], + 'price' => 3999, 'weight_g' => 350, 'inventory' => 16, + ], + [ + 'title' => 'Wide Leg Trousers', 'handle' => 'wide-leg-trousers', + 'status' => 'active', 'vendor' => 'Acme Denim', 'product_type' => 'Pants', + 'tags' => ['sale'], 'published_at' => now(), + 'description' => 'Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.', + 'collections' => ['pants-jeans', 'sale'], + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L']]], + 'price' => 4999, 'compare_at' => 6999, 'weight_g' => 550, 'inventory' => 7, + ], + [ + 'title' => 'Wool Scarf', 'handle' => 'wool-scarf', + 'status' => 'active', 'vendor' => 'Acme Accessories', 'product_type' => 'Accessories', + 'tags' => ['popular'], 'published_at' => now(), + 'description' => 'Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.', + 'collections' => [], + 'options' => [['name' => 'Color', 'values' => ['Grey', 'Burgundy', 'Navy']]], + 'price' => 2999, 'weight_g' => 120, 'inventory' => 30, + ], + [ + 'title' => 'Canvas Tote Bag', 'handle' => 'canvas-tote-bag', + 'status' => 'active', 'vendor' => 'Acme Accessories', 'product_type' => 'Accessories', + 'tags' => ['trending'], 'published_at' => now(), + 'description' => 'Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.', + 'collections' => [], + 'options' => [['name' => 'Color', 'values' => ['Natural', 'Black']]], + 'price' => 1999, 'weight_g' => 300, 'inventory' => 40, + ], + [ + 'title' => 'Bucket Hat', 'handle' => 'bucket-hat', + 'status' => 'active', 'vendor' => 'Acme Accessories', 'product_type' => 'Accessories', + 'tags' => ['new', 'trending'], 'published_at' => now(), + 'description' => 'Lightweight bucket hat for sun protection. Packable design, washed cotton twill.', + 'collections' => ['new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Beige', 'Black', 'Olive']], + ], + 'price' => 2499, 'weight_g' => 80, 'inventory' => 22, + ], + [ + 'title' => 'Unreleased Winter Jacket', 'handle' => 'unreleased-winter-jacket', + 'status' => 'draft', 'vendor' => 'Acme Outerwear', 'product_type' => 'Jackets', + 'tags' => ['limited'], 'published_at' => null, + 'description' => 'Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.', + 'collections' => [], + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], + 'price' => 14999, 'weight_g' => 900, 'inventory' => 0, + ], + [ + 'title' => 'Discontinued Raincoat', 'handle' => 'discontinued-raincoat', + 'status' => 'archived', 'vendor' => 'Acme Outerwear', 'product_type' => 'Jackets', + 'tags' => [], 'published_at' => now()->subMonths(6), + 'description' => 'Lightweight waterproof raincoat. This product has been discontinued.', + 'collections' => [], + 'options' => [['name' => 'Size', 'values' => ['M', 'L']]], + 'price' => 8999, 'weight_g' => 400, 'inventory' => 3, + ], + [ + 'title' => 'Limited Edition Sneakers', 'handle' => 'limited-edition-sneakers', + 'status' => 'active', 'vendor' => 'Acme Sport', 'product_type' => 'Shoes', + 'tags' => ['limited'], 'published_at' => now(), + 'description' => 'Limited edition collaboration sneakers. Once they are gone, they are gone.', + 'collections' => [], + 'options' => [['name' => 'Size', 'values' => ['EU 40', 'EU 42', 'EU 44']]], + 'price' => 15999, 'weight_g' => 650, 'inventory' => 0, + ], + [ + 'title' => 'Backorder Denim Jacket', 'handle' => 'backorder-denim-jacket', + 'status' => 'active', 'vendor' => 'Acme Denim', 'product_type' => 'Jackets', + 'tags' => ['popular'], 'published_at' => now(), + 'description' => 'Classic denim jacket. Currently on backorder - ships within 2-3 weeks.', + 'collections' => [], + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']]], + 'price' => 9999, 'weight_g' => 750, 'inventory' => 0, 'inventory_policy' => 'continue', + ], + [ + 'title' => 'Gift Card', 'handle' => 'gift-card', + 'status' => 'active', 'vendor' => 'Acme Fashion', 'product_type' => 'Gift Cards', + 'tags' => ['popular'], 'published_at' => now(), + 'description' => 'Digital gift card delivered via email. The perfect gift when you are not sure what to choose.', + 'collections' => [], + 'options' => [['name' => 'Amount', 'values' => ['25 EUR', '50 EUR', '100 EUR']]], + 'price' => 2500, 'weight_g' => 0, 'inventory' => 9999, 'requires_shipping' => false, + 'variant_prices' => [2500, 5000, 10000], + ], + [ + 'title' => 'Cashmere Overcoat', 'handle' => 'cashmere-overcoat', + 'status' => 'active', 'vendor' => 'Acme Premium', 'product_type' => 'Jackets', + 'tags' => ['limited', 'new'], 'published_at' => now(), + 'description' => 'Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.', + 'collections' => ['new-arrivals'], + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Camel', 'Charcoal']], + ], + 'price' => 49999, 'weight_g' => 1200, 'inventory' => 3, + ], + ]; + } +} diff --git a/database/seeders/ShippingSeeder.php b/database/seeders/ShippingSeeder.php new file mode 100644 index 00000000..e9a2d292 --- /dev/null +++ b/database/seeders/ShippingSeeder.php @@ -0,0 +1,95 @@ +seedFashionShipping(); + $this->seedElectronicsShipping(); + } + + private function seedFashionShipping(): void + { + $store = Store::where('handle', 'acme-fashion')->firstOrFail(); + + $domestic = ShippingZone::create([ + 'store_id' => $store->id, + 'name' => 'Domestic', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + + ShippingRate::create([ + 'zone_id' => $domestic->id, + 'name' => 'Standard Shipping', + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + + ShippingRate::create([ + 'zone_id' => $domestic->id, + 'name' => 'Express Shipping', + 'type' => 'flat', + 'config_json' => ['amount' => 999], + 'is_active' => true, + ]); + + $eu = ShippingZone::create([ + 'store_id' => $store->id, + 'name' => 'EU', + 'countries_json' => ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], + 'regions_json' => [], + ]); + + ShippingRate::create([ + 'zone_id' => $eu->id, + 'name' => 'EU Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 899], + 'is_active' => true, + ]); + + $row = ShippingZone::create([ + 'store_id' => $store->id, + 'name' => 'Rest of World', + 'countries_json' => ['US', 'GB', 'CA', 'AU'], + 'regions_json' => [], + ]); + + ShippingRate::create([ + 'zone_id' => $row->id, + 'name' => 'International', + 'type' => 'flat', + 'config_json' => ['amount' => 1499], + 'is_active' => true, + ]); + } + + private function seedElectronicsShipping(): void + { + $store = Store::where('handle', 'acme-electronics')->firstOrFail(); + + $zone = ShippingZone::create([ + 'store_id' => $store->id, + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + + ShippingRate::create([ + 'zone_id' => $zone->id, + 'name' => 'Standard', + 'type' => 'flat', + 'config_json' => ['amount' => 0], + 'is_active' => true, + ]); + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..a44f3f81 --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,40 @@ +firstOrFail(); + $electronics = Store::where('handle', 'acme-electronics')->firstOrFail(); + + StoreDomain::create([ + 'store_id' => $fashion->id, + 'hostname' => 'acme-fashion.test', + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]); + + StoreDomain::create([ + 'store_id' => $fashion->id, + 'hostname' => 'admin.acme-fashion.test', + 'type' => 'admin', + 'is_primary' => false, + 'tls_mode' => 'managed', + ]); + + StoreDomain::create([ + 'store_id' => $electronics->id, + 'hostname' => 'acme-electronics.test', + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]); + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..baa37fb0 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,35 @@ +firstOrFail(); + + Store::create([ + 'organization_id' => $org->id, + 'name' => 'Acme Fashion', + 'handle' => 'acme-fashion', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + + Store::create([ + 'organization_id' => $org->id, + 'name' => 'Acme Electronics', + 'handle' => 'acme-electronics', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + } +} diff --git a/database/seeders/StoreSettingsSeeder.php b/database/seeders/StoreSettingsSeeder.php new file mode 100644 index 00000000..a2197d8a --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,36 @@ +firstOrFail(); + $electronics = Store::where('handle', 'acme-electronics')->firstOrFail(); + + StoreSettings::create([ + 'store_id' => $fashion->id, + 'settings_json' => [ + 'store_name' => 'Acme Fashion', + 'contact_email' => 'hello@acme-fashion.test', + 'order_number_prefix' => '#', + 'order_number_start' => 1001, + ], + ]); + + StoreSettings::create([ + 'store_id' => $electronics->id, + 'settings_json' => [ + 'store_name' => 'Acme Electronics', + 'contact_email' => 'hello@acme-electronics.test', + 'order_number_prefix' => '#', + 'order_number_start' => 5001, + ], + ]); + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..01b70bb3 --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,25 @@ +get(); + + foreach ($stores as $store) { + TaxSettings::create([ + 'store_id' => $store->id, + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => ['default_rate_bps' => 1900], + ]); + } + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..89511d96 --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,47 @@ + 'admin@acme.test', 'name' => 'Admin User'], + ['email' => 'staff@acme.test', 'name' => 'Staff User'], + ['email' => 'support@acme.test', 'name' => 'Support User'], + ['email' => 'manager@acme.test', 'name' => 'Store Manager'], + ['email' => 'admin2@acme.test', 'name' => 'Admin Two'], + ]; + + foreach ($users as $data) { + User::create([ + 'email' => $data['email'], + 'name' => $data['name'], + 'password' => Hash::make('password'), + ]); + } + + $fashion = Store::where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::where('handle', 'acme-electronics')->firstOrFail(); + + $storeUsers = [ + ['admin@acme.test', $fashion->id, StoreUserRole::Owner], + ['staff@acme.test', $fashion->id, StoreUserRole::Staff], + ['support@acme.test', $fashion->id, StoreUserRole::Support], + ['manager@acme.test', $fashion->id, StoreUserRole::Admin], + ['admin2@acme.test', $electronics->id, StoreUserRole::Owner], + ]; + + foreach ($storeUsers as [$email, $storeId, $role]) { + $user = User::where('email', $email)->first(); + $user->stores()->attach($storeId, ['role' => $role->value]); + } + } +} diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..3d352842 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,26 @@ + + + + + + Page Not Found + @vite(['resources/css/app.css']) + + +
+

404

+

+ Page not found +

+

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

+ +
+ + diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..fc14e4d2 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,20 @@ + + + + + + Service Unavailable + @vite(['resources/css/app.css']) + + +
+

503

+

+ Service unavailable +

+

+ We are performing maintenance. Please check back shortly. +

+
+ + From 95607b2a6aa45b902faaca585730bb3232734f73 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Fri, 20 Mar 2026 22:51:44 +0100 Subject: [PATCH 13/17] Phase 11 polish: dark mode, responsive tables, wire:key, wire:loading - Fix missing dark: prefix on status badges in account dashboard - Add dark:text-gray-400 to all admin empty states and data cells - Add dark mode text colors to admin order summary, payment, settings - Add overflow-x-auto to storefront account tables for mobile - Add wire:key to all loops in Livewire components (dashboard tiles, chart bars, top products, funnel steps, order lines, fulfillments, refunds, payments, variants, addresses, domains, nav items, stores) - Add wire:loading states to all form submit buttons (customer login, register, admin login, address save, product save, order fulfill, refund, cancel, confirm payment, logout) - Fix toast notification dark mode (border, text, close button) All 314 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/views/layouts/admin/app.blade.php | 6 +-- .../livewire/admin/analytics/index.blade.php | 16 +++---- .../views/livewire/admin/auth/login.blade.php | 5 ++- .../admin/collections/index.blade.php | 4 +- .../livewire/admin/customers/index.blade.php | 8 ++-- .../livewire/admin/customers/show.blade.php | 12 +++--- .../views/livewire/admin/dashboard.blade.php | 16 +++---- .../livewire/admin/discounts/index.blade.php | 8 ++-- .../livewire/admin/layout/top-bar.blade.php | 5 ++- .../livewire/admin/navigation/index.blade.php | 10 ++--- .../livewire/admin/orders/show.blade.php | 43 +++++++++++-------- .../admin/pages-admin/index.blade.php | 4 +- .../livewire/admin/products/form.blade.php | 11 ++--- .../livewire/admin/settings/index.blade.php | 12 +++--- .../livewire/admin/themes/index.blade.php | 4 +- .../account/addresses/index.blade.php | 5 ++- .../storefront/account/auth/login.blade.php | 5 ++- .../account/auth/register.blade.php | 5 ++- .../storefront/account/dashboard.blade.php | 17 ++++---- .../storefront/account/orders/index.blade.php | 2 +- .../storefront/account/orders/show.blade.php | 2 +- 21 files changed, 108 insertions(+), 92 deletions(-) diff --git a/resources/views/layouts/admin/app.blade.php b/resources/views/layouts/admin/app.blade.php index 7c0548e6..6e3034a3 100644 --- a/resources/views/layouts/admin/app.blade.php +++ b/resources/views/layouts/admin/app.blade.php @@ -29,14 +29,14 @@ class="fixed right-4 top-16 z-50 flex flex-col gap-2">