Compare commits

..

1 Commits

Author SHA1 Message Date
Pinto 9224bde11f Initial commit 2025-12-07 07:42:52 +00:00
390 changed files with 1 additions and 53767 deletions
-18
View File
@@ -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
-11
View File
@@ -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
View File
@@ -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
-9
View File
@@ -1,9 +0,0 @@
php:
preset: laravel
disabled:
- no_unused_imports
finder:
not-name:
- index.php
js: true
css: true
-126
View File
@@ -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 -58
View File
@@ -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).
-74
View File
@@ -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()]);
}
}
}
-18
View File
@@ -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,
) {
}
}
-18
View File
@@ -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,
) {
}
}
-29
View File
@@ -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'),
];
}
}
-21
View File
@@ -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,
) {
}
}
-18
View File
@@ -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,
) {
}
}
-18
View File
@@ -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,
) {
}
}
-19
View File
@@ -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 = '',
) {
}
}
-18
View File
@@ -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,
) {
}
}
-17
View File
@@ -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,
) {
}
}
-18
View File
@@ -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 = '',
) {
}
}
-18
View File
@@ -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,
) {
}
}
-18
View File
@@ -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,
) {
}
}
-21
View File
@@ -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,
) {
}
}
-18
View File
@@ -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'),
];
}
}
-141
View File
@@ -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(),
];
}
}
-128
View File
@@ -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(),
];
}
}
-62
View File
@@ -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';
}
}
-69
View File
@@ -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,
],
],
];
}
}
-75
View File
@@ -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'),
];
}
}
-50
View File
@@ -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('/');
}
}
-206
View File
@@ -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!');
}
}
-8
View File
@@ -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
]);
}
}
-26
View File
@@ -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
]);
}
}
-24
View File
@@ -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
]);
}
}
-238
View File
@@ -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"
);
}
}
-790
View File
@@ -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>&copy; 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
]);
}
}
-18
View File
@@ -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/*',
];
}
-140
View File
@@ -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>&copy; 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,
]);
}
}
-63
View File
@@ -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'),
];
}
}
-40
View File
@@ -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]
);
}
}
-38
View File
@@ -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);
}
}
-135
View File
@@ -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;
}
}
-35
View File
@@ -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');
}
}
-44
View File
@@ -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');
}
}
-42
View File
@@ -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);
}
}
-33
View File
@@ -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',
];
}
}
-70
View File
@@ -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
}}
-36
View File
@@ -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);
}
}
-22
View File
@@ -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');
}
}
-63
View File
@@ -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';
}
}
-19
View File
@@ -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);
}
}
-51
View File
@@ -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',
];
}
}
-40
View File
@@ -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;
});
}
}
-115
View File
@@ -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');
}
}
-275
View File
@@ -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);
}
}
-119
View File
@@ -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;
}
}
-114
View File
@@ -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;
}
}
}
-292
View File
@@ -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);
}
}
-45
View File
@@ -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);
}
}
-90
View File
@@ -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(),
]);
}
}
}
-233
View File
@@ -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);
}
}
-18
View File
@@ -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);
-19
View File
@@ -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();
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-6
View File
@@ -1,6 +0,0 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
];
-12
View File
@@ -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";
}
}
-93
View File
@@ -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