Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abf5ced6d7 | |||
| 2a10f9af38 | |||
| b8cc8bd421 | |||
| dd950b5aba | |||
| 124ab46507 | |||
| 206ea2d35c | |||
| 027278c216 | |||
| 22dfb12948 | |||
| 5cd8c05a7c | |||
| 341421d2a0 | |||
| 66862df44b | |||
| 4a8b4ddac2 | |||
| ed57956694 | |||
| 93148ef9af | |||
| 5dfb63dacb | |||
| 09198a9f7d | |||
| 12f42d5efc | |||
| e503cd3479 | |||
| 3040681842 | |||
| bb6b92df10 | |||
| 74525e8955 | |||
| db0454d102 | |||
| 2ddb3dc19f | |||
| aa30111841 | |||
| 1c68fff301 | |||
| 291393d497 | |||
| 986f12f685 | |||
| 9fd138582a | |||
| d29f08bd98 | |||
| ca7d16da16 | |||
| 783cc88c6d | |||
| 12aadfd917 | |||
| 4730f5770c | |||
| 94b90f603d | |||
| e8e9b1f03c | |||
| a898c35a39 | |||
| 3c24c0e9ef | |||
| 00b8ff77b2 | |||
| 6de01c13c5 |
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,9 @@
|
||||
php:
|
||||
preset: laravel
|
||||
disabled:
|
||||
- no_unused_imports
|
||||
finder:
|
||||
not-name:
|
||||
- index.php
|
||||
js: true
|
||||
css: true
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# Release Notes
|
||||
|
||||
## [Unreleased](https://github.com/laravel/laravel/compare/v12.10.0...12.x)
|
||||
|
||||
## [v12.10.0](https://github.com/laravel/laravel/compare/v12.9.1...v12.10.0) - 2025-11-04
|
||||
|
||||
* Add background driver by [@barryvdh](https://github.com/barryvdh) in https://github.com/laravel/laravel/pull/6699
|
||||
|
||||
## [v12.9.1](https://github.com/laravel/laravel/compare/v12.9.0...v12.9.1) - 2025-10-23
|
||||
|
||||
* [12.x] Replace Bootcamp with Laravel Learn by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6692
|
||||
* [12.x] Comment out CLI workers for fresh applications by [@timacdonald](https://github.com/timacdonald) in https://github.com/laravel/laravel/pull/6693
|
||||
|
||||
## [v12.9.0](https://github.com/laravel/laravel/compare/v12.8.0...v12.9.0) - 2025-10-21
|
||||
|
||||
**Full Changelog**: https://github.com/laravel/laravel/compare/v12.8.0...v12.9.0
|
||||
|
||||
## [v12.8.0](https://github.com/laravel/laravel/compare/v12.7.1...v12.8.0) - 2025-10-20
|
||||
|
||||
* [12.x] Makes test suite using broadcast's `null` driver by [@nunomaduro](https://github.com/nunomaduro) in https://github.com/laravel/laravel/pull/6691
|
||||
|
||||
## [v12.7.1](https://github.com/laravel/laravel/compare/v12.7.0...v12.7.1) - 2025-10-15
|
||||
|
||||
* Added `failover` driver to the `queue` config comment. by [@sajjadhossainshohag](https://github.com/sajjadhossainshohag) in https://github.com/laravel/laravel/pull/6688
|
||||
|
||||
## [v12.7.0](https://github.com/laravel/laravel/compare/v12.6.0...v12.7.0) - 2025-10-14
|
||||
|
||||
**Full Changelog**: https://github.com/laravel/laravel/compare/v12.6.0...v12.7.0
|
||||
|
||||
## [v12.6.0](https://github.com/laravel/laravel/compare/v12.5.0...v12.6.0) - 2025-10-02
|
||||
|
||||
* Fix setup script by [@goldmont](https://github.com/goldmont) in https://github.com/laravel/laravel/pull/6682
|
||||
|
||||
## [v12.5.0](https://github.com/laravel/laravel/compare/v12.4.0...v12.5.0) - 2025-09-30
|
||||
|
||||
* [12.x] Fix type casting for environment variables in config files by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6670
|
||||
* Fix CVEs affecting vite by [@faissaloux](https://github.com/faissaloux) in https://github.com/laravel/laravel/pull/6672
|
||||
* Update .editorconfig to target compose.yaml by [@fredikaputra](https://github.com/fredikaputra) in https://github.com/laravel/laravel/pull/6679
|
||||
* Add pre-package-uninstall script to composer.json by [@cosmastech](https://github.com/cosmastech) in https://github.com/laravel/laravel/pull/6681
|
||||
|
||||
## [v12.4.0](https://github.com/laravel/laravel/compare/v12.3.1...v12.4.0) - 2025-08-29
|
||||
|
||||
* [12.x] Add default Redis retry configuration by [@mateusjatenee](https://github.com/mateusjatenee) in https://github.com/laravel/laravel/pull/6666
|
||||
|
||||
## [v12.3.1](https://github.com/laravel/laravel/compare/v12.3.0...v12.3.1) - 2025-08-21
|
||||
|
||||
* [12.x] Bump Pint version by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6653
|
||||
* [12.x] Making sure all related processed are closed when terminating the currently command by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6654
|
||||
* [12.x] Use application name from configuration by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6655
|
||||
* Bring back postAutoloadDump script by [@jasonvarga](https://github.com/jasonvarga) in https://github.com/laravel/laravel/pull/6662
|
||||
|
||||
## [v12.3.0](https://github.com/laravel/laravel/compare/v12.2.0...v12.3.0) - 2025-08-03
|
||||
|
||||
* Fix Critical Security Vulnerability in form-data Dependency by [@izzygld](https://github.com/izzygld) in https://github.com/laravel/laravel/pull/6645
|
||||
* Revert "fix" by [@RobertBoes](https://github.com/RobertBoes) in https://github.com/laravel/laravel/pull/6646
|
||||
* Change composer post-autoload-dump script to Artisan command by [@lmjhs](https://github.com/lmjhs) in https://github.com/laravel/laravel/pull/6647
|
||||
|
||||
## [v12.2.0](https://github.com/laravel/laravel/compare/v12.1.0...v12.2.0) - 2025-07-11
|
||||
|
||||
* Add Vite 7 support by [@timacdonald](https://github.com/timacdonald) in https://github.com/laravel/laravel/pull/6639
|
||||
|
||||
## [v12.1.0](https://github.com/laravel/laravel/compare/v12.0.11...v12.1.0) - 2025-07-03
|
||||
|
||||
* [12.x] Disable nightwatch in testing by [@laserhybiz](https://github.com/laserhybiz) in https://github.com/laravel/laravel/pull/6632
|
||||
* [12.x] Reorder environment variables in phpunit.xml for logical grouping by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6634
|
||||
* Change to hyphenate prefixes and cookie names by [@u01jmg3](https://github.com/u01jmg3) in https://github.com/laravel/laravel/pull/6636
|
||||
* [12.x] Fix type casting for environment variables in config files by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6637
|
||||
|
||||
## [v12.0.11](https://github.com/laravel/laravel/compare/v12.0.10...v12.0.11) - 2025-06-10
|
||||
|
||||
**Full Changelog**: https://github.com/laravel/laravel/compare/v12.0.10...v12.0.11
|
||||
|
||||
## [v12.0.10](https://github.com/laravel/laravel/compare/v12.0.9...v12.0.10) - 2025-06-09
|
||||
|
||||
* fix alphabetical order by [@Khuthaily](https://github.com/Khuthaily) in https://github.com/laravel/laravel/pull/6627
|
||||
* [12.x] Reduce redundancy and keeps the .gitignore file cleaner by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6629
|
||||
* [12.x] Fix: Add void return type to satisfy Rector analysis by [@Aluisio-Pires](https://github.com/Aluisio-Pires) in https://github.com/laravel/laravel/pull/6628
|
||||
|
||||
## [v12.0.9](https://github.com/laravel/laravel/compare/v12.0.8...v12.0.9) - 2025-05-26
|
||||
|
||||
* [12.x] Remove apc by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6611
|
||||
* [12.x] Add JSON Schema to package.json by [@martinbean](https://github.com/martinbean) in https://github.com/laravel/laravel/pull/6613
|
||||
* Minor language update by [@woganmay](https://github.com/woganmay) in https://github.com/laravel/laravel/pull/6615
|
||||
* Enhance .gitignore to exclude common OS and log files by [@mohammadRezaei1380](https://github.com/mohammadRezaei1380) in https://github.com/laravel/laravel/pull/6619
|
||||
|
||||
## [v12.0.8](https://github.com/laravel/laravel/compare/v12.0.7...v12.0.8) - 2025-05-12
|
||||
|
||||
* [12.x] Clean up URL formatting in README by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6601
|
||||
|
||||
## [v12.0.7](https://github.com/laravel/laravel/compare/v12.0.6...v12.0.7) - 2025-04-15
|
||||
|
||||
* Add `composer run test` command by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/laravel/pull/6598
|
||||
* Partner Directory Changes in ReadME by [@joshcirre](https://github.com/joshcirre) in https://github.com/laravel/laravel/pull/6599
|
||||
|
||||
## [v12.0.6](https://github.com/laravel/laravel/compare/v12.0.5...v12.0.6) - 2025-04-08
|
||||
|
||||
**Full Changelog**: https://github.com/laravel/laravel/compare/v12.0.5...v12.0.6
|
||||
|
||||
## [v12.0.5](https://github.com/laravel/laravel/compare/v12.0.4...v12.0.5) - 2025-04-02
|
||||
|
||||
* [12.x] Update `config/mail.php` to match the latest core configuration by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6594
|
||||
|
||||
## [v12.0.4](https://github.com/laravel/laravel/compare/v12.0.3...v12.0.4) - 2025-03-31
|
||||
|
||||
* Bump vite from 6.0.11 to 6.2.3 - Vulnerability patch by [@abdel-aouby](https://github.com/abdel-aouby) in https://github.com/laravel/laravel/pull/6586
|
||||
* Bump vite from 6.2.3 to 6.2.4 by [@thinkverse](https://github.com/thinkverse) in https://github.com/laravel/laravel/pull/6590
|
||||
|
||||
## [v12.0.3](https://github.com/laravel/laravel/compare/v12.0.2...v12.0.3) - 2025-03-17
|
||||
|
||||
* Remove reverted change from CHANGELOG.md by [@AJenbo](https://github.com/AJenbo) in https://github.com/laravel/laravel/pull/6565
|
||||
* Improves clarity in app.css file by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6569
|
||||
* [12.x] Refactor: Structural improvement for clarity by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6574
|
||||
* Bump axios from 1.7.9 to 1.8.2 - Vulnerability patch by [@abdel-aouby](https://github.com/abdel-aouby) in https://github.com/laravel/laravel/pull/6572
|
||||
* [12.x] Remove Unnecessarily [@source](https://github.com/source) by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/laravel/pull/6584
|
||||
|
||||
## [v12.0.2](https://github.com/laravel/laravel/compare/v12.0.1...v12.0.2) - 2025-03-04
|
||||
|
||||
* Make the github test action run out of the box independent of the choice of testing framework by [@ndeblauw](https://github.com/ndeblauw) in https://github.com/laravel/laravel/pull/6555
|
||||
|
||||
## [v12.0.1](https://github.com/laravel/laravel/compare/v12.0.0...v12.0.1) - 2025-02-24
|
||||
|
||||
* [12.x] prefer stable stability by [@pataar](https://github.com/pataar) in https://github.com/laravel/laravel/pull/6548
|
||||
|
||||
## [v12.0.0 (2025-??-??)](https://github.com/laravel/laravel/compare/v11.0.2...v12.0.0)
|
||||
|
||||
Laravel 12 includes a variety of changes to the application skeleton. Please consult the diff to see what's new.
|
||||
@@ -1,2 +1,59 @@
|
||||
# additional_design
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# Shiplogic Integration Testing Guide
|
||||
|
||||
This guide explains how to test the Shiplogic API integration with mocked responses.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. **routes/shiplogic-mock.php** - Mock API Endpoints
|
||||
Routes that simulate the Shiplogic API responses for local testing without hitting the live API.
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/v1/mock/rates` - Returns available shipping rates/service levels
|
||||
- `POST /api/v1/mock/shipments` - Returns created shipment confirmation
|
||||
- `GET /api/v1/mock/shipments/label` - Returns waybill/label PDF
|
||||
- `GET /api/v1/mock/shipments/label/stickers` - Returns sticker labels PDF
|
||||
|
||||
**To Enable:**
|
||||
Add this line to `routes/web.php`:
|
||||
```php
|
||||
include base_path('routes/shiplogic-mock.php');
|
||||
```
|
||||
|
||||
Then mock routes are available at `http://localhost:8000/api/v1/mock/*`
|
||||
|
||||
### 2. **app/Testing/ShiplogicMockClient.php** - Test Helper
|
||||
PHP class for setting up HTTP mocking in tests. Uses Laravel's `Http::fake()` to intercept HTTP requests.
|
||||
|
||||
**Usage in Tests:**
|
||||
```php
|
||||
public function test_something()
|
||||
{
|
||||
ShiplogicMockClient::setup();
|
||||
|
||||
// Now all requests to shiplogic.* URLs will return mocked responses
|
||||
// Run your shipment creation logic here
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **tests/Feature/ShipmentCreationTest.php** - Example Tests
|
||||
Two example test cases demonstrating:
|
||||
- End-to-end shipment creation workflow
|
||||
- ECO service level selection verification
|
||||
|
||||
## Test Flow
|
||||
|
||||
### Local Testing with Mock Routes
|
||||
|
||||
1. **Start Laravel server:**
|
||||
```bash
|
||||
php artisan serve
|
||||
```
|
||||
|
||||
2. **Update CourierService.php** to point to mock endpoints temporarily:
|
||||
```php
|
||||
// In CourierService.php constructor or config
|
||||
// Change: $this->baseUrl = config('services.shiplogic.api_base_url');
|
||||
// To test locally: $this->baseUrl = 'http://localhost:8000/api/v1/mock';
|
||||
```
|
||||
|
||||
3. **Trigger shipment creation** via Filament UI or directly:
|
||||
```php
|
||||
// Create an order
|
||||
$order = Order::factory()->create([...]);
|
||||
|
||||
// Trigger ReadyToShipIntent event
|
||||
event(new App\Events\ReadyToShipIntent($order));
|
||||
```
|
||||
|
||||
4. **Check logs** for detailed request/response logging:
|
||||
```bash
|
||||
tail -f storage/logs/laravel.log
|
||||
```
|
||||
|
||||
### Unit/Feature Testing with HTTP Mocking
|
||||
|
||||
1. **Run the test:**
|
||||
```bash
|
||||
php artisan test tests/Feature/ShipmentCreationTest.php
|
||||
```
|
||||
|
||||
2. **Test will:**
|
||||
- Mock all HTTP requests to shiplogic API
|
||||
- Create test order with required fields
|
||||
- Trigger shipment creation
|
||||
- Assert order has shipment metadata
|
||||
- Assert PDFs were stored locally
|
||||
|
||||
## Logging Details
|
||||
|
||||
The enhanced CourierService now logs at each stage:
|
||||
|
||||
### GET Rates Request
|
||||
```
|
||||
INFO: Fetching shipping rates from Shiplogic
|
||||
- order_uuid: 019b8335-4b47-7087-8b93-73f6aa39ee7a
|
||||
- api_url: https://api.shiplogic.com/rates
|
||||
- collection_address: {...}
|
||||
- delivery_address: {...}
|
||||
- parcel_dimensions: {...}
|
||||
```
|
||||
|
||||
### GET Rates Response
|
||||
```
|
||||
INFO: Rates API response received
|
||||
- status: 200
|
||||
- successful: true
|
||||
|
||||
OR
|
||||
|
||||
ERROR: Rates API error response
|
||||
- status: 400 (or other error code)
|
||||
- error_message: Invalid address format
|
||||
- full_response: {...}
|
||||
```
|
||||
|
||||
### CREATE Shipment Request
|
||||
```
|
||||
INFO: Creating Shiplogic shipment
|
||||
- order_uuid: 019b8335-4b47-7087-8b93-73f6aa39ee7a
|
||||
- order_number: ORDER-001
|
||||
- customer: John Doe
|
||||
- delivery_address: Apt 5B, 123 Main Street
|
||||
- service_level: FEDEX_INTERNATIONAL_ECONOMY
|
||||
- api_url: https://api.shiplogic.com/shipments
|
||||
- payload: {...}
|
||||
```
|
||||
|
||||
### CREATE Shipment Response
|
||||
```
|
||||
INFO: Shipment created in API
|
||||
- shipment_id: 550e8400-e29b-41d4-a716-446655440002
|
||||
- tracking_reference: SHP123456789
|
||||
```
|
||||
|
||||
## Debugging "Unknown Error"
|
||||
|
||||
If you see `ERROR: Failed to get shipping rates {"error":"Failed to fetch rates: Unknown error"}`:
|
||||
|
||||
1. **Enable detailed logging** - Now included in updated CourierService
|
||||
2. **Check the full API response** - Logs will now show the actual error response
|
||||
3. **Common issues:**
|
||||
- Invalid API key format
|
||||
- Missing required address fields
|
||||
- Invalid parcel dimensions
|
||||
- API endpoint URL incorrect
|
||||
- Network/SSL certificate issues
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all shipment creation tests
|
||||
php artisan test tests/Feature/ShipmentCreationTest.php
|
||||
|
||||
# Run specific test
|
||||
php artisan test tests/Feature/ShipmentCreationTest.php::test_shipment_creation_with_mock_api
|
||||
|
||||
# Run with verbose output
|
||||
php artisan test tests/Feature/ShipmentCreationTest.php -v
|
||||
|
||||
# Run and dump test database
|
||||
php artisan test tests/Feature/ShipmentCreationTest.php --debug
|
||||
```
|
||||
|
||||
## Mock Response Structure
|
||||
|
||||
All mock responses follow the actual Shiplogic API structure:
|
||||
|
||||
### Rates Response
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"company_shipment_rates": [
|
||||
{
|
||||
"id": "9f67ff00-7a82-4d81-9481-4c5d8c8f1a01",
|
||||
"company_code": "FEDEX",
|
||||
"company_name": "FedEx",
|
||||
"service_level": {
|
||||
"id": "123456790",
|
||||
"code": "FEDEX_INTERNATIONAL_ECONOMY",
|
||||
"name": "International Economy",
|
||||
"description": "Economy service (ECO)"
|
||||
},
|
||||
"rate": 45.75,
|
||||
"currency": "GBP",
|
||||
"transit_days": "5-7",
|
||||
"delivery_guarantee_date": "2026-01-09"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Shipments Response
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"short_tracking_reference": "SHP123456789",
|
||||
"tracking_reference": "SHP-123456789-ABC",
|
||||
"customer_reference": "ORDER-12345",
|
||||
"company_code": "FEDEX",
|
||||
"service_level": {
|
||||
"code": "FEDEX_INTERNATIONAL_ECONOMY",
|
||||
"name": "International Economy"
|
||||
},
|
||||
"collection_min_date": "2026-01-04",
|
||||
"delivery_min_date": "2026-01-09",
|
||||
"status": "created",
|
||||
"created_at": "2026-01-03T12:42:56.000000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Add detailed logging to CourierService
|
||||
2. ✅ Create mock API routes and test helper
|
||||
3. **TODO:** Test with actual order to capture real error
|
||||
4. **TODO:** Fix identified Shiplogic API integration issue
|
||||
5. **TODO:** Verify PDFs are being fetched and attached correctly
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
Key configuration files for Shiplogic:
|
||||
- `config/services.php` - API credentials and collection address
|
||||
- `config/courier.php` - Courier service settings
|
||||
- `.env` - Environment variables (SHIPLOGIC_API_URL, SHIPLOGIC_API_KEY, collection address details)
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\TrelloService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TestTrello extends Command
|
||||
{
|
||||
protected $signature = 'trello:test';
|
||||
protected $description = 'Test Trello integration with dummy data';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Testing Trello integration...');
|
||||
$this->newLine();
|
||||
|
||||
// Check environment variables
|
||||
$this->info('Checking environment variables:');
|
||||
$apiKey = config('trello.api_key');
|
||||
$apiToken = config('trello.api_token');
|
||||
$boardId = config('trello.board_id');
|
||||
|
||||
$this->line(' API Key: ' . ($apiKey ? '✓ Set (' . substr($apiKey, 0, 8) . '...)' : '✗ Empty'));
|
||||
$this->line(' API Token: ' . ($apiToken ? '✓ Set (' . substr($apiToken, 0, 8) . '...)' : '✗ Empty'));
|
||||
$this->line(' Board ID: ' . ($boardId ? '✓ Set (' . $boardId . ')' : '✗ Empty'));
|
||||
$this->newLine();
|
||||
|
||||
// Check list IDs
|
||||
$this->info('Checking list IDs (standard):');
|
||||
$lists = config('trello.lists.standard');
|
||||
foreach ($lists as $key => $id) {
|
||||
$this->line(' ' . str_replace('_', ' ', ucfirst($key)) . ': ' . ($id ? '✓ ' . $id : '✗ Empty'));
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
// Try to create a test card
|
||||
$this->info('Attempting to create a test card...');
|
||||
try {
|
||||
$service = new TrelloService();
|
||||
|
||||
$cardId = $service->createCard(
|
||||
'test-uuid-12345',
|
||||
'TEST-20260102-00000000',
|
||||
'standard'
|
||||
);
|
||||
|
||||
if ($cardId) {
|
||||
$this->info("✓ Test card created successfully!");
|
||||
$this->line(" Card ID: {$cardId}");
|
||||
$this->newLine();
|
||||
|
||||
// Try to move the card
|
||||
$this->info('Attempting to move test card to "Packing"...');
|
||||
$moved = $service->moveCard($cardId, 'Packing');
|
||||
if ($moved) {
|
||||
$this->info('✓ Card moved successfully!');
|
||||
} else {
|
||||
$this->warn('✗ Failed to move card (check list names)');
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
$this->info('Testing complete! Trello is configured correctly.');
|
||||
} else {
|
||||
$this->warn('✗ Failed to create test card');
|
||||
$this->line('Check logs for more details: storage/logs/laravel.log');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error('✗ Exception: ' . $e->getMessage());
|
||||
Log::error('Trello test command failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BalancePaid
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public float $balanceAmount,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DepositPaid
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public float $depositAmount,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InspectionFailed
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $reason = '',
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InspectionPassed
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('channel-name'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderCreated
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $orderType = 'standard', // 'standard' or 'custom'
|
||||
) {
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('channel-name'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToAwaitingApproval
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToInspection
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToPrep
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToPrinting
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderMovedToReadyForPrint
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order|CustomOrder $order
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderPacked
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public float $width,
|
||||
public float $length,
|
||||
public float $height,
|
||||
public float $weight,
|
||||
public ?int $packedBy = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ParcelCollected
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $waybillId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ParcelDelivered
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $waybillId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ParcelFailedDelivery
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $waybillId,
|
||||
public string $failureReason = '',
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ParcelInTransit
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $waybillId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ProofApproved
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CustomOrder $customOrder,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ProofRevisionRequested
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CustomOrder $customOrder,
|
||||
public string $revisionNotes = '',
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ProofUploaded
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CustomOrder $customOrder,
|
||||
public string $proofFilePath,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ReadyToShipIntent
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public ?string $trelloCardId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ShipmentCreated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $waybillId,
|
||||
public string $trackingNumber,
|
||||
public ?string $stickerPath = null,
|
||||
public ?string $waybillPath = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ShipmentCreationFailed
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $errorMessage,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\CategoryResource\Pages;
|
||||
use App\Models\Category;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CategoryResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Category::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-tag';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('products_count')
|
||||
->counts('products')
|
||||
->label('Products'),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
//
|
||||
])
|
||||
->bulkActions([
|
||||
//
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ManageCategories::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\CategoryResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageCategories extends ManageRecords
|
||||
{
|
||||
protected static string $resource = CategoryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Models\HeroImage;
|
||||
use App\Filament\Resources\HeroImageResource\Pages;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class HeroImageResource extends Resource
|
||||
{
|
||||
protected static ?string $model = HeroImage::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-photo';
|
||||
|
||||
protected static ?string $navigationLabel = 'Hero Images';
|
||||
|
||||
protected static ?string $modelLabel = 'Hero Image';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Hero Images';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\Select::make('page')
|
||||
->label('Page')
|
||||
->options(HeroImage::getPages())
|
||||
->required()
|
||||
->helperText('Choose which page this hero image will appear on'),
|
||||
|
||||
Forms\Components\FileUpload::make('image_path')
|
||||
->label('Hero Image')
|
||||
->image()
|
||||
->required()
|
||||
->disk('public')
|
||||
->directory('hero-images')
|
||||
->maxSize(5120)
|
||||
->acceptedFileTypes(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
|
||||
->helperText('Upload a high-quality image (max 5MB). Recommended size: 1920x1080px'),
|
||||
|
||||
Forms\Components\TextInput::make('title')
|
||||
->label('Heading')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Main heading text displayed on the hero image'),
|
||||
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('Description')
|
||||
->maxLength(500)
|
||||
->rows(3)
|
||||
->helperText('Subheading text displayed below the title'),
|
||||
|
||||
Forms\Components\TextInput::make('button_text')
|
||||
->label('Button Text')
|
||||
->maxLength(100)
|
||||
->helperText('Text for the call-to-action button (optional)'),
|
||||
|
||||
Forms\Components\TextInput::make('button_link')
|
||||
->label('Button Link')
|
||||
->url()
|
||||
->maxLength(255)
|
||||
->helperText('URL or page anchor (e.g., #products or /products)'),
|
||||
|
||||
Forms\Components\TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Lower numbers appear first in the slider'),
|
||||
|
||||
Forms\Components\Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Disable to hide this image from the website'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('page')
|
||||
->label('Page')
|
||||
->sortable()
|
||||
->formatStateUsing(fn($state) => HeroImage::getPages()[$state] ?? $state)
|
||||
->badge(),
|
||||
|
||||
Tables\Columns\ImageColumn::make('image_path')
|
||||
->label('Image')
|
||||
->size(80),
|
||||
|
||||
Tables\Columns\TextColumn::make('title')
|
||||
->label('Title')
|
||||
->searchable()
|
||||
->limit(50),
|
||||
|
||||
Tables\Columns\TextColumn::make('sort_order')
|
||||
->label('Order')
|
||||
->sortable()
|
||||
->numeric(),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime('M d, Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('page')
|
||||
->options(HeroImage::getPages()),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label('Active')
|
||||
->native(false),
|
||||
])
|
||||
->actions([
|
||||
//
|
||||
])
|
||||
->bulkActions([
|
||||
//
|
||||
])
|
||||
->defaultSort('page', 'asc')
|
||||
->defaultSort('sort_order', 'asc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListHeroImages::route('/'),
|
||||
'create' => Pages\CreateHeroImage::route('/create'),
|
||||
'edit' => Pages\EditHeroImage::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\HeroImageResource\Pages;
|
||||
|
||||
use App\Filament\Resources\HeroImageResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateHeroImage extends CreateRecord
|
||||
{
|
||||
protected static string $resource = HeroImageResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\HeroImageResource\Pages;
|
||||
|
||||
use App\Filament\Resources\HeroImageResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditHeroImage extends EditRecord
|
||||
{
|
||||
protected static string $resource = HeroImageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\HeroImageResource\Pages;
|
||||
|
||||
use App\Filament\Resources\HeroImageResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListHeroImages extends ListRecords
|
||||
{
|
||||
protected static string $resource = HeroImageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('Add New Hero Image'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OrderResource\Pages;
|
||||
use App\Models\Order;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class OrderResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Order::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shopping-bag';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('order_number')
|
||||
->required()
|
||||
->disabled()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('customer_name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('customer_email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('customer_phone')
|
||||
->tel()
|
||||
->maxLength(20),
|
||||
Forms\Components\Textarea::make('shipping_address')
|
||||
->required()
|
||||
->maxLength(500)
|
||||
->columnSpanFull(),
|
||||
Forms\Components\Textarea::make('notes')
|
||||
->maxLength(500)
|
||||
->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('total')
|
||||
->required()
|
||||
->numeric()
|
||||
->prefix('R'),
|
||||
Forms\Components\Select::make('status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'processing' => 'Processing',
|
||||
'completed' => 'Completed',
|
||||
'cancelled' => 'Cancelled',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\Select::make('payment_status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'paid' => 'Paid',
|
||||
'failed' => 'Failed',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('payment_method')
|
||||
->maxLength(50),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('order_number')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('customer_name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('customer_email')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('total')
|
||||
->money('ZAR')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
'processing' => 'info',
|
||||
'completed' => 'success',
|
||||
'cancelled' => 'danger',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('payment_status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
'paid' => 'success',
|
||||
'failed' => 'danger',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'processing' => 'Processing',
|
||||
'completed' => 'Completed',
|
||||
'cancelled' => 'Cancelled',
|
||||
]),
|
||||
Tables\Filters\SelectFilter::make('payment_status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'paid' => 'Paid',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
//
|
||||
])
|
||||
->bulkActions([
|
||||
//
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListOrders::route('/'),
|
||||
'create' => Pages\CreateOrder::route('/create'),
|
||||
'view' => Pages\ViewOrder::route('/{record}'),
|
||||
'edit' => Pages\EditOrder::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OrderResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrderResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateOrder extends CreateRecord
|
||||
{
|
||||
protected static string $resource = OrderResource::class;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OrderResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrderResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditOrder extends EditRecord
|
||||
{
|
||||
protected static string $resource = OrderResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OrderResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrderResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListOrders extends ListRecords
|
||||
{
|
||||
protected static string $resource = OrderResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OrderResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrderResource;
|
||||
use App\Filament\Resources\OrderResource\Widgets\OrderItemsTable;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
class ViewOrder extends ViewRecord
|
||||
{
|
||||
protected static string $resource = OrderResource::class;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Order #' . $this->record->order_number;
|
||||
}
|
||||
|
||||
public function getHeading(): string | Htmlable
|
||||
{
|
||||
return 'Order #' . $this->record->order_number;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
OrderItemsTable::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHeaderWidgetsColumns(): int | array
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OrderResource\Widgets;
|
||||
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class OrderItemsTable extends BaseWidget
|
||||
{
|
||||
public ?Model $record = null;
|
||||
|
||||
protected static ?string $heading = 'Order Items';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
fn () => $this->record->items()->getQuery()
|
||||
)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('product.name')
|
||||
->label('Product'),
|
||||
Tables\Columns\TextColumn::make('printStock.name')
|
||||
->label('Print Stock')
|
||||
->default('N/A'),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->badge(),
|
||||
Tables\Columns\TextColumn::make('specifications')
|
||||
->label('Specifications')
|
||||
->getStateUsing(function ($record) {
|
||||
if ($record->type === 'wallpaper') {
|
||||
return "Length: {$record->length}m";
|
||||
} elseif ($record->type === 'mural') {
|
||||
$area = number_format($record->width * $record->height, 2);
|
||||
return "{$record->width}m × {$record->height}m ({$area}m²)";
|
||||
}
|
||||
return 'N/A';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('price')
|
||||
->money('ZAR')
|
||||
->alignEnd(),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PrintStockResource\Pages;
|
||||
use App\Models\PrintStock;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PrintStockResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PrintStock::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cube';
|
||||
|
||||
protected static ?string $navigationLabel = 'Print Stocks';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('cost_per_meter')
|
||||
->required()
|
||||
->numeric()
|
||||
->prefix('R')
|
||||
->label('Cost per Meter'),
|
||||
Forms\Components\TextInput::make('cost_per_m2')
|
||||
->required()
|
||||
->numeric()
|
||||
->prefix('R')
|
||||
->label('Cost per m²'),
|
||||
Forms\Components\Select::make('type')
|
||||
->options([
|
||||
'wallpaper' => 'Wallpaper',
|
||||
'mural' => 'Mural',
|
||||
'both' => 'Both',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('width')
|
||||
->label('Width (meters)')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->minValue(0.1)
|
||||
->default(1)
|
||||
->helperText('Width of wallpaper roll in meters'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('cost_per_meter')
|
||||
->money('ZAR')
|
||||
->label('Cost/m')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('cost_per_m2')
|
||||
->money('ZAR')
|
||||
->label('Cost/m²')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'wallpaper' => 'success',
|
||||
'mural' => 'info',
|
||||
'both' => 'warning',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->options([
|
||||
'wallpaper' => 'Wallpaper',
|
||||
'mural' => 'Mural',
|
||||
'both' => 'Both',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
//
|
||||
])
|
||||
->bulkActions([
|
||||
//
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ManagePrintStocks::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PrintStockResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PrintStockResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManagePrintStocks extends ManageRecords
|
||||
{
|
||||
protected static string $resource = PrintStockResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ProductResource\Pages;
|
||||
use App\Models\Product;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProductResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Product::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Select::make('category_id')
|
||||
->relationship('category', 'name')
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('price')
|
||||
->required()
|
||||
->numeric()
|
||||
->prefix('R'),
|
||||
Forms\Components\TextInput::make('stock')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(100),
|
||||
Forms\Components\FileUpload::make('images')
|
||||
->multiple()
|
||||
->disk('public')
|
||||
->directory('products')
|
||||
->reorderable()
|
||||
->maxSize(5120)
|
||||
->acceptedFileTypes(['image/jpeg', 'image/png', 'image/webp']),
|
||||
Forms\Components\Select::make('type')
|
||||
->options([
|
||||
'wallpaper' => 'Wallpaper',
|
||||
'mural' => 'Mural',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\Select::make('printStocks')
|
||||
->relationship('printStocks', 'name')
|
||||
->multiple()
|
||||
->preload(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('images.image_path')
|
||||
->label('Image')
|
||||
->disk('public')
|
||||
->getStateUsing(function ($record) {
|
||||
return $record->images->first()?->image_path;
|
||||
})
|
||||
->defaultImageUrl('/images/placeholder.png')
|
||||
->circular()
|
||||
->size(60),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('category.name')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('price')
|
||||
->money('ZAR')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('stock')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'wallpaper' => 'success',
|
||||
'mural' => 'info',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->options([
|
||||
'wallpaper' => 'Wallpaper',
|
||||
'mural' => 'Mural',
|
||||
]),
|
||||
Tables\Filters\SelectFilter::make('category')
|
||||
->relationship('category', 'name'),
|
||||
])
|
||||
->actions([
|
||||
//
|
||||
])
|
||||
->bulkActions([
|
||||
//
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListProducts::route('/'),
|
||||
'create' => Pages\CreateProduct::route('/create'),
|
||||
'edit' => Pages\EditProduct::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ProductResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProductResource;
|
||||
use App\Models\ProductImage;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateProduct extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ProductResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
// Store images in a temporary property
|
||||
if (isset($data['images']) && is_array($data['images'])) {
|
||||
$this->pendingImages = $data['images'];
|
||||
}
|
||||
unset($data['images']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
if (!isset($this->pendingImages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->pendingImages as $index => $imagePath) {
|
||||
ProductImage::create([
|
||||
'product_id' => $this->record->id,
|
||||
'image_path' => $imagePath,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ProductResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProductResource;
|
||||
use App\Models\ProductImage;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditProduct extends EditRecord
|
||||
{
|
||||
protected static string $resource = ProductResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
// Load existing images for the form
|
||||
$data['images'] = $this->record->images()->orderBy('sort_order')->pluck('image_path')->toArray();
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
// Store images for processing after save
|
||||
$this->pendingImages = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
if (!isset($this->pendingImages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newImages = $this->pendingImages;
|
||||
$existingImages = $this->record->images()->pluck('image_path')->toArray();
|
||||
|
||||
// Find images to delete (in existing but not in new)
|
||||
$imagesToDelete = array_diff($existingImages, $newImages);
|
||||
foreach ($imagesToDelete as $imagePath) {
|
||||
$this->record->images()->where('image_path', $imagePath)->delete();
|
||||
}
|
||||
|
||||
// Add new images and update sort order
|
||||
foreach ($newImages as $index => $imagePath) {
|
||||
ProductImage::firstOrCreate(
|
||||
[
|
||||
'product_id' => $this->record->id,
|
||||
'image_path' => $imagePath,
|
||||
],
|
||||
[
|
||||
'sort_order' => $index,
|
||||
]
|
||||
)->update(['sort_order' => $index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ProductResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProductResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProducts extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProductResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Filament\Resources\OrderResource;
|
||||
use App\Models\Order;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
|
||||
class LatestOrders extends BaseWidget
|
||||
{
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected int | string | array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(OrderResource::getEloquentQuery()->latest()->limit(10))
|
||||
->defaultPaginationPageOption(5)
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('order_number')
|
||||
->label('Order #')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->url(fn (Order $record): string => OrderResource::getUrl('view', ['record' => $record]))
|
||||
->color('primary'),
|
||||
Tables\Columns\TextColumn::make('customer_name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('total')
|
||||
->money('ZAR')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
'processing' => 'info',
|
||||
'completed' => 'success',
|
||||
'cancelled' => 'danger',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('payment_status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
'paid' => 'success',
|
||||
'failed' => 'danger',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->since(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getTableHeading(): string
|
||||
{
|
||||
return 'Latest Orders';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Order;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RevenueChart extends ChartWidget
|
||||
{
|
||||
protected ?string $heading = 'Revenue (Last 7 Days)';
|
||||
|
||||
protected static ?int $sort = 3;
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$data = Order::where('payment_status', 'paid')
|
||||
->where('created_at', '>=', now()->subDays(7))
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('SUM(total) as revenue')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
$labels = [];
|
||||
$revenues = [];
|
||||
|
||||
// Fill in last 7 days
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$date = now()->subDays($i)->format('Y-m-d');
|
||||
$dayName = now()->subDays($i)->format('D');
|
||||
$labels[] = $dayName;
|
||||
|
||||
$dayData = $data->firstWhere('date', $date);
|
||||
$revenues[] = $dayData ? $dayData->revenue : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Revenue (R)',
|
||||
'data' => $revenues,
|
||||
'backgroundColor' => 'rgba(59, 130, 246, 0.1)',
|
||||
'borderColor' => 'rgb(59, 130, 246)',
|
||||
'fill' => true,
|
||||
],
|
||||
],
|
||||
'labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'scales' => [
|
||||
'y' => [
|
||||
'beginAtZero' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class StatsOverview extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$totalOrders = Order::count();
|
||||
$pendingOrders = Order::where('status', 'pending')->count();
|
||||
$processingOrders = Order::where('status', 'processing')->count();
|
||||
$lowStockProducts = Product::where('stock', '<', 10)->count();
|
||||
|
||||
$totalRevenue = Order::where('payment_status', 'paid')->sum('total');
|
||||
$monthlyRevenue = Order::where('payment_status', 'paid')
|
||||
->whereMonth('created_at', now()->month)
|
||||
->whereYear('created_at', now()->year)
|
||||
->sum('total');
|
||||
|
||||
$todayOrders = Order::whereDate('created_at', today())->count();
|
||||
$todayRevenue = Order::where('payment_status', 'paid')
|
||||
->whereDate('created_at', today())
|
||||
->sum('total');
|
||||
|
||||
return [
|
||||
Stat::make('Pending Orders', $pendingOrders)
|
||||
->description('Awaiting processing')
|
||||
->descriptionIcon('heroicon-m-clock')
|
||||
->color('warning')
|
||||
->chart([7, 5, 10, 5, $pendingOrders]),
|
||||
|
||||
Stat::make('Processing Orders', $processingOrders)
|
||||
->description('Currently being processed')
|
||||
->descriptionIcon('heroicon-m-arrow-path')
|
||||
->color('info'),
|
||||
|
||||
Stat::make('Low Stock Alert', $lowStockProducts)
|
||||
->description('Products with less than 10 units')
|
||||
->descriptionIcon('heroicon-m-exclamation-triangle')
|
||||
->color($lowStockProducts > 0 ? 'danger' : 'success'),
|
||||
|
||||
Stat::make('Today\'s Orders', $todayOrders)
|
||||
->description('Orders placed today')
|
||||
->descriptionIcon('heroicon-m-shopping-bag')
|
||||
->color('success'),
|
||||
|
||||
Stat::make('Today\'s Revenue', 'R' . number_format($todayRevenue, 2))
|
||||
->description(now()->format('l, F j'))
|
||||
->descriptionIcon('heroicon-m-banknotes')
|
||||
->color('success'),
|
||||
|
||||
Stat::make('Monthly Revenue', 'R' . number_format($monthlyRevenue, 2))
|
||||
->description(now()->format('F Y'))
|
||||
->descriptionIcon('heroicon-m-chart-bar')
|
||||
->color('info'),
|
||||
|
||||
Stat::make('Total Revenue', 'R' . number_format($totalRevenue, 2))
|
||||
->description('All time revenue')
|
||||
->descriptionIcon('heroicon-m-currency-dollar')
|
||||
->color('success'),
|
||||
|
||||
Stat::make('Total Orders', $totalOrders)
|
||||
->description('All time orders')
|
||||
->descriptionIcon('heroicon-m-shopping-cart')
|
||||
->color('primary'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
if (!function_exists('progress_log')) {
|
||||
/**
|
||||
* Write a progress log entry to a file in the project root
|
||||
*
|
||||
* @param string|array|object $message The message or data to log
|
||||
* @param array|object|null $context Optional context data
|
||||
* @return void
|
||||
*/
|
||||
function progress_log($message, $context = null): void {
|
||||
try {
|
||||
// Get the project root path
|
||||
$rootPath = base_path();
|
||||
$logsDir = $rootPath . DIRECTORY_SEPARATOR . 'logs';
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
if (!is_dir($logsDir)) {
|
||||
@mkdir($logsDir, 0777, true);
|
||||
}
|
||||
|
||||
$logFile = $logsDir . DIRECTORY_SEPARATOR . 'progress.log';
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
|
||||
// Normalize message
|
||||
if (is_array($message) || is_object($message)) {
|
||||
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
// Normalize context (optional extra data)
|
||||
if ($context !== null) {
|
||||
if (is_array($context) || is_object($context)) {
|
||||
$context = json_encode($context, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
$message .= ' | CONTEXT: ' . $context;
|
||||
}
|
||||
|
||||
$line = "[{$timestamp}] {$message}" . PHP_EOL;
|
||||
|
||||
// Append atomically
|
||||
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// Never allow logging failures to break execution
|
||||
// Silent by design
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show user account page
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('account.profile', [
|
||||
'user' => auth()->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user account
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . auth()->id(),
|
||||
]);
|
||||
|
||||
auth()->user()->update($validated);
|
||||
|
||||
return redirect()->route('my-account')->with('success', 'Profile updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user's orders (both standard and custom)
|
||||
*/
|
||||
public function orders(): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$standardOrders = Order::where('user_id', $user->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$customOrders = CustomOrder::where('user_id', $user->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('account.orders', [
|
||||
'standardOrders' => $standardOrders,
|
||||
'customOrders' => $customOrders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show order detail (both standard and custom)
|
||||
*/
|
||||
public function orderDetail(Order $order)
|
||||
{
|
||||
// Check authorization
|
||||
if ($order->user_id !== auth()->id() && !auth()->user()->is_admin) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return view('account.order-detail', [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class GoogleAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Redirect to Google OAuth
|
||||
*/
|
||||
public function redirect()
|
||||
{
|
||||
return Socialite::driver('google')->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Google OAuth callback
|
||||
*/
|
||||
public function callback()
|
||||
{
|
||||
try {
|
||||
$googleUser = Socialite::driver('google')->user();
|
||||
|
||||
// Find or create user
|
||||
$user = User::firstOrCreate(
|
||||
['email' => $googleUser->getEmail()],
|
||||
[
|
||||
'name' => $googleUser->getName(),
|
||||
'google_id' => $googleUser->getId(),
|
||||
'email_verified_at' => now(),
|
||||
// Set a random password so the row passes DB constraints; not used for login
|
||||
'password' => Str::random(32),
|
||||
]
|
||||
);
|
||||
|
||||
// Update Google ID if not already set
|
||||
if (!$user->google_id) {
|
||||
$user->update(['google_id' => $googleUser->getId()]);
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
return redirect()->intended('/');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Google OAuth callback failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
return redirect('/login')->with('error', 'Failed to authenticate with Google. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
Log::info('User logging out', [
|
||||
'user_id' => optional(Auth::user())->id,
|
||||
'email' => optional(Auth::user())->email,
|
||||
]);
|
||||
Auth::logout();
|
||||
request()->session()->invalidate();
|
||||
request()->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Services\ShippingService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CartController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$cart = session()->get('cart', []);
|
||||
$total = 0;
|
||||
$items = [];
|
||||
|
||||
foreach ($cart as $itemKey => $cartItem) {
|
||||
// Handle both old and new cart formats
|
||||
if (is_array($cartItem)) {
|
||||
$productId = $cartItem['product_id'] ?? null;
|
||||
$type = $cartItem['type'] ?? 'wallpaper';
|
||||
$printStockId = $cartItem['print_stock_id'] ?? null;
|
||||
} else {
|
||||
// Old format: just the product ID
|
||||
$productId = $itemKey;
|
||||
$type = 'wallpaper';
|
||||
$printStockId = null;
|
||||
}
|
||||
|
||||
if ($productId) {
|
||||
$product = Product::find($productId);
|
||||
if ($product) {
|
||||
$quantity = is_array($cartItem) ? ($cartItem['quantity'] ?? 1) : $cartItem;
|
||||
$isSample = is_array($cartItem) ? ($cartItem['is_sample'] ?? false) : false;
|
||||
$stockCost = 0;
|
||||
$stock = null;
|
||||
|
||||
// For samples, use fixed sample cost
|
||||
if ($isSample) {
|
||||
$itemTotal = ShippingService::getSampleCost() * $quantity;
|
||||
} else {
|
||||
// Get print stock if available
|
||||
if ($printStockId) {
|
||||
$stock = $product->printStocks()->find($printStockId);
|
||||
if ($stock) {
|
||||
$stockCost = ($type === 'wallpaper') ? $stock->cost_per_meter : $stock->cost_per_m2;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate price based on stock cost only (no base design cost)
|
||||
$basePrice = $stockCost;
|
||||
|
||||
if ($type === 'wallpaper') {
|
||||
$length = is_array($cartItem) ? ($cartItem['length'] ?? 0) : 0;
|
||||
$itemTotal = $basePrice * $length * $quantity;
|
||||
} elseif ($type === 'mural') {
|
||||
$width = is_array($cartItem) ? ($cartItem['width'] ?? 0) : 0;
|
||||
$height = is_array($cartItem) ? ($cartItem['height'] ?? 0) : 0;
|
||||
$m2 = $width * $height;
|
||||
$itemTotal = $basePrice * $m2 * $quantity;
|
||||
} else {
|
||||
$itemTotal = $basePrice * $quantity;
|
||||
}
|
||||
}
|
||||
|
||||
$total += $itemTotal;
|
||||
$items[] = [
|
||||
'key' => $itemKey,
|
||||
'product' => $product,
|
||||
'stock' => $stock,
|
||||
'quantity' => $quantity,
|
||||
'type' => $type,
|
||||
'is_sample' => $isSample,
|
||||
'length' => is_array($cartItem) ? ($cartItem['length'] ?? null) : null,
|
||||
'width' => is_array($cartItem) ? ($cartItem['width'] ?? null) : null,
|
||||
'height' => is_array($cartItem) ? ($cartItem['height'] ?? null) : null,
|
||||
'subtotal' => $itemTotal
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$shippingFee = ShippingService::calculateShippingFee($total);
|
||||
$shippingLabel = ShippingService::getShippingLabel($total);
|
||||
|
||||
return view('cart', [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'shippingFee' => $shippingFee,
|
||||
'shippingLabel' => $shippingLabel,
|
||||
'itemCount' => count($cart)
|
||||
]);
|
||||
}
|
||||
|
||||
public function add(Request $request, Product $product)
|
||||
{
|
||||
$request->validate([
|
||||
'print_stock_id' => 'required|exists:print_stocks,id',
|
||||
'length' => $product->type === 'wallpaper' ? 'required|numeric|min:0.5' : 'nullable',
|
||||
'width' => $product->type === 'mural' ? 'required|numeric|min:0.5' : 'nullable',
|
||||
'height' => $product->type === 'mural' ? 'required|numeric|min:0.5' : 'nullable',
|
||||
]);
|
||||
|
||||
$cart = session()->get('cart', []);
|
||||
$cartItemKey = $product->id . '_' . $request->input('print_stock_id') . '_' . uniqid();
|
||||
|
||||
// Create a unique cart item with dimensions and stock
|
||||
$cartItem = [
|
||||
'product_id' => $product->id,
|
||||
'print_stock_id' => $request->input('print_stock_id'),
|
||||
'quantity' => 1,
|
||||
'type' => $product->type,
|
||||
];
|
||||
|
||||
if ($product->type === 'wallpaper') {
|
||||
$cartItem['length'] = $request->input('length');
|
||||
} elseif ($product->type === 'mural') {
|
||||
$cartItem['width'] = $request->input('width');
|
||||
$cartItem['height'] = $request->input('height');
|
||||
}
|
||||
|
||||
$cart[$cartItemKey] = $cartItem;
|
||||
session()->put('cart', $cart);
|
||||
|
||||
return redirect()->back()->with('success', $product->name . ' added to cart!');
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'item_key' => 'required',
|
||||
'quantity' => 'required|integer|min:1'
|
||||
]);
|
||||
|
||||
$cart = session()->get('cart', []);
|
||||
$itemKey = $request->input('item_key');
|
||||
$quantity = $request->input('quantity');
|
||||
|
||||
if (isset($cart[$itemKey])) {
|
||||
$cart[$itemKey]['quantity'] = $quantity;
|
||||
}
|
||||
|
||||
session()->put('cart', $cart);
|
||||
|
||||
return redirect()->back()->with('success', 'Cart updated!');
|
||||
}
|
||||
|
||||
public function remove(Request $request)
|
||||
{
|
||||
$itemKey = $request->input('item_key');
|
||||
$cart = session()->get('cart', []);
|
||||
|
||||
if (isset($cart[$itemKey])) {
|
||||
unset($cart[$itemKey]);
|
||||
}
|
||||
|
||||
session()->put('cart', $cart);
|
||||
|
||||
return redirect()->back()->with('success', 'Product removed from cart!');
|
||||
}
|
||||
|
||||
public function clear()
|
||||
{
|
||||
session()->forget('cart');
|
||||
return redirect()->back()->with('success', 'Cart cleared!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Order a sample of a product
|
||||
*/
|
||||
public function orderSample(Request $request, Product $product)
|
||||
{
|
||||
$request->validate([
|
||||
'print_stock_id' => 'required|exists:print_stocks,id',
|
||||
]);
|
||||
|
||||
// Create a sample cart item
|
||||
$cart = session()->get('cart', []);
|
||||
$sampleSize = 0.3;
|
||||
|
||||
$cartItemKey = $product->id . '_sample_' . $request->input('print_stock_id') . '_' . uniqid();
|
||||
|
||||
$cartItem = [
|
||||
'product_id' => $product->id,
|
||||
'print_stock_id' => $request->input('print_stock_id'),
|
||||
'quantity' => 1,
|
||||
'type' => $product->type,
|
||||
'is_sample' => true,
|
||||
];
|
||||
|
||||
if ($product->type === 'wallpaper') {
|
||||
// For wallpaper samples: use 30cm length (0.3m) with default width
|
||||
$cartItem['width'] = 0.3;
|
||||
$cartItem['height'] = 0.3;
|
||||
} elseif ($product->type === 'mural') {
|
||||
// For mural samples: use 6cm × 5cm (0.06m × 0.05m = 0.003m²)
|
||||
$cartItem['width'] = 0.3;
|
||||
$cartItem['height'] = 0.3;
|
||||
}
|
||||
|
||||
$cart[$cartItemKey] = $cartItem;
|
||||
session()->put('cart', $cart);
|
||||
|
||||
return redirect()->route('cart')->with('success', 'Sample added to cart!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ParcelCollected;
|
||||
use App\Events\ParcelDelivered;
|
||||
use App\Events\ParcelFailedDelivery;
|
||||
use App\Events\ParcelInTransit;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CourierWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle incoming courier webhooks (Shiplogic)
|
||||
*
|
||||
* POST /api/webhooks/courier
|
||||
*/
|
||||
public function handle(Request $request)
|
||||
{
|
||||
// Verify webhook signature
|
||||
if (! $this->verifyWebhookSignature($request)) {
|
||||
Log::warning('Invalid courier webhook signature');
|
||||
|
||||
return response()->json(['error' => 'Invalid signature'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $request->json()->all();
|
||||
|
||||
Log::info('Courier webhook received', [
|
||||
'shipment_id' => $payload['shipment_id'] ?? 'unknown',
|
||||
'status' => $payload['status'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
// Find order by waybill ID
|
||||
$order = Order::where('courier_waybill_id', $payload['waybill_id'] ?? null)->first();
|
||||
|
||||
if (! $order) {
|
||||
Log::warning('Order not found for courier webhook', [
|
||||
'waybill_id' => $payload['waybill_id'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
return response()->json(['error' => 'Order not found'], 404);
|
||||
}
|
||||
|
||||
// Handle status updates
|
||||
match ($payload['status'] ?? null) {
|
||||
'collected' => $this->handleParcelCollected($order, $payload),
|
||||
'in_transit' => $this->handleParcelInTransit($order, $payload),
|
||||
'delivered' => $this->handleParcelDelivered($order, $payload),
|
||||
'failed_delivery' => $this->handleParcelFailedDelivery($order, $payload),
|
||||
default => Log::info('Unhandled courier status', ['status' => $payload['status'] ?? 'unknown']),
|
||||
};
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error processing courier webhook', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json(['error' => 'Processing failed'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle parcel collected status
|
||||
*/
|
||||
private function handleParcelCollected(Order $order, array $payload): void
|
||||
{
|
||||
$order->update([
|
||||
'courier_status' => 'collected',
|
||||
'status' => 'in_transit',
|
||||
]);
|
||||
|
||||
ParcelCollected::dispatch($order, $order->courier_waybill_id);
|
||||
|
||||
Log::info('Parcel collected', ['order_id' => $order->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle parcel in transit status
|
||||
*/
|
||||
private function handleParcelInTransit(Order $order, array $payload): void
|
||||
{
|
||||
$order->update([
|
||||
'courier_status' => 'in_transit',
|
||||
]);
|
||||
|
||||
ParcelInTransit::dispatch($order, $order->courier_waybill_id);
|
||||
|
||||
Log::info('Parcel in transit', ['order_id' => $order->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle parcel delivered status
|
||||
*/
|
||||
private function handleParcelDelivered(Order $order, array $payload): void
|
||||
{
|
||||
$order->update([
|
||||
'courier_status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
ParcelDelivered::dispatch($order, $order->courier_waybill_id);
|
||||
|
||||
Log::info('Parcel delivered', ['order_id' => $order->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle parcel failed delivery status
|
||||
*/
|
||||
private function handleParcelFailedDelivery(Order $order, array $payload): void
|
||||
{
|
||||
$reason = $payload['failure_reason'] ?? 'Unknown reason';
|
||||
|
||||
$order->update([
|
||||
'courier_status' => 'failed',
|
||||
'delivery_failure_reason' => $reason,
|
||||
]);
|
||||
|
||||
ParcelFailedDelivery::dispatch($order, $order->courier_waybill_id, $reason);
|
||||
|
||||
Log::info('Parcel delivery failed', [
|
||||
'order_id' => $order->id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature (placeholder)
|
||||
*/
|
||||
private function verifyWebhookSignature(Request $request): bool
|
||||
{
|
||||
// TODO: Implement Shiplogic HMAC verification
|
||||
// Compare signature with hash of request body using COURIER_WEBHOOK_SECRET
|
||||
// For now, accept all
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CustomOrder;
|
||||
use App\Models\CustomOrderFile;
|
||||
use App\Models\CustomOrderSpecification;
|
||||
use App\Models\CustomOrderProof;
|
||||
use App\Models\AppSetting;
|
||||
use App\Models\PrintStock;
|
||||
use App\Events\OrderCreated;
|
||||
use App\Events\DepositPaid;
|
||||
use App\Events\ProofApproved;
|
||||
use App\Events\ProofRevisionRequested;
|
||||
use App\Events\BalancePaid;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CustomOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show custom orders index/list
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$customOrders = CustomOrder::where('user_id', auth()->id())
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('custom-orders.index', [
|
||||
'customOrders' => $customOrders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show custom order creation form
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$designFee = AppSetting::get('design_fee', 500);
|
||||
|
||||
return view('custom-orders.create', [
|
||||
'printStocks' => PrintStock::all(),
|
||||
'designFee' => $designFee,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store custom order and calculate quote
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
error_log('=== CUSTOM ORDER STORE METHOD CALLED (error_log) ===');
|
||||
error_log('Request method: ' . $request->method());
|
||||
error_log('Request path: ' . $request->path());
|
||||
error_log('Request all data: ' . json_encode($request->all()));
|
||||
$userId = auth()->id();
|
||||
error_log('User ID: ' . ($userId ?? 'NOT AUTHENTICATED'));
|
||||
|
||||
Log::warning('=== CUSTOM ORDER STORE METHOD CALLED ===');
|
||||
Log::warning('Request all data', $request->all());
|
||||
Log::warning('User ID', ['user_id' => auth()->id()]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:wallpaper,mural,fabric',
|
||||
'length' => 'nullable|numeric|min:0.1',
|
||||
'width' => 'nullable|numeric|min:0.1',
|
||||
'height' => 'nullable|numeric|min:0.1',
|
||||
'print_stock_id' => 'required|exists:print_stocks,id',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
'customer_brief' => 'required|string|min:50|max:5000',
|
||||
'special_instructions' => 'nullable|string|max:1000',
|
||||
'library_discount' => 'boolean',
|
||||
'reference_images.*' => 'nullable|file|mimes:jpeg,png,jpg,gif,webp|max:5120',
|
||||
]);
|
||||
|
||||
Log::warning('Validation passed', $validated);
|
||||
|
||||
// Get design fee from settings
|
||||
$designFee = AppSetting::get('design_fee', 500);
|
||||
$libraryDiscountApplied = $request->boolean('library_discount', false);
|
||||
|
||||
// Deduct library discount if selected
|
||||
$finalDesignFee = $libraryDiscountApplied ? $designFee * 0.8 : $designFee;
|
||||
|
||||
// Get print stock for material cost calculation
|
||||
$printStock = PrintStock::find($validated['print_stock_id']);
|
||||
|
||||
// Calculate material cost based on type and dimensions
|
||||
$materialCost = $this->calculateMaterialCost(
|
||||
$validated['type'],
|
||||
$validated['length'] ?? 0,
|
||||
$validated['width'] ?? 0,
|
||||
$validated['height'] ?? 0,
|
||||
$validated['quantity'],
|
||||
$printStock
|
||||
);
|
||||
|
||||
// Total cost = material cost + design fee
|
||||
$totalCost = $materialCost + $finalDesignFee;
|
||||
|
||||
// Deposit is 20% of total, balance is 80%
|
||||
$depositAmount = $totalCost * 0.2;
|
||||
$balanceAmount = $totalCost * 0.8;
|
||||
|
||||
// Create custom order
|
||||
$customOrder = CustomOrder::create([
|
||||
'user_id' => auth()->id(),
|
||||
'type' => $validated['type'],
|
||||
'design_fee' => $designFee,
|
||||
'library_discount_applied' => $libraryDiscountApplied,
|
||||
'material_cost' => $materialCost,
|
||||
'total_cost' => $totalCost,
|
||||
'deposit_amount' => $depositAmount,
|
||||
'balance_amount' => $balanceAmount,
|
||||
'customer_brief' => $validated['customer_brief'],
|
||||
]);
|
||||
|
||||
// Create specifications
|
||||
CustomOrderSpecification::create([
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'length' => $validated['length'] ?? null,
|
||||
'width' => $validated['width'] ?? null,
|
||||
'height' => $validated['height'] ?? null,
|
||||
'print_stock_id' => $validated['print_stock_id'],
|
||||
'quantity' => $validated['quantity'],
|
||||
'special_instructions' => $validated['special_instructions'] ?? null,
|
||||
]);
|
||||
|
||||
// Handle file uploads
|
||||
if ($request->hasFile('reference_images')) {
|
||||
foreach ($request->file('reference_images') as $file) {
|
||||
$path = $file->store('custom-orders/references', 'local');
|
||||
|
||||
CustomOrderFile::create([
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'file_type' => 'reference_image',
|
||||
'file_path' => $path,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'uploaded_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit events for custom order creation with deposit
|
||||
OrderCreated::dispatch($customOrder, 'custom');
|
||||
|
||||
return redirect()->route('custom-orders.show', $customOrder)->with('success', 'Custom order created successfully. Please review the quote and proceed with deposit payment.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show custom order detail with quote
|
||||
*/
|
||||
public function show(CustomOrder $customOrder): View
|
||||
{
|
||||
// Check authorization
|
||||
if ($customOrder->user_id !== auth()->id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return view('custom-orders.show', [
|
||||
'customOrder' => $customOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process deposit payment
|
||||
*/
|
||||
public function depositPayment(Request $request)
|
||||
{
|
||||
Log::info('Deposit payment initiated', [
|
||||
'request_data' => $request->all(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
$validated = $request->validate([
|
||||
'custom_order_id' => 'required|exists:custom_orders,id',
|
||||
]);
|
||||
Log::info('Deposit payment validation passed', $validated);
|
||||
|
||||
$customOrder = CustomOrder::findOrFail($validated['custom_order_id']);
|
||||
|
||||
// Check authorization
|
||||
if ($customOrder->user_id !== auth()->id()) {
|
||||
Log::warning('Unauthorized deposit payment attempt', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Check if already paid
|
||||
if ($customOrder->deposit_status === 'paid') {
|
||||
Log::info('Deposit already paid for custom order', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
]);
|
||||
return redirect()->route('custom-orders.show', $customOrder)
|
||||
->with('info', 'Deposit already paid for this order.');
|
||||
}
|
||||
|
||||
// Initiate Yoco payment for deposit
|
||||
Log::info('Initiating Yoco payment for custom order deposit', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'deposit_amount' => $customOrder->deposit_amount,
|
||||
]);
|
||||
$yocoResponse = $this->initiateYocoPayment(
|
||||
amount: (int)($customOrder->deposit_amount * 100), // Convert to cents
|
||||
customOrder: $customOrder,
|
||||
orderType: 'custom_deposit',
|
||||
description: "Deposit for Order #{$customOrder->order_number}"
|
||||
);
|
||||
|
||||
if (!$yocoResponse) {
|
||||
Log::error('Failed to initiate Yoco payment for custom order deposit', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
]);
|
||||
return redirect()->route('custom-orders.show', $customOrder)
|
||||
->with('error', 'Failed to initiate payment. Please try again.');
|
||||
}
|
||||
Log::info('Yoco payment initiated successfully for custom order deposit', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'checkout_url' => $yocoResponse['checkout_url'],
|
||||
]);
|
||||
return redirect($yocoResponse['checkout_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful deposit payment
|
||||
*/
|
||||
public function depositSuccess(CustomOrder $customOrder)
|
||||
{
|
||||
// Check authorization
|
||||
if ($customOrder->user_id !== auth()->id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// // Update order status
|
||||
// $customOrder->update([
|
||||
// 'deposit_status' => 'paid',
|
||||
// 'status' => 'submitted',
|
||||
// ]);
|
||||
|
||||
return view('custom-orders.deposit-success', [
|
||||
'customOrder' => $customOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate material cost based on dimensions and print stock
|
||||
*/
|
||||
private function calculateMaterialCost($type, $length, $width, $height, $quantity, $printStock)
|
||||
{
|
||||
$costPerUnit = $printStock->cost_per_meter ?? $printStock->cost_per_m2 ?? 0;
|
||||
|
||||
if (!$costPerUnit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cost = 0;
|
||||
|
||||
if ($type === 'wallpaper' && $length > 0 && $width > 0) {
|
||||
// Area-based: length x width in meters
|
||||
$area = $length * $width;
|
||||
$cost = $area * $costPerUnit * $quantity;
|
||||
} elseif ($type === 'mural' && $width > 0 && $height > 0) {
|
||||
// Area-based: width x height in meters
|
||||
$area = $width * $height;
|
||||
$cost = $area * $costPerUnit * $quantity;
|
||||
} elseif ($type === 'fabric' && $length > 0) {
|
||||
// Linear: length in meters
|
||||
$cost = $length * $costPerUnit * $quantity;
|
||||
}
|
||||
|
||||
return round($cost, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate Yoco payment
|
||||
*/
|
||||
private function initiateYocoPayment($amount, $customOrder, $orderType, $description)
|
||||
{
|
||||
|
||||
Log::info('Initiating Yoco payment', [
|
||||
'order_id' => $customOrder->uuid,
|
||||
'order_type' => $orderType,
|
||||
'amount' => $amount,
|
||||
'description' => $description,
|
||||
]);
|
||||
|
||||
if (!$customOrder) {
|
||||
Log::error('Custom order not found for Yoco payment', [
|
||||
'order_id' => $customOrder->uuid ?? 'unknown',
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
Log::info('Custom order found for Yoco payment', [
|
||||
'order_id' => $customOrder->uuid,
|
||||
'custom_order_data' => $customOrder->toArray(),
|
||||
]);
|
||||
// Get configuration
|
||||
$secretKey = config('services.yoco.secret_key');
|
||||
$mode = config('services.yoco.mode');
|
||||
|
||||
Log::info('Yoco configuration', [
|
||||
'mode' => $mode,
|
||||
'secret_key_set' => !empty($secretKey) && $secretKey !== 'sk_test_your_key_here',
|
||||
]);
|
||||
|
||||
// Check if API key is configured
|
||||
if (empty($secretKey) || $secretKey === 'sk_test_your_key_here') {
|
||||
Log::error('Yoco secret key not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseUrl = $mode === 'live'
|
||||
? 'https://payments.yoco.com/api/checkouts'
|
||||
: 'https://payments.yoco.com/api/checkouts';
|
||||
|
||||
$checkoutData = [
|
||||
'amount' => $amount,
|
||||
'currency' => 'ZAR',
|
||||
'successUrl' => route('yoco-custom-deposit-success', ['customOrder' => $customOrder->uuid]),
|
||||
'cancelUrl' => route('custom-orders.show', ['customOrder' => $customOrder->uuid]),
|
||||
'failureUrl' => route('custom-orders.show', ['customOrder' => $customOrder->uuid]),
|
||||
'metadata' => [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'order_type' => $orderType,
|
||||
'site' => 'additional_design',
|
||||
'description' => $description
|
||||
]
|
||||
];
|
||||
|
||||
Log::info('Yoco checkout data prepared', [
|
||||
'order_id' => $customOrder->uuid,
|
||||
'checkout_data' => $checkoutData,
|
||||
]);
|
||||
|
||||
// Make API request to Yoco
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $secretKey,
|
||||
'Content-Type' => 'application/json',
|
||||
])->post($baseUrl, $checkoutData);
|
||||
|
||||
if ($response->successful()) {
|
||||
$checkout = $response->json();
|
||||
|
||||
$checkoutId = $checkout['id'] ?? null;
|
||||
$redirectUrl = $checkout['redirectUrl'] ?? null;
|
||||
|
||||
Log::info('Yoco checkout created for custom order', [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'checkout_id' => $checkoutId,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
|
||||
if (!$checkoutId || !$redirectUrl) {
|
||||
Log::error('Invalid Yoco checkout response: missing id or redirectUrl', [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'response' => $checkout,
|
||||
]);
|
||||
throw new \Exception('Invalid Yoco checkout response: missing id or redirectUrl');
|
||||
}
|
||||
|
||||
// Persist checkout info to database
|
||||
try {
|
||||
$customOrder->update([
|
||||
'yoco_checkout_id' => $checkoutId,
|
||||
'yoco_redirect_url' => $redirectUrl,
|
||||
'yoco_checkout_response' => json_encode($checkout),
|
||||
]);
|
||||
|
||||
// Reload the model to verify update
|
||||
$customOrder->refresh();
|
||||
|
||||
if ($customOrder->yoco_checkout_id) {
|
||||
Log::info('Yoco checkout info saved successfully for custom order', [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'yoco_checkout_id' => $customOrder->yoco_checkout_id,
|
||||
]);
|
||||
} else {
|
||||
Log::warning('Yoco checkout ID not saved after update for custom order', [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'order_data' => $customOrder->toArray(),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $dbException) {
|
||||
Log::error('Database error while saving Yoco checkout info for custom order', [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'error_message' => $dbException->getMessage(),
|
||||
'error_code' => $dbException->getCode(),
|
||||
'checkout_id' => $checkoutId,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
throw $dbException;
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $redirectUrl,
|
||||
'checkout_id' => $checkoutId,
|
||||
];
|
||||
} else {
|
||||
Log::error('Yoco API Error for custom order', [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Yoco Payment Exception for custom order', [
|
||||
'order_uuid' => $customOrder->uuid,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve proof for custom order
|
||||
*
|
||||
* POST /custom-orders/{id}/approve-proof
|
||||
*/
|
||||
public function approveProof(Request $request, CustomOrder $customOrder)
|
||||
{
|
||||
// Authorization: only allow owner or admin
|
||||
if (auth()->user()->id !== $customOrder->user_id && !auth()->user()->is_admin) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
$customOrder->update([
|
||||
'proof_approved' => true,
|
||||
'proof_approved_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('Proof approved for custom order', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Emit event
|
||||
ProofApproved::dispatch($customOrder);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Proof approved successfully',
|
||||
'proof_approved_at' => $customOrder->proof_approved_at,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to approve proof', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Failed to approve proof',
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request proof revision for custom order
|
||||
*
|
||||
* POST /custom-orders/{id}/request-changes
|
||||
*/
|
||||
public function requestChanges(Request $request, CustomOrder $customOrder)
|
||||
{
|
||||
// Authorization: only allow owner or admin
|
||||
if (auth()->user()->id !== $customOrder->user_id && !auth()->user()->is_admin) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'revision_notes' => 'required|string|min:10|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$customOrder->update([
|
||||
'proof_approved' => false,
|
||||
]);
|
||||
|
||||
Log::info('Proof revision requested for custom order', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'requested_by' => auth()->id(),
|
||||
'notes' => $validated['revision_notes'],
|
||||
]);
|
||||
|
||||
// Emit event
|
||||
ProofRevisionRequested::dispatch($customOrder, $validated['revision_notes']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Revision request sent successfully',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to request proof revision', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Failed to request revision',
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark balance as paid for custom order
|
||||
*
|
||||
* POST /custom-orders/{id}/pay-balance
|
||||
*/
|
||||
public function markBalancePaid(Request $request, CustomOrder $customOrder)
|
||||
{
|
||||
// Authorization: only allow owner or admin
|
||||
if (auth()->user()->id !== $customOrder->user_id && !auth()->user()->is_admin) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Verify proof is approved before balance payment
|
||||
if (!$customOrder->proof_approved) {
|
||||
return response()->json([
|
||||
'error' => 'Proof must be approved before balance payment',
|
||||
'proof_approved' => $customOrder->proof_approved,
|
||||
], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
$customOrder->update([
|
||||
'balance_status' => 'paid',
|
||||
'status' => 'printing',
|
||||
]);
|
||||
|
||||
Log::info('Balance paid for custom order', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'marked_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Emit event
|
||||
BalancePaid::dispatch($customOrder, $customOrder->balance_amount);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Balance payment recorded successfully',
|
||||
'status' => $customOrder->status,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to mark balance paid', [
|
||||
'custom_order_id' => $customOrder->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Failed to record balance payment',
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
|
||||
class FabricsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$fabrics = Product::take(6)->get();
|
||||
|
||||
return view('fabrics', [
|
||||
'fabrics' => $fabrics
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Models\Category;
|
||||
use App\Models\HeroImage;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$categories = Category::all();
|
||||
$featuredWallpapers = Product::where('type', 'wallpaper')->where('featured', true)->take(3)->get();
|
||||
$featuredMurals = Product::where('type', 'mural')->where('featured', true)->take(3)->get();
|
||||
$heroImages = HeroImage::where('page', 'home')->where('is_active', true)->orderBy('sort_order')->get();
|
||||
|
||||
return view('home', [
|
||||
'categories' => $categories,
|
||||
'featuredWallpapers' => $featuredWallpapers,
|
||||
'featuredMurals' => $featuredMurals,
|
||||
'heroImages' => $heroImages
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Models\Category;
|
||||
use App\Models\HeroImage;
|
||||
|
||||
class MuralsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$products = Product::where('type', 'mural')->get();
|
||||
$categories = Category::all();
|
||||
$heroImages = HeroImage::where('page', 'mural')->where('is_active', true)->orderBy('sort_order')->get();
|
||||
|
||||
return view('murals', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'heroImages' => $heroImages
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OpsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display order details via QR token (state-machine driven UI)
|
||||
*
|
||||
* GET /ops/orders/{token}
|
||||
*/
|
||||
public function showOrder(Request $request, $token)
|
||||
{
|
||||
// Find order by QR token
|
||||
$order = Order::where('qr_token', $token)->first();
|
||||
|
||||
if (! $order) {
|
||||
Log::warning('QR token not found', ['token' => substr($token, 0, 8) . '...']);
|
||||
|
||||
return response()->view('ops.order-not-found', [], 404);
|
||||
}
|
||||
|
||||
// Check user authorization - must be logged in ops user
|
||||
// TODO: Add middleware to enforce this
|
||||
if (! auth()->check() || ! auth()->user()->can('access-ops')) {
|
||||
Log::warning('Unauthorized QR access attempt', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->view('ops.unauthorized', [], 403);
|
||||
}
|
||||
|
||||
Log::info('QR order accessed', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'status' => $order->status,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Determine available actions based on order status
|
||||
$availableActions = $this->getAvailableActions($order);
|
||||
|
||||
return view('ops.order-detail', [
|
||||
'order' => $order,
|
||||
'availableActions' => $availableActions,
|
||||
'qrUrl' => route('ops.order.show', ['token' => $order->qr_token]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm packing via QR form
|
||||
*
|
||||
* POST /ops/orders/{id}/pack
|
||||
*/
|
||||
public function confirmPacking(Request $request, Order $order)
|
||||
{
|
||||
// Validate input
|
||||
$validated = $request->validate([
|
||||
'weight' => ['required', 'numeric', 'min:0.1'],
|
||||
'width' => ['required', 'numeric', 'min:1'],
|
||||
'length' => ['required', 'numeric', 'min:1'],
|
||||
'height' => ['required', 'numeric', 'min:1'],
|
||||
]);
|
||||
|
||||
// Ensure order is in packing state
|
||||
if ($order->status !== 'packing' && $order->status !== 'inspection') {
|
||||
return response()->json([
|
||||
'error' => 'Order cannot be packed in current state',
|
||||
'current_status' => $order->status,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// Save packing dimensions
|
||||
$order->update([
|
||||
'packing_width' => $validated['width'],
|
||||
'packing_length' => $validated['length'],
|
||||
'packing_height' => $validated['height'],
|
||||
'packing_weight' => $validated['weight'],
|
||||
'packing_completed_at' => now(),
|
||||
'packed_by' => auth()->id(),
|
||||
'status' => 'packing',
|
||||
]);
|
||||
|
||||
Log::info('Order packed via QR', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'packed_by' => auth()->user()->name,
|
||||
'dimensions' => "{$validated['width']}x{$validated['length']}cm, {$validated['weight']}kg",
|
||||
]);
|
||||
|
||||
// Emit event to trigger Slack notification and Trello update
|
||||
\App\Events\OrderPacked::dispatch(
|
||||
$order,
|
||||
(float) $validated['width'],
|
||||
(float) $validated['length'],
|
||||
(float) $validated['height'],
|
||||
(float) $validated['weight'],
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Order packed successfully',
|
||||
'order' => [
|
||||
'uuid' => $order->uuid,
|
||||
'number' => $order->order_number,
|
||||
'status' => $order->status,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark inspection passed via QR
|
||||
*
|
||||
* POST /ops/orders/{id}/inspection-passed
|
||||
*/
|
||||
public function markInspectionPassed(Request $request, Order $order)
|
||||
{
|
||||
if (! in_array($order->status, ['inspection', 'printing'])) {
|
||||
return response()->json([
|
||||
'error' => 'Order is not in inspection or printing state',
|
||||
'current_status' => $order->status,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// Update status to awaiting_collection (move to inspected list in Trello)
|
||||
$order->update(['status' => 'awaiting_collection']);
|
||||
|
||||
Log::info('Inspection passed via QR', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'marked_by' => auth()->user()->name,
|
||||
]);
|
||||
|
||||
// Emit event to trigger Slack/Trello updates
|
||||
\App\Events\InspectionPassed::dispatch($order);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Inspection passed',
|
||||
'order' => ['uuid' => $order->uuid, 'status' => $order->status],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag inspection issue via QR
|
||||
*
|
||||
* POST /ops/orders/{id}/inspection-failed
|
||||
*/
|
||||
public function flagInspectionIssue(Request $request, Order $order)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'issue_description' => ['required', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
if (! in_array($order->status, ['inspection', 'printing'])) {
|
||||
return response()->json([
|
||||
'error' => 'Order is not in inspection or printing state',
|
||||
'current_status' => $order->status,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// Update status and store issue
|
||||
$order->update([
|
||||
'status' => 'review_required',
|
||||
'notes' => $validated['issue_description'],
|
||||
]);
|
||||
|
||||
Log::warning('Inspection issue flagged via QR', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'flagged_by' => auth()->user()->name,
|
||||
'issue' => $validated['issue_description'],
|
||||
]);
|
||||
|
||||
// Emit event for listeners to alert ops
|
||||
\App\Events\InspectionFailed::dispatch($order, $validated['issue_description']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Issue flagged - order moved to review',
|
||||
'order' => ['uuid' => $order->uuid, 'status' => $order->status],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which actions are available based on order status
|
||||
*/
|
||||
private function getAvailableActions(Order $order): array
|
||||
{
|
||||
return match ($order->status) {
|
||||
'inspection', 'printing' => [
|
||||
'markInspectionPassed' => true,
|
||||
'flagInspectionIssue' => true,
|
||||
],
|
||||
'packing' => [
|
||||
'confirmPacking' => true,
|
||||
],
|
||||
'ready_to_ship', 'awaiting_collection', 'in_transit' => [
|
||||
'readOnly' => true,
|
||||
],
|
||||
default => []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download QR sticker PDF for printing
|
||||
*
|
||||
* GET /ops/orders/{id}/sticker/download
|
||||
*/
|
||||
public function downloadSticker(Order $order)
|
||||
{
|
||||
// Authorize
|
||||
if (! auth()->user()->can('access-ops')) {
|
||||
abort(403, 'Unauthorized to access ops interface');
|
||||
}
|
||||
|
||||
$stickerPath = "qr-stickers/{$order->uuid}.pdf";
|
||||
|
||||
if (! \Illuminate\Support\Facades\Storage::disk('public')->exists($stickerPath)) {
|
||||
Log::warning('QR sticker PDF not found', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'path' => $stickerPath,
|
||||
]);
|
||||
|
||||
abort(404, 'QR sticker not found');
|
||||
}
|
||||
|
||||
Log::info('QR sticker downloaded', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'user_id' => auth()->id(),
|
||||
'user_name' => auth()->user()->name,
|
||||
]);
|
||||
|
||||
return \Illuminate\Support\Facades\Storage::disk('public')->download(
|
||||
$stickerPath,
|
||||
"QR-{$order->order_number}.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-download shipment PDFs from Shiplogic API
|
||||
*
|
||||
* POST /ops/orders/{id}/redownload-pdfs
|
||||
*/
|
||||
public function redownloadShipmentPdfs(Order $order)
|
||||
{
|
||||
// Authorize
|
||||
if (! auth()->user()->can('access-ops')) {
|
||||
abort(403, 'Unauthorized to access ops interface');
|
||||
}
|
||||
|
||||
// Only allow for orders with shipments
|
||||
if (! $order->courier_shipment_id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No shipment exists for this order',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$courierService = new \App\Services\CourierService();
|
||||
$result = $courierService->redownloadShipmentPdfs($order);
|
||||
|
||||
Log::info('Shipment PDFs re-downloaded via ops', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'user_id' => auth()->id(),
|
||||
'user_name' => auth()->user()->name,
|
||||
'success' => $result['success'],
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $result['message'],
|
||||
'sticker_path' => $result['sticker_path'],
|
||||
'waybill_path' => $result['waybill_path'],
|
||||
]);
|
||||
} else {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $result['message'],
|
||||
], 500);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to re-download shipment PDFs via ops', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to re-download PDFs: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark order as ready for collection and move Trello card
|
||||
*
|
||||
* POST /ops/orders/{id}/ready-for-collection
|
||||
*/
|
||||
public function markReadyForCollection(Order $order)
|
||||
{
|
||||
// Authorize
|
||||
if (! auth()->user()->can('access-ops')) {
|
||||
abort(403, 'Unauthorized to access ops interface');
|
||||
}
|
||||
|
||||
// Only allow for orders with shipments in ready_to_ship status
|
||||
if (! $order->courier_shipment_id || $order->status !== 'ready_to_ship') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Order must have a shipment and be in Ready to Ship status',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update order status
|
||||
$order->update([
|
||||
'status' => 'awaiting_collection',
|
||||
'courier_status' => 'awaiting_collection',
|
||||
]);
|
||||
|
||||
// Move Trello card to Awaiting Collection
|
||||
if ($order->trello_card_id) {
|
||||
$trelloService = new \App\Services\TrelloService();
|
||||
$trelloService->moveCard($order->trello_card_id, 'Awaiting Collection');
|
||||
}
|
||||
|
||||
Log::info('Order marked ready for collection via ops', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'user_id' => auth()->id(),
|
||||
'user_name' => auth()->user()->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Order moved to Awaiting Collection',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to mark order ready for collection', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to mark ready for collection: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CustomOrder;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\PrintStock;
|
||||
use App\Services\ShippingService;
|
||||
use App\Services\InvoiceService;
|
||||
use App\Services\MailjetService;
|
||||
use App\Events\OrderCreated;
|
||||
use App\Events\DepositPaid;
|
||||
use App\Events\BalancePaid;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function checkout()
|
||||
{
|
||||
$cart = session()->get('cart', []);
|
||||
|
||||
if (empty($cart)) {
|
||||
return redirect()->route('cart')->with('error', 'Your cart is empty!');
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$total = 0;
|
||||
|
||||
foreach ($cart as $itemKey => $cartItem) {
|
||||
// Handle both old and new cart formats
|
||||
if (is_array($cartItem)) {
|
||||
$productId = $cartItem['product_id'] ?? null;
|
||||
$type = $cartItem['type'] ?? 'wallpaper';
|
||||
$printStockId = $cartItem['print_stock_id'] ?? null;
|
||||
$isSample = $cartItem['is_sample'] ?? false;
|
||||
} else {
|
||||
$productId = $itemKey;
|
||||
$type = 'wallpaper';
|
||||
$printStockId = null;
|
||||
$isSample = false;
|
||||
}
|
||||
|
||||
if ($productId) {
|
||||
$product = Product::find($productId);
|
||||
if ($product) {
|
||||
$quantity = is_array($cartItem) ? ($cartItem['quantity'] ?? 1) : $cartItem;
|
||||
|
||||
// For samples, use fixed sample cost
|
||||
if ($isSample) {
|
||||
$subtotal = ShippingService::getSampleCost() * $quantity;
|
||||
} else {
|
||||
$stockCost = 0;
|
||||
$stock = null;
|
||||
|
||||
// Get print stock if available
|
||||
if ($printStockId) {
|
||||
$stock = $product->printStocks()->find($printStockId);
|
||||
if ($stock) {
|
||||
$stockCost = ($type === 'wallpaper') ? $stock->cost_per_meter : $stock->cost_per_m2;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate price based on stock cost only (no base design cost)
|
||||
$basePrice = $stockCost;
|
||||
|
||||
if ($type === 'wallpaper') {
|
||||
$length = is_array($cartItem) ? ($cartItem['length'] ?? 0) : 0;
|
||||
$subtotal = $basePrice * $length * $quantity;
|
||||
} elseif ($type === 'mural') {
|
||||
$width = is_array($cartItem) ? ($cartItem['width'] ?? 0) : 0;
|
||||
$height = is_array($cartItem) ? ($cartItem['height'] ?? 0) : 0;
|
||||
$m2 = $width * $height;
|
||||
$subtotal = $basePrice * $m2 * $quantity;
|
||||
} else {
|
||||
$subtotal = $basePrice * $quantity;
|
||||
}
|
||||
$stock = null;
|
||||
}
|
||||
|
||||
$total += $subtotal;
|
||||
$items[] = [
|
||||
'key' => $itemKey,
|
||||
'product' => $product,
|
||||
'stock' => isset($stock) ? $stock : null,
|
||||
'quantity' => $quantity,
|
||||
'type' => $type,
|
||||
'is_sample' => $isSample,
|
||||
'length' => is_array($cartItem) ? ($cartItem['length'] ?? null) : null,
|
||||
'width' => is_array($cartItem) ? ($cartItem['width'] ?? null) : null,
|
||||
'height' => is_array($cartItem) ? ($cartItem['height'] ?? null) : null,
|
||||
'subtotal' => $subtotal
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$shippingFee = ShippingService::calculateShippingFee($total);
|
||||
$shippingLabel = ShippingService::getShippingLabel($total);
|
||||
$grandTotal = $total + $shippingFee;
|
||||
|
||||
return view('checkout', [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'shippingFee' => $shippingFee,
|
||||
'shippingLabel' => $shippingLabel,
|
||||
'grandTotal' => $grandTotal,
|
||||
'itemCount' => count($cart)
|
||||
]);
|
||||
}
|
||||
|
||||
public function process(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'customer_name' => 'required|string|max:255',
|
||||
'customer_email' => 'required|email',
|
||||
'customer_phone' => 'required|string|max:20',
|
||||
'shipping_street_address' => 'required|string|max:255',
|
||||
'shipping_unit_number' => 'nullable|string|max:255',
|
||||
'shipping_local_area' => 'required|string|max:255',
|
||||
'shipping_city' => 'required|string|max:255',
|
||||
'shipping_zone' => 'required|string|max:255',
|
||||
'shipping_postcode' => 'required|string|max:20',
|
||||
'shipping_country' => 'required|string|max:255',
|
||||
'shipping_type' => 'required|in:residential,business',
|
||||
'business_name' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:500'
|
||||
]);
|
||||
|
||||
$cart = session()->get('cart', []);
|
||||
|
||||
if (empty($cart)) {
|
||||
return redirect()->route('cart')->with('error', 'Your cart is empty!');
|
||||
}
|
||||
|
||||
// Calculate total and validate stock
|
||||
$total = 0;
|
||||
$orderItems = [];
|
||||
|
||||
foreach ($cart as $itemKey => $cartItem) {
|
||||
// Handle both old and new cart formats
|
||||
if (is_array($cartItem)) {
|
||||
$productId = $cartItem['product_id'] ?? null;
|
||||
$quantity = $cartItem['quantity'] ?? 1;
|
||||
$type = $cartItem['type'] ?? 'wallpaper';
|
||||
$printStockId = $cartItem['print_stock_id'] ?? null;
|
||||
$isSample = $cartItem['is_sample'] ?? false;
|
||||
} else {
|
||||
$productId = $itemKey;
|
||||
$quantity = $cartItem;
|
||||
$type = 'wallpaper';
|
||||
$printStockId = null;
|
||||
$isSample = false;
|
||||
}
|
||||
|
||||
$product = Product::find($productId);
|
||||
if (!$product) {
|
||||
return redirect()->route('cart')->with('error', 'Product not found!');
|
||||
}
|
||||
|
||||
if ($product->stock < $quantity) {
|
||||
return redirect()->route('cart')->with('error', "Insufficient stock for {$product->name}");
|
||||
}
|
||||
|
||||
// For samples, use fixed sample cost
|
||||
if ($isSample) {
|
||||
$subtotal = ShippingService::getSampleCost() * $quantity;
|
||||
$stockCost = ShippingService::getSampleCost();
|
||||
} else {
|
||||
$stockCost = 0;
|
||||
$stock = null;
|
||||
|
||||
// Get print stock if available
|
||||
if ($printStockId) {
|
||||
$stock = $product->printStocks()->find($printStockId);
|
||||
if ($stock) {
|
||||
$stockCost = ($type === 'wallpaper') ? $stock->cost_per_meter : $stock->cost_per_m2;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate price based on stock cost only (no base design cost)
|
||||
$basePrice = $stockCost;
|
||||
|
||||
if ($type === 'wallpaper') {
|
||||
$length = is_array($cartItem) ? ($cartItem['length'] ?? 0) : 0;
|
||||
$subtotal = $basePrice * $length * $quantity;
|
||||
} elseif ($type === 'mural') {
|
||||
$width = is_array($cartItem) ? ($cartItem['width'] ?? 0) : 0;
|
||||
$height = is_array($cartItem) ? ($cartItem['height'] ?? 0) : 0;
|
||||
$m2 = $width * $height;
|
||||
$subtotal = $basePrice * $m2 * $quantity;
|
||||
} else {
|
||||
$subtotal = $basePrice * $quantity;
|
||||
}
|
||||
}
|
||||
|
||||
$total += $subtotal;
|
||||
$orderItems[$itemKey] = [
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'price' => $subtotal / $quantity,
|
||||
'print_stock_id' => $printStockId,
|
||||
'type' => $type,
|
||||
'is_sample' => $isSample,
|
||||
'length' => is_array($cartItem) ? ($cartItem['length'] ?? null) : null,
|
||||
'width' => is_array($cartItem) ? ($cartItem['width'] ?? null) : null,
|
||||
'height' => is_array($cartItem) ? ($cartItem['height'] ?? null) : null,
|
||||
'subtotal' => $subtotal
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate shipping
|
||||
$shippingFee = ShippingService::calculateShippingFee($total);
|
||||
|
||||
// Create order
|
||||
$orderNumber = 'ORD-' . date('Ymd') . '-' . strtoupper(uniqid());
|
||||
|
||||
$orderData = [
|
||||
'user_id' => auth()->check() ? auth()->id() : null,
|
||||
'order_number' => $orderNumber,
|
||||
'total' => $total + $shippingFee,
|
||||
'shipping_fee' => $shippingFee,
|
||||
'status' => 'pending',
|
||||
'payment_method' => 'yoco',
|
||||
'payment_status' => 'pending',
|
||||
'customer_name' => $request->input('customer_name'),
|
||||
'customer_email' => $request->input('customer_email'),
|
||||
'customer_phone' => $request->input('customer_phone'),
|
||||
'shipping_street_address' => $request->input('shipping_street_address'),
|
||||
'shipping_unit_number' => $request->input('shipping_unit_number'),
|
||||
'shipping_local_area' => $request->input('shipping_local_area'),
|
||||
'shipping_city' => $request->input('shipping_city'),
|
||||
'shipping_zone' => $request->input('shipping_zone'),
|
||||
'shipping_postcode' => $request->input('shipping_postcode'),
|
||||
'shipping_country' => $request->input('shipping_country'),
|
||||
'shipping_type' => $request->input('shipping_type'),
|
||||
'business_name' => $request->input('business_name'),
|
||||
];
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$orderData['notes'] = $request->input('notes');
|
||||
}
|
||||
|
||||
$order = Order::create($orderData);
|
||||
|
||||
// Create order items
|
||||
foreach ($orderItems as $itemKey => $data) {
|
||||
$product = Product::find($data['product_id']);
|
||||
|
||||
OrderItem::create([
|
||||
'order_id' => $order->uuid,
|
||||
'product_id' => $data['product_id'],
|
||||
'quantity' => $data['quantity'],
|
||||
'price' => $data['price'],
|
||||
'print_stock_id' => $data['print_stock_id'],
|
||||
'type' => $data['type'],
|
||||
'is_sample' => $data['is_sample'],
|
||||
'length' => $data['length'],
|
||||
'width' => $data['width'],
|
||||
'height' => $data['height']
|
||||
]);
|
||||
}
|
||||
|
||||
// Store order UUID in session for payment
|
||||
session(['pending_order_uuid' => $order->uuid]);
|
||||
|
||||
// Redirect to Yoco payment
|
||||
return redirect()->route('yoco-payment', ['order' => $order->uuid]);
|
||||
}
|
||||
|
||||
public function success(Order $order)
|
||||
{
|
||||
// Authorization: only allow viewing own orders or admin
|
||||
if (auth()->check() && auth()->user()->id !== $order->user_id && !auth()->user()->is_admin) {
|
||||
abort(403, 'Unauthorized access to this order.');
|
||||
}
|
||||
|
||||
// For guest orders, verify via session
|
||||
if (!auth()->check() && session('pending_order_uuid') !== $order->uuid) {
|
||||
abort(403, 'Unauthorized access to this order.');
|
||||
}
|
||||
|
||||
return view('order-success', ['order' => $order]);
|
||||
}
|
||||
|
||||
public function history()
|
||||
{
|
||||
$orders = Order::orderBy('created_at', 'desc')->get();
|
||||
|
||||
return view('order-history', ['orders' => $orders]);
|
||||
}
|
||||
|
||||
public function yocoPayment(Order $order)
|
||||
{
|
||||
// Verify this is a pending payment
|
||||
if ($order->payment_status !== 'pending') {
|
||||
abort(400, 'This order has already been paid.');
|
||||
}
|
||||
|
||||
// Create Yoco checkout
|
||||
$secretKey = config('services.yoco.secret_key');
|
||||
$mode = config('services.yoco.mode');
|
||||
|
||||
// Check if API key is configured
|
||||
if (empty($secretKey) || $secretKey === 'sk_test_your_key_here') {
|
||||
return redirect()->route('checkout')->with('error', 'Payment gateway not configured. Please contact support.');
|
||||
}
|
||||
|
||||
$baseUrl = $mode === 'live'
|
||||
? 'https://payments.yoco.com/api/checkouts'
|
||||
: 'https://payments.yoco.com/api/checkouts';
|
||||
|
||||
$checkoutData = [
|
||||
'amount' => (int)($order->total * 100), // Amount in cents (total already includes shipping)
|
||||
'currency' => 'ZAR',
|
||||
'successUrl' => route('yoco-success', ['order' => $order->uuid]),
|
||||
'cancelUrl' => route('yoco-cancel', ['order' => $order->uuid]),
|
||||
'failureUrl' => route('yoco-failure', ['order' => $order->uuid]),
|
||||
'metadata' => [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'order_type' => 'standard',
|
||||
'site' => 'additional_design',
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $secretKey,
|
||||
'Content-Type' => 'application/json',
|
||||
])->post($baseUrl, $checkoutData);
|
||||
|
||||
if ($response->successful()) {
|
||||
$checkout = $response->json();
|
||||
|
||||
$checkoutId = $checkout['id'] ?? null;
|
||||
$redirectUrl = $checkout['redirectUrl'] ?? null;
|
||||
\Log::info('Yoco checkout created', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'checkout_id' => $checkoutId,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
if (!$checkoutId || !$redirectUrl) {
|
||||
throw new \Exception('Invalid Yoco checkout response: missing id or redirectUrl');
|
||||
}
|
||||
|
||||
// Persist checkout info to database
|
||||
try {
|
||||
$order->update([
|
||||
'yoco_checkout_id' => $checkoutId,
|
||||
'yoco_redirect_url' => $redirectUrl,
|
||||
'yoco_checkout_response' => json_encode($checkout),
|
||||
]);
|
||||
|
||||
// Reload the model to verify update
|
||||
$order->refresh();
|
||||
|
||||
if ($order->yoco_checkout_id) {
|
||||
//clear cart
|
||||
session()->forget('cart');
|
||||
|
||||
\Log::info('Yoco checkout info saved successfully', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'yoco_checkout_id' => $order->yoco_checkout_id,
|
||||
]);
|
||||
} else {
|
||||
\Log::warning('Yoco checkout ID not saved after update', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_data' => $order->toArray(),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $dbException) {
|
||||
\Log::error('Database error while saving Yoco checkout info', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error_message' => $dbException->getMessage(),
|
||||
'error_code' => $dbException->getCode(),
|
||||
'checkout_id' => $checkoutId,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
throw $dbException;
|
||||
}
|
||||
|
||||
return redirect($redirectUrl);
|
||||
} else {
|
||||
\Log::error('Yoco API Error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body()
|
||||
]);
|
||||
return redirect()->route('checkout')->with('error', 'Unable to initialize payment: ' . $response->body());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Yoco Payment Exception', ['message' => $e->getMessage()]);
|
||||
return redirect()->route('checkout')->with('error', 'Payment error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function yocoSuccess(Order $order)
|
||||
{
|
||||
// Verify order is pending payment
|
||||
if ($order->payment_status === 'paid') {
|
||||
return redirect()->route('order-success', ['order' => $order])
|
||||
->with('success', 'Payment was already processed for this order.');
|
||||
}
|
||||
|
||||
// // Update order status
|
||||
// $order->update([
|
||||
// 'payment_status' => 'paid',
|
||||
// 'status' => 'processing',
|
||||
// ]);
|
||||
|
||||
// // Reduce stock
|
||||
// foreach ($order->items as $item) {
|
||||
// $product = $item->product;
|
||||
// $product->stock -= $item->quantity;
|
||||
// $product->save();
|
||||
// }
|
||||
|
||||
// Clear pending order from session
|
||||
session()->forget('pending_order_uuid');
|
||||
session()->forget('cart');
|
||||
|
||||
return redirect()->route('order-success', ['order' => $order])
|
||||
->with('success', 'Payment successful! Your order has been confirmed.');
|
||||
}
|
||||
|
||||
public function yocoCancel(Order $order)
|
||||
{
|
||||
return redirect()->route('checkout')
|
||||
->with('error', 'Payment was cancelled. Your order is still pending.');
|
||||
}
|
||||
|
||||
public function yocoFailure(Order $order)
|
||||
{
|
||||
$order->update([
|
||||
'payment_status' => 'failed',
|
||||
]);
|
||||
|
||||
return redirect()->route('checkout')
|
||||
->with('error', 'Payment failed. Please try again or use a different payment method.');
|
||||
}
|
||||
|
||||
public function yocoWebhook(Request $request)
|
||||
{
|
||||
// 1. Get Raw Body and Headers
|
||||
\Log::info('Yoco Webhook: Received webhook');
|
||||
|
||||
$rawBody = $request->getContent();
|
||||
$trimmedBody = trim($rawBody);
|
||||
|
||||
$webhookId = $_SERVER['HTTP_WEBHOOK_ID'] ?? null;
|
||||
$webhookTimestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null;
|
||||
$webhookSignatureHeader = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null;
|
||||
|
||||
// Validate headers exist
|
||||
if (!$webhookId || !$webhookTimestamp || !$webhookSignatureHeader) {
|
||||
\Log::warning('Yoco Webhook: Missing headers', [
|
||||
'has_id' => !empty($webhookId),
|
||||
'has_timestamp' => !empty($webhookTimestamp),
|
||||
'has_signature' => !empty($webhookSignatureHeader),
|
||||
]);
|
||||
return response()->json(['error' => 'Missing headers'], 400);
|
||||
}
|
||||
|
||||
// 2. Parse the Secret Safely
|
||||
$envSecret = trim(config('services.yoco.webhook_secret'));
|
||||
|
||||
if (empty($envSecret)) {
|
||||
\Log::error('Yoco Webhook: Missing webhook secret configuration');
|
||||
return response()->json(['error' => 'Webhook secret not configured'], 500);
|
||||
}
|
||||
|
||||
$secretKeyString = strpos($envSecret, 'whsec_') === 0
|
||||
? substr($envSecret, 6)
|
||||
: $envSecret;
|
||||
|
||||
$secretBytes = base64_decode($secretKeyString);
|
||||
|
||||
// 3. Parse Incoming Signature (Standardize)
|
||||
$incomingSignature = '';
|
||||
if (preg_match('/(?:v1=|v1,)([^,\s]+)/', $webhookSignatureHeader, $matches)) {
|
||||
$incomingSignature = $matches[1];
|
||||
} else {
|
||||
$incomingSignature = trim($webhookSignatureHeader);
|
||||
}
|
||||
|
||||
// 4. Verification Function
|
||||
$verifySignature = function($id, $timestamp, $body, $secretBytes, $expectedSig) {
|
||||
$signedContent = $id . '.' . $timestamp . '.' . $body;
|
||||
$calculatedHmac = hash_hmac('sha256', $signedContent, $secretBytes, true);
|
||||
$calculatedSig = base64_encode($calculatedHmac);
|
||||
return hash_equals($expectedSig, $calculatedSig);
|
||||
};
|
||||
|
||||
// 5. Try Verification (Attempt both Trimmed and Raw)
|
||||
$isValid = false;
|
||||
$methodUsed = '';
|
||||
|
||||
// Attempt 1: Trimmed Body (Most likely correct for JSON)
|
||||
if ($verifySignature($webhookId, $webhookTimestamp, trim($rawBody), $secretBytes, $incomingSignature)) {
|
||||
$isValid = true;
|
||||
$methodUsed = 'trimmed';
|
||||
}
|
||||
// Attempt 2: Raw Body (Fallback if Yoco signed the whitespace)
|
||||
elseif ($verifySignature($webhookId, $webhookTimestamp, $rawBody, $secretBytes, $incomingSignature)) {
|
||||
$isValid = true;
|
||||
$methodUsed = 'raw';
|
||||
}
|
||||
|
||||
if (!$isValid) {
|
||||
\Log::warning('Yoco Webhook: Signature verification failed', [
|
||||
'webhook_id' => $webhookId,
|
||||
'timestamp' => $webhookTimestamp,
|
||||
'received_signature' => $incomingSignature,
|
||||
]);
|
||||
return response()->json(['error' => 'Invalid signature'], 403);
|
||||
}
|
||||
|
||||
\Log::info('Yoco Webhook: Signature verified successfully', ['method' => $methodUsed]);
|
||||
|
||||
// 6. Parse Event
|
||||
$event = json_decode($rawBody, true);
|
||||
|
||||
if (!$event) {
|
||||
\Log::warning('Yoco Webhook: Failed to parse JSON payload');
|
||||
return response()->json(['error' => 'Invalid JSON'], 400);
|
||||
}
|
||||
|
||||
$type = $event['type'] ?? null;
|
||||
$payload = $event['payload'] ?? [];
|
||||
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$orderUuid = $metadata['order_uuid'] ?? null;
|
||||
$orderType = $metadata['order_type'] ?? null;
|
||||
$site = $metadata['site'] ?? null;
|
||||
$paymentId = $payload['id'] ?? null;
|
||||
$status = $payload['status'] ?? null;
|
||||
|
||||
\Log::info('Yoco Webhook: Payload extracted', [
|
||||
'type' => $type,
|
||||
'site' => $site,
|
||||
'order_type' => $orderType,
|
||||
'order_uuid' => $orderUuid,
|
||||
'payment_id' => $paymentId,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
if ($site !== 'additional_design') {
|
||||
\Log::warning('Yoco Webhook: Ignored event for different site', [
|
||||
'expected_site' => 'additional_design',
|
||||
'received_site' => $site,
|
||||
]);
|
||||
return response()->json(['status' => 'ignored'], 200);
|
||||
}
|
||||
|
||||
if (!$orderUuid || !$status) {
|
||||
\Log::warning('Yoco Webhook: Validation failed', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'status' => $status,
|
||||
'type' => $type,
|
||||
]);
|
||||
return response()->json(['error' => 'Invalid payload'], 400);
|
||||
}
|
||||
|
||||
// 7. Update Payment State where orderType is 'standard'
|
||||
if ($orderType === 'standard') {
|
||||
|
||||
$order = Order::where('uuid', $orderUuid)->first();
|
||||
|
||||
if (!$order) {
|
||||
\Log::warning('Yoco Webhook: Order not found', ['order_uuid' => $orderUuid]);
|
||||
return response()->json(['error' => 'Order not found'], 404);
|
||||
}
|
||||
|
||||
if ($status === 'succeeded') {
|
||||
\Log::info('Yoco Webhook: Processing successful payment', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'payment_id' => $paymentId,
|
||||
]);
|
||||
|
||||
if ($order->payment_status !== 'paid') {
|
||||
$order->update([
|
||||
'payment_status' => 'paid',
|
||||
'status' => 'prep',
|
||||
'yoco_checkout_response' => json_encode($payload),
|
||||
]);
|
||||
|
||||
// Reduce stock
|
||||
foreach ($order->items as $item) {
|
||||
$product = $item->product;
|
||||
$product->stock -= $item->quantity;
|
||||
$product->save();
|
||||
}
|
||||
|
||||
// Generate invoice PDF and send email via Mailjet
|
||||
try {
|
||||
$invoicePath = InvoiceService::generateInvoice($order);
|
||||
$fullPath = storage_path('app/public/' . $invoicePath);
|
||||
|
||||
// Send invoice email directly via Mailjet API (no queue needed)
|
||||
$mailjetService = new MailjetService();
|
||||
$customerName = $order->customer_name ?? explode('@', $order->customer_email)[0];
|
||||
|
||||
$success = $mailjetService->send(
|
||||
toEmail: $order->customer_email,
|
||||
toName: $customerName,
|
||||
subject: 'Invoice #' . $order->order_number . ' - ADDITIONAL DESIGN',
|
||||
htmlContent: $this->getBasicInvoiceHtml($order),
|
||||
attachments: [$fullPath]
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
\Log::info('Yoco Webhook: Invoice generated and email sent via Mailjet', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'order_id' => $order->id,
|
||||
'invoice_path' => $invoicePath,
|
||||
]);
|
||||
} else {
|
||||
\Log::warning('Yoco Webhook: Mailjet email send returned false', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Yoco Webhook: Invoice generation or email failed', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Emit OrderCreated and DepositPaid events (standard orders are fully paid upfront)
|
||||
OrderCreated::dispatch($order, 'standard');
|
||||
DepositPaid::dispatch($order, $order->total); // Full payment is treated as deposit confirmation
|
||||
|
||||
\Log::info('Yoco Webhook: Order updated for successful payment', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
}
|
||||
} elseif ($status === 'failed' || $status === 'cancelled') {
|
||||
\Log::info('Yoco Webhook: Processing failed/cancelled payment', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'payment_id' => $paymentId,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
$order->update([
|
||||
'payment_status' => 'failed',
|
||||
]);
|
||||
}
|
||||
} elseif ($orderType === 'custom_deposit' || $orderType === 'custom_balance') {
|
||||
|
||||
$order = CustomOrder::where('uuid', $orderUuid)->first();
|
||||
|
||||
if (!$order) {
|
||||
\Log::warning('Yoco Webhook: Order not found', ['order_uuid' => $orderUuid]);
|
||||
return response()->json(['error' => 'Order not found'], 404);
|
||||
}
|
||||
|
||||
if ($status === 'succeeded') {
|
||||
\Log::info('Yoco Webhook: Processing successful payment', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'payment_id' => $paymentId,
|
||||
'order_type' => $orderType,
|
||||
]);
|
||||
|
||||
if ($orderType === 'custom_deposit' && $order->deposit_status !== 'paid') {
|
||||
$order->update([
|
||||
'deposit_status' => 'paid',
|
||||
'status' => 'design',
|
||||
'yoco_checkout_response' => json_encode($payload),
|
||||
]);
|
||||
|
||||
// Emit events for custom order deposit
|
||||
OrderCreated::dispatch($order, 'custom');
|
||||
DepositPaid::dispatch($order, $order->deposit_amount);
|
||||
|
||||
\Log::info('Yoco Webhook: Order updated for successful deposit payment', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'order_id' => $order->id,
|
||||
'order_type' => $orderType,
|
||||
]);
|
||||
|
||||
} elseif ($orderType === 'custom_balance' && $order->balance_status !== 'paid') {
|
||||
$order->update([
|
||||
'balance_status' => 'paid',
|
||||
'status' => 'printing',
|
||||
'yoco_checkout_response' => json_encode($payload),
|
||||
]);
|
||||
|
||||
// Emit BalancePaid event
|
||||
BalancePaid::dispatch($order, $order->balance_amount);
|
||||
|
||||
\Log::info('Yoco Webhook: Order updated for successful balance payment', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'order_id' => $order->id,
|
||||
'order_type' => $orderType,
|
||||
]);
|
||||
}
|
||||
} elseif ($status === 'failed' || $status === 'cancelled') {
|
||||
\Log::info('Yoco Webhook: Processing failed/cancelled payment', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'payment_id' => $paymentId,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
if ($orderType === 'custom_deposit') {
|
||||
$order->update(['deposit_status' => 'failed']);
|
||||
} elseif ($orderType === 'custom_balance') {
|
||||
$order->update(['balance_status' => 'failed']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
\Log::info('Yoco Webhook: Processed successfully', [
|
||||
'order_uuid' => $orderUuid,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
public function trackForm()
|
||||
{
|
||||
return view('track-order');
|
||||
}
|
||||
|
||||
public function trackSearch(Request $request)
|
||||
{
|
||||
$key = 'track-order:' . $request->ip();
|
||||
|
||||
// Check if IP has already exceeded rate limit from previous failed attempts
|
||||
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($key, 5)) {
|
||||
return redirect()->route('track-order-form')
|
||||
->withErrors(['error' => 'Too many search attempts. Please try again later.']);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'order_number' => 'required|string',
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$order = Order::where('order_number', strtoupper($request->input('order_number')))
|
||||
->where('customer_email', strtolower($request->input('email')))
|
||||
->first();
|
||||
|
||||
if (!$order) {
|
||||
// Only increment rate limit on failed searches
|
||||
\Illuminate\Support\Facades\RateLimiter::hit($key, 600); // 10 minutes
|
||||
return redirect()->route('track-order-form')
|
||||
->withErrors(['error' => 'No order found with the provided information.']);
|
||||
}
|
||||
|
||||
// Successful search - no rate limit increment
|
||||
return view('track-order-result', ['order' => $order]);
|
||||
}
|
||||
|
||||
private function getBasicInvoiceHtml(Order $order): string
|
||||
{
|
||||
return <<<HTML
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #333; }
|
||||
.header { background-color: #f5f5f5; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; }
|
||||
.footer { background-color: #f5f5f5; padding: 20px; text-align: center; font-size: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f5f5f5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Invoice #{$order->order_number}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear {$order->customer_name},</p>
|
||||
<p>Please find your invoice attached to this email.</p>
|
||||
<h3>Order Details</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Order Number</th>
|
||||
<td>{$order->order_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Order Date</th>
|
||||
<td>{$order->created_at->format('d M Y')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total Amount</th>
|
||||
<td>R {$order->total}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>Thank you for your order!</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2025 ADDITIONAL DESIGN. All rights reserved.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\OrderPacked;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PackingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Confirm order packing with dimensions and weight
|
||||
*
|
||||
* POST /orders/{id}/pack
|
||||
* POST /custom-orders/{id}/pack
|
||||
*/
|
||||
public function confirmPacked(Request $request, Order $order)
|
||||
{
|
||||
// Validate packing input
|
||||
$validated = $request->validate([
|
||||
'width' => 'required|numeric|min:0.01',
|
||||
'length' => 'required|numeric|min:0.01',
|
||||
'weight' => 'required|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
// Check: Order must exist and not already be packed
|
||||
if (! $order) {
|
||||
return response()->json(['error' => 'Order not found'], 404);
|
||||
}
|
||||
|
||||
if ($order->packing_completed_at !== null) {
|
||||
return response()->json(['error' => 'Order already packed'], 409);
|
||||
}
|
||||
|
||||
// Check: Order must be in Inspection state
|
||||
if ($order->status !== 'inspection') {
|
||||
return response()->json([
|
||||
'error' => 'Order must be in Inspection state before packing',
|
||||
'current_status' => $order->status,
|
||||
], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
// Save packing data
|
||||
$order->update([
|
||||
'packing_width' => $validated['width'],
|
||||
'packing_length' => $validated['length'],
|
||||
'packing_height' => $validated['height'],
|
||||
'packing_weight' => $validated['weight'],
|
||||
'packing_completed_at' => now(),
|
||||
'packed_by' => Auth::id(),
|
||||
'status' => 'packing',
|
||||
]);
|
||||
|
||||
Log::info('Order packed', [
|
||||
'order_id' => $order->id,
|
||||
'width' => $validated['width'],
|
||||
'length' => $validated['length'],
|
||||
'weight' => $validated['weight'],
|
||||
'packed_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// Emit event to trigger Trello update, Slack notification
|
||||
OrderPacked::dispatch(
|
||||
$order,
|
||||
(float) $validated['width'],
|
||||
(float) $validated['length'],
|
||||
(float) $validated['weight'],
|
||||
Auth::id(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Order packed successfully',
|
||||
'order_id' => $order->id,
|
||||
'packing_completed_at' => $order->packing_completed_at,
|
||||
'dimensions' => [
|
||||
'width' => $validated['width'],
|
||||
'length' => $validated['length'],
|
||||
'weight' => $validated['weight'],
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error packing order', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Failed to pack order',
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function show(Product $product)
|
||||
{
|
||||
return view('product-detail', ['product' => $product]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ShipmentCreated;
|
||||
use App\Events\ShipmentCreationFailed;
|
||||
use App\Models\Order;
|
||||
use App\Services\CourierService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ShippingController extends Controller
|
||||
{
|
||||
public function __construct(protected CourierService $courierService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Create shipment and call courier API
|
||||
*
|
||||
* POST /orders/{id}/ship
|
||||
* POST /custom-orders/{id}/ship
|
||||
*/
|
||||
public function createShipment(Request $request, Order $order)
|
||||
{
|
||||
try {
|
||||
$result = $this->courierService->createShipmentForOrder($order);
|
||||
|
||||
// Emit event to trigger Slack notification and Trello updates
|
||||
ShipmentCreated::dispatch(
|
||||
$order,
|
||||
$result['waybill_id'],
|
||||
$result['tracking_number'],
|
||||
$result['sticker_path'],
|
||||
$result['waybill_path'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Shipment created successfully',
|
||||
'shipment' => [
|
||||
'waybill_id' => $result['waybill_id'],
|
||||
'tracking_number' => $result['tracking_number'],
|
||||
'sticker' => $result['sticker_path'] ? route('storage.file', $result['sticker_path']) : null,
|
||||
'waybill' => $result['waybill_path'] ? route('storage.file', $result['waybill_path']) : null,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Shipment creation failed', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Emit failure event
|
||||
ShipmentCreationFailed::dispatch($order, $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Failed to create shipment',
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry shipment creation after previous failure
|
||||
*
|
||||
* POST /admin/orders/{id}/retry-shipment
|
||||
*/
|
||||
public function retryShipment(Request $request, Order $order)
|
||||
{
|
||||
// Verify order exists and has not already been successfully shipped
|
||||
if ($order->courier_waybill_id) {
|
||||
return response()->json([
|
||||
'error' => 'Order already has a valid shipment',
|
||||
'waybill_id' => $order->courier_waybill_id,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// Re-run the shipment creation
|
||||
return $this->createShipment($request, $order);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ReadyToShipIntent;
|
||||
use App\Events\OrderMovedToPrep;
|
||||
use App\Events\OrderMovedToAwaitingApproval;
|
||||
use App\Events\OrderMovedToReadyForPrint;
|
||||
use App\Events\OrderMovedToPrinting;
|
||||
use App\Events\OrderMovedToInspection;
|
||||
use App\Models\Order;
|
||||
use App\Models\CustomOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TrelloWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle incoming Trello webhooks
|
||||
*
|
||||
* POST /api/webhooks/trello
|
||||
*/
|
||||
public function handle(Request $request)
|
||||
{
|
||||
// Trello validation ping or real event - both return 200
|
||||
Log::info('Trello webhook received', [
|
||||
'method' => $request->method(),
|
||||
'content_length' => strlen($request->getContent()),
|
||||
]);
|
||||
|
||||
// If empty body or validation ping, just return success
|
||||
if ($request->getContent() === '' || $request->method() === 'HEAD') {
|
||||
return response()->json(['ok' => true], 200);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $request->json()->all();
|
||||
|
||||
Log::info('Trello action received', [
|
||||
'action' => $payload['action']['type'] ?? 'unknown',
|
||||
'card' => $payload['action']['data']['card']['name'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
match ($payload['action']['type'] ?? null) {
|
||||
'updateCard' => $this->handleCardUpdate($payload),
|
||||
'updateCheckItem' => $this->handleChecklistUpdate($payload),
|
||||
default => Log::debug('Unhandled Trello action', [
|
||||
'type' => $payload['action']['type'] ?? 'unknown',
|
||||
]),
|
||||
};
|
||||
|
||||
return response()->json(['success' => true], 200);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error processing Trello webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true], 200);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handle card movement between lists
|
||||
*/
|
||||
private function handleCardUpdate(array $payload): void
|
||||
{
|
||||
$action = $payload['action'] ?? [];
|
||||
$cardData = $action['data']['card'] ?? [];
|
||||
$cardId = $cardData['id'] ?? null;
|
||||
$cardName = $cardData['name'] ?? null;
|
||||
$listAfter = $action['data']['listAfter'] ?? [];
|
||||
$listName = $listAfter['name'] ?? null;
|
||||
|
||||
if (! $cardId || ! $cardName || ! $listName) {
|
||||
Log::warning('Trello card update missing required data', [
|
||||
'card_id' => $cardId,
|
||||
'card_name' => $cardName,
|
||||
'list_name' => $listName,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract order number from card name (e.g., "Order #1043" or "Order #ORD-20260102-ABC123")
|
||||
if (! preg_match('/Order #([A-Za-z0-9\-]+)/', $cardName, $matches)) {
|
||||
Log::debug('Could not extract order number from card name', ['card_name' => $cardName]);
|
||||
return;
|
||||
}
|
||||
|
||||
$orderNumber = $matches[1];
|
||||
|
||||
// Try to find the order (could be standard or custom)
|
||||
$order = Order::where('order_number', $orderNumber)->first()
|
||||
?? CustomOrder::where('order_number', $orderNumber)->first();
|
||||
|
||||
if (! $order) {
|
||||
Log::warning('Order not found for Trello card', [
|
||||
'order_number' => $orderNumber,
|
||||
'card_name' => $cardName,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('Trello card moved', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $orderNumber,
|
||||
'list' => $listName,
|
||||
]);
|
||||
|
||||
// Handle list-specific actions
|
||||
match ($listName) {
|
||||
'Prep/Design' => (function () use ($order) {
|
||||
$order->update(['status' => 'prep']);
|
||||
OrderMovedToPrep::dispatch($order);
|
||||
})(),
|
||||
'Awaiting Customer Approval' => (function () use ($order) {
|
||||
$order->update(['status' => 'awaiting_approval']);
|
||||
OrderMovedToAwaitingApproval::dispatch($order);
|
||||
})(),
|
||||
'Ready for Print' => (function () use ($order) {
|
||||
$order->update(['status' => 'ready_for_print']);
|
||||
OrderMovedToReadyForPrint::dispatch($order);
|
||||
})(),
|
||||
'Printing' => (function () use ($order) {
|
||||
$order->update(['status' => 'printing']);
|
||||
OrderMovedToPrinting::dispatch($order);
|
||||
})(),
|
||||
'Inspection' => (function () use ($order) {
|
||||
$order->update(['status' => 'inspection']);
|
||||
OrderMovedToInspection::dispatch($order);
|
||||
})(),
|
||||
'Packing' => $order->update(['status' => 'packing']),
|
||||
'Ready to Ship' => (function () use ($order, $cardId) {
|
||||
$order->update(['status' => 'ready_to_ship']);
|
||||
$this->handleReadyToShipIntent($order, $cardId);
|
||||
})(),
|
||||
'Awaiting Collection' => (function () use ($order, $cardId) {
|
||||
$order->update(['status' => 'awaiting_collection']);
|
||||
$this->handleAwaitingCollectionIntent($order, $cardId);
|
||||
})(),
|
||||
'In Transit' => $order->update(['status' => 'in_transit']),
|
||||
'Done' => $order->update(['status' => 'completed']),
|
||||
default => Log::debug('Card moved to list', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'list' => $listName,
|
||||
]),
|
||||
};
|
||||
|
||||
Log::info('Order status updated from Trello', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'list' => $listName,
|
||||
'status' => $order->status,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle checklist item completion
|
||||
*/
|
||||
private function handleChecklistUpdate(array $payload): void
|
||||
{
|
||||
$action = $payload['action'] ?? [];
|
||||
$cardData = $action['data']['card'] ?? [];
|
||||
$cardName = $cardData['name'] ?? null;
|
||||
$itemName = $action['data']['checkItem']['name'] ?? null;
|
||||
$itemState = $action['data']['checkItem']['state'] ?? null;
|
||||
|
||||
if ($itemState !== 'complete' || ! $cardName || ! $itemName) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::debug('Trello checklist item completed', [
|
||||
'card' => $cardName,
|
||||
'item' => $itemName,
|
||||
]);
|
||||
|
||||
// TODO: Map checklist completions to domain events if needed
|
||||
// For now, just log for visibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Ready to Ship" intent
|
||||
*
|
||||
* Emit event to trigger shipment creation flow
|
||||
*/
|
||||
private function handleReadyToShipIntent($order, string $cardId): void
|
||||
{
|
||||
Log::info('Ready to Ship intent from Trello', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $cardId,
|
||||
]);
|
||||
|
||||
// Emit event so ShippingController can validate and create shipment
|
||||
ReadyToShipIntent::dispatch($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Awaiting Collection" intent
|
||||
*
|
||||
* Verify shipment exists before accepting transition
|
||||
*/
|
||||
private function handleAwaitingCollectionIntent($order, string $cardId): void
|
||||
{
|
||||
Log::info('Awaiting Collection intent from Trello', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $cardId,
|
||||
]);
|
||||
|
||||
// Check if order has a waybill (shipment was created)
|
||||
if (! $order->courier_waybill_id) {
|
||||
Log::warning('Cannot move to Awaiting Collection - no shipment created', [
|
||||
'order_uuid' => $order->uuid,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('Order ready for collection', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'waybill_id' => $order->courier_waybill_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Models\Category;
|
||||
use App\Models\HeroImage;
|
||||
|
||||
class WallpapersController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$products = Product::where('type', 'wallpaper')->get();
|
||||
$categories = Category::all();
|
||||
$heroImages = HeroImage::where('page', 'wallpaper')->where('is_active', true)->orderBy('sort_order')->get();
|
||||
|
||||
return view('wallpapers', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'heroImages' => $heroImages
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
|
||||
|
||||
class VerifyCsrfToken extends Middleware
|
||||
{
|
||||
/**
|
||||
* The URIs that should be excluded from CSRF verification.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
'api/webhook',
|
||||
'api/webhooks/*',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Services\MailjetService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
class SendInvoiceEmail implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $invoicePath
|
||||
) {}
|
||||
|
||||
public function handle(MailjetService $mailjetService): void
|
||||
{
|
||||
try {
|
||||
// Render the email view with proper view isolation
|
||||
// Use ViewNotFoundException handling for nested views
|
||||
try {
|
||||
$htmlContent = View::make('emails.invoice', [
|
||||
'order' => $this->order,
|
||||
'invoiceNumber' => $this->order->order_number,
|
||||
])->render();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// If the mail::message namespace isn't available, render without it
|
||||
// This is a fallback for when the mail views aren't published
|
||||
Log::warning('Mail views not published, using basic HTML template', [
|
||||
'order_id' => $this->order->id,
|
||||
]);
|
||||
$htmlContent = $this->getBasicInvoiceHtml();
|
||||
}
|
||||
|
||||
// Get customer name (fallback to email if name not available)
|
||||
$customerName = $this->order->customer_name ?? explode('@', $this->order->customer_email)[0];
|
||||
|
||||
// Send via Mailjet
|
||||
$success = $mailjetService->send(
|
||||
toEmail: $this->order->customer_email,
|
||||
toName: $customerName,
|
||||
subject: 'Invoice #' . $this->order->order_number . ' - ADDITIONAL DESIGN',
|
||||
htmlContent: $htmlContent,
|
||||
attachments: [$this->invoicePath]
|
||||
);
|
||||
|
||||
if (!$success) {
|
||||
throw new \Exception('Mailjet send returned false');
|
||||
}
|
||||
|
||||
Log::info('Invoice email sent successfully via Mailjet', [
|
||||
'order_id' => $this->order->id,
|
||||
'order_number' => $this->order->order_number,
|
||||
'customer_email' => $this->order->customer_email,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to send invoice email via Mailjet', [
|
||||
'order_id' => $this->order->id,
|
||||
'order_number' => $this->order->order_number,
|
||||
'customer_email' => $this->order->customer_email,
|
||||
'error' => $e->getMessage(),
|
||||
'attempt' => $this->attempts(),
|
||||
]);
|
||||
|
||||
// Re-throw to trigger queue retry mechanism
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback basic HTML template for invoice email
|
||||
*/
|
||||
private function getBasicInvoiceHtml(): string
|
||||
{
|
||||
return <<<HTML
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #333; }
|
||||
.header { background-color: #f5f5f5; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; }
|
||||
.footer { background-color: #f5f5f5; padding: 20px; text-align: center; font-size: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f5f5f5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Invoice #{$this->order->order_number}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear {$this->order->customer_name ?: 'Customer'},</p>
|
||||
<p>Please find your invoice attached to this email.</p>
|
||||
<h3>Order Details</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Order Number</th>
|
||||
<td>{$this->order->order_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Order Date</th>
|
||||
<td>{$this->order->created_at->format('d M Y')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total Amount</th>
|
||||
<td>R {$this->order->total}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>Thank you for your order!</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2025 ADDITIONAL DESIGN. All rights reserved.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::critical('Invoice email job failed permanently after all retries', [
|
||||
'order_id' => $this->order->id,
|
||||
'order_number' => $this->order->order_number,
|
||||
'customer_email' => $this->order->customer_email,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ReadyToShipIntent;
|
||||
use App\Services\CourierService;
|
||||
use App\Services\TrelloService;
|
||||
use App\Events\ShipmentCreated;
|
||||
use App\Events\ShipmentCreationFailed;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CreateShipmentOnReadyToShip
|
||||
{
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct(
|
||||
protected CourierService $courierService,
|
||||
protected TrelloService $trelloService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event - create shipment when Trello card moves to Ready to Ship.
|
||||
*/
|
||||
public function handle(ReadyToShipIntent $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
Log::info('Creating shipment from Ready to Ship intent', [
|
||||
'order_uuid' => $order->uuid,
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->courierService->createShipmentForOrder($order);
|
||||
|
||||
// Attach shipment PDFs to Trello card
|
||||
if ($order->trello_card_id) {
|
||||
$this->attachShipmentDocumentsToTrello($order, $result);
|
||||
}
|
||||
|
||||
// Emit event to trigger Slack/Trello updates
|
||||
ShipmentCreated::dispatch(
|
||||
$order,
|
||||
$result['waybill_id'],
|
||||
$result['tracking_number'],
|
||||
$result['sticker_path'],
|
||||
$result['waybill_path'],
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to create shipment from Ready to Ship intent', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Emit failure event
|
||||
ShipmentCreationFailed::dispatch($order, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach shipment documents (label and sticker) to the Trello card
|
||||
*/
|
||||
private function attachShipmentDocumentsToTrello($order, array $result): void
|
||||
{
|
||||
if (! $result['sticker_path'] && ! $result['waybill_path']) {
|
||||
Log::warning('No shipment documents to attach', ['order_uuid' => $order->uuid]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Attach sticker PDF
|
||||
if ($result['sticker_path'] && Storage::disk('public')->exists($result['sticker_path'])) {
|
||||
$stickerUrl = asset('storage/' . $result['sticker_path']);
|
||||
$this->trelloService->attachFile($order->trello_card_id, 'Shipment Sticker.pdf', $stickerUrl);
|
||||
Log::info('Sticker attached to Trello card', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Attach waybill/label PDF
|
||||
if ($result['waybill_path'] && Storage::disk('public')->exists($result['waybill_path'])) {
|
||||
$waybillUrl = asset('storage/' . $result['waybill_path']);
|
||||
$this->trelloService->attachFile($order->trello_card_id, 'Shipment Label.pdf', $waybillUrl);
|
||||
Log::info('Waybill attached to Trello card', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('Shipment documents attached to Trello card', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to attach shipment documents to Trello', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderCreated;
|
||||
use App\Services\QrStickerService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateQrCodeOnOrderCreated
|
||||
{
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(OrderCreated $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
try {
|
||||
// Generate secure token if not already set
|
||||
if (! $order->qr_token) {
|
||||
$order->qr_token = Str::random(32);
|
||||
$order->qr_generated_at = now();
|
||||
$order->save();
|
||||
}
|
||||
|
||||
Log::debug('Generating QR sticker', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
// Generate both SVG (web) and PDF (print) stickers
|
||||
$service = new QrStickerService();
|
||||
$paths = $service->generateSticker($order);
|
||||
|
||||
Log::info('QR sticker generated successfully', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'svg_path' => $paths['svg_path'],
|
||||
'pdf_path' => $paths['pdf_path'],
|
||||
'token' => substr($order->qr_token, 0, 8) . '...',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to generate QR code', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\InspectionPassed;
|
||||
use App\Services\TrelloService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MoveCardToPackingOnInspectionPassed implements ShouldQueue
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
|
||||
public function __construct(
|
||||
protected TrelloService $trello,
|
||||
) {}
|
||||
|
||||
public function handle(InspectionPassed $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
if (!$order->trello_card_id) {
|
||||
Log::debug('No Trello card to move', ['order_uuid' => $order->uuid]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Move card to Packing list
|
||||
$success = $this->trello->moveCard($order->trello_card_id, 'Packing');
|
||||
|
||||
if ($success) {
|
||||
Log::info('Trello card moved to Packing', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
} else {
|
||||
Log::warning('Failed to move Trello card to Packing', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $order->trello_card_id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error moving Trello card to Packing', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToInspection;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnCardMovedToInspection
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
) {}
|
||||
|
||||
public function handle(OrderMovedToInspection $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
$message = "🔍 Order Moved to Inspection\n" .
|
||||
"Order: #{$order->order_number}\n" .
|
||||
"Status: Ready for Quality Check";
|
||||
|
||||
// Notify Slack #production
|
||||
$this->slack->production($message);
|
||||
|
||||
Log::info('Card moved to inspection notification sent to production', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\InspectionFailed;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnInspectionFailed
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
) {}
|
||||
|
||||
public function handle(InspectionFailed $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
$message = "⚠️ Order Inspection Failed\n" .
|
||||
"Order: #{$order->order_number}\n" .
|
||||
"Reason: {$event->reason}\n" .
|
||||
"Status: Awaiting Review";
|
||||
|
||||
// Notify Slack #ops-alerts
|
||||
$this->slack->opsAlerts($message);
|
||||
|
||||
Log::info('Inspection failed notification sent to ops-alerts', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'reason' => $event->reason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\InspectionPassed;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnInspectionPassed
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
) {}
|
||||
|
||||
public function handle(InspectionPassed $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
$message = "✅ Order Inspection Passed\n" .
|
||||
"Order: #{$order->order_number}\n" .
|
||||
"Status: Ready for Packing";
|
||||
|
||||
// Notify Slack #production
|
||||
$this->slack->production($message);
|
||||
|
||||
Log::info('Inspection passed notification sent to production', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderCreated;
|
||||
use App\Services\QrStickerService;
|
||||
use App\Services\SlackNotifierService;
|
||||
use App\Services\TrelloService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class NotifySlackOnOrderCreated
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
protected TrelloService $trello,
|
||||
protected QrStickerService $qrSticker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(OrderCreated $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
// Extract design name from first order item's product
|
||||
$designName = $order->items->first()?->product?->name ?? '—';
|
||||
$printStock = $order->items->first()?->printStock?->name ?? '—';
|
||||
|
||||
// Calculate print size from first item's dimensions
|
||||
$printSize = '—';
|
||||
$firstItem = $order->items->first();
|
||||
if ($firstItem) {
|
||||
if ($firstItem->type === 'wallpaper' && $firstItem->length) {
|
||||
$printSize = $firstItem->length . 'm';
|
||||
} elseif ($firstItem->type === 'mural' && $firstItem->width && $firstItem->height) {
|
||||
$printSize = $firstItem->width . 'm × ' . $firstItem->height . 'm';
|
||||
}
|
||||
}
|
||||
|
||||
// Build detailed Slack message
|
||||
$message = "🆕 New {$event->orderType} order: Order #{$order->order_number}\n" .
|
||||
"Design: '{$designName}'\n" .
|
||||
"Size: {$printSize}";
|
||||
|
||||
// Notify Slack
|
||||
$this->slack->orders($message);
|
||||
|
||||
Log::info('Order created notification sent', ['order_uuid' => $order->uuid]);
|
||||
|
||||
// Create Trello card
|
||||
$cardId = $this->trello->createCard(
|
||||
$order->uuid,
|
||||
$order->order_number,
|
||||
$event->orderType,
|
||||
);
|
||||
|
||||
if ($cardId) {
|
||||
$order->update(['trello_card_id' => $cardId]);
|
||||
|
||||
// Populate custom fields with the same info
|
||||
$this->trello->setTextField($cardId, 'order_id', $order->uuid);
|
||||
$this->trello->setTextField($cardId, 'design_name', $designName);
|
||||
$this->trello->setTextField($cardId, 'print_stock', $printStock);
|
||||
$this->trello->setTextField($cardId, 'customer_name', $order->customer_name ?? '—');
|
||||
$this->trello->setTextField($cardId, 'print_size', $printSize);
|
||||
|
||||
Log::info('Trello card created for order', ['order_uuid' => $order->uuid, 'card_id' => $cardId]);
|
||||
|
||||
// Generate QR sticker and attach to Trello card
|
||||
try {
|
||||
$stickerPaths = $this->qrSticker->generateSticker($order);
|
||||
$pdfPath = storage_path('app/public/' . $stickerPaths['pdf_path']);
|
||||
|
||||
if (Storage::disk('public')->exists($stickerPaths['pdf_path'])) {
|
||||
// Generate a public URL for the PDF
|
||||
$stickerUrl = Storage::disk('public')->url($stickerPaths['pdf_path']);
|
||||
|
||||
// Attach to Trello card
|
||||
$this->trello->attachFile(
|
||||
$cardId,
|
||||
"QR-Sticker-{$order->order_number}.pdf",
|
||||
$stickerUrl
|
||||
);
|
||||
|
||||
Log::info('QR sticker attached to Trello card', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'card_id' => $cardId,
|
||||
'pdf_path' => $stickerPaths['pdf_path'],
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to generate or attach QR sticker to Trello', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToAwaitingApproval;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToAwaitingApproval
|
||||
{
|
||||
public function handle(OrderMovedToAwaitingApproval $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.design_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order awaiting approval', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack design channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Awaiting Customer Approval',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '⏳ Order Awaiting Customer Approval',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\nAwaiting Approval",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order awaiting approval', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'design',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order awaiting approval', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToPrep;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToPrep
|
||||
{
|
||||
public function handle(OrderMovedToPrep $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.design_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order moved to prep', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack design channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Moved to Prep/Design',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '📋 Order Moved to Prep/Design',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\n{$order->status}",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order moved to prep', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'design',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order moved to prep', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToPrinting;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToPrinting
|
||||
{
|
||||
public function handle(OrderMovedToPrinting $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.production_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order printing started', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack production channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Printing Started',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '🖨️ Order Printing Started',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\nPrinting in Progress",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order printing started', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'production',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order printing started', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderMovedToReadyForPrint;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifySlackOnOrderMovedToReadyForPrint
|
||||
{
|
||||
public function handle(OrderMovedToReadyForPrint $event): void
|
||||
{
|
||||
try {
|
||||
$order = $event->order;
|
||||
$webhook = config('services.slack.production_hook_url');
|
||||
|
||||
Log::info('Preparing Slack notification for order ready for print', [
|
||||
'webhook_configured' => !empty($webhook),
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
|
||||
if (! $webhook) {
|
||||
Log::warning('Slack production channel webhook not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'text' => 'Order Ready for Print',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '✅ Order Ready for Print',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Order Number:*\n#{$order->order_number}",
|
||||
],
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*Status:*\nReady for Print",
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'context',
|
||||
'elements' => [
|
||||
[
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "Updated at " . now()->format('Y-m-d H:i:s'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::post($webhook, $payload);
|
||||
|
||||
Log::info('Slack notification sent for order ready for print', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'order_number' => $order->order_number,
|
||||
'channel' => 'production',
|
||||
'status_code' => $response->status(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send Slack notification for order ready for print', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\OrderPacked;
|
||||
use App\Services\SlackNotifierService;
|
||||
use App\Services\TrelloService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnOrderPacked
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
protected TrelloService $trello,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(OrderPacked $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$message = "Order packed: Order #{$order->order_number}\n" .
|
||||
"Dimensions: {$event->width}cm × {$event->length}cm\n" .
|
||||
"Weight: {$event->weight}kg";
|
||||
|
||||
// Notify Slack #shipping
|
||||
$this->slack->shipping($message);
|
||||
|
||||
Log::info('Order packed notification sent', ['order_uuid' => $order->uuid]);
|
||||
|
||||
// Move Trello card to Ready to Ship and populate shipping dimensions as custom fields
|
||||
if ($order->trello_card_id) {
|
||||
// Move card to Ready to Ship list
|
||||
$this->trello->moveCard($order->trello_card_id, 'Ready to Ship');
|
||||
|
||||
// Populate custom fields with packing dimensions
|
||||
$this->trello->setNumberField($order->trello_card_id, 'ship_w', $event->width);
|
||||
$this->trello->setNumberField($order->trello_card_id, 'ship_h', $event->height);
|
||||
$this->trello->setNumberField($order->trello_card_id, 'ship_l', $event->length);
|
||||
$this->trello->setNumberField($order->trello_card_id, 'weight', $event->weight);
|
||||
|
||||
// Try to check off "Packed" item in checklist
|
||||
$this->trello->checkItem($order->trello_card_id, 'Packing', 'Packed');
|
||||
|
||||
Log::info('Trello card moved to Ready to Ship and dimensions added', [
|
||||
'order_uuid' => $order->uuid,
|
||||
'dimensions' => "{$event->width}x{$event->length}x{$event->height}cm",
|
||||
'weight' => "{$event->weight}kg",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ParcelCollected;
|
||||
use App\Services\SlackNotifierService;
|
||||
use App\Services\TrelloService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnParcelCollected
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
protected TrelloService $trello,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ParcelCollected $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$message = "📤 Parcel collected: Order #{$order->order_number}\n" .
|
||||
"Tracking: {$order->courier_tracking_number}";
|
||||
|
||||
$this->slack->shipping($message);
|
||||
|
||||
Log::info('Parcel collected notification sent', ['order_uuid' => $order->uuid]);
|
||||
|
||||
if ($order->trello_card_id) {
|
||||
$this->trello->moveCard($order->trello_card_id, 'In Transit');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ParcelDelivered;
|
||||
use App\Services\SlackNotifierService;
|
||||
use App\Services\TrelloService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnParcelDelivered
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
protected TrelloService $trello,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ParcelDelivered $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$message = "✅ Parcel delivered: Order #{$order->order_number}\n" .
|
||||
"Delivered at: {$order->delivered_at}";
|
||||
|
||||
$this->slack->shipping($message);
|
||||
|
||||
Log::info('Parcel delivered notification sent', ['order_uuid' => $order->uuid]);
|
||||
|
||||
if ($order->trello_card_id) {
|
||||
$this->trello->moveCard($order->trello_card_id, 'Done');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ParcelFailedDelivery;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnParcelFailedDelivery
|
||||
{
|
||||
public function __construct(protected SlackNotifierService $slack)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(ParcelFailedDelivery $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$message = "⚠️ Parcel delivery failed: Order #{$order->order_number}\n" .
|
||||
"Reason: {$event->failureReason}";
|
||||
|
||||
$this->slack->opsAlerts($message);
|
||||
|
||||
Log::warning('Parcel delivery failed', [
|
||||
'order_id' => $order->id,
|
||||
'reason' => $event->failureReason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ShipmentCreated;
|
||||
use App\Services\SlackNotifierService;
|
||||
use App\Services\TrelloService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class NotifySlackOnShipmentCreated
|
||||
{
|
||||
public function __construct(
|
||||
protected SlackNotifierService $slack,
|
||||
protected TrelloService $trello,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ShipmentCreated $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$message = "📦 Shipment created: Order #{$order->order_number}\n" .
|
||||
"Waybill: {$event->waybillId}\n" .
|
||||
"Tracking: {$event->trackingNumber}";
|
||||
|
||||
// Notify Slack #shipping
|
||||
$this->slack->shipping($message);
|
||||
|
||||
Log::info('Shipment created notification sent', ['order_uuid' => $order->uuid]);
|
||||
|
||||
// Note: PDFs are already attached to Trello card by CreateShipmentOnReadyToShip listener
|
||||
// This listener only handles Slack notification
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ShipmentCreationFailed;
|
||||
use App\Services\SlackNotifierService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotifySlackOnShipmentCreationFailed
|
||||
{
|
||||
public function __construct(protected SlackNotifierService $slack)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(ShipmentCreationFailed $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$message = "🚨 Shipment creation failed – Order #{$order->order_number}\n" .
|
||||
"Reason: {$event->errorMessage}";
|
||||
|
||||
// Alert ops team
|
||||
$this->slack->opsAlerts($message);
|
||||
|
||||
Log::error('Shipment creation failed', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $event->errorMessage,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InvoiceEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public string $invoicePath
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Invoice #' . $this->order->order_number . ' from Additional Design',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.invoice',
|
||||
with: [
|
||||
'order' => $this->order,
|
||||
'invoiceNumber' => $this->order->order_number,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [
|
||||
Attachment::fromPath($this->invoicePath)
|
||||
->as('Invoice-' . $this->order->order_number . '.pdf')
|
||||
->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AppSetting extends Model
|
||||
{
|
||||
protected $fillable = ['key', 'value', 'type', 'description'];
|
||||
|
||||
/**
|
||||
* Get a setting by key
|
||||
*/
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$setting = self::where('key', $key)->first();
|
||||
|
||||
if (!$setting) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return match ($setting->type) {
|
||||
'integer' => (int) $setting->value,
|
||||
'boolean' => (bool) $setting->value,
|
||||
'json' => json_decode($setting->value, true),
|
||||
default => $setting->value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting
|
||||
*/
|
||||
public static function set(string $key, mixed $value, string $type = 'string'): void
|
||||
{
|
||||
self::updateOrCreate(
|
||||
['key' => $key],
|
||||
['value' => is_array($value) ? json_encode($value) : (string) $value, 'type' => $type]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'image'
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($category) {
|
||||
if (empty($category->slug)) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($category) {
|
||||
if ($category->isDirty('name') && !$category->isDirty('slug')) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function products()
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class CustomOrder extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'uuid',
|
||||
'order_number',
|
||||
'type',
|
||||
'status',
|
||||
'design_fee',
|
||||
'library_discount_applied',
|
||||
'material_cost',
|
||||
'total_cost',
|
||||
'deposit_amount',
|
||||
'balance_amount',
|
||||
'deposit_status',
|
||||
'balance_status',
|
||||
'yoco_checkout_id',
|
||||
'yoco_redirect_url',
|
||||
'yoco_checkout_response',
|
||||
'customer_brief',
|
||||
'admin_notes',
|
||||
'submitted_at',
|
||||
'approved_at',
|
||||
'rejected_at',
|
||||
'completed_at',
|
||||
'proof_approved',
|
||||
'proof_approved_at',
|
||||
'packing_width',
|
||||
'packing_length',
|
||||
'packing_height',
|
||||
'packing_weight',
|
||||
'packing_completed_at',
|
||||
'packed_by',
|
||||
'courier_waybill_id',
|
||||
'courier_tracking_number',
|
||||
'courier_status',
|
||||
'delivered_at',
|
||||
'delivery_failure_reason',
|
||||
'trello_card_id',
|
||||
'qr_token',
|
||||
'qr_generated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'library_discount_applied' => 'boolean',
|
||||
'proof_approved' => 'boolean',
|
||||
'design_fee' => 'decimal:2',
|
||||
'material_cost' => 'decimal:2',
|
||||
'total_cost' => 'decimal:2',
|
||||
'deposit_amount' => 'decimal:2',
|
||||
'balance_amount' => 'decimal:2',
|
||||
'submitted_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
'rejected_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'proof_approved_at' => 'datetime',
|
||||
'packing_completed_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot method for model
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
$model->uuid = \Illuminate\Support\Str::uuid();
|
||||
$model->order_number = 'CUSTOM-' . now()->format('Ymd') . '-' . strtoupper(uniqid());
|
||||
$model->submitted_at = now();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user that owns this custom order
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the specifications for this custom order
|
||||
*/
|
||||
public function specifications(): HasOne
|
||||
{
|
||||
return $this->hasOne(CustomOrderSpecification::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the files uploaded for this custom order
|
||||
*/
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomOrderFile::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the proofs uploaded for this custom order
|
||||
*/
|
||||
public function proofs(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomOrderProof::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route key name for implicit route binding
|
||||
*/
|
||||
public function getRouteKeyName()
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function packedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'packed_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a custom order
|
||||
*/
|
||||
public function isCustomOrder(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CustomOrderFile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'custom_order_id',
|
||||
'file_type',
|
||||
'file_path',
|
||||
'original_filename',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'uploaded_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the custom order that owns this file
|
||||
*/
|
||||
public function customOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who uploaded this file
|
||||
*/
|
||||
public function uploadedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CustomOrderProof extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'custom_order_id',
|
||||
'file_path',
|
||||
'original_filename',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'notes',
|
||||
'uploaded_by',
|
||||
'status',
|
||||
'rejection_reason',
|
||||
'approved_at',
|
||||
'rejected_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'approved_at' => 'datetime',
|
||||
'rejected_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the custom order that owns this proof
|
||||
*/
|
||||
public function customOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the admin who uploaded this proof
|
||||
*/
|
||||
public function uploadedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CustomOrderSpecification extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'custom_order_id',
|
||||
'length',
|
||||
'width',
|
||||
'height',
|
||||
'print_stock_id',
|
||||
'quantity',
|
||||
'special_instructions',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'length' => 'decimal:2',
|
||||
'width' => 'decimal:2',
|
||||
'height' => 'decimal:2',
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the custom order that owns this specification
|
||||
*/
|
||||
public function customOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the print stock
|
||||
*/
|
||||
public function printStock(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PrintStock::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class HeroImage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'page',
|
||||
'image_path',
|
||||
'title',
|
||||
'description',
|
||||
'button_text',
|
||||
'button_link',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public static function getPages()
|
||||
{
|
||||
return [
|
||||
'home' => 'Home Page',
|
||||
'wallpaper' => 'Wallpaper Product Page',
|
||||
'mural' => 'Mural Product Page',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $primaryKey = 'uuid';
|
||||
protected $keyType = 'string';
|
||||
public $incrementing = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'order_number',
|
||||
'total',
|
||||
'shipping_fee',
|
||||
'invoice_path',
|
||||
'status',
|
||||
'payment_method',
|
||||
'payment_status',
|
||||
'customer_name',
|
||||
'customer_email',
|
||||
'customer_phone',
|
||||
'shipping_address',
|
||||
'shipping_street_address',
|
||||
'shipping_unit_number',
|
||||
'shipping_local_area',
|
||||
'shipping_city',
|
||||
'shipping_zone',
|
||||
'shipping_country',
|
||||
'shipping_postcode',
|
||||
'shipping_type',
|
||||
'business_name',
|
||||
'notes',
|
||||
'yoco_checkout_id',
|
||||
'yoco_redirect_url',
|
||||
'yoco_checkout_response',
|
||||
'yoco_payment_id',
|
||||
'packing_width',
|
||||
'packing_length',
|
||||
'packing_height',
|
||||
'packing_weight',
|
||||
'packing_completed_at',
|
||||
'packed_by',
|
||||
'courier_shipment_id',
|
||||
'courier_waybill_id',
|
||||
'courier_tracking_number',
|
||||
'courier_status',
|
||||
'courier_rate',
|
||||
'courier_service_level_code',
|
||||
'courier_service_level_id',
|
||||
'courier_collection_min_date',
|
||||
'courier_delivery_min_date',
|
||||
'delivered_at',
|
||||
'delivery_failure_reason',
|
||||
'trello_card_id',
|
||||
'qr_token',
|
||||
'qr_generated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'packing_completed_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
'qr_generated_at' => 'datetime',
|
||||
'courier_collection_min_date' => 'datetime',
|
||||
'courier_delivery_min_date' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(OrderItem::class, 'order_id', 'uuid');
|
||||
}
|
||||
|
||||
public function packedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'packed_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a custom order
|
||||
*/
|
||||
public function isCustomOrder(): bool
|
||||
{
|
||||
return false; // Standard orders are not custom
|
||||
}}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class OrderItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'product_id',
|
||||
'print_stock_id',
|
||||
'quantity',
|
||||
'price',
|
||||
'type',
|
||||
'is_sample',
|
||||
'length',
|
||||
'width',
|
||||
'height'
|
||||
];
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function printStock()
|
||||
{
|
||||
return $this->belongsTo(PrintStock::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PrintStock extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'cost_per_meter',
|
||||
'cost_per_m2',
|
||||
'type',
|
||||
'width'
|
||||
];
|
||||
|
||||
public function products()
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'product_print_stocks');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'price',
|
||||
'image',
|
||||
'stock',
|
||||
'featured',
|
||||
'type'
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->slug)) {
|
||||
$model->slug = Str::slug($model->name);
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($model) {
|
||||
if (empty($model->slug)) {
|
||||
$model->slug = Str::slug($model->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function orderItems()
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function printStocks()
|
||||
{
|
||||
return $this->belongsToMany(PrintStock::class, 'product_print_stocks');
|
||||
}
|
||||
|
||||
public function images()
|
||||
{
|
||||
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function getRouteKeyName()
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProductImage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'is_admin',
|
||||
'google_id',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user