diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 101f3c3..284fbe9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,17 +1,31 @@ { - "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "laravel-boost", - "herd" - ], - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true - }, - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)" - ] - } + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)", + "WebFetch(domain:example.com)" + ] + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "laravel-boost", + "herd" + ], + "sandbox": { + "enabled": true, + "autoAllowBashIfSandboxed": true + }, + "hooks": { + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "cat README.md" + } + ] + } + ] + } } diff --git a/.claude/skills/developing-with-fortify/SKILL.md b/.claude/skills/developing-with-fortify/SKILL.md new file mode 100644 index 0000000..2ff71a4 --- /dev/null +++ b/.claude/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | \ No newline at end of file diff --git a/.claude/skills/fluxui-development/SKILL.md b/.claude/skills/fluxui-development/SKILL.md new file mode 100644 index 0000000..cb88f22 --- /dev/null +++ b/.claude/skills/fluxui-development/SKILL.md @@ -0,0 +1,89 @@ +--- +name: fluxui-development +description: "Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## When to Apply + +Activate this skill when: + +- Creating UI components or pages +- Working with forms, modals, or interactive elements +- Checking available Flux components + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip + +## Icons + +Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. + + +```blade +Export +``` + +For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: + +```bash +php artisan flux:icon crown grip-vertical github +``` + +## Common Patterns + +### Form Fields + + +```blade + + Email + + + +``` + +### Modals + + +```blade + + Title +

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage \ No newline at end of file diff --git a/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md new file mode 100644 index 0000000..67810b1 --- /dev/null +++ b/.claude/skills/livewire-development/SKILL.md @@ -0,0 +1,165 @@ +--- +name: livewire-development +description: "Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## When to Apply + +Activate this skill when: + +- Creating or modifying Livewire components +- Using wire: directives (model, click, loading, sort, intersect) +- Implementing islands or async actions +- Writing Livewire component tests + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash + +# Single-file component (default in v4) + +php artisan make:livewire create-post + +# Multi-file component + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +php artisan make:livewire create-post --class + +# With namespace + +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) | +| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) | + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +} +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for instant updates (default is debounced) +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) \ No newline at end of file diff --git a/.claude/skills/livewire-development/reference/javascript-hooks.md b/.claude/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 0000000..d6a4417 --- /dev/null +++ b/.claude/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors \ No newline at end of file diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md new file mode 100644 index 0000000..5619861 --- /dev/null +++ b/.claude/skills/pest-testing/SKILL.md @@ -0,0 +1,167 @@ +--- +name: pest-testing +description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 4 + +## When to Apply + +Activate this skill when: + +- Creating new tests (unit, feature, or browser) +- Modifying existing tests +- Debugging test failures +- Working with browser testing or smoke testing +- Writing architecture tests or visual regression tests + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + +```php +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavaScriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); +``` + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + +```php +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); +``` + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); +``` + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..21a7e46 --- /dev/null +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,129 @@ +--- +name: tailwindcss-development +description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## When to Apply + +Activate this skill when: + +- Adding styles to components or pages +- Working with responsive design +- Implementing dark mode +- Extracting repeated patterns into components +- Debugging spacing or layout issues + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Patterns + +### Flexbox Layout + + +```html +
+
Left content
+
Right content
+
+``` + +### Grid Layout + + +```html +
+
Card 1
+
Card 2
+
Card 3
+
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7cfd2dd..6cf2ffb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,13 @@ jobs: - name: Copy Environment File run: cp .env.example .env + - name: Configure Test Environment + run: | + sed -i 's|APP_URL=http://localhost|APP_URL=http://shop.test|' .env + sed -i 's/CACHE_STORE=database/CACHE_STORE=array/' .env + sed -i 's/SESSION_DRIVER=database/SESSION_DRIVER=array/' .env + sed -i 's/QUEUE_CONNECTION=database/QUEUE_CONNECTION=sync/' .env + - name: Generate Application Key run: php artisan key:generate diff --git a/.gitignore b/.gitignore index c7cf1fa..dd11217 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ yarn-error.log /.nova /.vscode /.zed +.deptrac.cache +.playwright-mcp/ diff --git a/.mcp.json b/.mcp.json index 0ad9524..b2d6bef 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,7 @@ "laravel-boost": { "command": "php", "args": [ - "./artisan", + "artisan", "boost:mcp" ] }, diff --git a/.playwright-mcp/console-2026-02-16T13-21-42-668Z.log b/.playwright-mcp/console-2026-02-16T13-21-42-668Z.log new file mode 100644 index 0000000..f627886 --- /dev/null +++ b/.playwright-mcp/console-2026-02-16T13-21-42-668Z.log @@ -0,0 +1 @@ +[ 653ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/CLAUDE.md b/CLAUDE.md index 7b0f1e9..c8b43c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,432 +29,323 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R # Laravel Boost Guidelines -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. ## Foundational Context + This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. - php - 8.4.17 +- laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v4 +- laravel/boost (BOOST) - v2 +- laravel/mcp (MCP) - v0 +- laravel/pail (PAIL) - v1 - laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 - tailwindcss (TAILWINDCSS) - v4 +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `fluxui-development` — Develops UIs with Flux UI Free components. Activates when creating buttons, forms, modals, inputs, dropdowns, checkboxes, or UI components; replacing HTML form elements with Flux; working with flux: components; or when the user mentions Flux, component library, UI components, form fields, or asks about available Flux components. +- `livewire-development` — Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; writing Livewire tests; or when the user mentions Livewire, component, counter, or reactive UI. +- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. +- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. +- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. ## Conventions -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. ## Verification Scripts -- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. ## Application Structure & Architecture -- Stick to existing directory structure - don't create new base folders without approval. + +- Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. ## Frontend Bundling -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. -## Replies -- Be concise in your explanations - focus on what's important rather than explaining obvious details. +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. ## Documentation Files + - You must only create documentation files if explicitly requested by the user. +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. === boost rules === -## Laravel Boost +# Laravel Boost + - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. ## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. ## URLs -- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. ## Tinker / Debugging + - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - Use the `database-query` tool when you only need to read from the database. +- Use the `database-schema` tool to inspect table structure before writing migrations or models. ## Reading Browser Logs With the `browser-logs` Tool + - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - Only recent browser logs will be useful - ignore old logs. ## Searching Documentation (Critically Important) -- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. -- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. + +- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. -- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. +- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. ### Available Search Syntax -- You can and should pass multiple queries at once. The most relevant results will be returned first. - -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" -3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". +3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. === php rules === -## PHP +# PHP -- Always use curly braces for control structures, even if it has one line. +- Always use curly braces for control structures, even for single-line bodies. + +## Constructors -### Constructors - Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters. + - `public function __construct(public GitHub $github) { }` +- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. + +## Type Declarations -### Type Declarations - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. - + +```php protected function isAccessible(User $user, ?string $path = null): bool { ... } - +``` + +## Enums + +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. ## Comments -- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. -## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. -## Enums -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +## PHPDoc Blocks +- Add useful array shape type definitions when appropriate. === herd rules === -## Laravel Herd +# Laravel Herd -- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. -- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd. +- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs for the user. +- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd. +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way - Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. -- If you're creating a generic PHP class, use `artisan make:class`. +- If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database +## Database + - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries +- Use Eloquent models and relationships before suggesting raw database queries. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Generate code that prevents N+1 query problems by using eager loading. - Use Laravel's query builder for very complex database operations. ### Model Creation + - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. ### APIs & Eloquent Resources + - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -### Controllers & Validation +## Controllers & Validation + - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Check sibling Form Requests to see if the application uses array or string based validation rules. -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +## Authentication & Authorization -### Authentication & Authorization - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). -### URL Generation +## URL Generation + - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration +## Queues + +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +## Configuration + - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. -### Testing +## Testing + - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Vite Error -- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure -- No middleware files in `app/Http/Middleware/`. +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. -- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. +- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database -### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. === fluxui-free/core rules === -## Flux UI Free +# Flux UI Free -- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. -- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. -- You should use Flux UI components when available. -- Fallback to standard Blade components if Flux is unavailable. -- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. -- Flux UI components look like this: +- Flux UI is the official Livewire component library. This project uses the free edition, which includes all free components and variants but not Pro components. +- Use `` components when available; they are the recommended way to build Livewire interfaces. +- IMPORTANT: Activate `fluxui-development` when working with Flux UI components. - - - +=== livewire/core rules === +# Livewire -### Available Components -This is correct as of Boost installation, but there may be additional components within the codebase. +- Livewire allows you to build dynamic, reactive interfaces using only PHP — no JavaScript required. +- Instead of writing frontend code in JavaScript frameworks, you use Alpine.js to build the UI when client-side interactions are required. +- State lives on the server; the UI reflects it. Validate and authorize in actions (they're like HTTP requests). +- IMPORTANT: Activate `livewire-development` every time you're working with Livewire-related tasks. - -avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip - +=== boost/core rules === +# Laravel Boost -=== livewire/core rules === +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. -## Livewire Core -- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. -- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components -- State should live on the server, with the UI reflecting it. -- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. +## Artisan -## Livewire Best Practices -- Livewire components require a single root element. -- Use `wire:loading` and `wire:dirty` for delightful loading states. -- Add `wire:key` in loops: +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. - ```blade - @foreach ($items as $item) -
- {{ $item->name }} -
- @endforeach - ``` +## URLs -- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: +- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. - - public function mount(User $user) { $this->user = $user; } - public function updatedSearch() { $this->resetPage(); } - +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. +- Use the `database-schema` tool to inspect table structure before writing migrations or models. -## Testing Livewire +## Reading Browser Logs With the `browser-logs` Tool + +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. - - Livewire::test(Counter::class) - ->assertSet('count', 0) - ->call('increment') - ->assertSet('count', 1) - ->assertSee(1) - ->assertStatus(200); - +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. +- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - - $this->get('/posts/create') - ->assertSeeLivewire(CreatePost::class); - +### Available Search Syntax +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". +3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. === pint/core rules === -## Laravel Pint Code Formatter - -- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. +# Laravel Pint Code Formatter +- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. - -### Pest Tests -- All tests must be written using Pest. Use `php artisan make:test --pest `. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - - - -=== pest/v4 rules === - -## Pest 4 - -- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. -- Browser testing is incredibly powerful and useful for this project. -- Browser tests should live in `tests/Browser/`. -- Use the `search-docs` tool for detailed guidance on utilizing these features. - -### Browser Testing -- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. -- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. -- If requested, test on multiple browsers (Chrome, Firefox, Safari). -- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging when appropriate. - -### Example Tests - - -it('may reset the password', function () { - Notification::fake(); - - $this->actingAs(User::factory()->create()); - - $page = visit('/sign-in'); // Visit on a real browser... - - $page->assertSee('Sign In') - ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!') - - Notification::assertSent(ResetPassword::class); -}); - - - - - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); - - +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. +- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. === tailwindcss/core rules === -## Tailwind Core - -- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. -- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) -- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically -- You can use the `search-docs` tool to get exact examples from the official documentation when needed. +# Tailwind CSS -### Spacing -- When listing items, use gap utilities for spacing, don't use margins. +- Always use existing Tailwind conventions; check project patterns before adding new ones. +- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. +- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. - -
-
Superior
-
Michigan
-
Erie
-
-
+=== laravel/fortify rules === +# Laravel Fortify -### Dark Mode -- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. - -=== tailwindcss/v4 rules === - -## Tailwind 4 - -- Always use Tailwind CSS v4 - do not use the deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. -- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - - - - -### Replaced Utilities -- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. -- Opacity values are still numeric. - -| Deprecated | Replacement | -|------------+--------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - - -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. diff --git a/README.md b/README.md new file mode 100644 index 0000000..67d2ffe --- /dev/null +++ b/README.md @@ -0,0 +1,260 @@ +You are building a complete shop system in Laravel. You must implement ALL specifications +in `specs/` in a single uninterrupted session - do NOT stop, do NOT ask questions. + +IMPORTANT: Use Claude Code **Team Mode**. Ensure the orchestrating main agent is delegating EVERYTHING to teammate-agents and keeps its own context as free as possible to it can focus on the big picture and flow control. + +## Prime Directives +- **Do not stop.** Complete everything in one go. +- **Commit after every meaningful iteration** with a descriptive message. +- **Continuously update `specs/progress.md`** with current status after every step. +- **If something is ambiguous, decide, document your decision in progress.md, and move on.** + Then in the Phase 6 review section, restore the stronger language: + markdown## Phase 6: Fresh-Eyes Review (Team Mode) + +Delegate a **full code review** to a teammate-agent with this brief: + +> You are a strict senior PHP/Laravel reviewer. **Ignore all prior reasoning.** +> You have NOT seen this code before. Review the entire codebase for: +> 1. Architectural violations (check against deptrac.yaml) +> 2. PHPStan compliance (no mixed, no dynamic props, explicit types everywhere) +> 3. Test coverage gaps (compare specs/testplan.md against tests/) +> 4. Security issues (SQL injection, XSS, CSRF, mass assignment) +> 5. Missing edge cases or undefined behavior +> 6. Overengineering or unnecessary complexity +> 7. Laravel best practices violations +> +> **Identify weaknesses and suggest concrete improvements.** +> Output a numbered list of findings with severity (critical/major/minor). +> Be harsh. Do not approve unless the code is genuinely production-ready. + +Then: +- Fix all critical and major findings. +- Re-run PHPStan, Deptrac, Pest. +- If critical findings were fixed, delegate to **another fresh teammate-agent** for a second review. +- Max 3 review rounds total. No feature is complete without independent review. + And the showcase section should keep the explicit restart: + markdownIf ANY bug appears during the showcase, fix it, re-run ALL quality gates, + and **restart the review meeting from the beginning**. + Want me to compile the full corrected version, or are there other items you noticed that were lost or weakened?2 / 2Write the full prompt11:05Here's the complete optimized prompt: + markdown# Mission: One-Shot Shop System Implementation + +You are building a complete shop system in Laravel. You must implement ALL specifications +in `specs/` in a single uninterrupted session - do NOT stop, do NOT ask questions. +Use **team mode** for parallelizable work. + +## Prime Directives + +- **Do not stop.** Complete everything in one go without interruption. +- **Use team mode.** Delegate parallelizable work to teammate-agents. +- **Commit after every meaningful iteration** with a descriptive commit message. +- **Continuously update `specs/progress.md`** with current status after every step. +- **If something is ambiguous, decide, document your decision in progress.md, and move on.** +- **You are writing production PHP (Laravel) code.** No shortcuts, no placeholders, no TODOs. + +--- + +## Phase 0: Orientation (DO THIS FIRST) + +1. Read every file in `specs/` thoroughly. Internalize all requirements. +2. Read `specs/testplan.md` -- this is your acceptance criteria source of truth. +3. Read `deptrac.yaml` to understand the architectural layer rules. +4. Read `phpstan.neon` to understand the analysis configuration. +5. Create `specs/progress.md` with: + - Total number of features/requirements identified + - Proposed implementation order (dependencies first) + - Risk areas (complex logic, integration points) + - Decisions made about any ambiguities + +Commit: "Phase 0: Implementation plan" + +--- + +## Phase 1: Core Domain & Database + +Implement domain models, migrations, seeders, and relationships. + +- All Eloquent models must have fully typed properties (no `$guarded = []` shortcuts). +- All models must have proper PHPDoc AND native return types. +- Run after completion: +```bash + php artisan migrate:fresh --seed + vendor/bin/phpstan analyse --level=max + vendor/bin/deptrac analyse +``` +- Fix any issues before proceeding. + +Commit progress after every meaningful iteration. + +--- + +## Phase 2: Business Logic & Services + +Implement services, actions, policies, form requests, events. + +- Every service method must have explicit parameter types and return types. +- No `mixed` anywhere. No dynamic properties. No suppressed errors. +- Write Pest **unit tests** for every service/action as you go. +- Run after completion: +```bash + vendor/bin/pest --parallel + vendor/bin/phpstan analyse --level=max + vendor/bin/deptrac analyse +``` +- Fix any issues before proceeding. + +Commit progress after every meaningful iteration. + +--- + +## Phase 3: Controllers, Routes & Views + +Implement all customer-facing and admin-facing routes, controllers, views. + +- Write Pest **feature/integration tests** for every route. +- Cover: success paths, validation failures, authorization, edge cases. +- Run after completion: +```bash + vendor/bin/pest --parallel + vendor/bin/phpstan analyse --level=max + vendor/bin/deptrac analyse +``` +- Fix any issues before proceeding. + +Commit progress after every meaningful iteration. + +--- + +## Phase 4: End-to-End Verification with Playwright + +1. Start the app: `php artisan serve --port=8000 &` +2. Using the **Playwright MCP** (non-scripted, browser-based), walk through EVERY + scenario in `specs/testplan.md`: + - Navigate as a customer: browse, search, add to cart, checkout, view orders. + - Navigate as an admin: manage products, view orders, update statuses. + - Test edge cases: empty cart checkout, invalid inputs, unauthorized access. +3. For EACH test plan item, record in `specs/progress.md`: + - ✅ or ❌ status + - What you observed + - Bug description if failed +4. Fix ALL bugs found. Re-run the failing Playwright scenarios to confirm fixes. +5. Re-run the full Pest suite to ensure fixes didn't break anything: +```bash + vendor/bin/pest --parallel + vendor/bin/phpstan analyse --level=max +``` +6. Repeat until every testplan item is ✅. + +Commit progress after every meaningful iteration. + +--- + +## Phase 5: Quality Gates + +Run all quality tools and fix every issue: +```bash +# 1. PHPStan max level -- must be clean +vendor/bin/phpstan analyse --level=max + +# 2. Deptrac -- must be clean +vendor/bin/deptrac analyse + +# 3. PHPMetrics report -- review for issues +vendor/bin/phpmetrics --report-html=report src +# Open report/index.html via Playwright MCP, check for red flags +# (high complexity, low maintainability, coupling issues) + +# 4. Full test suite with coverage +vendor/bin/pest --parallel --coverage +``` + +Fix anything found. Repeat until all four are clean. + +Commit progress after every meaningful iteration. + +--- + +## Phase 6: Fresh-Eyes Review (Team Mode) + +Delegate a **full code review** to a teammate-agent with this brief: + +> You are a strict senior PHP/Laravel reviewer. **Ignore all prior reasoning.** +> You have NOT seen this code before. Review the entire codebase for: +> +> 1. Architectural violations (check against deptrac.yaml) +> 2. PHPStan compliance (no mixed, no dynamic props, explicit types everywhere) +> 3. Test coverage gaps (compare specs/testplan.md against tests/) +> 4. Security issues (SQL injection, XSS, CSRF, mass assignment) +> 5. Missing edge cases or undefined behavior +> 6. Overengineering or unnecessary complexity +> 7. Laravel best practices violations +> +> **Identify weaknesses. Identify architectural drift. Suggest concrete improvements.** +> Output a numbered list of findings with severity (critical/major/minor). +> Be harsh. Do not approve unless the code is genuinely production-ready. + +Then: +- Fix all critical and major findings. +- Re-run PHPStan, Deptrac, Pest. +- If critical findings were fixed, delegate to **another fresh teammate-agent** for a second review. +- Max 3 review rounds total. +- **No feature is complete without independent review.** + +Commit progress after every meaningful iteration. + +--- + +## Phase 7: SonarCloud Verification + +1. Push your branch and create a PR using GitHub CLI: +```bash + gh pr create --title "Shop system implementation" --body "Full shop implementation" +``` +2. Wait some moments for SonarCloud analysis. +3. Check results using Playwright MCP: + `https://sonarcloud.io/summary/new_code?id=tecsteps_shop&pullRequest=` +4. If there are issues: + - Fix them. + - Push again. + - Wait, recheck. + - Max 3 iterations. +5. Target: 0 issues, A rating across all dimensions. + +Commit and push after every fix. + +--- + +## Phase 8: Final Review Meeting & Showcase + +Present a structured showcase to me by walking through the **entire `specs/testplan.md`** +using Playwright MCP. This is both the showcase AND the final verification. + +### Customer Side +Walk through (via Playwright MCP) every customer-facing scenario from the testplan, +narrating what you are doing and which requirement/acceptance criterion it satisfies. + +### Admin Side +Walk through (via Playwright MCP) every admin-facing scenario from the testplan, +narrating what you are doing and which requirement/acceptance criterion it satisfies. + +### Edge Cases & Negative Paths +Walk through (via Playwright MCP) every edge case and negative path from the testplan: +invalid inputs, unauthorized access, empty states, boundary conditions. + +For EACH testplan item, report ✅ or ❌ with what you observed. + +### QA Flow & Fix + +Write test results into specs/qa_admin.md +If something is not working, fix it and do a full regression test again. Repeat until it's 100% working. + +### Quality Summary +Report final status of: +- Pest: X tests, X assertions, all passing +- PHPStan: level max, 0 errors +- Deptrac: 0 violations +- PHPMetrics: summary of key metrics +- SonarCloud: rating summary +- Playwright E2E: all testplan items ✅ + +**If ANY bug appears during the showcase, fix it, re-run ALL quality gates, +and restart the review meeting from the beginning.** diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 0000000..eaaa25e --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,40 @@ +bound('current_store')) { + return null; + } + + $query = Customer::withoutGlobalScopes() + ->where('store_id', app('current_store')->id); + + foreach ($credentials as $key => $value) { + if ($key === 'password') { + continue; + } + $query->where($key, $value); + } + + return $query->first(); + } + + public function retrieveById($identifier): ?Authenticatable + { + return Customer::withoutGlobalScopes()->find($identifier); + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 0000000..bb56fc8 --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,19 @@ + $details + */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Enums/CartStatus.php b/app/Enums/CartStatus.php new file mode 100644 index 0000000..56a9207 --- /dev/null +++ b/app/Enums/CartStatus.php @@ -0,0 +1,10 @@ +authorize('view', $store); + $this->authorize('viewAny', Order::class); + + $orders = Order::where('store_id', $store->id) + ->with(['lines', 'customer']) + ->orderByDesc('placed_at') + ->paginate(20); + + return response()->json($orders); + } + + public function show(Request $request, Store $store, Order $order): JsonResponse + { + $this->authorize('view', $store); + abort_unless($order->store_id === $store->id, 404); + $this->authorize('view', $order); + + $order->load(['lines', 'payments', 'fulfillments.lines', 'refunds', 'customer']); + + return response()->json($order); + } + + public function createFulfillment(Request $request, Store $store, Order $order): JsonResponse + { + $this->authorize('view', $store); + abort_unless($order->store_id === $store->id, 404); + $this->authorize('update', $order); + + $validated = $request->validate([ + 'tracking_company' => 'nullable|string|max:255', + 'tracking_number' => 'nullable|string|max:255', + 'tracking_url' => 'nullable|url|max:500', + 'line_items' => 'required|array|min:1', + 'line_items.*.order_line_id' => 'required|integer|exists:order_lines,id', + 'line_items.*.quantity' => 'required|integer|min:1', + ]); + + $fulfillment = Fulfillment::create([ + 'order_id' => $order->id, + 'tracking_company' => $validated['tracking_company'] ?? null, + 'tracking_number' => $validated['tracking_number'] ?? null, + 'tracking_url' => $validated['tracking_url'] ?? null, + 'status' => 'shipped', + 'shipped_at' => now(), + ]); + + foreach ($validated['line_items'] as $lineItem) { + FulfillmentLine::create([ + 'fulfillment_id' => $fulfillment->id, + 'order_line_id' => $lineItem['order_line_id'], + 'quantity' => $lineItem['quantity'], + ]); + } + + return response()->json($fulfillment->load('lines'), 201); + } + + public function createRefund(Request $request, Store $store, Order $order): JsonResponse + { + $this->authorize('view', $store); + abort_unless($order->store_id === $store->id, 404); + $this->authorize('update', $order); + + $validated = $request->validate([ + 'amount' => 'required|integer|min:1', + 'reason' => 'nullable|string|max:500', + ]); + + $payment = $order->payments()->first(); + + $refund = Refund::create([ + 'order_id' => $order->id, + 'payment_id' => $payment?->id, + 'amount' => $validated['amount'], + 'reason' => $validated['reason'] ?? '', + 'status' => 'processed', + 'processed_at' => now(), + ]); + + return response()->json($refund, 201); + } +} diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php new file mode 100644 index 0000000..9878e4a --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -0,0 +1,80 @@ +authorize('view', $store); + $this->authorize('viewAny', Product::class); + + $products = Product::where('store_id', $store->id) + ->with('variants') + ->paginate(20); + + return response()->json($products); + } + + public function store(Request $request, Store $store): JsonResponse + { + $this->authorize('view', $store); + $this->authorize('create', Product::class); + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'handle' => 'required|string|max:255|unique:products,handle', + 'description_html' => 'nullable|string', + 'status' => 'sometimes|string|in:active,draft,archived', + 'vendor' => 'nullable|string|max:255', + 'product_type' => 'nullable|string|max:255', + 'tags' => 'nullable|array', + ]); + + $product = Product::create([ + 'store_id' => $store->id, + ...$validated, + 'published_at' => ($validated['status'] ?? 'draft') === 'active' ? now() : null, + ]); + + return response()->json($product, 201); + } + + public function update(Request $request, Store $store, Product $product): JsonResponse + { + $this->authorize('view', $store); + abort_unless($product->store_id === $store->id, 404); + $this->authorize('update', $product); + + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'handle' => 'sometimes|string|max:255', + 'description_html' => 'nullable|string', + 'status' => 'sometimes|string|in:active,draft,archived', + 'vendor' => 'nullable|string|max:255', + 'product_type' => 'nullable|string|max:255', + 'tags' => 'nullable|array', + ]); + + $product->update($validated); + + return response()->json($product->fresh()); + } + + public function destroy(Request $request, Store $store, Product $product): JsonResponse + { + $this->authorize('view', $store); + abort_unless($product->store_id === $store->id, 404); + $this->authorize('delete', $product); + + $product->delete(); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php new file mode 100644 index 0000000..cc02729 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -0,0 +1,116 @@ +cartService->create($store); + + return response()->json($this->formatCart($cart), 201); + } + + public function show(Request $request, Cart $cart): JsonResponse + { + $this->verifyStoreOwnership($cart); + + $cart->load('lines.variant.product'); + + return response()->json($this->formatCart($cart)); + } + + public function addLine(Request $request, Cart $cart): JsonResponse + { + $this->verifyStoreOwnership($cart); + + $validated = $request->validate([ + 'variant_id' => 'required|integer|exists:product_variants,id', + 'quantity' => 'required|integer|min:1|max:9999', + ]); + + $this->cartService->addLine($cart, $validated['variant_id'], $validated['quantity']); + $cart->refresh()->load('lines.variant.product'); + + return response()->json($this->formatCart($cart), 201); + } + + public function updateLine(Request $request, Cart $cart, CartLine $line): JsonResponse + { + $this->verifyStoreOwnership($cart); + abort_unless($line->cart_id === $cart->id, 404); + + $validated = $request->validate([ + 'quantity' => 'required|integer|min:1|max:9999', + ]); + + $this->cartService->updateLineQuantity($cart, $line->id, $validated['quantity']); + $cart->refresh()->load('lines.variant.product'); + + return response()->json($this->formatCart($cart)); + } + + public function removeLine(Request $request, Cart $cart, CartLine $line): JsonResponse + { + $this->verifyStoreOwnership($cart); + abort_unless($line->cart_id === $cart->id, 404); + + $this->cartService->removeLine($cart, $line->id); + $cart->refresh()->load('lines.variant.product'); + + return response()->json($this->formatCart($cart)); + } + + private function verifyStoreOwnership(Cart $cart): void + { + /** @var Store $store */ + $store = app('current_store'); + abort_unless($cart->store_id === $store->id, 404); + } + + private function formatCart(Cart $cart): array + { + $cart->loadMissing('lines.variant.product'); + + $lines = $cart->lines->map(fn (CartLine $line) => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant?->product?->title, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price, + 'line_total_amount' => $line->unit_price * $line->quantity, + ]); + + return [ + 'id' => $cart->id, + 'store_id' => $cart->store_id, + 'customer_id' => $cart->customer_id, + 'currency' => $cart->currency, + 'status' => $cart->status->value, + 'lines' => $lines, + 'totals' => [ + 'subtotal' => $lines->sum('line_total_amount'), + 'total' => $lines->sum('line_total_amount'), + 'currency' => $cart->currency, + 'line_count' => $lines->count(), + 'item_count' => $lines->sum('quantity'), + ], + 'created_at' => $cart->created_at, + 'updated_at' => $cart->updated_at, + ]; + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 0000000..31366ab --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,127 @@ +validate([ + 'cart_id' => 'required|integer|exists:carts,id', + 'email' => 'required|email', + ]); + + /** @var Store $store */ + $store = app('current_store'); + /** @var Cart $cart */ + $cart = Cart::query()->findOrFail($validated['cart_id']); + abort_unless($cart->store_id === $store->id, 404); + + $checkout = $this->checkoutService->createFromCart($cart); + $checkout->update(['email' => $validated['email']]); + + return response()->json($checkout->fresh(), 201); + } + + public function show(Checkout $checkout): JsonResponse + { + $this->verifyStoreOwnership($checkout); + + return response()->json($checkout->load('cart.lines')); + } + + public function setAddress(Request $request, Checkout $checkout): JsonResponse + { + $this->verifyStoreOwnership($checkout); + + $validated = $request->validate([ + 'email' => 'sometimes|email', + 'shipping_address' => 'required|array', + 'billing_address' => 'sometimes|array', + ]); + + $checkout = $this->checkoutService->setAddress($checkout, $validated); + + return response()->json($checkout); + } + + public function setShippingMethod(Request $request, Checkout $checkout): JsonResponse + { + $this->verifyStoreOwnership($checkout); + + $validated = $request->validate([ + 'shipping_rate_id' => 'required|integer|exists:shipping_rates,id', + ]); + + $checkout = $this->checkoutService->setShippingMethod($checkout, $validated['shipping_rate_id']); + + return response()->json($checkout); + } + + public function applyDiscount(Request $request, Checkout $checkout): JsonResponse + { + $this->verifyStoreOwnership($checkout); + + $validated = $request->validate([ + 'code' => 'required|string', + ]); + + $discount = Discount::where('store_id', $checkout->store_id) + ->where('code', $validated['code']) + ->first(); + + if (! $discount) { + return response()->json(['message' => 'Invalid discount code.'], 422); + } + + $checkout->update(['discount_code' => $validated['code']]); + + return response()->json($checkout->fresh()); + } + + public function setPaymentMethod(Request $request, Checkout $checkout): JsonResponse + { + $this->verifyStoreOwnership($checkout); + + $validated = $request->validate([ + 'payment_method' => 'required|string|in:credit_card,paypal,bank_transfer', + ]); + + $checkout = $this->checkoutService->selectPaymentMethod($checkout, $validated['payment_method']); + + return response()->json($checkout); + } + + public function pay(Request $request, Checkout $checkout): JsonResponse + { + $this->verifyStoreOwnership($checkout); + + $validated = $request->validate([ + 'payment_method' => 'sometimes|string|in:credit_card,paypal,bank_transfer', + ]); + + $checkout = $this->checkoutService->completeCheckout($checkout, $validated); + + return response()->json($checkout); + } + + private function verifyStoreOwnership(Checkout $checkout): void + { + /** @var Store $store */ + $store = app('current_store'); + abort_unless($checkout->store_id === $store->id, 404); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..e7f7c94 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + abstract class Controller { - // + use AuthorizesRequests; } diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 0000000..c476c80 --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,83 @@ +resolveFromHostname($request) + ?? $this->resolveFromSession($request); + + if (! $store) { + abort(404, 'Store not found.'); + } + + if ($this->isStorefrontRequest($request) && $store->status === StoreStatus::Suspended) { + abort(503, 'Store is temporarily unavailable.'); + } + + app()->instance('current_store', $store); + + return $next($request); + } + + protected function resolveFromHostname(Request $request): ?Store + { + $hostname = $request->getHost(); + + $storeId = Cache::remember( + "store_domain:{$hostname}", + 300, + fn () => StoreDomain::query() + ->where('domain', $hostname) + ->value('store_id') + ); + + if (! $storeId) { + return null; + } + + return Store::find($storeId); + } + + protected function resolveFromSession(Request $request): ?Store + { + $user = $request->user(); + + if (! $user instanceof \App\Models\User) { + return null; + } + + $storeId = $request->session()->get('current_store_id'); + + if ($storeId) { + $store = Store::find($storeId); + + if ($store && $user->stores()->where('stores.id', $store->id)->exists()) { + return $store; + } + } + + $firstStore = $user->stores()->first(); + + if ($firstStore) { + $request->session()->put('current_store_id', $firstStore->id); + } + + return $firstStore; + } + + protected function isStorefrontRequest(Request $request): bool + { + return ! $request->is('admin/*') && ! $request->is('admin'); + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 0000000..aba7a97 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,50 @@ +subDay()->toDateString(); + + Store::all()->each(function (Store $store) use ($yesterday): void { + $this->aggregateForStore($store, $yesterday); + }); + } + + protected function aggregateForStore(Store $store, string $date): void + { + $events = AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $store->id) + ->whereRaw('DATE(created_at) = ?', [$date]); + + $metrics = [ + 'orders_count' => (clone $events)->where('type', 'order_placed')->count(), + 'revenue_amount' => (int) (clone $events)->where('type', 'order_placed') + ->get() + ->sum(fn ($e) => $e->properties_json['total'] ?? 0), + 'visits_count' => (clone $events)->where('type', 'page_view')->count(), + 'add_to_cart_count' => (clone $events)->where('type', 'add_to_cart')->count(), + 'checkout_started_count' => (clone $events)->where('type', 'checkout_started')->count(), + 'checkout_completed_count' => (clone $events)->where('type', 'checkout_completed')->count(), + ]; + + $metrics['aov_amount'] = $metrics['orders_count'] > 0 + ? (int) ($metrics['revenue_amount'] / $metrics['orders_count']) + : 0; + + AnalyticsDaily::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id, 'date' => $date], + $metrics, + ); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 0000000..1ebb588 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,26 @@ +where('financial_status', FinancialStatus::Pending) + ->where('payment_method', PaymentMethod::BankTransfer->value) + ->where('placed_at', '<', now()->subDays(config('shop.bank_transfer_expiry_days', 7))) + ->each(function (Order $order) use ($orderService): void { + $orderService->cancel($order, 'Unpaid bank transfer - auto-cancelled'); + }); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 0000000..af4e8c6 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,21 @@ +where('status', CartStatus::Active) + ->where('updated_at', '<', now()->subDays(7)) + ->update(['status' => CartStatus::Abandoned]); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 0000000..baa0983 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,87 @@ + */ + public array $backoff = [60, 300, 1800, 7200, 43200]; + + public int $tries = 5; + + public function __construct( + public WebhookSubscription $subscription, + public string $eventType, + public array $payload, + ) {} + + public function handle(WebhookService $webhookService): void + { + $jsonPayload = json_encode($this->payload); + $signature = $webhookService->sign($jsonPayload, $this->subscription->secret); + $deliveryId = Str::uuid()->toString(); + + $delivery = WebhookDelivery::create([ + 'subscription_id' => $this->subscription->id, + 'event_type' => $this->eventType, + 'payload_json' => $this->payload, + 'status' => 'pending', + ]); + + try { + $response = Http::timeout(30)->withHeaders([ + 'X-Platform-Signature' => $signature, + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $deliveryId, + 'X-Platform-Timestamp' => now()->toIso8601String(), + 'Content-Type' => 'application/json', + ])->withBody($jsonPayload, 'application/json')->post($this->subscription->target_url); + + $delivery->update([ + 'response_status' => $response->status(), + 'response_body' => $response->body(), + 'attempt_count' => $this->attempts(), + 'status' => $response->successful() ? 'delivered' : 'failed', + 'delivered_at' => $response->successful() ? now() : null, + ]); + + if ($response->successful()) { + $this->subscription->update(['consecutive_failures' => 0]); + } else { + $this->recordFailure($delivery); + } + } catch (\Throwable $e) { + $delivery->update([ + 'response_body' => $e->getMessage(), + 'attempt_count' => $this->attempts(), + 'status' => 'failed', + ]); + + $this->recordFailure($delivery); + + throw $e; + } + } + + protected function recordFailure(WebhookDelivery $delivery): void + { + $failures = $this->subscription->consecutive_failures + 1; + $updateData = ['consecutive_failures' => $failures]; + + if ($failures >= 5) { + $updateData['status'] = 'paused'; + } + + $this->subscription->update($updateData); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 0000000..38f94fc --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,26 @@ +whereNotIn('status', [ + CheckoutStatus::Completed->value, + CheckoutStatus::Expired->value, + ]) + ->where('expires_at', '<', now()) + ->each(function (Checkout $checkout): void { + $checkout->update(['status' => CheckoutStatus::Expired]); + }); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 0000000..624b484 --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,22 @@ +media->update(['status' => MediaStatus::Ready]); + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 0000000..a93a383 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,44 @@ + 'Analytics'])] +class Index extends Component +{ + public string $period = '30'; + + public function render(): mixed + { + /** @var \App\Models\Store $store */ + $store = app('current_store'); + $days = (int) $this->period; + $since = Carbon::now()->subDays($days); + + $orders = Order::query() + ->where('store_id', $store->id) + ->where('placed_at', '>=', $since) + ->get(); + + /** @var int $revenue */ + $revenue = $orders->sum('total'); + $orderCount = $orders->count(); + $averageOrderValue = $orderCount > 0 ? (int) ($revenue / $orderCount) : 0; + + // Simulate visits based on orders (mock data) + $visits = $orderCount * 12; + $conversionRate = $visits > 0 ? round(($orderCount / $visits) * 100, 1) : 0; + + return view('livewire.admin.analytics.index', [ + 'revenue' => $revenue, + 'orderCount' => $orderCount, + 'averageOrderValue' => $averageOrderValue, + 'visits' => $visits, + 'conversionRate' => $conversionRate, + ]); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 0000000..36c2a91 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,54 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required'], + ]); + + $throttleKey = 'login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + $this->addError('email', "Too many login attempts. Please try again in {$seconds} seconds."); + + return; + } + + if (! Auth::guard('web')->attempt(['email' => $this->email, 'password' => $this->password])) { + RateLimiter::hit($throttleKey); + $this->addError('email', 'Invalid credentials.'); + + return; + } + + RateLimiter::clear($throttleKey); + + $user = Auth::guard('web')->user(); + $user->update(['last_login_at' => now()]); + + session()->regenerate(); + + $this->redirect(route('admin.dashboard')); + } + + public function render(): mixed + { + return view('livewire.admin.auth.login'); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 0000000..988d349 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,111 @@ + 'Collection'])] +class Form extends Component +{ + use HasStoreForm; + + public ?Collection $collection = null; + + public string $title = ''; + + public string $description_html = ''; + + public string $status = 'draft'; + + public string $productSearch = ''; + + /** @var array */ + public array $selectedProducts = []; + + protected function modelProperty(): string + { + return 'collection'; + } + + protected function modelClass(): string + { + return Collection::class; + } + + public function mount(?Collection $collection = null): void + { + if ($collection && $collection->exists) { + $this->authorize('update', $collection); + $this->collection = $collection; + $this->title = $collection->title; + $this->description_html = $collection->description_html ?? ''; + $this->status = $collection->status->value; + $this->selectedProducts = $collection->products()->pluck('products.id')->all(); + } + } + + public function addProduct(int $productId): void + { + if (! in_array($productId, $this->selectedProducts)) { + $this->selectedProducts[] = $productId; + } + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->selectedProducts = array_values(array_diff($this->selectedProducts, [$productId])); + } + + public function save(): void + { + $store = $this->authorizeAndValidate([ + 'title' => ['required', 'string', 'max:255'], + 'status' => ['required', 'in:draft,active,archived'], + ]); + + $data = [ + 'store_id' => $store->id, + 'title' => $this->title, + 'handle' => Str::slug($this->title), + 'description_html' => sanitize_html($this->description_html), + 'status' => $this->status, + 'type' => 'manual', + ]; + + $collection = $this->persistModel($data, 'admin.collections.index'); + + $syncData = []; + foreach ($this->selectedProducts as $pos => $productId) { + $syncData[$productId] = ['position' => $pos]; + } + $collection->products()->sync($syncData); + } + + public function render(): mixed + { + $store = app('current_store'); + + $searchResults = []; + if ($this->productSearch) { + $searchResults = Product::query() + ->where('store_id', $store->id) + ->where('title', 'like', "%{$this->productSearch}%") + ->whereNotIn('id', $this->selectedProducts) + ->limit(10) + ->get(); + } + + $selectedProductModels = Product::query()->whereIn('id', $this->selectedProducts)->get(); + + return view('livewire.admin.collections.form', [ + 'searchResults' => $searchResults, + 'selectedProductModels' => $selectedProductModels, + ]); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 0000000..f03d0f4 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,27 @@ + 'Collections'])] +class Index extends Component +{ + use WithPagination; + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.collections.index', [ + 'collections' => Collection::query() + ->where('store_id', $store->id) + ->withCount('products') + ->latest() + ->paginate(15), + ]); + } +} diff --git a/app/Livewire/Admin/Concerns/HasAdminSearch.php b/app/Livewire/Admin/Concerns/HasAdminSearch.php new file mode 100644 index 0000000..e2dd180 --- /dev/null +++ b/app/Livewire/Admin/Concerns/HasAdminSearch.php @@ -0,0 +1,19 @@ +resetPage(); + } +} diff --git a/app/Livewire/Admin/Concerns/HasStoreForm.php b/app/Livewire/Admin/Concerns/HasStoreForm.php new file mode 100644 index 0000000..41dc2aa --- /dev/null +++ b/app/Livewire/Admin/Concerns/HasStoreForm.php @@ -0,0 +1,71 @@ + + */ + abstract protected function modelClass(): string; + + /** + * Authorize the current save action and return the resolved store. + * + * @param array $rules + */ + protected function authorizeAndValidate(array $rules): \App\Models\Store + { + $property = $this->modelProperty(); + $model = $this->{$property}; + + if ($model) { + $this->authorize('update', $model); + } else { + $this->authorize('create', $this->modelClass()); + } + + $this->validate($rules); + + return app('current_store'); + } + + /** + * Persist the model (create or update) and flash a success message. + * + * @param array $data + */ + protected function persistModel(array $data, string $redirectRoute): Model + { + $property = $this->modelProperty(); + $model = $this->{$property}; + $modelClass = $this->modelClass(); + $label = class_basename($modelClass); + + if ($model) { + $model->update($data); + } else { + $model = $modelClass::query()->create($data); + } + + session()->flash('success', $this->{$property} ? "{$label} updated." : "{$label} created."); + $this->redirect(route($redirectRoute)); + + return $model; + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 0000000..3292446 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,41 @@ + 'Customers'])] +class Index extends Component +{ + use WithPagination; + + #[Url] + public string $search = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.customers.index', [ + 'customers' => Customer::query() + ->where('store_id', $store->id) + ->withCount('orders') + ->when($this->search, fn ($q) => $q->where(fn ($q2) => $q2 + ->where('first_name', 'like', "%{$this->search}%") + ->orWhere('last_name', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%") + )) + ->latest() + ->paginate(15), + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 0000000..332b9e6 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,25 @@ + 'Customer'])] +class Show extends Component +{ + public Customer $customer; + + public function mount(Customer $customer): void + { + $this->authorize('view', $customer); + + $this->customer = $customer->load(['addresses', 'orders' => fn ($q) => $q->latest()->limit(10)]); + } + + public function render(): mixed + { + return view('livewire.admin.customers.show'); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 0000000..4491bfe --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,44 @@ + 'Dashboard'])] +class Dashboard extends Component +{ + public string $dateRange = '30'; + + public function render(): mixed + { + $store = app('current_store'); + $days = $this->dateRange === 'custom' ? 30 : (int) $this->dateRange; + $since = Carbon::now()->subDays($days); + + $orders = Order::query() + ->where('store_id', $store->id) + ->where('placed_at', '>=', $since) + ->get(); + + $totalSales = $orders->sum('total'); + $orderCount = $orders->count(); + $averageOrderValue = $orderCount > 0 ? (int) ($totalSales / $orderCount) : 0; + + $recentOrders = Order::query() + ->where('store_id', $store->id) + ->with('customer') + ->latest('placed_at') + ->limit(10) + ->get(); + + return view('livewire.admin.dashboard', [ + 'totalSales' => $totalSales, + 'orderCount' => $orderCount, + 'averageOrderValue' => $averageOrderValue, + 'recentOrders' => $recentOrders, + ]); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 0000000..9578467 --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,15 @@ + 'Developers'])] +class Index extends Component +{ + public function render(): mixed + { + return view('livewire.admin.developers.index'); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 0000000..0869af0 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,94 @@ + 'Discount'])] +class Form extends Component +{ + use HasStoreForm; + + public ?Discount $discount = null; + + public string $code = ''; + + public string $title = ''; + + public string $type = 'code'; + + public string $value_type = 'percent'; + + public string $value_amount = ''; + + public ?string $starts_at = null; + + public ?string $ends_at = null; + + public string $usage_limit = ''; + + public string $minimum_purchase = ''; + + public string $status = 'draft'; + + protected function modelProperty(): string + { + return 'discount'; + } + + protected function modelClass(): string + { + return Discount::class; + } + + public function mount(?Discount $discount = null): void + { + if ($discount && $discount->exists) { + $this->authorize('update', $discount); + $this->discount = $discount; + $this->code = $discount->code ?? ''; + $this->title = $discount->title ?? ''; + $this->type = $discount->type->value; + $this->value_type = $discount->value_type->value; + $this->value_amount = $discount->value_amount ? (string) ($discount->value_amount / 100) : ''; + $this->starts_at = $discount->starts_at?->format('Y-m-d\TH:i'); + $this->ends_at = $discount->ends_at?->format('Y-m-d\TH:i'); + $this->usage_limit = $discount->usage_limit ? (string) $discount->usage_limit : ''; + $this->minimum_purchase = $discount->minimum_purchase ? (string) ($discount->minimum_purchase / 100) : ''; + $this->status = $discount->status->value; + } + } + + public function save(): void + { + $store = $this->authorizeAndValidate([ + 'code' => ['required', 'string', 'max:50'], + 'value_type' => ['required', 'in:percent,fixed,free_shipping'], + 'status' => ['required', 'in:draft,active,expired,disabled'], + ]); + + $data = [ + 'store_id' => $store->id, + 'code' => $this->code, + 'title' => $this->title ?: $this->code, + 'type' => $this->type, + 'value_type' => $this->value_type, + 'value_amount' => $this->value_amount ? (int) (((float) $this->value_amount) * 100) : 0, + 'starts_at' => $this->starts_at ?: null, + 'ends_at' => $this->ends_at ?: null, + 'usage_limit' => $this->usage_limit ? (int) $this->usage_limit : null, + 'minimum_purchase' => $this->minimum_purchase ? (int) (((float) $this->minimum_purchase) * 100) : null, + 'status' => $this->status, + ]; + + $this->persistModel($data, 'admin.discounts.index'); + } + + public function render(): mixed + { + return view('livewire.admin.discounts.form'); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 0000000..e167ee0 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,26 @@ + 'Discounts'])] +class Index extends Component +{ + use WithPagination; + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.discounts.index', [ + 'discounts' => Discount::query() + ->where('store_id', $store->id) + ->latest() + ->paginate(15), + ]); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 0000000..59ae0bf --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,37 @@ + 'Inventory'])] +class Index extends Component +{ + use WithPagination; + + public function adjustQuantity(int $itemId, int $adjustment): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + $item = InventoryItem::query() + ->where('store_id', $store->id) + ->findOrFail($itemId); + $item->update(['quantity_on_hand' => $item->quantity_on_hand + $adjustment]); + } + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.inventory.index', [ + 'items' => InventoryItem::query() + ->where('store_id', $store->id) + ->with('variant.product') + ->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 0000000..dbad7cc --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,126 @@ + 'Navigation'])] +class Index extends Component +{ + public bool $showMenuModal = false; + + public bool $showItemModal = false; + + public ?int $editingMenuId = null; + + public string $menuName = ''; + + public string $menuHandle = ''; + + public ?int $activeMenuId = null; + + public string $itemTitle = ''; + + public string $itemUrl = ''; + + public function openMenuModal(?int $menuId = null): void + { + if ($menuId) { + $menu = NavigationMenu::query()->find($menuId); + $this->editingMenuId = $menuId; + $this->menuName = $menu->name; + $this->menuHandle = $menu->handle; + } else { + $this->editingMenuId = null; + $this->menuName = ''; + $this->menuHandle = ''; + } + $this->showMenuModal = true; + } + + public function saveMenu(): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + $this->validate(['menuName' => ['required', 'string']]); + + if ($this->editingMenuId) { + NavigationMenu::query()->where('id', $this->editingMenuId)->update([ + 'name' => $this->menuName, + 'handle' => $this->menuHandle ?: \Illuminate\Support\Str::slug($this->menuName), + ]); + } else { + NavigationMenu::query()->create([ + 'store_id' => $store->id, + 'name' => $this->menuName, + 'handle' => $this->menuHandle ?: \Illuminate\Support\Str::slug($this->menuName), + ]); + } + + $this->showMenuModal = false; + session()->flash('success', 'Menu saved.'); + } + + public function deleteMenu(int $menuId): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + NavigationMenu::query()->where('id', $menuId)->where('store_id', $store->id)->delete(); + session()->flash('success', 'Menu deleted.'); + } + + public function openItemModal(int $menuId): void + { + $this->activeMenuId = $menuId; + $this->itemTitle = ''; + $this->itemUrl = ''; + $this->showItemModal = true; + } + + public function saveItem(): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + $this->validate([ + 'itemTitle' => ['required', 'string'], + 'itemUrl' => ['required', 'string'], + ]); + + NavigationItem::query()->create([ + 'menu_id' => $this->activeMenuId, + 'title' => $this->itemTitle, + 'type' => 'url', + 'url' => $this->itemUrl, + 'position' => NavigationItem::query()->where('menu_id', $this->activeMenuId)->count(), + ]); + + $this->showItemModal = false; + session()->flash('success', 'Item added.'); + } + + public function deleteItem(int $itemId): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + NavigationItem::query()->where('id', $itemId)->delete(); + } + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.navigation.index', [ + 'menus' => NavigationMenu::query() + ->where('store_id', $store->id) + ->with(['items' => fn ($q) => $q->orderBy('position')]) + ->get(), + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 0000000..aed9c83 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,57 @@ + 'Orders'])] +class Index extends Component +{ + use WithPagination; + + #[Url] + public string $search = ''; + + #[Url] + public string $status = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function render(): mixed + { + $store = app('current_store'); + + $orders = Order::query() + ->where('store_id', $store->id) + ->with('customer') + ->when($this->search, fn ($q) => $q->where(fn ($q2) => $q2 + ->where('order_number', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%") + )) + ->when($this->status, fn ($q) => match ($this->status) { + 'pending' => $q->where('financial_status', 'pending'), + 'paid' => $q->where('financial_status', 'paid'), + 'fulfilled' => $q->where('fulfillment_status', 'fulfilled'), + 'cancelled' => $q->where('status', 'cancelled'), + default => $q, + }) + ->latest('placed_at') + ->paginate(15); + + return view('livewire.admin.orders.index', [ + 'orders' => $orders, + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 0000000..ea9b05f --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,183 @@ + 'Order'])] +class Show extends Component +{ + public Order $order; + + public bool $showFulfillmentModal = false; + + public bool $showRefundModal = false; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + /** @var array */ + public array $fulfillmentLines = []; + + public string $refundAmount = ''; + + public string $refundReason = ''; + + public bool $refundRestock = false; + + public function mount(Order $order): void + { + $this->authorize('view', $order); + + $this->order = $order->load([ + 'lines.product', + 'lines.variant', + 'payments', + 'refunds', + 'fulfillments.lines.orderLine', + 'customer', + ]); + + $this->initFulfillmentLines(); + } + + public function openFulfillmentModal(): void + { + $this->initFulfillmentLines(); + $this->showFulfillmentModal = true; + } + + public function createFulfillment(): void + { + $this->authorize('update', $this->order); + + $fulfillment = $this->order->fulfillments()->create([ + 'tracking_company' => $this->trackingCompany ?: null, + 'tracking_number' => $this->trackingNumber ?: null, + 'status' => FulfillmentShipmentStatus::Pending, + ]); + + foreach ($this->fulfillmentLines as $fl) { + if ($fl['quantity'] > 0) { + $fulfillment->lines()->create([ + 'order_line_id' => $fl['line_id'], + 'quantity' => $fl['quantity'], + ]); + } + } + + $this->updateFulfillmentStatus(); + $this->showFulfillmentModal = false; + $this->trackingCompany = ''; + $this->trackingNumber = ''; + $this->order->refresh(); + $this->mount($this->order); + } + + public function markAsShipped(int $fulfillmentId): void + { + $this->authorize('update', $this->order); + + $this->order->fulfillments()->where('id', $fulfillmentId)->update([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'shipped_at' => now(), + ]); + $this->order->refresh(); + $this->mount($this->order); + } + + public function markAsDelivered(int $fulfillmentId): void + { + $this->authorize('update', $this->order); + + $this->order->fulfillments()->where('id', $fulfillmentId)->update([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now(), + ]); + $this->order->refresh(); + $this->mount($this->order); + } + + public function openRefundModal(): void + { + $this->refundAmount = (string) ($this->order->total / 100); + $this->showRefundModal = true; + } + + public function createRefund(): void + { + $this->authorize('update', $this->order); + + $this->validate([ + 'refundAmount' => ['required', 'numeric', 'min:0.01'], + ]); + + $payment = $this->order->payments()->where('status', PaymentStatus::Captured)->firstOrFail(); + + $refundService = app(RefundService::class); + $refundService->create( + $this->order, + $payment, + (int) (((float) $this->refundAmount) * 100), + $this->refundReason ?: null, + $this->refundRestock, + ); + + $this->showRefundModal = false; + $this->order->refresh(); + $this->mount($this->order); + } + + public function confirmPayment(): void + { + $this->authorize('update', $this->order); + + $this->order->payments()->where('status', PaymentStatus::Pending)->update([ + 'status' => PaymentStatus::Captured, + 'captured_at' => now(), + ]); + $this->order->update(['financial_status' => FinancialStatus::Paid]); + $this->order->refresh(); + $this->mount($this->order); + } + + public function render(): mixed + { + return view('livewire.admin.orders.show'); + } + + private function initFulfillmentLines(): void + { + $this->fulfillmentLines = []; + foreach ($this->order->lines as $line) { + $this->fulfillmentLines[] = [ + 'line_id' => $line->id, + 'quantity' => $line->quantity, + ]; + } + } + + private function updateFulfillmentStatus(): void + { + $totalQty = $this->order->lines->sum('quantity'); + $fulfilledQty = $this->order->fulfillments() + ->with('lines') + ->get() + ->flatMap->lines + ->sum('quantity'); + + if ($fulfilledQty >= $totalQty) { + $this->order->update(['fulfillment_status' => FulfillmentStatus::Fulfilled]); + } elseif ($fulfilledQty > 0) { + $this->order->update(['fulfillment_status' => FulfillmentStatus::Partial]); + } + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 0000000..33945c9 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,68 @@ + 'Page'])] +class Form extends Component +{ + use HasStoreForm; + + public ?Page $page = null; + + public string $title = ''; + + public string $content = ''; + + public string $status = 'draft'; + + protected function modelProperty(): string + { + return 'page'; + } + + protected function modelClass(): string + { + return Page::class; + } + + public function mount(?Page $page = null): void + { + if ($page && $page->exists) { + $this->authorize('update', $page); + $this->page = $page; + $this->title = $page->title; + $this->content = $page->content ?? ''; + $this->status = $page->status->value; + } + } + + public function save(): void + { + $store = $this->authorizeAndValidate([ + 'title' => ['required', 'string', 'max:255'], + 'status' => ['required', 'in:draft,published,archived'], + ]); + + $data = [ + 'store_id' => $store->id, + 'title' => $this->title, + 'handle' => Str::slug($this->title), + 'content' => sanitize_html($this->content), + 'status' => $this->status, + 'published_at' => $this->status === 'published' ? now() : null, + ]; + + $this->persistModel($data, 'admin.pages.index'); + } + + public function render(): mixed + { + return view('livewire.admin.pages.form'); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 0000000..129bb1a --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,23 @@ + 'Pages'])] +class Index extends Component +{ + use WithPagination; + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.pages.index', [ + 'pages' => Page::query()->where('store_id', $store->id)->latest()->paginate(15), + ]); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 0000000..bf3ff77 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,274 @@ + 'Product'])] +class Form extends Component +{ + use HasStoreForm; + use WithFileUploads; + + public ?Product $product = null; + + public string $title = ''; + + public string $description_html = ''; + + public string $status = 'draft'; + + public string $vendor = ''; + + public string $product_type = ''; + + public string $tags = ''; + + /** @var array */ + public array $options = []; + + /** @var array}> */ + public array $variants = []; + + /** @var array */ + public array $newMedia = []; + + protected function modelProperty(): string + { + return 'product'; + } + + protected function modelClass(): string + { + return Product::class; + } + + public function mount(?Product $product = null): void + { + if ($product && $product->exists) { + $this->authorize('update', $product); + $this->product = $product; + $this->title = $product->title; + $this->description_html = $product->description_html ?? ''; + $this->status = $product->status->value; + $this->vendor = $product->vendor ?? ''; + $this->product_type = $product->product_type ?? ''; + $this->tags = is_array($product->tags) ? implode(', ', $product->tags) : ''; + + $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues']); + + foreach ($product->options as $option) { + $this->options[] = [ + 'name' => $option->name, + 'values' => $option->values->pluck('value')->implode(', '), + ]; + } + + foreach ($product->variants as $variant) { + $this->variants[] = [ + 'price' => (string) ($variant->price_amount / 100), + 'compare_at_price' => $variant->compare_at_amount ? (string) ($variant->compare_at_amount / 100) : '', + 'sku' => $variant->sku ?? '', + 'barcode' => $variant->barcode ?? '', + 'weight' => $variant->weight_g ? (string) $variant->weight_g : '', + 'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0, + 'option_values' => $variant->optionValues->pluck('value')->all(), + ]; + } + + if (empty($this->variants)) { + $this->addDefaultVariant(); + } + } else { + $this->addDefaultVariant(); + } + } + + public function addOption(): void + { + $this->options[] = ['name' => '', 'values' => '']; + } + + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + $this->generateVariants(); + } + + public function generateVariants(): void + { + $optionSets = []; + foreach ($this->options as $option) { + $values = array_map('trim', explode(',', $option['values'])); + $values = array_filter($values); + if (! empty($values)) { + $optionSets[] = $values; + } + } + + if (empty($optionSets)) { + $this->variants = []; + $this->addDefaultVariant(); + + return; + } + + $combinations = $this->cartesian($optionSets); + $this->variants = []; + + foreach ($combinations as $combo) { + $comboArray = is_array($combo) ? $combo : [$combo]; + $this->variants[] = [ + 'price' => '', + 'compare_at_price' => '', + 'sku' => '', + 'barcode' => '', + 'weight' => '', + 'quantity' => 0, + 'option_values' => $comboArray, + ]; + } + } + + public function save(): void + { + $store = $this->authorizeAndValidate([ + 'title' => ['required', 'string', 'max:255'], + 'status' => ['required', 'in:draft,active,archived'], + 'variants' => ['required', 'array', 'min:1'], + 'variants.*.price' => ['required', 'numeric', 'min:0'], + ]); + + $productData = [ + 'store_id' => $store->id, + 'title' => $this->title, + 'handle' => Str::slug($this->title), + 'description_html' => sanitize_html($this->description_html), + 'status' => $this->status, + 'vendor' => $this->vendor ?: null, + 'product_type' => $this->product_type ?: null, + 'tags' => $this->tags ? array_map('trim', explode(',', $this->tags)) : [], + 'published_at' => $this->status === 'active' ? now() : null, + ]; + + if ($this->product) { + $this->product->update($productData); + $product = $this->product; + } else { + $product = Product::query()->create($productData); + } + + // Sync options + $product->options()->delete(); + foreach ($this->options as $pos => $opt) { + if (! $opt['name']) { + continue; + } + $option = $product->options()->create(['name' => $opt['name'], 'position' => $pos]); + $values = array_filter(array_map('trim', explode(',', $opt['values']))); + foreach ($values as $vPos => $val) { + $option->values()->create(['value' => $val, 'position' => $vPos]); + } + } + + // Sync variants + $product->variants()->delete(); + foreach ($this->variants as $pos => $v) { + $variant = $product->variants()->create([ + 'sku' => $v['sku'] ?: null, + 'barcode' => $v['barcode'] ?: null, + 'price_amount' => (int) (((float) $v['price']) * 100), + 'compare_at_amount' => $v['compare_at_price'] ? (int) (((float) $v['compare_at_price']) * 100) : null, + 'currency' => $store->currency ?? 'USD', + 'weight_g' => $v['weight'] ? (int) $v['weight'] : null, + 'is_default' => $pos === 0, + 'position' => $pos, + ]); + + $variant->inventoryItem()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $store->id, + 'quantity_on_hand' => $v['quantity'] ?? 0, + 'quantity_reserved' => 0, + ] + ); + + // Attach option values + if (! empty($v['option_values'])) { + $optionValueIds = []; + foreach ($v['option_values'] as $ov) { + $pov = ProductOptionValue::query() + ->whereHas('option', fn ($q) => $q->where('product_id', $product->id)) + ->where('value', $ov) + ->first(); + if ($pov) { + $optionValueIds[] = $pov->id; + } + } + $variant->optionValues()->sync($optionValueIds); + } + } + + // Handle media uploads + foreach ($this->newMedia as $file) { + $path = $file->store('products', 'public'); + $product->media()->create([ + 'store_id' => $store->id, + 'type' => 'image', + 'url' => $path, + 'alt' => $this->title, + 'position' => $product->media()->count(), + ]); + } + $this->newMedia = []; + + session()->flash('success', $this->product ? 'Product updated.' : 'Product created.'); + $this->redirect(route('admin.products.index')); + } + + public function render(): mixed + { + return view('livewire.admin.products.form'); + } + + private function addDefaultVariant(): void + { + $this->variants[] = [ + 'price' => '', + 'compare_at_price' => '', + 'sku' => '', + 'barcode' => '', + 'weight' => '', + 'quantity' => 0, + 'option_values' => [], + ]; + } + + /** + * @param array> $arrays + * @return array> + */ + private function cartesian(array $arrays): array + { + $result = [[]]; + foreach ($arrays as $array) { + $temp = []; + foreach ($result as $r) { + foreach ($array as $item) { + $temp[] = array_merge($r, [$item]); + } + } + $result = $temp; + } + + return $result; + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 0000000..d320179 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,93 @@ + 'Products'])] +class Index extends Component +{ + use WithPagination; + + #[Url] + public string $search = ''; + + #[Url] + public string $status = ''; + + /** @var array */ + public array $selected = []; + + public bool $selectAll = false; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function updatedSelectAll(bool $value): void + { + if ($value) { + $this->selected = $this->getProductsQuery()->pluck('id')->all(); + } else { + $this->selected = []; + } + } + + public function bulkArchive(): void + { + $this->authorize('viewAny', Product::class); + + $store = app('current_store'); + Product::query() + ->where('store_id', $store->id) + ->whereIn('id', $this->selected) + ->update(['status' => ProductStatus::Archived]); + $this->selected = []; + $this->selectAll = false; + session()->flash('success', 'Products archived.'); + } + + public function bulkDelete(): void + { + $this->authorize('viewAny', Product::class); + + $store = app('current_store'); + Product::query() + ->where('store_id', $store->id) + ->whereIn('id', $this->selected) + ->delete(); + $this->selected = []; + $this->selectAll = false; + session()->flash('success', 'Products deleted.'); + } + + public function render(): mixed + { + return view('livewire.admin.products.index', [ + 'products' => $this->getProductsQuery()->paginate(15), + ]); + } + + private function getProductsQuery(): \Illuminate\Database\Eloquent\Builder + { + $store = app('current_store'); + + return Product::query() + ->where('store_id', $store->id) + ->with(['variants' => fn ($q) => $q->where('is_default', true), 'variants.inventoryItem', 'media']) + ->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%")) + ->when($this->status, fn ($q) => $q->where('status', $this->status)) + ->latest(); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 0000000..a95e135 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,66 @@ + 'Settings'])] +class Index extends Component +{ + public string $store_name = ''; + + public string $store_email = ''; + + public string $timezone = 'UTC'; + + public string $currency = 'USD'; + + public string $weight_unit = 'g'; + + public function mount(): void + { + $store = app('current_store'); + $settings = $store->settings; + + if ($settings) { + $this->store_name = $settings->store_name ?? $store->name; + $this->store_email = $settings->store_email ?? ''; + $this->timezone = $settings->timezone ?? 'UTC'; + $this->currency = $settings->currency ?? $store->currency ?? 'USD'; + $this->weight_unit = $settings->weight_unit ?? 'g'; + } else { + $this->store_name = $store->name; + } + } + + public function save(): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + $this->validate([ + 'store_name' => ['required', 'string', 'max:255'], + 'store_email' => ['nullable', 'email'], + ]); + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'store_name' => $this->store_name, + 'store_email' => $this->store_email, + 'timezone' => $this->timezone, + 'currency' => $this->currency, + 'weight_unit' => $this->weight_unit, + ] + ); + + session()->flash('success', 'Settings saved.'); + } + + public function render(): mixed + { + return view('livewire.admin.settings.index'); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 0000000..56f83db --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,132 @@ + 'Shipping'])] +class Shipping extends Component +{ + public bool $showZoneModal = false; + + public bool $showRateModal = false; + + public ?int $editingZoneId = null; + + public ?int $editingRateZoneId = null; + + public string $zoneName = ''; + + public string $zoneCountries = ''; + + public string $rateName = ''; + + public string $rateAmount = ''; + + public string $rateType = 'flat'; + + public function openZoneModal(?int $zoneId = null): void + { + if ($zoneId) { + $zone = ShippingZone::query()->find($zoneId); + $this->editingZoneId = $zoneId; + $this->zoneName = $zone->name; + $this->zoneCountries = is_array($zone->countries_json) ? implode(', ', $zone->countries_json) : ''; + } else { + $this->editingZoneId = null; + $this->zoneName = ''; + $this->zoneCountries = ''; + } + $this->showZoneModal = true; + } + + public function saveZone(): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + $this->validate(['zoneName' => ['required', 'string']]); + $countries = array_map('trim', explode(',', $this->zoneCountries)); + + if ($this->editingZoneId) { + ShippingZone::query()->where('id', $this->editingZoneId)->update([ + 'name' => $this->zoneName, + 'countries_json' => $countries, + ]); + } else { + ShippingZone::query()->create([ + 'store_id' => $store->id, + 'name' => $this->zoneName, + 'countries_json' => $countries, + 'is_active' => true, + ]); + } + + $this->showZoneModal = false; + session()->flash('success', 'Shipping zone saved.'); + } + + public function deleteZone(int $zoneId): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + ShippingZone::query()->where('id', $zoneId)->where('store_id', $store->id)->delete(); + session()->flash('success', 'Zone deleted.'); + } + + public function openRateModal(int $zoneId): void + { + $this->editingRateZoneId = $zoneId; + $this->rateName = ''; + $this->rateAmount = ''; + $this->rateType = 'flat'; + $this->showRateModal = true; + } + + public function saveRate(): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + $this->validate([ + 'rateName' => ['required', 'string'], + 'rateAmount' => ['required', 'numeric', 'min:0'], + ]); + + ShippingRate::query()->create([ + 'zone_id' => $this->editingRateZoneId, + 'name' => $this->rateName, + 'type' => $this->rateType, + 'amount' => (int) (((float) $this->rateAmount) * 100), + 'is_active' => true, + ]); + + $this->showRateModal = false; + session()->flash('success', 'Rate added.'); + } + + public function deleteRate(int $rateId): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + ShippingRate::query()->where('id', $rateId)->delete(); + session()->flash('success', 'Rate deleted.'); + } + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.settings.shipping', [ + 'zones' => ShippingZone::query() + ->where('store_id', $store->id) + ->with('rates') + ->get(), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 0000000..b223de3 --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,59 @@ + 'Taxes'])] +class Taxes extends Component +{ + public string $mode = 'manual'; + + public string $rate_basis_points = '0'; + + public string $tax_name = 'Tax'; + + public bool $prices_include_tax = false; + + public bool $charge_tax_on_shipping = false; + + public function mount(): void + { + $store = app('current_store'); + $settings = $store->taxSettings; + + if ($settings) { + $this->mode = $settings->mode->value; + $this->rate_basis_points = (string) $settings->rate_basis_points; + $this->tax_name = $settings->tax_name ?? 'Tax'; + $this->prices_include_tax = $settings->prices_include_tax; + $this->charge_tax_on_shipping = $settings->charge_tax_on_shipping; + } + } + + public function save(): void + { + $store = app('current_store'); + $this->authorize('update', $store); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => $this->mode, + 'rate_basis_points' => (int) $this->rate_basis_points, + 'tax_name' => $this->tax_name, + 'prices_include_tax' => $this->prices_include_tax, + 'charge_tax_on_shipping' => $this->charge_tax_on_shipping, + ] + ); + + session()->flash('success', 'Tax settings saved.'); + } + + public function render(): mixed + { + return view('livewire.admin.settings.taxes'); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 0000000..bc31420 --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,29 @@ + 'Themes'])] +class Index extends Component +{ + public function activate(int $themeId): void + { + $store = app('current_store'); + $this->authorize('update', $store); + Theme::query()->where('store_id', $store->id)->update(['is_active' => false]); + Theme::query()->where('id', $themeId)->update(['is_active' => true, 'status' => 'published']); + session()->flash('success', 'Theme activated.'); + } + + public function render(): mixed + { + $store = app('current_store'); + + return view('livewire.admin.themes.index', [ + 'themes' => Theme::query()->where('store_id', $store->id)->get(), + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 0000000..ddcc334 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,139 @@ +findOrFail($addressId); + $this->editingId = $addressId; + $this->first_name = $address->first_name ?? ''; + $this->last_name = $address->last_name ?? ''; + $this->company = $address->company ?? ''; + $this->address1 = $address->address1 ?? ''; + $this->address2 = $address->address2 ?? ''; + $this->city = $address->city ?? ''; + $this->province = $address->province ?? ''; + $this->country = $address->country ?? ''; + $this->zip = $address->zip ?? ''; + $this->phone = $address->phone ?? ''; + $this->is_default = $address->is_default; + } else { + $this->resetForm(); + } + $this->showForm = true; + } + + public function save(): void + { + $this->validate([ + 'first_name' => ['required', 'string'], + 'last_name' => ['required', 'string'], + 'address1' => ['required', 'string'], + 'city' => ['required', 'string'], + 'country' => ['required', 'string'], + 'zip' => ['required', 'string'], + ]); + + $customer = Auth::guard('customer')->user(); + + $data = [ + 'customer_id' => $customer->id, + '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' => $this->country, + 'zip' => $this->zip, + 'phone' => $this->phone ?: null, + 'is_default' => $this->is_default, + ]; + + if ($this->is_default) { + CustomerAddress::query()->where('customer_id', $customer->id)->update(['is_default' => false]); + } + + if ($this->editingId) { + CustomerAddress::query()->where('id', $this->editingId)->update($data); + } else { + CustomerAddress::query()->create($data); + } + + $this->showForm = false; + $this->resetForm(); + } + + public function setDefault(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + CustomerAddress::query()->where('customer_id', $customer->id)->update(['is_default' => false]); + CustomerAddress::query()->where('id', $addressId)->update(['is_default' => true]); + } + + public function delete(int $addressId): void + { + CustomerAddress::query()->where('id', $addressId)->delete(); + } + + public function render(): mixed + { + $customer = Auth::guard('customer')->user(); + + return view('livewire.storefront.account.addresses.index', [ + 'addresses' => $customer->addresses()->orderByDesc('is_default')->get(), + ]); + } + + private function resetForm(): void + { + $this->editingId = null; + $this->first_name = ''; + $this->last_name = ''; + $this->company = ''; + $this->address1 = ''; + $this->address2 = ''; + $this->city = ''; + $this->province = ''; + $this->country = 'DE'; + $this->zip = ''; + $this->phone = ''; + $this->is_default = false; + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 0000000..2e5bf4c --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,50 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required'], + ]); + + $throttleKey = 'customer-login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + $this->addError('email', "Too many login attempts. Please try again in {$seconds} seconds."); + + return; + } + + if (! Auth::guard('customer')->attempt(['email' => $this->email, 'password' => $this->password])) { + RateLimiter::hit($throttleKey); + $this->addError('email', 'Invalid credentials.'); + + return; + } + + RateLimiter::clear($throttleKey); + session()->regenerate(); + + $this->redirect(route('storefront.account')); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.login'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 0000000..3540079 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,63 @@ +validate([ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $store = app('current_store'); + + $existingCustomer = Customer::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('email', $this->email) + ->exists(); + + if ($existingCustomer) { + $this->addError('email', 'An account with this email already exists.'); + + return; + } + + $customer = Customer::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'email' => $this->email, + 'password' => $this->password, + ]); + + Auth::guard('customer')->login($customer); + session()->regenerate(); + + $this->redirect('/'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.register'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 0000000..6102876 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,21 @@ +user(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => $customer->orders()->latest('placed_at')->limit(5)->get(), + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 0000000..fb19e42 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,23 @@ +user(); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $customer->orders()->latest('placed_at')->paginate(10), + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 0000000..991eb0c --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,32 @@ +user(); + + $fullOrderNumber = str_starts_with($orderNumber, '#') ? $orderNumber : '#'.$orderNumber; + + $this->order = Order::query() + ->where('customer_id', $customer->id) + ->where('order_number', $fullOrderNumber) + ->with(['lines', 'fulfillments', 'payments']) + ->firstOrFail(); + } + + public function render(): mixed + { + return view('livewire.storefront.account.orders.show'); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 0000000..99bf7a5 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,115 @@ + */ + protected $listeners = [ + 'cart-updated' => '$refresh', + ]; + + public function updateQuantity(int $lineId, int $quantity): void + { + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + + try { + $cartService->updateLineQuantity($cart, $lineId, $quantity); + } catch (InsufficientInventoryException) { + session()->flash('error', 'Not enough stock available.'); + } + } + + public function removeLine(int $lineId): void + { + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + $cartService->removeLine($cart, $lineId); + } + + public function applyDiscount(): void + { + $this->discountError = null; + + if (trim($this->discountCode) === '') { + $this->discountError = 'Please enter a discount code.'; + + return; + } + + $store = app('current_store'); + $cartService = app(CartService::class); + $discountService = app(DiscountService::class); + $cart = $cartService->getOrCreateForSession($store); + $cart->load('lines'); + + try { + $discountService->validate($this->discountCode, $store, $cart); + session()->put('discount_code', trim($this->discountCode)); + } catch (InvalidDiscountException $e) { + $this->discountError = match ($e->reason) { + 'not_found' => 'This discount code is not valid.', + 'disabled' => 'This discount code is no longer active.', + 'not_yet_active' => 'This discount code is not yet active.', + 'expired' => 'This discount code has expired.', + 'usage_limit_reached' => 'This discount code has reached its usage limit.', + 'minimum_not_met' => 'Your cart does not meet the minimum purchase requirement.', + default => 'This discount code could not be applied.', + }; + } + } + + public function removeDiscount(): void + { + session()->forget('discount_code'); + $this->discountCode = ''; + $this->discountError = null; + } + + public function render(): mixed + { + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + $cart->load('lines.variant.product', 'lines.variant.optionValues'); + + $appliedDiscount = null; + $discountResult = null; + $appliedCode = session('discount_code'); + + if ($appliedCode && $cart->lines->isNotEmpty()) { + $discountService = app(DiscountService::class); + + try { + $appliedDiscount = $discountService->validate($appliedCode, $store, $cart); + $subtotal = $cart->lines->sum('total'); + $discountResult = $discountService->calculate($appliedDiscount, $subtotal, $cart->lines->all()); + } catch (InvalidDiscountException) { + session()->forget('discount_code'); + $appliedDiscount = null; + } + } + + return view('livewire.storefront.cart.show', [ + 'cart' => $cart, + 'appliedDiscount' => $appliedDiscount, + 'discountResult' => $discountResult, + 'appliedCode' => $appliedCode, + ]); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 0000000..c3cce47 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,64 @@ + */ + protected $listeners = [ + 'cart-updated' => 'openDrawer', + 'open-cart-drawer' => 'openDrawer', + 'close-cart-drawer' => 'closeDrawer', + ]; + + public function openDrawer(): void + { + $this->open = true; + } + + public function closeDrawer(): void + { + $this->open = false; + } + + public function updateQuantity(int $lineId, int $quantity): void + { + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + + try { + $cartService->updateLineQuantity($cart, $lineId, $quantity); + } catch (\App\Exceptions\InsufficientInventoryException $e) { + session()->flash('error', 'Not enough stock available. Only '.$e->available.' items left.'); + } + } + + public function removeLine(int $lineId): void + { + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + $cartService->removeLine($cart, $lineId); + } + + public function render(): mixed + { + $cart = null; + if (app()->bound('current_store')) { + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + $cart->load('lines.variant.product'); + } + + return view('livewire.storefront.cart-drawer', [ + 'cart' => $cart, + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 0000000..ec07b5e --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,32 @@ +orderId = session('order_id'); + + if (! $this->orderId) { + $this->redirect(route('storefront.home')); + } + } + + public function render(): mixed + { + $order = $this->orderId + ? \App\Models\Order::withoutGlobalScopes()->with('lines')->find($this->orderId) + : null; + + return view('livewire.storefront.checkout.confirmation', [ + 'order' => $order, + ]); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 0000000..fc28ab5 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,179 @@ +getOrCreateForSession($store); + + if ($cart->lines()->count() === 0) { + $this->redirect(route('storefront.cart')); + + return; + } + + $checkoutService = app(CheckoutService::class); + $existingCheckout = \App\Models\Checkout::query() + ->where('cart_id', $cart->id) + ->whereNotIn('status', [ + \App\Enums\CheckoutStatus::Completed->value, + \App\Enums\CheckoutStatus::Expired->value, + ]) + ->latest() + ->first(); + + $checkout = $existingCheckout ?? $checkoutService->createFromCart($cart); + $this->checkoutId = $checkout->id; + } + + public function submitAddress(): void + { + $this->validate([ + 'email' => 'required|email', + 'firstName' => 'required|string|max:255', + 'lastName' => 'required|string|max:255', + 'address1' => 'required|string|max:255', + 'city' => 'required|string|max:255', + 'country' => 'required|string|max:2', + 'zip' => 'required|string|max:20', + ]); + + $checkoutService = app(CheckoutService::class); + $checkout = \App\Models\Checkout::query()->findOrFail($this->checkoutId); + + $checkoutService->setAddress($checkout, [ + 'email' => $this->email, + 'shipping_address' => [ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'address1' => $this->address1, + 'address2' => $this->address2, + 'city' => $this->city, + 'province' => $this->province, + 'country_code' => $this->country, + 'zip' => $this->zip, + 'phone' => $this->phone, + ], + ]); + + $this->step = 2; + } + + public function submitShipping(): void + { + $this->validate([ + 'selectedShippingRateId' => 'required|integer', + ]); + + $checkoutService = app(CheckoutService::class); + $checkout = \App\Models\Checkout::query()->findOrFail($this->checkoutId); + + $checkoutService->setShippingMethod($checkout, $this->selectedShippingRateId); + + // Apply session discount to checkout + $discountCode = session('discount_code'); + if ($discountCode) { + $discountService = app(DiscountService::class); + $store = app('current_store'); + $cart = $checkout->cart()->with('lines')->first(); + + try { + $discount = $discountService->validate($discountCode, $store, $cart); + $subtotal = $cart->lines->sum('total'); + $result = $discountService->calculate($discount, $subtotal, $cart->lines->all()); + $checkout->update([ + 'discount_code' => $discountCode, + 'discount_amount' => $result->amount, + ]); + } catch (\Exception) { + // Discount no longer valid, ignore + } + } + + $this->step = 3; + } + + public function submitPayment(): void + { + $checkoutService = app(CheckoutService::class); + $orderService = app(OrderService::class); + $checkout = \App\Models\Checkout::query()->findOrFail($this->checkoutId); + + $checkoutService->selectPaymentMethod($checkout, $this->paymentMethod); + $checkout->refresh(); + + try { + $order = $orderService->createFromCheckout($checkout, [ + 'card_number' => str_replace(' ', '', $this->cardNumber), + ]); + session()->flash('order_id', $order->id); + $this->redirect(route('storefront.checkout.confirmation')); + } catch (\Exception $e) { + $this->errorMessage = $e->getMessage(); + } + } + + public function render(): mixed + { + $store = app('current_store'); + $checkout = $this->checkoutId + ? \App\Models\Checkout::query()->with('cart.lines.variant.product')->find($this->checkoutId) + : null; + + $shippingRates = collect(); + if ($this->step >= 2 && $checkout) { + $calculator = app(ShippingCalculator::class); + $shippingRates = $calculator->getAvailableRates($store, $checkout->shipping_address_json ?? []); + } + + return view('livewire.storefront.checkout.show', [ + 'checkout' => $checkout, + 'shippingRates' => $shippingRates, + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 0000000..08ddcdf --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,25 @@ +where('status', CollectionStatus::Active) + ->withCount('products') + ->orderBy('title') + ->get(); + + return view('livewire.storefront.collections.index', [ + 'collections' => $collections, + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 0000000..df855ac --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,75 @@ +handle = $handle; + + $collection = Collection::query() + ->where('handle', $handle) + ->where('status', CollectionStatus::Active) + ->first(); + + if (! $collection) { + abort(404); + } + } + + public function updatedSort(): void + { + $this->resetPage(); + } + + public function render(): mixed + { + $collection = Collection::query() + ->where('handle', $this->handle) + ->where('status', CollectionStatus::Active) + ->first(); + + if (! $collection) { + abort(404); + } + + $productsQuery = $collection->products() + ->where('products.status', ProductStatus::Active) + ->with('variants', 'media'); + + $productsQuery = match ($this->sort) { + 'price-asc' => $productsQuery->join('product_variants', function ($join): void { + $join->on('products.id', '=', 'product_variants.product_id') + ->where('product_variants.is_default', true); + })->orderBy('product_variants.price_amount', 'asc')->select('products.*'), + 'price-desc' => $productsQuery->join('product_variants', function ($join): void { + $join->on('products.id', '=', 'product_variants.product_id') + ->where('product_variants.is_default', true); + })->orderBy('product_variants.price_amount', 'desc')->select('products.*'), + 'newest' => $productsQuery->orderBy('products.created_at', 'desc'), + default => $productsQuery->orderByPivot('position'), + }; + + $products = $productsQuery->paginate(12); + + return view('livewire.storefront.collections.show', [ + 'collection' => $collection, + 'products' => $products, + ]); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 0000000..7ba029a --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,37 @@ +bound('current_store') ? app('current_store') : null; + + $featuredProducts = Product::query() + ->where('status', ProductStatus::Active) + ->with('variants', 'media') + ->latest() + ->limit(8) + ->get(); + + $collections = Collection::query() + ->where('status', CollectionStatus::Active) + ->limit(4) + ->get(); + + return view('livewire.storefront.home', [ + 'storeName' => $store?->name ?? config('app.name'), + 'featuredProducts' => $featuredProducts, + 'collections' => $collections, + ]); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 0000000..9f75b4a --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,35 @@ +handle = $handle; + } + + public function render(): mixed + { + $page = Page::query() + ->where('handle', $this->handle) + ->where('status', PageStatus::Published) + ->first(); + + if (! $page) { + abort(404); + } + + return view('livewire.storefront.pages.show', [ + 'page' => $page, + ]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 0000000..7e596d6 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,154 @@ + */ + public array $selectedOptions = []; + + public ?int $selectedVariantId = null; + + public function mount(string $handle): void + { + $this->handle = $handle; + + $product = Product::query() + ->where('handle', $handle) + ->where('status', ProductStatus::Active) + ->with(['variants.optionValues.option', 'options.values']) + ->first(); + + if (! $product) { + abort(404); + } + + $defaultVariant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + if ($defaultVariant) { + $this->selectedVariantId = $defaultVariant->id; + foreach ($defaultVariant->optionValues as $optionValue) { + $this->selectedOptions[$optionValue->product_option_id] = $optionValue->id; + } + } + } + + public function updatedSelectedOptions(): void + { + $product = Product::query() + ->where('handle', $this->handle) + ->where('status', ProductStatus::Active) + ->with('variants.optionValues') + ->first(); + + if (! $product) { + return; + } + + $selectedValueIds = collect($this->selectedOptions)->values()->filter()->sort()->values(); + + foreach ($product->variants as $variant) { + $variantValueIds = $variant->optionValues->pluck('id')->sort()->values(); + if ($variantValueIds->toArray() === $selectedValueIds->toArray()) { + $this->selectedVariantId = $variant->id; + + return; + } + } + } + + public function addToCart(): void + { + $product = Product::query() + ->where('handle', $this->handle) + ->where('status', ProductStatus::Active) + ->first(); + + if (! $product) { + return; + } + + $variantId = $this->selectedVariantId; + if (! $variantId) { + $variant = $product->variants()->where('is_default', true)->first() ?? $product->variants()->first(); + $variantId = $variant?->id; + } + + if (! $variantId) { + return; + } + + $store = app('current_store'); + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($store); + + try { + $cartService->addLine($cart, $variantId, $this->quantity); + } catch (\App\Exceptions\InsufficientInventoryException $e) { + session()->flash('error', 'Not enough stock available. Only '.$e->available.' items left.'); + + return; + } + + session()->flash('success', 'Added to cart!'); + $this->dispatch('cart-updated'); + } + + public function incrementQuantity(): void + { + $this->quantity++; + } + + public function decrementQuantity(): void + { + if ($this->quantity > 1) { + $this->quantity--; + } + } + + public function render(): mixed + { + $product = Product::query() + ->where('handle', $this->handle) + ->where('status', ProductStatus::Active) + ->with(['variants.optionValues.option', 'variants.inventoryItem', 'options.values', 'media']) + ->first(); + + if (! $product) { + abort(404); + } + + $selectedVariant = $this->selectedVariantId + ? $product->variants->firstWhere('id', $this->selectedVariantId) + : ($product->variants->firstWhere('is_default', true) ?? $product->variants->first()); + + $inventoryItem = $selectedVariant?->inventoryItem; + $quantityAvailable = $inventoryItem !== null ? $inventoryItem->quantity_available : 0; + $inventoryPolicy = $inventoryItem !== null ? $inventoryItem->policy : InventoryPolicy::Deny; + + if ($quantityAvailable > 0) { + $stockStatus = 'in_stock'; + } elseif ($inventoryPolicy === InventoryPolicy::Continue) { + $stockStatus = 'backorder'; + } else { + $stockStatus = 'sold_out'; + } + + return view('livewire.storefront.products.show', [ + 'product' => $product, + 'selectedVariant' => $selectedVariant, + 'stockStatus' => $stockStatus, + ]); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 0000000..d2d4ce5 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,38 @@ +resetPage(); + } + + public function render(): mixed + { + $products = null; + + if (strlen(trim($this->q)) >= 2) { + $store = app('current_store'); + $searchService = app(SearchService::class); + $products = $searchService->search($store, $this->q); + } + + return view('livewire.storefront.search.index', [ + 'products' => $products, + ]); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 0000000..8ac9b7b --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,45 @@ + 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } + + /** @return BelongsTo */ + 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 0000000..98f182d --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,43 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 0000000..013e08a --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,41 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'app_id', + 'settings_json', + 'installed_at', + ]; + + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'installed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function app(): BelongsTo + { + return $this->belongsTo(AppModel::class, 'app_id'); + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/AppModel.php b/app/Models/AppModel.php new file mode 100644 index 0000000..b6eccc0 --- /dev/null +++ b/app/Models/AppModel.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + protected $table = 'apps'; + + protected $fillable = [ + 'name', + 'slug', + 'description', + 'developer', + 'icon_url', + 'is_public', + ]; + + protected function casts(): array + { + return [ + 'is_public' => 'boolean', + ]; + } + + /** @return HasMany */ + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class, 'app_id'); + } + + /** @return HasMany */ + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class, 'app_id'); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 0000000..fd5a2c5 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,58 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'customer_id', + 'session_id', + 'status', + 'currency', + 'cart_version', + 'note', + ]; + + protected function casts(): array + { + return [ + 'status' => CartStatus::class, + 'cart_version' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** @return HasMany */ + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + /** @return HasMany */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 0000000..e270d64 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + protected $fillable = [ + 'cart_id', + 'variant_id', + 'quantity', + 'unit_price', + 'subtotal', + 'total', + ]; + + protected function casts(): array + { + return [ + 'quantity' => 'integer', + 'unit_price' => 'integer', + 'subtotal' => 'integer', + 'total' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 0000000..fb39cfd --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,66 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'cart_id', + 'customer_id', + 'email', + 'shipping_address_json', + 'billing_address_json', + 'shipping_rate_id', + 'shipping_method_name', + 'shipping_amount', + 'discount_code', + 'discount_amount', + 'payment_method', + 'status', + 'totals_json', + 'expires_at', + 'completed_at', + ]; + + protected function casts(): array + { + return [ + 'status' => CheckoutStatus::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'totals_json' => 'array', + 'shipping_amount' => 'integer', + 'discount_amount' => 'integer', + 'expires_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** @return BelongsTo */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 0000000..5de74f8 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,46 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + protected function casts(): array + { + return [ + 'status' => CollectionStatus::class, + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsToMany */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position') + ->orderByPivot('position'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 0000000..aaf2ee1 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,23 @@ +bound('current_store') && ! $model->getAttribute('store_id')) { + /** @var Store $store */ + $store = app('current_store'); + $model->setAttribute('store_id', $store->id); + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 0000000..99e4045 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,60 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'first_name', + 'last_name', + 'email', + 'password', + 'accepts_marketing', + ]; + + protected $hidden = [ + 'password', + ]; + + protected function casts(): array + { + return [ + 'password' => 'hashed', + 'accepts_marketing' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return HasMany */ + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + /** @return HasMany */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** @return HasMany */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 0000000..25ae7ba --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + protected $fillable = [ + 'customer_id', + 'first_name', + 'last_name', + 'company', + 'address1', + 'address2', + 'city', + 'province', + 'province_code', + 'country', + 'country_code', + 'zip', + 'phone', + 'is_default', + ]; + + protected function casts(): array + { + return [ + 'is_default' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 0000000..7da50c9 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,55 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'code', + 'title', + 'type', + 'value_type', + 'value_amount', + 'starts_at', + 'ends_at', + 'usage_limit', + 'usage_count', + 'minimum_purchase', + 'status', + 'rules_json', + ]; + + protected function casts(): array + { + return [ + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'status' => DiscountStatus::class, + 'rules_json' => 'array', + 'value_amount' => 'integer', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + 'minimum_purchase' => 'integer', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 0000000..1b8c6d1 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + protected $fillable = [ + 'order_id', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'status', + 'shipped_at', + 'delivered_at', + ]; + + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** @return HasMany */ + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 0000000..f666a9b --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + protected function casts(): array + { + return [ + 'quantity' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + /** @return BelongsTo */ + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 0000000..dfb58a1 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,49 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + protected function casts(): array + { + return [ + 'policy' => InventoryPolicy::class, + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function getQuantityAvailableAttribute(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 0000000..27b6617 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,50 @@ + */ + use HasFactory; + + protected $fillable = [ + 'menu_id', + 'parent_id', + 'title', + 'type', + 'url', + 'resource_id', + 'position', + ]; + + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + ]; + } + + /** @return BelongsTo */ + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } + + /** @return BelongsTo */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('position'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 0000000..1ebf2ad --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,26 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'name', + 'handle', + ]; + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id'); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 0000000..fd71a6c --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,26 @@ + */ + use HasFactory; + + protected $fillable = [ + 'app_id', + 'client_id', + 'client_secret', + 'redirect_uri', + ]; + + /** @return BelongsTo */ + public function app(): BelongsTo + { + return $this->belongsTo(AppModel::class, 'app_id'); + } +} diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 0000000..aa03b02 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + protected $fillable = [ + 'installation_id', + 'access_token', + 'refresh_token', + 'scopes_json', + 'expires_at', + ]; + + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..e5e7467 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,99 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'customer_id', + 'order_number', + 'email', + 'phone', + 'status', + 'financial_status', + 'fulfillment_status', + 'currency', + 'subtotal', + 'discount_amount', + 'shipping_amount', + 'tax_amount', + 'total', + 'shipping_address_json', + 'billing_address_json', + 'discount_code', + 'payment_method', + 'note', + 'placed_at', + 'cancelled_at', + 'cancel_reason', + 'totals_json', + ]; + + protected function casts(): array + { + return [ + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'totals_json' => 'array', + 'subtotal' => 'integer', + 'discount_amount' => 'integer', + 'shipping_amount' => 'integer', + 'tax_amount' => 'integer', + 'total' => 'integer', + 'placed_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** @return HasMany */ + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + /** @return HasMany */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + /** @return HasMany */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + /** @return HasMany */ + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 0000000..07f395e --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + protected $fillable = [ + 'order_id', + 'product_id', + 'variant_id', + 'title_snapshot', + 'sku_snapshot', + 'variant_title_snapshot', + 'quantity', + 'unit_price', + 'subtotal', + 'total', + 'requires_shipping', + ]; + + protected function casts(): array + { + return [ + 'quantity' => 'integer', + 'unit_price' => 'integer', + 'subtotal' => 'integer', + 'total' => 'integer', + 'requires_shipping' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** @return BelongsTo */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 0000000..a5a6441 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,24 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'slug', + ]; + + /** @return HasMany */ + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 0000000..9890789 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,31 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'content', + 'status', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 0000000..fdf5d3c --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + protected $fillable = [ + 'order_id', + 'method', + 'provider', + 'provider_payment_id', + 'amount', + 'currency', + 'status', + 'error_code', + 'error_message', + 'captured_at', + ]; + + protected function casts(): array + { + return [ + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'captured_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..6dedc33 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,75 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'status', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return HasMany */ + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + + /** @return HasMany */ + public function options(): HasMany + { + return $this->hasMany(ProductOption::class); + } + + /** @return HasMany */ + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class); + } + + /** @return BelongsToMany */ + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position'); + } + + /** @return HasMany */ + 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 0000000..ada2433 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + ]; + + protected function casts(): array + { + return [ + 'type' => MediaType::class, + 'status' => MediaStatus::class, + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 0000000..d72f43e --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,34 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + /** @return BelongsTo */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** @return HasMany */ + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class)->orderBy('position'); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 0000000..5f55e8e --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + /** @return BelongsTo */ + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 0000000..900e6fa --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,60 @@ + */ + use HasFactory; + + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + protected function casts(): array + { + return [ + 'status' => VariantStatus::class, + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** @return HasOne */ + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + /** @return BelongsToMany */ + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id'); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 0000000..1ca2329 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'restock', + 'processed_at', + ]; + + protected function casts(): array + { + return [ + 'status' => RefundStatus::class, + 'amount' => 'integer', + 'restock' => 'boolean', + 'processed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** @return BelongsTo */ + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 0000000..f76d9e9 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,19 @@ +bound('current_store')) { + /** @var \App\Models\Store $store */ + $store = app('current_store'); + $builder->where($model->getTable().'.store_id', $store->id); + } + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 0000000..54040f6 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,43 @@ + 'integer', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/SearchSettings.php b/app/Models/SearchSettings.php new file mode 100644 index 0000000..a3a5cf8 --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,36 @@ + 'array', + 'stop_words_json' => 'array', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 0000000..1a75696 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'amount', + 'config_json', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'amount' => 'integer', + 'is_active' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 0000000..65d036d --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,44 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'name', + 'countries_json', + 'regions_json', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return HasMany */ + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 0000000..857d084 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,107 @@ + */ + use HasFactory; + + protected $fillable = [ + 'organization_id', + 'name', + 'slug', + 'status', + 'currency', + ]; + + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } + + /** @return BelongsTo */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + /** @return HasMany */ + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + /** @return BelongsToMany */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role') + ->withTimestamps(); + } + + /** @return HasOne */ + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + /** @return HasMany */ + public function themes(): HasMany + { + return $this->hasMany(Theme::class); + } + + /** @return HasMany */ + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + /** @return HasMany */ + public function navigationMenus(): HasMany + { + return $this->hasMany(NavigationMenu::class); + } + + /** @return HasOne */ + public function taxSettings(): HasOne + { + return $this->hasOne(TaxSettings::class); + } + + /** @return HasMany */ + public function shippingZones(): HasMany + { + return $this->hasMany(ShippingZone::class); + } + + /** @return HasMany */ + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + + /** @return HasMany */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** @return HasMany */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 0000000..349311e --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + protected $fillable = [ + 'store_id', + 'domain', + 'type', + 'is_primary', + ]; + + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 0000000..ada1f65 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + protected $fillable = [ + 'store_id', + 'store_name', + 'store_email', + 'timezone', + 'weight_unit', + 'currency', + 'checkout_requires_account', + ]; + + protected function casts(): array + { + return [ + 'checkout_requires_account' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 0000000..4170180 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,26 @@ + StoreUserRole::class, + ]; + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 0000000..f04604a --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + protected $fillable = [ + 'store_id', + 'mode', + 'rate_basis_points', + 'tax_name', + 'prices_include_tax', + 'charge_tax_on_shipping', + ]; + + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'rate_basis_points' => 'integer', + 'prices_include_tax' => 'boolean', + 'charge_tax_on_shipping' => 'boolean', + ]; + } + + /** @return BelongsTo */ + 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 0000000..c19a720 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,43 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'name', + 'is_active', + 'status', + ]; + + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + 'status' => ThemeStatus::class, + ]; + } + + /** @return HasMany */ + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + /** @return HasOne */ + public function themeSettings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 0000000..696292f --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,25 @@ + */ + use HasFactory; + + protected $fillable = [ + 'theme_id', + 'path', + 'content', + ]; + + /** @return BelongsTo */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 0000000..8388f8b --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + protected $fillable = [ + 'theme_id', + 'settings_json', + ]; + + protected function casts(): array + { + return [ + 'settings_json' => 'array', + ]; + } + + /** @return BelongsTo */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4..1c6ce97 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,7 +3,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; @@ -23,6 +25,8 @@ class User extends Authenticatable 'name', 'email', 'password', + 'status', + 'last_login_at', ]; /** @@ -46,6 +50,7 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', 'password' => 'hashed', ]; } @@ -53,6 +58,29 @@ protected function casts(): array /** * Get the user's initials */ + /** @return BelongsToMany */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role') + ->withTimestamps(); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $storeRecord = $this->stores()->where('stores.id', $store->id)->first(); + + if (! $storeRecord) { + return null; + } + + /** @var string $role */ + $role = $storeRecord->pivot->getAttribute('role'); + + return StoreUserRole::from($role); + } + public function initials(): string { return Str::of($this->name) diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 0000000..6ace686 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + protected $fillable = [ + 'subscription_id', + 'event_type', + 'payload_json', + 'response_status', + 'response_body', + 'attempt_count', + 'status', + 'delivered_at', + ]; + + protected function casts(): array + { + return [ + 'payload_json' => 'array', + 'response_status' => 'integer', + 'attempt_count' => 'integer', + 'delivered_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 0000000..6fc1de7 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,51 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'target_url', + 'secret', + 'event_types_json', + 'status', + 'consecutive_failures', + ]; + + protected function casts(): array + { + return [ + 'event_types_json' => 'array', + 'consecutive_failures' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + /** @return BelongsTo */ + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + /** @return HasMany */ + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 0000000..7c63517 --- /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/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 0000000..c4d43c0 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,8 @@ +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 0000000..d7804b4 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,8 @@ +resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Fulfillment $fulfillment): bool + { + /** @var int $storeId */ + $storeId = $fulfillment->order?->store_id; + + return $this->isOwnerAdminOrStaff($user, $storeId); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 0000000..a169d9c --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,34 @@ +resolveStoreId(); + + return $storeId && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Order $order): bool + { + return $this->isAnyRole($user, $order->store_id); + } + + public function update(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function cancel(User $user, Order $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 0000000..c19c9dd --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,24 @@ +resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function view(User $user, Model $model): bool + { + /** @var int $storeId */ + $storeId = $model->getAttribute('store_id'); + + return $this->isOwnerAdminOrStaff($user, $storeId); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 0000000..9a80a43 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,14 @@ +isOwnerOrAdmin($user, $product->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 0000000..d5b5779 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,27 @@ +isAnyRole($user, $store->id); + } + + public function update(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, [\App\Enums\StoreUserRole::Owner]); + } +} diff --git a/app/Policies/StoreResourcePolicy.php b/app/Policies/StoreResourcePolicy.php new file mode 100644 index 0000000..f7406c1 --- /dev/null +++ b/app/Policies/StoreResourcePolicy.php @@ -0,0 +1,50 @@ +resolveStoreId(); + + return $storeId && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Model $model): bool + { + /** @var int $storeId */ + $storeId = $model->getAttribute('store_id'); + + return $this->isAnyRole($user, $storeId); + } + + public function create(User $user): bool + { + $storeId = $this->resolveStoreId(); + + return $storeId && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Model $model): bool + { + /** @var int $storeId */ + $storeId = $model->getAttribute('store_id'); + + return $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function delete(User $user, Model $model): bool + { + /** @var int $storeId */ + $storeId = $model->getAttribute('store_id'); + + return $this->isOwnerOrAdmin($user, $storeId); + } +} diff --git a/app/Policies/StoreSettingsPolicy.php b/app/Policies/StoreSettingsPolicy.php new file mode 100644 index 0000000..6642c8f --- /dev/null +++ b/app/Policies/StoreSettingsPolicy.php @@ -0,0 +1,22 @@ +isOwnerOrAdmin($user, $settings->store_id); + } + + public function update(User $user, StoreSettings $settings): bool + { + return $this->isOwnerOrAdmin($user, $settings->store_id); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 0000000..6002919 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,41 @@ +resolveStoreId(); + + return $storeId && $this->isOwnerOrAdmin($user, $storeId); + } + + public function view(User $user, Theme $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, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f..6eda3a5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,19 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Models\Product; +use App\Observers\ProductObserver; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +23,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind( + \App\Contracts\PaymentProvider::class, + \App\Services\Payments\MockPaymentProvider::class, + ); } /** @@ -24,6 +35,28 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureRateLimiting(); + $this->configureAuth(); + + Product::observe(ProductObserver::class); + + Livewire::addPersistentMiddleware([ + \App\Http\Middleware\ResolveStore::class, + ]); + } + + protected function configureAuth(): void + { + Auth::provider('customers', function ($app, array $config) { + return new CustomerUserProvider($app['hash']); + }); + } + + protected function configureRateLimiting(): void + { + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); + }); } /** diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 0000000..a4eb100 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,33 @@ +create([ + 'store_id' => $store->id, + 'type' => $type, + 'properties_json' => ! empty($properties) ? $properties : null, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'created_at' => now(), + ]); + } + + 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/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 0000000..84f2f60 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,146 @@ +create([ + 'store_id' => $store->id, + 'customer_id' => $customer?->id, + 'session_id' => session()->getId(), + 'status' => CartStatus::Active, + 'currency' => $store->currency ?? 'USD', + ]); + } + + public function addLine(Cart $cart, int $variantId, int $qty): CartLine + { + $variant = \App\Models\ProductVariant::with(['product', 'inventoryItem'])->findOrFail($variantId); + + if ($variant->product->status !== ProductStatus::Active) { + throw new \InvalidArgumentException('Product is not active.'); + } + + $inventory = $variant->inventoryItem; + if ($inventory && $inventory->policy === InventoryPolicy::Deny) { + $existingQty = $cart->lines()->where('variant_id', $variantId)->value('quantity') ?? 0; + $totalNeeded = $existingQty + $qty; + if ($inventory->quantity_available < $totalNeeded) { + throw new InsufficientInventoryException( + requested: $totalNeeded, + available: $inventory->quantity_available, + ); + } + } + + $existing = $cart->lines()->where('variant_id', $variantId)->first(); + if ($existing) { + $existing->quantity += $qty; + $existing->unit_price = $variant->price_amount; + $existing->subtotal = $existing->quantity * $variant->price_amount; + $existing->total = $existing->subtotal; + $existing->save(); + $cart->increment('cart_version'); + + return $existing; + } + + $line = $cart->lines()->create([ + 'variant_id' => $variantId, + 'quantity' => $qty, + 'unit_price' => $variant->price_amount, + 'subtotal' => $qty * $variant->price_amount, + 'total' => $qty * $variant->price_amount, + ]); + + $cart->increment('cart_version'); + + return $line; + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $qty): CartLine + { + $line = $cart->lines()->with('variant.inventoryItem')->findOrFail($lineId); + + if ($qty <= 0) { + $line->delete(); + $cart->increment('cart_version'); + + return $line; + } + + $inventory = $line->variant?->inventoryItem; + if ($inventory && $inventory->policy === InventoryPolicy::Deny && $inventory->quantity_available < $qty) { + throw new InsufficientInventoryException( + requested: $qty, + available: $inventory->quantity_available, + ); + } + + $line->quantity = $qty; + $line->subtotal = $qty * $line->unit_price; + $line->total = $line->subtotal; + $line->save(); + + $cart->increment('cart_version'); + + return $line; + } + + public function removeLine(Cart $cart, int $lineId): void + { + $cart->lines()->findOrFail($lineId)->delete(); + $cart->increment('cart_version'); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $sessionId = session()->getId(); + + $cart = Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', CartStatus::Active) + ->when($customer, fn ($q) => $q->where('customer_id', $customer->id)) + ->when(! $customer, fn ($q) => $q->where('session_id', $sessionId)->whereNull('customer_id')) + ->first(); + + if ($cart) { + return $cart; + } + + return $this->create($store, $customer); + } + + public function mergeOnLogin(Cart $guestCart, Cart $customerCart): Cart + { + foreach ($guestCart->lines as $guestLine) { + $existing = $customerCart->lines()->where('variant_id', $guestLine->variant_id)->first(); + if ($existing) { + $existing->quantity += $guestLine->quantity; + $existing->subtotal = $existing->quantity * $existing->unit_price; + $existing->total = $existing->subtotal; + $existing->save(); + } else { + $customerCart->lines()->create($guestLine->only([ + 'variant_id', 'quantity', 'unit_price', 'subtotal', 'total', + ])); + } + } + + $guestCart->update(['status' => CartStatus::Abandoned]); + $customerCart->increment('cart_version'); + + return $customerCart; + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 0000000..65f4747 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,117 @@ +create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => CheckoutStatus::Started, + 'expires_at' => now()->addHours(24), + ]); + } + + /** + * @param array $data + */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + if (! in_array($checkout->status, [CheckoutStatus::Started, CheckoutStatus::Addressed])) { + throw new InvalidCheckoutTransitionException; + } + + $checkout->update([ + 'email' => $data['email'] ?? $checkout->email, + 'shipping_address_json' => $data['shipping_address'] ?? $checkout->shipping_address_json, + 'billing_address_json' => $data['billing_address'] ?? $data['shipping_address'] ?? $checkout->billing_address_json, + 'status' => CheckoutStatus::Addressed, + ]); + + return $checkout->refresh(); + } + + public function setShippingMethod(Checkout $checkout, int $rateId): Checkout + { + if ($checkout->status !== CheckoutStatus::Addressed) { + throw new InvalidCheckoutTransitionException; + } + + $rate = \App\Models\ShippingRate::findOrFail($rateId); + $shippingCalculator = app(ShippingCalculator::class); + $amount = $shippingCalculator->calculate($rate, $checkout->cart); + + $checkout->update([ + 'shipping_rate_id' => $rateId, + 'shipping_method_name' => $rate->name, + 'shipping_amount' => $amount, + 'status' => CheckoutStatus::ShippingSelected, + ]); + + return $checkout->refresh(); + } + + public function selectPaymentMethod(Checkout $checkout, string $method): Checkout + { + if (! in_array($checkout->status, [CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentPending])) { + throw new InvalidCheckoutTransitionException; + } + + $pricing = $this->pricingEngine->calculate($checkout); + + $checkout->update([ + 'payment_method' => $method, + 'status' => CheckoutStatus::PaymentPending, + 'totals_json' => [ + 'subtotal' => $pricing->subtotal, + 'discount' => $pricing->discount, + 'shipping' => $pricing->shipping, + 'tax_total' => $pricing->taxTotal, + 'total' => $pricing->total, + ], + ]); + + return $checkout->refresh(); + } + + /** + * @param array $paymentDetails + */ + public function completeCheckout(Checkout $checkout, array $paymentDetails = []): Checkout + { + if ($checkout->status === CheckoutStatus::Completed) { + return $checkout; + } + + if ($checkout->status !== CheckoutStatus::PaymentPending) { + throw new InvalidCheckoutTransitionException; + } + + $checkout->update([ + 'status' => CheckoutStatus::Completed, + 'completed_at' => now(), + ]); + + $checkout->cart->update(['status' => CartStatus::Converted]); + + return $checkout->refresh(); + } + + public function expireCheckout(Checkout $checkout): void + { + $checkout->update(['status' => CheckoutStatus::Expired]); + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 0000000..1ba1ac5 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,74 @@ +where('store_id', $store->id) + ->whereRaw('LOWER(code) = ?', [strtolower($code)]) + ->first(); + + if (! $discount) { + throw new InvalidDiscountException('not_found'); + } + + if ($discount->status === DiscountStatus::Disabled) { + throw new InvalidDiscountException('disabled'); + } + + if ($discount->status !== DiscountStatus::Active) { + throw new InvalidDiscountException('disabled'); + } + + if ($discount->starts_at && $discount->starts_at->isFuture()) { + throw new InvalidDiscountException('not_yet_active'); + } + + if ($discount->ends_at && $discount->ends_at->isPast()) { + throw new InvalidDiscountException('expired'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw new InvalidDiscountException('usage_limit_reached'); + } + + $subtotal = $cart->lines->sum('total'); + if ($discount->minimum_purchase !== null && $subtotal < $discount->minimum_purchase) { + throw new InvalidDiscountException('minimum_not_met'); + } + + return $discount; + } + + /** + * @param array $lines + */ + public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountResult(amount: 0, freeShipping: true, allocations: []); + } + + if ($discount->value_type === DiscountValueType::Fixed) { + $amount = min($discount->value_amount, $subtotal); + + return new DiscountResult(amount: $amount, freeShipping: false, allocations: []); + } + + // Percent (value_amount is stored as whole number, e.g. 10 = 10%) + $amount = (int) floor($subtotal * $discount->value_amount / 100); + + return new DiscountResult(amount: $amount, freeShipping: false, allocations: []); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 0000000..cdd35c1 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,89 @@ + $lines [order_line_id => quantity] + * @param array|null $tracking + */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded])) { + throw new FulfillmentGuardException; + } + + $fulfillment = $order->fulfillments()->create([ + 'tracking_company' => $tracking['company'] ?? null, + 'tracking_number' => $tracking['number'] ?? null, + 'tracking_url' => $tracking['url'] ?? null, + 'status' => FulfillmentShipmentStatus::Pending, + ]); + + foreach ($lines as $orderLineId => $quantity) { + $fulfillment->lines()->create([ + 'order_line_id' => $orderLineId, + 'quantity' => $quantity, + ]); + } + + $this->updateOrderFulfillmentStatus($order); + + return $fulfillment; + } + + /** + * @param array|null $tracking + */ + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Shipped, + 'shipped_at' => now(), + 'tracking_company' => $tracking['company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $tracking['number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $tracking['url'] ?? $fulfillment->tracking_url, + ]); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + $fulfillment->update([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'delivered_at' => now(), + ]); + + $this->updateOrderFulfillmentStatus($fulfillment->order); + } + + private function updateOrderFulfillmentStatus(Order $order): void + { + $order->refresh(); + $totalOrderedQty = $order->lines->sum('quantity'); + $totalFulfilledQty = $order->fulfillments() + ->with('lines') + ->get() + ->flatMap->lines + ->sum('quantity'); + + if ($totalFulfilledQty >= $totalOrderedQty) { + $order->update([ + 'fulfillment_status' => FulfillmentStatus::Fulfilled, + 'status' => OrderStatus::Fulfilled, + ]); + event(new OrderFulfilled($order)); + } elseif ($totalFulfilledQty > 0) { + $order->update(['fulfillment_status' => FulfillmentStatus::Partial]); + } + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 0000000..b16113b --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,68 @@ +policy === InventoryPolicy::Continue) { + return true; + } + + return $item->quantity_available >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->lockForUpdate(); + $item->refresh(); + + if (! $this->checkAvailability($item, $quantity)) { + throw new InsufficientInventoryException( + requested: $quantity, + available: $item->quantity_available, + ); + } + + $item->increment('quantity_reserved', $quantity); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->lockForUpdate(); + $item->refresh(); + + $item->decrement('quantity_reserved', min($quantity, $item->quantity_reserved)); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->lockForUpdate(); + $item->refresh(); + + $item->decrement('quantity_on_hand', $quantity); + $item->decrement('quantity_reserved', min($quantity, $item->quantity_reserved)); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + DB::transaction(function () use ($item, $quantity) { + $item->lockForUpdate(); + $item->refresh(); + + $item->increment('quantity_on_hand', $quantity); + }); + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 0000000..cb1cf17 --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,52 @@ + + */ + public function buildTree(NavigationMenu $menu): array + { + $storeId = $menu->store_id; + + return Cache::remember( + "nav_tree_{$storeId}_{$menu->id}", + 300, + function () use ($menu): array { + $items = $menu->items() + ->whereNull('parent_id') + ->orderBy('position') + ->with('children') + ->get(); + + return $items->map(fn (NavigationItem $item) => [ + 'id' => $item->id, + 'title' => $item->title, + 'url' => $this->resolveUrl($item), + 'children' => $item->children->map(fn (NavigationItem $child) => [ + 'id' => $child->id, + 'title' => $child->title, + 'url' => $this->resolveUrl($child), + ])->all(), + ])->all(); + } + ); + } + + public function resolveUrl(NavigationItem $item): string + { + return match ($item->type) { + NavigationItemType::Link => $item->url ?? '#', + NavigationItemType::Page => '/pages/'.$item->resource_id, + NavigationItemType::Collection => '/collections/'.$item->resource_id, + NavigationItemType::Product => '/products/'.$item->resource_id, + }; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 0000000..16f423c --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,164 @@ + $paymentDetails + */ + public function createFromCheckout(Checkout $checkout, array $paymentDetails = []): Order + { + return DB::transaction(function () use ($checkout, $paymentDetails) { + $cart = $checkout->cart()->with('lines.variant.product', 'lines.variant.inventoryItem')->first(); + $totals = $checkout->totals_json; + $paymentMethod = PaymentMethod::from($checkout->payment_method); + + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $checkout->customer_id, + 'order_number' => $this->generateOrderNumber($checkout->store), + 'email' => $checkout->email, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => $cart->currency, + 'subtotal' => $totals['subtotal'] ?? 0, + 'discount_amount' => $totals['discount'] ?? 0, + 'shipping_amount' => $totals['shipping'] ?? 0, + 'tax_amount' => $totals['tax_total'] ?? 0, + 'total' => $totals['total'] ?? 0, + 'shipping_address_json' => $checkout->shipping_address_json, + 'billing_address_json' => $checkout->billing_address_json, + 'discount_code' => $checkout->discount_code, + 'payment_method' => $checkout->payment_method, + 'placed_at' => now(), + 'totals_json' => $totals, + ]); + + foreach ($cart->lines as $line) { + $variant = $line->variant; + $product = $variant->product; + + $order->lines()->create([ + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'variant_title_snapshot' => $variant->is_default ? null : $variant->sku, + 'quantity' => $line->quantity, + 'unit_price' => $line->unit_price, + 'subtotal' => $line->subtotal, + 'total' => $line->total, + 'requires_shipping' => $variant->requires_shipping, + ]); + + if ($variant->inventoryItem) { + if (in_array($paymentMethod, [PaymentMethod::CreditCard, PaymentMethod::Paypal])) { + $this->inventoryService->commit($variant->inventoryItem, $line->quantity); + } else { + $this->inventoryService->reserve($variant->inventoryItem, $line->quantity); + } + } + } + + // Process payment + $paymentResult = $this->paymentProvider->charge($checkout, $paymentMethod, $paymentDetails); + + $payment = $order->payments()->create([ + 'method' => $paymentMethod, + 'provider' => 'mock', + 'provider_payment_id' => $paymentResult->providerPaymentId, + 'amount' => $order->total, + 'currency' => $order->currency, + 'status' => $paymentResult->success + ? PaymentStatus::from($paymentResult->status) + : PaymentStatus::Failed, + 'error_code' => $paymentResult->errorCode, + 'error_message' => $paymentResult->errorMessage, + 'captured_at' => $paymentResult->success && $paymentResult->status === 'captured' ? now() : null, + ]); + + if (! $paymentResult->success) { + throw new \App\Exceptions\PaymentFailedException( + $paymentResult->errorMessage ?? 'Payment failed.', + ); + } + + if ($paymentResult->status === 'captured') { + $order->update([ + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + ]); + } + + $cart->update(['status' => CartStatus::Converted]); + $checkout->update([ + 'status' => \App\Enums\CheckoutStatus::Completed, + 'completed_at' => now(), + ]); + + event(new OrderCreated($order)); + + return $order; + }); + } + + public function generateOrderNumber(Store $store): string + { + $lastOrder = Order::withoutGlobalScopes() + ->where('store_id', $store->id) + ->orderByDesc('id') + ->lockForUpdate() + ->first(); + + if (! $lastOrder) { + return '#1001'; + } + + $lastNumber = (int) str_replace('#', '', $lastOrder->order_number); + + return '#'.($lastNumber + 1); + } + + public function cancel(Order $order, string $reason): void + { + if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw new \App\Exceptions\FulfillmentGuardException('Cannot cancel a fulfilled order.'); + } + + $order->update([ + 'status' => OrderStatus::Cancelled, + 'cancelled_at' => now(), + 'cancel_reason' => $reason, + ]); + + // Release reserved inventory + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + if ($line->variant?->inventoryItem) { + $this->inventoryService->release($line->variant->inventoryItem, $line->quantity); + } + } + + event(new OrderCancelled($order)); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 0000000..6dda8a1 --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,73 @@ + $this->chargeCreditCard($details, $mockId), + PaymentMethod::Paypal => new PaymentResult( + success: true, + status: 'captured', + providerPaymentId: $mockId, + ), + PaymentMethod::BankTransfer => new PaymentResult( + success: true, + status: 'pending', + providerPaymentId: $mockId, + ), + }; + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return new RefundResult( + success: true, + providerRefundId: 'mock_'.Str::random(24), + ); + } + + /** + * @param array $details + */ + private function chargeCreditCard(array $details, string $mockId): PaymentResult + { + $cardNumber = $details['card_number'] ?? ''; + + if ($cardNumber === '4000000000000002') { + return new PaymentResult( + success: false, + status: 'failed', + errorCode: 'card_declined', + errorMessage: 'Your card was declined.', + ); + } + + if ($cardNumber === '4000000000009995') { + return new PaymentResult( + success: false, + status: 'failed', + errorCode: 'insufficient_funds', + errorMessage: 'Your card has insufficient funds.', + ); + } + + return new PaymentResult( + success: true, + status: 'captured', + providerPaymentId: $mockId, + ); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 0000000..0a72d57 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,54 @@ +cart()->with('lines.variant')->first(); + $subtotal = $cart->lines->sum(fn ($line) => $line->quantity * $line->unit_price); + + $discountAmount = $checkout->discount_amount; + $shippingAmount = $checkout->shipping_amount; + + $taxableAmount = $subtotal - $discountAmount; + $taxSettings = TaxSettings::find($checkout->store_id); + + $taxLines = []; + $taxTotal = 0; + + if ($taxSettings && $taxSettings->rate_basis_points > 0) { + $address = $checkout->shipping_address_json ?? []; + $taxResult = $this->taxCalculator->calculate($taxableAmount, $taxSettings, $address); + $taxLines = $taxResult['taxLines']; + $taxTotal = $taxResult['taxTotal']; + + if ($taxSettings->charge_tax_on_shipping && $shippingAmount > 0) { + $shippingTax = $this->taxCalculator->calculate($shippingAmount, $taxSettings, $address); + $taxTotal += $shippingTax['taxTotal']; + $taxLines = array_merge($taxLines, $shippingTax['taxLines']); + } + } + + $total = $subtotal - $discountAmount + $shippingAmount + $taxTotal; + + return new PricingResult( + subtotal: $subtotal, + discount: $discountAmount, + shipping: $shippingAmount, + taxLines: $taxLines, + taxTotal: $taxTotal, + total: max(0, $total), + currency: $cart->currency, + ); + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 0000000..d1f8a31 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,141 @@ +id); + + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $handle, + 'description_html' => $data['description_html'] ?? null, + 'status' => $data['status'] ?? ProductStatus::Draft, + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + 'published_at' => $data['published_at'] ?? null, + ]); + + if (! empty($data['options'])) { + foreach ($data['options'] as $position => $optionData) { + $option = $product->options()->create([ + 'name' => $optionData['name'], + 'position' => $position, + ]); + + foreach ($optionData['values'] as $valuePosition => $value) { + $option->values()->create([ + 'value' => $value, + 'position' => $valuePosition, + ]); + } + } + + if (! empty($data['variants'])) { + foreach ($data['variants'] as $variantPosition => $variantData) { + $variant = $product->variants()->create([ + 'sku' => $variantData['sku'] ?? null, + 'barcode' => $variantData['barcode'] ?? null, + 'price_amount' => $variantData['price_amount'] ?? 0, + 'compare_at_amount' => $variantData['compare_at_amount'] ?? null, + 'weight_g' => $variantData['weight_g'] ?? null, + 'position' => $variantPosition, + 'is_default' => $variantPosition === 0, + ]); + + $variant->inventoryItem()->create([ + 'store_id' => $store->id, + 'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0, + ]); + } + } + } else { + $variant = $product->variants()->create([ + 'price_amount' => $data['price_amount'] ?? 0, + 'compare_at_amount' => $data['compare_at_amount'] ?? null, + 'weight_g' => $data['weight_g'] ?? null, + 'is_default' => true, + 'position' => 0, + ]); + + $variant->inventoryItem()->create([ + 'store_id' => $store->id, + 'quantity_on_hand' => $data['quantity_on_hand'] ?? 0, + ]); + } + + return $product->load(['variants.inventoryItem', 'options.values']); + }); + } + + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data) { + if (isset($data['title']) && ! isset($data['handle'])) { + $data['handle'] = HandleGenerator::generate($data['title'], 'products', $product->store_id, $product->id); + } + + $product->update(array_intersect_key($data, array_flip([ + 'title', 'handle', 'description_html', 'status', 'vendor', + 'product_type', 'tags', 'published_at', + ]))); + + return $product->fresh(['variants.inventoryItem', 'options.values']); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + if ($product->status === ProductStatus::Draft && $newStatus === ProductStatus::Active) { + $hasPricedVariant = $product->variants() + ->where('status', VariantStatus::Active) + ->where('price_amount', '>', 0) + ->exists(); + + if (! $hasPricedVariant) { + throw new \InvalidArgumentException('Product must have at least one active variant with a price to be activated.'); + } + } + + if ($product->status === ProductStatus::Active && $newStatus === ProductStatus::Draft) { + $hasOrders = DB::table('order_lines') + ->where('product_id', $product->id) + ->exists(); + + if ($hasOrders) { + throw new \InvalidArgumentException('Cannot revert an active product with orders back to draft.'); + } + } + + $product->update(['status' => $newStatus]); + } + + public function delete(Product $product): void + { + if ($product->status !== ProductStatus::Draft) { + throw new \InvalidArgumentException('Only draft products can be deleted.'); + } + + $hasOrders = DB::table('order_lines') + ->where('product_id', $product->id) + ->exists(); + + if ($hasOrders) { + throw new \InvalidArgumentException('Cannot delete a product that has order references.'); + } + + $product->delete(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 0000000..409ce70 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,57 @@ +paymentProvider->refund($payment, $amount); + + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $result->success ? RefundStatus::Processed : RefundStatus::Failed, + 'restock' => $restock, + 'processed_at' => $result->success ? now() : null, + ]); + + if ($result->success) { + $totalRefunded = $order->refunds() + ->where('status', RefundStatus::Processed) + ->sum('amount'); + + $financialStatus = $totalRefunded >= $order->total + ? FinancialStatus::Refunded + : FinancialStatus::PartiallyRefunded; + + $order->update(['financial_status' => $financialStatus]); + + if ($restock) { + foreach ($order->lines()->with('variant.inventoryItem')->get() as $line) { + if ($line->variant?->inventoryItem) { + $this->inventoryService->restock($line->variant->inventoryItem, $line->quantity); + } + } + } + + event(new OrderRefunded($order)); + } + + return $refund; + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 0000000..e898b4e --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,127 @@ +sanitizeQuery($query); + + $matchingProductIds = collect(); + if ($ftsQuery !== '') { + $matchingProductIds = collect( + DB::select('SELECT rowid FROM products_fts WHERE products_fts MATCH ?', [$ftsQuery]) + )->pluck('rowid'); + } + + $productQuery = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active'); + + if ($ftsQuery !== '' && $matchingProductIds->isEmpty()) { + $productQuery->whereRaw('0 = 1'); + } elseif ($ftsQuery !== '') { + $productQuery->whereIn('id', $matchingProductIds); + } + + if (! empty($filters['vendor'])) { + $productQuery->where('vendor', $filters['vendor']); + } + + if (! empty($filters['product_type'])) { + $productQuery->where('product_type', $filters['product_type']); + } + + if (! empty($filters['collection_id'])) { + $productQuery->whereHas('collections', function ($q) use ($filters): void { + $q->where('collections.id', $filters['collection_id']); + }); + } + + if (! empty($filters['price_min']) || ! empty($filters['price_max'])) { + $productQuery->whereHas('variants', function ($q) use ($filters): void { + if (! empty($filters['price_min'])) { + $q->where('price', '>=', $filters['price_min']); + } + if (! empty($filters['price_max'])) { + $q->where('price', '<=', $filters['price_max']); + } + }); + } + + $results = $productQuery->paginate($perPage); + + SearchQuery::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'query' => $query, + 'results_count' => $results->total(), + 'created_at' => now(), + ]); + + return $results; + } + + public function autocomplete(Store $store, string $prefix, int $limit = 5): Collection + { + $sanitized = $this->sanitizeQuery($prefix); + if ($sanitized === '') { + return collect(); + } + + $prefixMatch = $sanitized.'*'; + + $rows = DB::select( + 'SELECT rowid FROM products_fts WHERE products_fts MATCH ? LIMIT ?', + [$prefixMatch, $limit * 2] + ); + + $productIds = collect($rows)->pluck('rowid'); + + return Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->whereIn('id', $productIds) + ->limit($limit) + ->get(['id', 'title', 'handle']); + } + + public function syncProduct(Product $product): void + { + $this->removeProduct($product->id); + + $tags = is_array($product->tags) ? implode(' ', $product->tags) : ($product->tags ?? ''); + + DB::statement( + 'INSERT INTO products_fts(rowid, title, description, vendor, product_type, tags) VALUES (?, ?, ?, ?, ?, ?)', + [ + $product->id, + $product->title ?? '', + strip_tags($product->description_html ?? ''), + $product->vendor ?? '', + $product->product_type ?? '', + $tags, + ] + ); + } + + public function removeProduct(int $productId): void + { + DB::statement('DELETE FROM products_fts WHERE rowid = ?', [$productId]); + } + + protected function sanitizeQuery(string $query): string + { + $query = trim($query); + $query = preg_replace('/[^\w\s]/u', '', $query); + + return trim($query); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 0000000..baae9f7 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,38 @@ + $address + */ + public function getAvailableRates(Store $store, array $address): Collection + { + $country = $address['country_code'] ?? $address['country'] ?? ''; + + return \App\Models\ShippingZone::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('is_active', true) + ->with(['rates' => fn ($q) => $q->where('is_active', true)]) + ->get() + ->filter(function ($zone) use ($country) { + if (empty($zone->countries_json)) { + return true; + } + + return in_array($country, $zone->countries_json); + }) + ->flatMap->rates; + } + + public function calculate(ShippingRate $rate, Cart $cart): int + { + return $rate->amount; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 0000000..3c16555 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,44 @@ + $address + * @return array{taxLines: array, taxTotal: int} + */ + public function calculate(int $amount, TaxSettings $settings, array $address): array + { + if ($settings->rate_basis_points <= 0) { + return ['taxLines' => [], 'taxTotal' => 0]; + } + + if ($settings->prices_include_tax) { + $taxAmount = $this->extractInclusive($amount, $settings->rate_basis_points); + } else { + $taxAmount = $this->addExclusive($amount, $settings->rate_basis_points); + } + + $taxLine = new TaxLine( + name: $settings->tax_name, + rate: $settings->rate_basis_points, + amount: $taxAmount, + ); + + return ['taxLines' => [$taxLine], 'taxTotal' => $taxAmount]; + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + return (int) floor($grossAmount * $rateBasisPoints / (10000 + $rateBasisPoints)); + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return (int) floor($netAmount * $rateBasisPoints / 10000); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 0000000..8442436 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,94 @@ +load(['options.values', 'variants']); + + $options = $product->options->sortBy('position'); + + if ($options->isEmpty()) { + return; + } + + $valueSets = $options->map(fn ($option) => $option->values->sortBy('position')->pluck('id')->all())->values()->all(); + + $combinations = $this->cartesianProduct($valueSets); + + $existingVariants = $product->variants->keyBy(function ($variant) { + return $variant->optionValues->pluck('id')->sort()->implode('-'); + }); + + $matchedVariantIds = []; + + foreach ($combinations as $position => $combination) { + $key = collect($combination)->sort()->implode('-'); + + if ($existingVariants->has($key)) { + $matchedVariantIds[] = $existingVariants[$key]->id; + } else { + $variant = $product->variants()->create([ + 'price_amount' => 0, + 'position' => $position, + 'status' => VariantStatus::Active, + ]); + + $variant->optionValues()->attach($combination); + + $variant->inventoryItem()->create([ + 'store_id' => $product->store_id, + ]); + + $matchedVariantIds[] = $variant->id; + } + } + + $orphanedVariants = $product->variants()->whereNotIn('id', $matchedVariantIds)->get(); + + foreach ($orphanedVariants as $variant) { + $hasOrders = DB::table('order_lines') + ->where('variant_id', $variant->id) + ->exists(); + + if ($hasOrders) { + $variant->update(['status' => VariantStatus::Archived]); + } else { + $variant->delete(); + } + } + }); + } + + /** + * @param array> $sets + * @return array> + */ + private function cartesianProduct(array $sets): array + { + if (empty($sets)) { + return []; + } + + $result = [[]]; + + foreach ($sets as $set) { + $newResult = []; + foreach ($result as $existing) { + foreach ($set as $item) { + $newResult[] = array_merge($existing, [$item]); + } + } + $result = $newResult; + } + + return $result; + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 0000000..7349c70 --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,34 @@ +where('store_id', $store->id) + ->where('status', 'active') + ->get() + ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void { + $eventTypes = $subscription->event_types_json ?? []; + if (in_array($eventType, $eventTypes) || in_array('*', $eventTypes)) { + DeliverWebhook::dispatch($subscription, $eventType, $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 + { + return hash_equals($this->sign($payload, $secret), $signature); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 0000000..d47c339 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,41 @@ +where('store_id', $storeId) + ->where('handle', $handle); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 0000000..62cba04 --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,77 @@ +stores()->where('stores.id', $storeId)->first(); + + if (! $store) { + return null; + } + + $role = $store->pivot->getAttribute('role'); + + if ($role instanceof StoreUserRole) { + return $role; + } + + /** @var int|string $role */ + return 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(?Model $model = null): ?int + { + if ($model && $model->getAttribute('store_id')) { + /** @var int $storeId */ + $storeId = $model->getAttribute('store_id'); + + return $storeId; + } + + if (app()->bound('current_store')) { + /** @var Store $store */ + $store = app('current_store'); + + return $store->id; + } + + return null; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 0000000..7b9b640 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,15 @@ + $allocations + */ + public function __construct( + public int $amount, + public bool $freeShipping, + public array $allocations, + ) {} +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 0000000..28aa0ac --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,14 @@ + $taxLines + */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + ) {} +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 0000000..42bdcb0 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,11 @@ +