Compare commits
1 Commits
66862df44b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9224bde11f |
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
* 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
@@ -1,24 +0,0 @@
|
|||||||
*.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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
php:
|
|
||||||
preset: laravel
|
|
||||||
disabled:
|
|
||||||
- no_unused_imports
|
|
||||||
finder:
|
|
||||||
not-name:
|
|
||||||
- index.php
|
|
||||||
js: true
|
|
||||||
css: true
|
|
||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
# 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,59 +1,2 @@
|
|||||||
<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>
|
# additional_design
|
||||||
|
|
||||||
<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).
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
<?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()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?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 $weight,
|
|
||||||
public ?int $packedBy = null,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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 = '',
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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 = '',
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?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('/'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?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('/'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<?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,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<?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('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
<?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!');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
abstract class Controller
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?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
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?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
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<?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_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);
|
|
||||||
|
|
||||||
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 ($order->status !== 'inspection') {
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'Order is not in inspection state',
|
|
||||||
'current_status' => $order->status,
|
|
||||||
], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status and emit event
|
|
||||||
$order->update(['status' => 'packing']);
|
|
||||||
|
|
||||||
Log::info('Inspection passed via QR', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'marked_by' => auth()->user()->name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Emit event for listeners to handle Slack/Trello updates
|
|
||||||
// TODO: Create InspectionPassed event
|
|
||||||
|
|
||||||
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 ($order->status !== 'inspection') {
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'Order is not in inspection 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
|
|
||||||
// TODO: Create InspectionFailed event
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
$actions = [];
|
|
||||||
|
|
||||||
match ($order->status) {
|
|
||||||
'inspection' => [
|
|
||||||
$actions['markInspectionPassed'] = true,
|
|
||||||
$actions['flagInspectionIssue'] = true,
|
|
||||||
],
|
|
||||||
'packing' => [
|
|
||||||
$actions['confirmPacking'] = true,
|
|
||||||
],
|
|
||||||
'ready_to_ship', 'awaiting_collection', 'in_transit' => [
|
|
||||||
$actions['readOnly'] = true,
|
|
||||||
],
|
|
||||||
default => []
|
|
||||||
};
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,790 +0,0 @@
|
|||||||
<?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_address' => 'required|string|max:500',
|
|
||||||
'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_address' => $request->input('shipping_address'),
|
|
||||||
];
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<?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_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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Events\ReadyToShipIntent;
|
|
||||||
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) {
|
|
||||||
'Ready to Ship' => $this->handleReadyToShipIntent($order, $cardId),
|
|
||||||
'Awaiting Collection' => $this->handleAwaitingCollectionIntent($order, $cardId),
|
|
||||||
'In Transit' => Log::info('Card in transit', ['order_uuid' => $order->uuid]),
|
|
||||||
'Done' => Log::info('Card completed', ['order_uuid' => $order->uuid]),
|
|
||||||
default => Log::debug('Card moved to list', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'list' => $listName,
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?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
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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/*',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Events\ReadyToShipIntent;
|
|
||||||
use App\Services\CourierService;
|
|
||||||
use App\Events\ShipmentCreated;
|
|
||||||
use App\Events\ShipmentCreationFailed;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class CreateShipmentOnReadyToShip
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Create the event listener.
|
|
||||||
*/
|
|
||||||
public function __construct(protected CourierService $courierService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?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 Packing list
|
|
||||||
if ($order->trello_card_id) {
|
|
||||||
$this->trello->moveCard($order->trello_card_id, 'Packing');
|
|
||||||
|
|
||||||
// Try to check off "Packed" item in checklist
|
|
||||||
$this->trello->checkItem($order->trello_card_id, 'Packing', 'Packed');
|
|
||||||
|
|
||||||
Log::info('Trello card moved to Packing', ['order_uuid' => $order->uuid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
|
|
||||||
// Attach shipping documents to Trello card
|
|
||||||
if ($order->trello_card_id) {
|
|
||||||
if ($event->stickerPath && Storage::disk('public')->exists($event->stickerPath)) {
|
|
||||||
$stickerUrl = Storage::disk('public')->url($event->stickerPath);
|
|
||||||
$this->trello->attachFile($order->trello_card_id, 'Sticker.pdf', $stickerUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($event->waybillPath && Storage::disk('public')->exists($event->waybillPath)) {
|
|
||||||
$waybillUrl = Storage::disk('public')->url($event->waybillPath);
|
|
||||||
$this->trello->attachFile($order->trello_card_id, 'Waybill.pdf', $waybillUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move card to Awaiting Collection
|
|
||||||
$this->trello->moveCard($order->trello_card_id, 'Awaiting Collection');
|
|
||||||
|
|
||||||
Log::info('Trello card updated with shipment details', ['order_uuid' => $order->uuid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<?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_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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<?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',
|
|
||||||
'notes',
|
|
||||||
'yoco_checkout_id',
|
|
||||||
'yoco_redirect_url',
|
|
||||||
'yoco_checkout_response',
|
|
||||||
'yoco_payment_id',
|
|
||||||
'packing_width',
|
|
||||||
'packing_length',
|
|
||||||
'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',
|
|
||||||
];
|
|
||||||
|
|
||||||
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
|
|
||||||
}}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
use Illuminate\Support\Facades\View;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
if (config('app.env') === 'production' || request()->server('HTTP_X_FORWARDED_PROTO') === 'https') {
|
|
||||||
URL::forceScheme('https');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share cart count with all views
|
|
||||||
View::composer('*', function ($view) {
|
|
||||||
$cartCount = session('cart') ? count(session('cart')) : 0;
|
|
||||||
$view->with('cartCount', $cartCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Authorization gates
|
|
||||||
\Illuminate\Support\Facades\Gate::define('access-ops', function ($user) {
|
|
||||||
// Allow admin users to access ops (flexible type check)
|
|
||||||
return $user && $user->is_admin;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Events\BalancePaid;
|
|
||||||
use App\Events\DepositPaid;
|
|
||||||
use App\Events\OrderCreated;
|
|
||||||
use App\Events\OrderPacked;
|
|
||||||
use App\Events\ParcelCollected;
|
|
||||||
use App\Events\ParcelDelivered;
|
|
||||||
use App\Events\ParcelFailedDelivery;
|
|
||||||
use App\Events\ParcelInTransit;
|
|
||||||
use App\Events\ProofApproved;
|
|
||||||
use App\Events\ProofRevisionRequested;
|
|
||||||
use App\Events\ProofUploaded;
|
|
||||||
use App\Events\ReadyToShipIntent;
|
|
||||||
use App\Events\ShipmentCreated;
|
|
||||||
use App\Events\ShipmentCreationFailed;
|
|
||||||
use App\Listeners\CreateShipmentOnReadyToShip;
|
|
||||||
use App\Listeners\GenerateQrCodeOnOrderCreated;
|
|
||||||
use App\Listeners\NotifySlackOnOrderCreated;
|
|
||||||
use App\Listeners\NotifySlackOnOrderPacked;
|
|
||||||
use App\Listeners\NotifySlackOnParcelCollected;
|
|
||||||
use App\Listeners\NotifySlackOnParcelDelivered;
|
|
||||||
use App\Listeners\NotifySlackOnParcelFailedDelivery;
|
|
||||||
use App\Listeners\NotifySlackOnShipmentCreated;
|
|
||||||
use App\Listeners\NotifySlackOnShipmentCreationFailed;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The event to listener mappings for the application.
|
|
||||||
*
|
|
||||||
* @var array<class-string, array<int, class-string>>
|
|
||||||
*/
|
|
||||||
protected $listen = [
|
|
||||||
// Order events
|
|
||||||
OrderCreated::class => [
|
|
||||||
NotifySlackOnOrderCreated::class,
|
|
||||||
GenerateQrCodeOnOrderCreated::class,
|
|
||||||
],
|
|
||||||
|
|
||||||
// Packing events
|
|
||||||
OrderPacked::class => [
|
|
||||||
NotifySlackOnOrderPacked::class,
|
|
||||||
],Ready to Ship intent (from Trello webhook)
|
|
||||||
ReadyToShipIntent::class => [
|
|
||||||
CreateShipmentOnReadyToShip::class,
|
|
||||||
],
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
// Shipment events
|
|
||||||
ShipmentCreated::class => [
|
|
||||||
NotifySlackOnShipmentCreated::class,
|
|
||||||
],
|
|
||||||
ShipmentCreationFailed::class => [
|
|
||||||
NotifySlackOnShipmentCreationFailed::class,
|
|
||||||
],
|
|
||||||
|
|
||||||
// Courier events
|
|
||||||
ParcelCollected::class => [
|
|
||||||
NotifySlackOnParcelCollected::class,
|
|
||||||
],
|
|
||||||
ParcelInTransit::class => [
|
|
||||||
// TODO: Add listener
|
|
||||||
],
|
|
||||||
ParcelDelivered::class => [
|
|
||||||
NotifySlackOnParcelDelivered::class,
|
|
||||||
],
|
|
||||||
ParcelFailedDelivery::class => [
|
|
||||||
NotifySlackOnParcelFailedDelivery::class,
|
|
||||||
],
|
|
||||||
|
|
||||||
// Custom order events (listeners TODO)
|
|
||||||
DepositPaid::class => [
|
|
||||||
// TODO: NotifySlackOnDepositPaid
|
|
||||||
],
|
|
||||||
ProofUploaded::class => [
|
|
||||||
// TODO: NotifySlackOnProofUploaded
|
|
||||||
],
|
|
||||||
ProofApproved::class => [
|
|
||||||
// TODO: NotifySlackOnProofApproved
|
|
||||||
],
|
|
||||||
ProofRevisionRequested::class => [
|
|
||||||
// TODO: NotifySlackOnProofRevisionRequested
|
|
||||||
],
|
|
||||||
BalancePaid::class => [
|
|
||||||
// TODO: NotifySlackOnBalancePaid
|
|
||||||
],
|
|
||||||
|
|
||||||
Registered::class => [
|
|
||||||
SendEmailVerificationNotification::class,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable automatic event discovery to avoid permission issues
|
|
||||||
*/
|
|
||||||
public function shouldDiscoverEvents(): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register any events for your application.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers\Filament;
|
|
||||||
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
|
||||||
use Filament\Pages;
|
|
||||||
use Filament\Panel;
|
|
||||||
use Filament\PanelProvider;
|
|
||||||
use Filament\Support\Colors\Color;
|
|
||||||
use Filament\Widgets;
|
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
|
||||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
|
||||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
|
||||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
|
||||||
use Illuminate\Session\Middleware\StartSession;
|
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
|
||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
|
||||||
{
|
|
||||||
public function panel(Panel $panel): Panel
|
|
||||||
{
|
|
||||||
return $panel
|
|
||||||
->default()
|
|
||||||
->id('admin')
|
|
||||||
->path('admin')
|
|
||||||
->login()
|
|
||||||
->colors([
|
|
||||||
'primary' => Color::Amber,
|
|
||||||
])
|
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
|
||||||
->pages([
|
|
||||||
Pages\Dashboard::class,
|
|
||||||
])
|
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
|
||||||
->widgets([
|
|
||||||
Widgets\AccountWidget::class,
|
|
||||||
Widgets\FilamentInfoWidget::class,
|
|
||||||
])
|
|
||||||
->middleware([
|
|
||||||
EncryptCookies::class,
|
|
||||||
AddQueuedCookiesToResponse::class,
|
|
||||||
StartSession::class,
|
|
||||||
AuthenticateSession::class,
|
|
||||||
ShareErrorsFromSession::class,
|
|
||||||
VerifyCsrfToken::class,
|
|
||||||
SubstituteBindings::class,
|
|
||||||
DisableBladeIconComponents::class,
|
|
||||||
DispatchServingFilamentEvent::class,
|
|
||||||
])
|
|
||||||
->authMiddleware([
|
|
||||||
Authenticate::class,
|
|
||||||
])
|
|
||||||
->authGuard('web');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\Order;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class CourierService
|
|
||||||
{
|
|
||||||
protected string $apiKey;
|
|
||||||
protected string $baseUrl;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->apiKey = config('courier.api_key');
|
|
||||||
$this->baseUrl = config('courier.api_base_url', 'https://api.shiplogic.com/api');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create shipment with full validation and database updates
|
|
||||||
*
|
|
||||||
* @param Order $order
|
|
||||||
* @return array{waybill_id: string, tracking_number: string, sticker_path: ?string, waybill_path: ?string}
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
|
||||||
public function createShipmentForOrder(Order $order): array
|
|
||||||
{
|
|
||||||
Log::info('Creating shipment for order', ['order_uuid' => $order->uuid]);
|
|
||||||
|
|
||||||
// Guard 1: Validate packing
|
|
||||||
if (! $this->validatePacking($order)) {
|
|
||||||
throw new \Exception('Order not yet packed with valid dimensions');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard 2: Validate payment/approval
|
|
||||||
if (! $this->validatePayment($order)) {
|
|
||||||
throw new \Exception($order->is_custom_order ?
|
|
||||||
'Custom order requires proof approved and balance paid' :
|
|
||||||
'Standard order must be fully paid'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard 3: Validate order status
|
|
||||||
if ($order->status !== 'ready_to_ship') {
|
|
||||||
throw new \Exception('Order must be in Ready to Ship state');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard 4: Prevent duplicates
|
|
||||||
if ($order->courier_waybill_id) {
|
|
||||||
throw new \Exception('Shipment already exists for this order');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Call API to create shipment
|
|
||||||
$shipmentData = $this->callCreateShipmentApi(
|
|
||||||
$order->id,
|
|
||||||
$order->packing_width,
|
|
||||||
$order->packing_length,
|
|
||||||
$order->packing_weight,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save shipment details to database
|
|
||||||
$order->update([
|
|
||||||
'courier_waybill_id' => $shipmentData['waybill_id'],
|
|
||||||
'courier_tracking_number' => $shipmentData['tracking_number'],
|
|
||||||
'courier_status' => 'awaiting_collection',
|
|
||||||
'status' => 'awaiting_collection',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Log::info('Shipment created successfully', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'waybill_id' => $shipmentData['waybill_id'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Fetch shipping documents
|
|
||||||
$stickerPath = $this->fetchSticker($shipmentData['shipment_id'], $order->id);
|
|
||||||
$waybillPath = $this->fetchWaybill($shipmentData['shipment_id'], $order->id);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'waybill_id' => $shipmentData['waybill_id'],
|
|
||||||
'tracking_number' => $shipmentData['tracking_number'],
|
|
||||||
'sticker_path' => $stickerPath,
|
|
||||||
'waybill_path' => $waybillPath,
|
|
||||||
];
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Failed to create shipment', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a shipment with Shiplogic API
|
|
||||||
*
|
|
||||||
* @param string $orderId
|
|
||||||
* @param float $width Width in cm
|
|
||||||
* @param float $length Length in cm
|
|
||||||
* @param float $weight Weight in kg
|
|
||||||
* @return array{shipment_id: string, waybill_id: string, tracking_number: string}
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
|
||||||
private function callCreateShipmentApi(string $orderId, float $width, float $length, float $weight): array
|
|
||||||
{
|
|
||||||
if (! $this->isConfigured()) {
|
|
||||||
throw new \Exception('Courier API not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($width <= 0 || $length <= 0 || $weight <= 0) {
|
|
||||||
throw new \Exception('Invalid dimensions or weight: all must be greater than 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build shipment payload for Shiplogic
|
|
||||||
$payload = [
|
|
||||||
'parcel' => [
|
|
||||||
'weight' => $weight,
|
|
||||||
'height' => 10, // TODO: Update when height is captured separately
|
|
||||||
'width' => $width,
|
|
||||||
'length' => $length,
|
|
||||||
],
|
|
||||||
'destination' => [
|
|
||||||
// TODO: Get from order's shipping address
|
|
||||||
],
|
|
||||||
'reference' => $orderId,
|
|
||||||
];
|
|
||||||
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'Authorization' => "Bearer {$this->apiKey}",
|
|
||||||
])->post("{$this->baseUrl}/shipments", $payload);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
$errorMessage = $response->json('error.message', 'Unknown error');
|
|
||||||
throw new \Exception("Courier API error: {$errorMessage}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $response->json();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'shipment_id' => $data['id'] ?? null,
|
|
||||||
'waybill_id' => $data['waybill_number'] ?? null,
|
|
||||||
'tracking_number' => $data['tracking_number'] ?? null,
|
|
||||||
];
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Failed to call courier API', [
|
|
||||||
'order_id' => $orderId,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate packing gate: order must be packed with dimensions
|
|
||||||
*/
|
|
||||||
private function validatePacking(Order $order): bool
|
|
||||||
{
|
|
||||||
$valid = $order->packing_completed_at !== null &&
|
|
||||||
$order->packing_width > 0 &&
|
|
||||||
$order->packing_length > 0 &&
|
|
||||||
$order->packing_weight > 0;
|
|
||||||
|
|
||||||
if (! $valid) {
|
|
||||||
Log::warning('Packing validation failed', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'packing_completed_at' => $order->packing_completed_at,
|
|
||||||
'width' => $order->packing_width,
|
|
||||||
'length' => $order->packing_length,
|
|
||||||
'weight' => $order->packing_weight,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate payment and approval gate
|
|
||||||
*/
|
|
||||||
private function validatePayment(Order $order): bool
|
|
||||||
{
|
|
||||||
// Standard orders: must be fully paid
|
|
||||||
if ($order->is_custom_order === false) {
|
|
||||||
$valid = $order->payment_status === 'paid';
|
|
||||||
} else {
|
|
||||||
// Custom orders: proof must be approved and balance must be paid
|
|
||||||
// TODO: Add proof_approved and balance_status fields to CustomOrder model
|
|
||||||
$valid = true; // Placeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $valid) {
|
|
||||||
Log::warning('Payment validation failed', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'is_custom_order' => $order->is_custom_order,
|
|
||||||
'payment_status' => $order->payment_status,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch shipping label/sticker PDF from Shiplogic
|
|
||||||
*/
|
|
||||||
public function fetchSticker(string $shipmentId, string $orderId): ?string
|
|
||||||
{
|
|
||||||
if (! $this->isConfigured()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'Authorization' => "Bearer {$this->apiKey}",
|
|
||||||
])->get("{$this->baseUrl}/shipments/{$shipmentId}/sticker");
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
$path = "shipments/{$orderId}/sticker.pdf";
|
|
||||||
Storage::disk('public')->put($path, $response->body());
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::warning('Failed to fetch sticker from courier', ['shipment_id' => $shipmentId]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Exception fetching sticker', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch waybill PDF from Shiplogic
|
|
||||||
*/
|
|
||||||
public function fetchWaybill(string $shipmentId, string $orderId): ?string
|
|
||||||
{
|
|
||||||
if (! $this->isConfigured()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'Authorization' => "Bearer {$this->apiKey}",
|
|
||||||
])->get("{$this->baseUrl}/shipments/{$shipmentId}/label");
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
$path = "shipments/{$orderId}/waybill.pdf";
|
|
||||||
Storage::disk('public')->put($path, $response->body());
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::warning('Failed to fetch waybill from courier', ['shipment_id' => $shipmentId]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Exception fetching waybill', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if courier is configured
|
|
||||||
*/
|
|
||||||
protected function isConfigured(): bool
|
|
||||||
{
|
|
||||||
return ! empty($this->apiKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\Order;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
|
||||||
|
|
||||||
class InvoiceService
|
|
||||||
{
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an invoice PDF for an order
|
|
||||||
*
|
|
||||||
* @param Order $order
|
|
||||||
* @return string The file path to the stored PDF
|
|
||||||
*/
|
|
||||||
public static function generateInvoice(Order $order): string
|
|
||||||
{
|
|
||||||
|
|
||||||
|
|
||||||
// Create directory structure: invoices/{year}/{month}/
|
|
||||||
$year = $order->created_at->year;
|
|
||||||
$month = str_pad($order->created_at->month, 2, '0', STR_PAD_LEFT);
|
|
||||||
$directory = "invoices/{$year}/{$month}";
|
|
||||||
|
|
||||||
// Ensure directory exists in public disk
|
|
||||||
if (!Storage::disk('public')->exists($directory)) {
|
|
||||||
Storage::disk('public')->makeDirectory($directory, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify directory was created
|
|
||||||
if (!Storage::disk('public')->exists($directory)) {
|
|
||||||
throw new \Exception('Failed to create directory: ' . $directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create filename: INV-{order_number}.pdf
|
|
||||||
$filename = 'INV-' . $order->order_number . '.pdf';
|
|
||||||
$path = "{$directory}/{$filename}";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate PDF from blade template with font configuration
|
|
||||||
$pdf = Pdf::loadView('invoices.order-invoice', [
|
|
||||||
'order' => $order,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get PDF output as string
|
|
||||||
$pdfContent = $pdf->output();
|
|
||||||
|
|
||||||
if (empty($pdfContent)) {
|
|
||||||
throw new \Exception('PDF generation produced empty output');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store PDF to disk (using public disk)
|
|
||||||
$stored = Storage::disk('public')->put($path, $pdfContent);
|
|
||||||
|
|
||||||
if (!$stored) {
|
|
||||||
throw new \Exception('Storage::put() returned false for path: ' . $path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file was created
|
|
||||||
if (!Storage::disk('public')->exists($path)) {
|
|
||||||
throw new \Exception('File does not exist after being stored: ' . $path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update order with invoice path (include storage prefix for retrieval)
|
|
||||||
$order->update(['invoice_path' => 'storage/' . $path]);
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Invoice generation failed', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the full path to an invoice PDF
|
|
||||||
*
|
|
||||||
* @param string $invoicePath
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public static function getInvoicePath(string $invoicePath): string
|
|
||||||
{
|
|
||||||
return Storage::path($invoicePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an invoice exists for an order
|
|
||||||
*
|
|
||||||
* @param Order $order
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public static function invoiceExists(Order $order): bool
|
|
||||||
{
|
|
||||||
return !empty($order->invoice_path) && Storage::disk('public')->exists($order->invoice_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an invoice PDF
|
|
||||||
*
|
|
||||||
* @param Order $order
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public static function deleteInvoice(Order $order): bool
|
|
||||||
{
|
|
||||||
if ($order->invoice_path && Storage::disk('public')->exists($order->invoice_path)) {
|
|
||||||
Storage::disk('public')->delete($order->invoice_path);
|
|
||||||
$order->update(['invoice_path' => null]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use Mailjet\Client;
|
|
||||||
use Mailjet\Resources;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class MailjetService
|
|
||||||
{
|
|
||||||
protected Client $client;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->client = new Client(
|
|
||||||
getenv('MAILJET_APIKEY_PUBLIC'),
|
|
||||||
getenv('MAILJET_SECRETKEY'),
|
|
||||||
true,
|
|
||||||
['version' => 'v3.1']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email via Mailjet API
|
|
||||||
*
|
|
||||||
* @param string $toEmail Recipient email address
|
|
||||||
* @param string $toName Recipient name
|
|
||||||
* @param string $subject Email subject
|
|
||||||
* @param string $htmlContent HTML email content
|
|
||||||
* @param string|null $textContent Plain text email content
|
|
||||||
* @param array|null $attachments Array of file paths to attach
|
|
||||||
* @param array|null $attachmentNames Optional custom filenames for attachments (by file path)
|
|
||||||
* @return bool Success status
|
|
||||||
*/
|
|
||||||
public function send(
|
|
||||||
string $toEmail,
|
|
||||||
string $toName,
|
|
||||||
string $subject,
|
|
||||||
string $htmlContent,
|
|
||||||
?string $textContent = null,
|
|
||||||
?array $attachments = null,
|
|
||||||
?array $attachmentNames = null
|
|
||||||
): bool {
|
|
||||||
try {
|
|
||||||
$body = [
|
|
||||||
'Messages' => [
|
|
||||||
[
|
|
||||||
'From' => [
|
|
||||||
'Email' => config('mail.from.address'),
|
|
||||||
'Name' => config('mail.from.name'),
|
|
||||||
],
|
|
||||||
'To' => [
|
|
||||||
[
|
|
||||||
'Email' => $toEmail,
|
|
||||||
'Name' => $toName,
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'Subject' => $subject,
|
|
||||||
'HTMLPart' => $htmlContent,
|
|
||||||
]
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add text part if provided
|
|
||||||
if ($textContent) {
|
|
||||||
$body['Messages'][0]['TextPart'] = $textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add attachments if provided
|
|
||||||
if ($attachments && is_array($attachments)) {
|
|
||||||
$body['Messages'][0]['Attachments'] = [];
|
|
||||||
foreach ($attachments as $filePath) {
|
|
||||||
if (file_exists($filePath)) {
|
|
||||||
$fileContent = file_get_contents($filePath);
|
|
||||||
// Use custom filename if provided, otherwise use basename
|
|
||||||
$filename = $attachmentNames[$filePath] ?? basename($filePath);
|
|
||||||
$body['Messages'][0]['Attachments'][] = [
|
|
||||||
'ContentType' => mime_content_type($filePath) ?: 'application/octet-stream',
|
|
||||||
'Filename' => $filename,
|
|
||||||
'Base64Content' => base64_encode($fileContent),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $this->client->post(Resources::$Email, ['body' => $body]);
|
|
||||||
|
|
||||||
if ($response->success()) {
|
|
||||||
Log::info('Mailjet email sent successfully', [
|
|
||||||
'to' => $toEmail,
|
|
||||||
'subject' => $subject,
|
|
||||||
'response' => $response->getData(),
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
Log::error('Mailjet email failed', [
|
|
||||||
'to' => $toEmail,
|
|
||||||
'subject' => $subject,
|
|
||||||
'error' => $response->getStatus(),
|
|
||||||
'data' => $response->getData(),
|
|
||||||
]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Mailjet email exception', [
|
|
||||||
'to' => $toEmail,
|
|
||||||
'subject' => $subject,
|
|
||||||
'exception' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\Order;
|
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
|
||||||
use chillerlan\QRCode\QRCode;
|
|
||||||
use chillerlan\QRCode\QROptions;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class QrStickerService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Generate both SVG and A6 PDF sticker for an order
|
|
||||||
*/
|
|
||||||
public function generateSticker(Order $order): array
|
|
||||||
{
|
|
||||||
Log::info('Starting QR sticker generation', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'order_number' => $order->order_number,
|
|
||||||
]);
|
|
||||||
try {
|
|
||||||
// Generate QR code URL
|
|
||||||
$qrUrl = route('ops.order.show', ['token' => $order->qr_token]);
|
|
||||||
|
|
||||||
Log::debug('Generating QR sticker', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'order_number' => $order->order_number,
|
|
||||||
'qr_url' => $qrUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Generate SVG (for web display)
|
|
||||||
$svgPath = $this->generateSvg($order, $qrUrl);
|
|
||||||
Log::info('SVG QR code generated', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'svg_path' => $svgPath,
|
|
||||||
]);
|
|
||||||
// Generate A6 PDF (for printing)
|
|
||||||
$pdfPath = $this->generatePdf($order, $qrUrl);
|
|
||||||
|
|
||||||
Log::info('QR sticker generated successfully', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'svg_path' => $svgPath,
|
|
||||||
'pdf_path' => $pdfPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'svg_path' => $svgPath,
|
|
||||||
'pdf_path' => $pdfPath,
|
|
||||||
];
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Failed to generate QR sticker', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SVG QR code for web display
|
|
||||||
*/
|
|
||||||
private function generateSvg(Order $order, string $qrUrl): string
|
|
||||||
{
|
|
||||||
$options = new QROptions([
|
|
||||||
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
|
|
||||||
'eccLevel' => QRCode::ECC_H,
|
|
||||||
'scale' => 3,
|
|
||||||
'imageBase64' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$qrCode = new QRCode($options);
|
|
||||||
$qrSvg = $qrCode->render($qrUrl);
|
|
||||||
|
|
||||||
$path = "qr-codes/{$order->uuid}.svg";
|
|
||||||
Storage::disk('public')->put($path, $qrSvg);
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate A6 PDF sticker (105mm × 148mm) with QR code and readable info
|
|
||||||
*/
|
|
||||||
private function generatePdf(Order $order, string $qrUrl): string
|
|
||||||
{
|
|
||||||
// Generate QR code as SVG, then convert to data URI
|
|
||||||
$options = new QROptions([
|
|
||||||
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
|
|
||||||
'eccLevel' => QRCode::ECC_H,
|
|
||||||
'scale' => 4,
|
|
||||||
'imageBase64' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$qrCode = new QRCode($options);
|
|
||||||
$qrSvg = $qrCode->render($qrUrl);
|
|
||||||
|
|
||||||
// Convert SVG to data URI for embedding in HTML
|
|
||||||
$qrDataUri = 'data:image/svg+xml;base64,' . base64_encode($qrSvg);
|
|
||||||
|
|
||||||
// Determine order type
|
|
||||||
$orderType = $order->type === 'standard' ? 'STOCK ORDER' : 'CUSTOM ORDER';
|
|
||||||
|
|
||||||
// Extract design name
|
|
||||||
$designName = '';
|
|
||||||
if ($order->items && $order->items->count() > 0) {
|
|
||||||
$firstItem = $order->items->first();
|
|
||||||
$designName = $firstItem->product?->name ?? $firstItem->name ?? 'Custom Design';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract customer surname
|
|
||||||
$customerName = $order->user?->name ?? '';
|
|
||||||
$nameParts = explode(' ', trim($customerName));
|
|
||||||
$customerSurname = end($nameParts);
|
|
||||||
|
|
||||||
// Build HTML with proper SVG embedding (avoid heredoc issues)
|
|
||||||
$html = '<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: Arial, sans-serif; margin: 0; padding: 0; }
|
|
||||||
.sticker {
|
|
||||||
width: 148mm;
|
|
||||||
height: 105mm;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 5mm;
|
|
||||||
background: white;
|
|
||||||
page-break-after: avoid;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
gap: 2mm;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.info-section {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.qr-section {
|
|
||||||
width: 100mm;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
border-bottom: 1pt solid #ddd;
|
|
||||||
padding-bottom: 2mm;
|
|
||||||
}
|
|
||||||
.order-type {
|
|
||||||
display: inline-block;
|
|
||||||
background: #333;
|
|
||||||
color: white;
|
|
||||||
padding: 1mm 2mm;
|
|
||||||
font-size: 7pt;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.5pt;
|
|
||||||
}
|
|
||||||
.order-number {
|
|
||||||
font-size: 16pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 1mm;
|
|
||||||
color: #222;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details {
|
|
||||||
font-size: 10pt;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.detail-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2mm;
|
|
||||||
margin-bottom: 1mm;
|
|
||||||
}
|
|
||||||
.detail-label {
|
|
||||||
|
|
||||||
color: #999;
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.detail-value {
|
|
||||||
text-align: right;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #222;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.qr-code {
|
|
||||||
width: 95mm;
|
|
||||||
height: 95mm;
|
|
||||||
}
|
|
||||||
.qr-code img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
font-size: 6pt;
|
|
||||||
color: #999;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 0.5pt solid #eee;
|
|
||||||
padding-top: 1mm;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="sticker">
|
|
||||||
<div class="container">
|
|
||||||
<div class="info-section">
|
|
||||||
<div class="header">
|
|
||||||
<span class="order-type">' . htmlspecialchars($orderType) . '</span>
|
|
||||||
<div class="order-number">' . htmlspecialchars($order->order_number) . '</div>
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Customer: </span>
|
|
||||||
<span class="detail-value">' . htmlspecialchars($customerSurname) . '</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Design:</span>
|
|
||||||
<span class="detail-value">' . htmlspecialchars($this->truncate($designName, 12)) . '</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Date:</span>
|
|
||||||
<span class="detail-value">' . htmlspecialchars($order->created_at->format('M d')) . '</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Status:</span>
|
|
||||||
<span class="detail-value">' . htmlspecialchars($this->formatStatus($order->status)) . '</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="qr-code">
|
|
||||||
<img src="' . $qrDataUri . '" alt="QR Code" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
Gen: ' . htmlspecialchars($order->created_at->format('Y-m-d H:i')) . '
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pdf = Pdf::loadHTML($html);
|
|
||||||
// Set to A6 size (105mm × 148mm) in portrait orientation
|
|
||||||
// 105mm = 297.64 points, 148mm = 420.94 points
|
|
||||||
$pdf->setPaper([0, 0, 297.64, 420.94], 'portrait');
|
|
||||||
$pdf->setOption('isRemoteEnabled', true);
|
|
||||||
|
|
||||||
$filename = "{$order->uuid}.pdf";
|
|
||||||
$path = "qr-stickers/{$filename}";
|
|
||||||
$pdfContent = $pdf->output();
|
|
||||||
Storage::disk('public')->put($path, $pdfContent);
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('DomPDF generation failed', [
|
|
||||||
'order_uuid' => $order->uuid,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Truncate string
|
|
||||||
*/
|
|
||||||
private function truncate($string, $length): string
|
|
||||||
{
|
|
||||||
return substr($string, 0, $length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Format status text
|
|
||||||
*/
|
|
||||||
private function formatStatus($status): string
|
|
||||||
{
|
|
||||||
return str_replace('_', ' ', $status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\AppSetting;
|
|
||||||
|
|
||||||
class ShippingService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Calculate shipping fee based on order subtotal
|
|
||||||
*/
|
|
||||||
public static function calculateShippingFee(float $subtotal): float
|
|
||||||
{
|
|
||||||
$freeShippingThreshold = AppSetting::get('free_shipping_threshold', 2000);
|
|
||||||
|
|
||||||
if ($subtotal >= $freeShippingThreshold) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$localShipping = AppSetting::get('local_shipping', 200);
|
|
||||||
return $localShipping;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get shipping label based on order subtotal
|
|
||||||
*/
|
|
||||||
public static function getShippingLabel(float $subtotal): string
|
|
||||||
{
|
|
||||||
$freeShippingThreshold = AppSetting::get('free_shipping_threshold', 2000);
|
|
||||||
|
|
||||||
if ($subtotal >= $freeShippingThreshold) {
|
|
||||||
return 'Free Shipping';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Local Delivery';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get sample cost
|
|
||||||
*/
|
|
||||||
public static function getSampleCost(): float
|
|
||||||
{
|
|
||||||
return AppSetting::get('sample_cost', 80);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class SlackNotifierService
|
|
||||||
{
|
|
||||||
protected array $webhooks = [];
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->webhooks = [
|
|
||||||
'orders' => config('slack.webhooks.orders'),
|
|
||||||
'design' => config('slack.webhooks.design'),
|
|
||||||
'production' => config('slack.webhooks.production'),
|
|
||||||
'shipping' => config('slack.webhooks.shipping'),
|
|
||||||
'ops_alerts' => config('slack.webhooks.ops_alerts'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification to #orders channel
|
|
||||||
*/
|
|
||||||
public function orders(string $message, array $blocks = []): void
|
|
||||||
{
|
|
||||||
$this->send($this->webhooks['orders'], $message, $blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification to #design channel
|
|
||||||
*/
|
|
||||||
public function design(string $message, array $blocks = []): void
|
|
||||||
{
|
|
||||||
$this->send($this->webhooks['design'], $message, $blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification to #production channel
|
|
||||||
*/
|
|
||||||
public function production(string $message, array $blocks = []): void
|
|
||||||
{
|
|
||||||
$this->send($this->webhooks['production'], $message, $blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification to #shipping channel
|
|
||||||
*/
|
|
||||||
public function shipping(string $message, array $blocks = []): void
|
|
||||||
{
|
|
||||||
$this->send($this->webhooks['shipping'], $message, $blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification to #ops-alerts channel
|
|
||||||
*/
|
|
||||||
public function opsAlerts(string $message, array $blocks = []): void
|
|
||||||
{
|
|
||||||
$this->send($this->webhooks['ops_alerts'], $message, $blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send payload to Slack webhook
|
|
||||||
*/
|
|
||||||
protected function send(string $webhook, string $message, array $blocks = []): void
|
|
||||||
{
|
|
||||||
if (! $webhook) {
|
|
||||||
Log::warning('Slack webhook not configured for channel', ['message' => $message]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$payload = ['text' => $message];
|
|
||||||
|
|
||||||
if (! empty($blocks)) {
|
|
||||||
$payload['blocks'] = $blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
Http::post($webhook, $payload);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Failed to send Slack notification', [
|
|
||||||
'webhook' => substr($webhook, 0, 20).'...',
|
|
||||||
'message' => $message,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use DateTimeInterface;
|
|
||||||
|
|
||||||
class TrelloService
|
|
||||||
{
|
|
||||||
protected string $apiKey;
|
|
||||||
protected string $apiToken;
|
|
||||||
protected string $boardId;
|
|
||||||
protected string $baseUrl = 'https://api.trello.com/1';
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->apiKey = config('trello.api_key');
|
|
||||||
$this->apiToken = config('trello.api_token');
|
|
||||||
$this->boardId = config('trello.board_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
| Public API
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new Trello card and populate base fields
|
|
||||||
*/
|
|
||||||
public function createCard(
|
|
||||||
string $orderId,
|
|
||||||
string $orderNumber,
|
|
||||||
string $orderType = 'standard',
|
|
||||||
?string $startingListId = null
|
|
||||||
): ?string {
|
|
||||||
if (! $this->isConfigured()) {
|
|
||||||
Log::warning('Trello not configured, skipping card creation');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$listId = $startingListId ?? $this->getStartingListId($orderType);
|
|
||||||
|
|
||||||
$response = Http::post("{$this->baseUrl}/cards", [
|
|
||||||
'name' => "Order #{$orderNumber}",
|
|
||||||
'desc' => "Order ID: {$orderId}\nType: {$orderType}",
|
|
||||||
'idList' => $listId,
|
|
||||||
'key' => $this->apiKey,
|
|
||||||
'token' => $this->apiToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
Log::error('Failed to create Trello card', [
|
|
||||||
'response' => $response->body(),
|
|
||||||
]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cardId = $response->json('id');
|
|
||||||
|
|
||||||
if ($cardId) {
|
|
||||||
$this->populateInitialFields($cardId, $orderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cardId;
|
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('Exception creating Trello card', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move card to another list
|
|
||||||
*/
|
|
||||||
public function moveCard(string $cardId, string $listName): bool
|
|
||||||
{
|
|
||||||
if (! $this->isConfigured()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$listId = $this->getListIdByName($listName);
|
|
||||||
|
|
||||||
if (! $listId) {
|
|
||||||
Log::warning('Trello list not found', ['list' => $listName]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = Http::put("{$this->baseUrl}/cards/{$cardId}", [
|
|
||||||
'idList' => $listId,
|
|
||||||
'key' => $this->apiKey,
|
|
||||||
'token' => $this->apiToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $response->successful();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach a URL/file to a card
|
|
||||||
*/
|
|
||||||
public function attachFile(string $cardId, string $name, string $url): bool
|
|
||||||
{
|
|
||||||
$response = Http::post("{$this->baseUrl}/cards/{$cardId}/attachments", [
|
|
||||||
'name' => $name,
|
|
||||||
'url' => $url,
|
|
||||||
'key' => $this->apiKey,
|
|
||||||
'token' => $this->apiToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $response->successful();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark checklist item complete
|
|
||||||
*/
|
|
||||||
public function checkItem(string $cardId, string $checklistName, string $itemName): bool
|
|
||||||
{
|
|
||||||
$card = Http::get("{$this->baseUrl}/cards/{$cardId}", [
|
|
||||||
'checklists' => 'open',
|
|
||||||
'key' => $this->apiKey,
|
|
||||||
'token' => $this->apiToken,
|
|
||||||
])->json();
|
|
||||||
|
|
||||||
$checklist = collect($card['checklists'] ?? [])
|
|
||||||
->firstWhere('name', $checklistName);
|
|
||||||
|
|
||||||
if (! $checklist) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$item = collect($checklist['checkItems'] ?? [])
|
|
||||||
->firstWhere('name', $itemName);
|
|
||||||
|
|
||||||
if (! $item) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = Http::put(
|
|
||||||
"{$this->baseUrl}/checklists/{$checklist['id']}/checkItems/{$item['id']}",
|
|
||||||
[
|
|
||||||
'state' => 'complete',
|
|
||||||
'key' => $this->apiKey,
|
|
||||||
'token' => $this->apiToken,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response->successful();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
| Custom Fields
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
public function setTextField(string $cardId, string $fieldKey, string $value): bool
|
|
||||||
{
|
|
||||||
return $this->setCustomField($cardId, $fieldKey, ['text' => $value]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setNumberField(string $cardId, string $fieldKey, float|int $value): bool
|
|
||||||
{
|
|
||||||
return $this->setCustomField($cardId, $fieldKey, ['number' => (string) $value]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDateField(string $cardId, string $fieldKey, DateTimeInterface $date): bool
|
|
||||||
{
|
|
||||||
return $this->setCustomField($cardId, $fieldKey, [
|
|
||||||
'date' => $date->format(DATE_ATOM),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function setCustomField(string $cardId, string $fieldKey, array $value): bool
|
|
||||||
{
|
|
||||||
$fieldId = config("trello.custom_fields.{$fieldKey}");
|
|
||||||
|
|
||||||
if (! $fieldId) {
|
|
||||||
Log::warning('Trello custom field not configured', ['field' => $fieldKey]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = Http::put(
|
|
||||||
"{$this->baseUrl}/cards/{$cardId}/customField/{$fieldId}/item",
|
|
||||||
[
|
|
||||||
'value' => $value,
|
|
||||||
'key' => $this->apiKey,
|
|
||||||
'token' => $this->apiToken,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response->successful();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =====================================================
|
|
||||||
| Internals
|
|
||||||
===================================================== */
|
|
||||||
|
|
||||||
protected function populateInitialFields(string $cardId, string $orderId): void
|
|
||||||
{
|
|
||||||
$this->setTextField($cardId, 'order_id', $orderId);
|
|
||||||
$this->setDateField($cardId, 'date_created', now());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getStartingListId(string $orderType): string
|
|
||||||
{
|
|
||||||
return $orderType === 'custom'
|
|
||||||
? config('trello.lists.custom.new_custom_order')
|
|
||||||
: config('trello.lists.standard.new_order');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getListIdByName(string $listName): ?string
|
|
||||||
{
|
|
||||||
$lists = array_merge(
|
|
||||||
config('trello.lists.standard', []),
|
|
||||||
config('trello.lists.custom', [])
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($lists as $key => $id) {
|
|
||||||
if (strtolower(str_replace('_', ' ', $key)) === strtolower($listName)) {
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function isConfigured(): bool
|
|
||||||
{
|
|
||||||
return ! empty($this->apiKey)
|
|
||||||
&& ! empty($this->apiToken)
|
|
||||||
&& ! empty($this->boardId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
|
||||||
|
|
||||||
// Register the Composer autoloader...
|
|
||||||
require __DIR__.'/vendor/autoload.php';
|
|
||||||
|
|
||||||
// Bootstrap Laravel and handle the command...
|
|
||||||
/** @var Application $app */
|
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
|
||||||
|
|
||||||
$status = $app->handleCommand(new ArgvInput);
|
|
||||||
|
|
||||||
exit($status);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
|
||||||
->withRouting(
|
|
||||||
web: __DIR__.'/../routes/web.php',
|
|
||||||
api: __DIR__.'/../routes/api.php',
|
|
||||||
commands: __DIR__.'/../routes/console.php',
|
|
||||||
health: '/up',
|
|
||||||
)
|
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
|
||||||
$middleware->trustProxies(at: '*');
|
|
||||||
})
|
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
|
||||||
//
|
|
||||||
})->create();
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
App\Providers\AppServiceProvider::class,
|
|
||||||
App\Providers\Filament\AdminPanelProvider::class,
|
|
||||||
];
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
require 'vendor/autoload.php';
|
|
||||||
$app = require_once 'bootstrap/app.php';
|
|
||||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
||||||
|
|
||||||
$products = \App\Models\Product::with('printStocks')->limit(5)->get();
|
|
||||||
foreach ($products as $p) {
|
|
||||||
echo "\n{$p->name} ({$p->type}):\n";
|
|
||||||
foreach ($p->printStocks as $s) {
|
|
||||||
echo " - {$s->name}: width={$s->width}m, cost_per_meter={$s->cost_per_meter}, cost_per_m2={$s->cost_per_m2}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://getcomposer.org/schema.json",
|
|
||||||
"name": "laravel/laravel",
|
|
||||||
"type": "project",
|
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
|
||||||
"keywords": ["laravel", "framework"],
|
|
||||||
"license": "MIT",
|
|
||||||
"require": {
|
|
||||||
"php": "^8.2",
|
|
||||||
"filament/filament": "~4.0",
|
|
||||||
"laravel/framework": "^12.0",
|
|
||||||
"laravel/socialite": "^5.23",
|
|
||||||
"laravel/tinker": "^2.10.1",
|
|
||||||
"mailjet/mailjet-apiv3-php": "*",
|
|
||||||
"barryvdh/laravel-dompdf": "^3.1" },
|
|
||||||
"require-dev": {
|
|
||||||
"fakerphp/faker": "^1.23",
|
|
||||||
"laravel/pail": "^1.2.2",
|
|
||||||
"laravel/pint": "^1.24",
|
|
||||||
"laravel/sail": "^1.41",
|
|
||||||
"mockery/mockery": "^1.6",
|
|
||||||
"nunomaduro/collision": "^8.6",
|
|
||||||
"phpunit/phpunit": "^11.5.3"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "app/",
|
|
||||||
"Database\\Factories\\": "database/factories/",
|
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"app/Helpers/Logger.php"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"setup": [
|
|
||||||
"composer install",
|
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
|
||||||
"@php artisan key:generate",
|
|
||||||
"@php artisan migrate --force",
|
|
||||||
"npm install",
|
|
||||||
"npm run build"
|
|
||||||
],
|
|
||||||
"dev": [
|
|
||||||
"Composer\\Config::disableProcessTimeout",
|
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
|
||||||
],
|
|
||||||
"test": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php artisan test"
|
|
||||||
],
|
|
||||||
"post-autoload-dump": [
|
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
|
||||||
"@php artisan package:discover --ansi",
|
|
||||||
"@php artisan filament:upgrade"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
|
||||||
],
|
|
||||||
"post-root-package-install": [
|
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
|
||||||
],
|
|
||||||
"post-create-project-cmd": [
|
|
||||||
"@php artisan key:generate --ansi",
|
|
||||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
|
||||||
"@php artisan migrate --graceful --ansi"
|
|
||||||
],
|
|
||||||
"pre-package-uninstall": [
|
|
||||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"dont-discover": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"preferred-install": "dist",
|
|
||||||
"sort-packages": true,
|
|
||||||
"allow-plugins": {
|
|
||||||
"pestphp/pest-plugin": true,
|
|
||||||
"php-http/discovery": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"minimum-stability": "stable",
|
|
||||||
"prefer-stable": true
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user