Compare commits

39 Commits

Author SHA1 Message Date
twotalesanimation abf5ced6d7 feat: show inspection actions on printing status and move to awaiting_collection on approval
- Show inspection pass/fail buttons when order status is 'printing' in ops page
- Updated getAvailableActions() to include 'printing' in inspection actions
- Changed markInspectionPassed() to accept both 'inspection' and 'printing' statuses
- Changed markInspectionPassed() to move order to 'awaiting_collection' instead of 'packing'
- Updated flagInspectionIssue() to accept both 'inspection' and 'printing' statuses
- Updated order-detail.blade.php conditional to show inspection actions for both 'inspection' and 'printing' statuses
2026-01-03 16:30:57 +02:00
twotalesanimation 2a10f9af38 feat: Complete Shiplogic integration with mobile-optimized ops workflow
**Shiplogic API Integration:**
- Fixed API base URL configuration (removed /api suffix)
- Implemented comprehensive request/response logging for rates and shipments endpoints
- Fixed PDF fetching: API returns S3 URLs, now downloads actual PDFs from S3
- Added tests and mock API responses for local development (routes/shiplogic-mock.php)

**Courier Service Enhancements:**
- Added redownloadShipmentPdfs() public method for re-downloading corrupted PDFs
- Enhanced error logging with full request/response bodies for debugging
- Proper binary PDF storage using Laravel Storage facade
- URL and S3 download handling for Shiplogic API responses

**Workflow & Operations:**
- Changed to manual "Ready for Collection" button instead of automatic move
- Operators now: scan QR → apply labels → click "Ready for Collection" → moves to Awaiting Collection
- Removed duplicate PDF attachments to Trello (was adding twice from two listeners)
- Fixed NotifySlackOnShipmentCreated to only handle Slack notifications

**Mobile-Optimized Ops Page:**
- Removed QR code display from order detail page
- Implemented responsive single-column layout for mobile phones
- Large touch-friendly buttons (full width, increased padding)
- Bold typography for better readability on small screens
- Larger input fields and tracking number displays
- Clear step-by-step instructions for warehouse operators
- Re-download PDF button for damaged/corrupted labels

**New Features:**
- POST /ops/orders/{uuid}/ready-for-collection endpoint
- Re-download PDFs functionality accessible from awaiting_collection and in_transit states
- Full audit logging for all operations via ops interface
- Proper error handling and user feedback

**Testing:**
- Added ShipmentCreationTest with mock HTTP client
- Created comprehensive testing guide (SHIPLOGIC_TESTING.md)
- Mock API routes for local development without hitting live API
2026-01-03 16:13:20 +02:00
twotalesanimation b8cc8bd421 feat: Add Google Places autocomplete for address search
- Add address search input field with autocomplete
- Integrate Google Places API to provide address suggestions
- Parse selected address components and auto-populate form fields
- Address fields initially hidden, show when address is selected
- Add clear button to reset address selection
- Support South Africa locations with country restriction
- Add Google Places API key config to services.php
- Hide/show address fields based on selection and validation errors
2026-01-03 00:15:30 +02:00
twotalesanimation dd950b5aba feat: Update checkout form to collect detailed address fields
- Replace single shipping_address textarea with individual components
- Collect: street_address, local_area, city, zone, postcode, country, type
- Update checkout validation to require all address components
- Update OrderController::process to save individual address fields
- Fields are now captured per ShipLogic API requirements
- Note: CustomOrder checkout address collection to be implemented later
2026-01-02 21:40:56 +02:00
twotalesanimation 124ab46507 refactor: Split shipping_address into component fields for ShipLogic API
- Create migration to add shipping_street_address, shipping_local_area, shipping_city, shipping_zone, shipping_country, shipping_postcode, shipping_type
- Update Order model fillable array with new address component fields
- Remove address parsing logic from CourierService
- Use individual address fields directly in ShipLogic API payload
- Fields match ShipLogic API requirements (street_address, local_area, city, zone, code, country, type)
- Note: Custom orders address collection can be implemented later
2026-01-02 21:40:16 +02:00
twotalesanimation 206ea2d35c fix: Update order status to ready_to_ship before handling intent
- Previously 'Ready to Ship' list movement was calling handler without updating status
- This caused status to remain 'packing' even after card moved to Ready to Ship
- Now status is updated to 'ready_to_ship' before attempting shipment creation
- Also added status update for Awaiting Collection list for consistency
2026-01-02 21:32:41 +02:00
twotalesanimation 027278c216 fix: Add datetime casts to Order model and null check in view
- Add casts array to Order model for packing_completed_at, delivered_at, qr_generated_at
- Update ops order-detail view to safely handle null packing_completed_at
- Prevents 'Call to a member function format() on string' error
2026-01-02 21:29:12 +02:00
twotalesanimation 22dfb12948 fix: Add height parameter to OrderPacked event
Added height to the OrderPacked event constructor and dispatch call to match
the packing form which captures width, length, height, and weight.
2026-01-02 21:25:05 +02:00
twotalesanimation 5cd8c05a7c feat: Move Trello card to Packing when inspection is passed
- Create InspectionPassed event
- Create MoveCardToPackingOnInspectionPassed listener
- Register event listener in EventServiceProvider
- Dispatch event when markInspectionPassed() is called

Now when an order passes inspection on the ops page, the Trello card
automatically moves from Inspection to Packing list.
2026-01-02 21:23:12 +02:00
twotalesanimation 341421d2a0 fix: Return available actions from getAvailableActions() method
The match statement wasn't returning the actions array, causing no actions
to display on inspection orders. Changed to directly return the match result.
2026-01-02 21:17:51 +02:00
twotalesanimation 66862df44b fix: Use flexible type check for is_admin authorization gate
Change strict equality check (=== true) to loose check that works with
both integer 1 and boolean true values.
2026-01-02 21:10:26 +02:00
twotalesanimation 4a8b4ddac2 fix: Double-charged shipping fee in Yoco payment + ops staff access
1. Fix Yoco payment amount calculation: order->total already includes
   shipping_fee, so don't add it again. Was charging 2x shipping.

2. Add authorization gate for ops staff access: users with is_admin=true
   can now access the ops page by scanning QR codes.
2026-01-02 21:07:21 +02:00
twotalesanimation ed57956694 feat: Attach QR sticker PDF to Trello card on order creation
When a new order is created and a Trello card is made, now automatically
generate the A6 QR sticker PDF and attach it to the card using Trello's
attachment API. Include error handling and logging.
2026-01-02 21:01:17 +02:00
twotalesanimation 93148ef9af fix: Use SVG data URI for QR code (no GD extension required)
Generate QR as SVG and encode as base64 data URI for embedding in PDF.
This avoids dependency on GD extension while keeping QR codes in PDF.
2026-01-02 20:43:59 +02:00
twotalesanimation 5dfb63dacb fix: Use base64-encoded PNG for QR code in PDF stickers
DomPDF renders embedded PNG images more reliably than inline SVG.
Changed QR code generation to OUTPUT_IMAGE_PNG with base64 encoding,
then embed as img tag in the PDF HTML.
2026-01-02 20:42:19 +02:00
twotalesanimation 09198a9f7d fix: Generate A6 sticker PDF with inline HTML instead of blade template
Build HTML directly in service without relying on view files. This eliminates
dependency on blade template file existing on production server.

Include all styling inline in heredoc string with:
- A6 dimensions (148mm  105mm)
- Order number, customer surname, design name
- Order status and date
- QR code in SVG format
- Clean, printable layout

Add helper methods:
- truncate(): Truncate design name to fit sticker
- formatStatus(): Format order status for display
2026-01-02 20:28:44 +02:00
twotalesanimation 12f42d5efc fix: Use View::file() to render sticker template directly, add missing test route, and allow GET for regenerate
- Changed from View::make() to View::file() with full path resolution
- Added file existence check with detailed error logging
- Allow GET/POST for regenerate sticker endpoint for easier testing
- Add design name and customer surname to A6 PDF stickers
- Add regenerate sticker endpoint POST /test/sticker/{orderNumber}/regenerate
- Add test sticker preview page at /test/sticker/{orderNumber}
2026-01-02 20:25:02 +02:00
twotalesanimation e503cd3479 fix: Correct web.php syntax and use correct QR library
- Fix malformed route on line 84 (missing closing paren and semicolon)
- Replace SimpleSoftwareIO QrCode with chillerlan/php-qrcode (already installed)
- Update QrStickerService to use chillerlan\QRCode\QRCode
- Configure QR options for SVG output with H error correction
2026-01-02 20:10:06 +02:00
twotalesanimation 3040681842 feat: Add A6 PDF QR stickers with human-readable order info
Implement QrStickerService to generate both SVG (web) and PDF (print) stickers:

- Generate 32x32mm QR codes using SimpleSoftwareIO
- Create A6 (148mm  105mm) PDF stickers using DomPDF
- Include human-readable info: order number, customer, status, date, tracking
- Store order type (STOCK/CUSTOM) with visual badge
- Add generation timestamp to footer

Create stickers/qr-sticker.blade.php template:
- Two-column layout: info left, QR right
- Styled for A6 landscape printing
- Shows order number, customer, status, date, tracking info
- QR code positioned for easy scanning
- Print-optimized CSS

Update GenerateQrCodeOnOrderCreated listener:
- Now calls QrStickerService::generateSticker()
- Generates both SVG and PDF on OrderCreated event
- Logs paths to both formats

Add OpsController::downloadSticker() endpoint:
- GET /ops/orders/{id}/sticker/download
- Returns PDF with filename QR-{order_number}.pdf
- Requires ops access authorization
- Logs all downloads with user context

Update ops order-detail view:
- Add download button for A6 sticker PDF
- Position next to QR code display
- Link to new sticker download route
2026-01-02 19:51:23 +02:00
twotalesanimation bb6b92df10 feat: Add Blade templates for ops QR interface (Phase 2)
Create state-driven UI templates:

Main Template (ops/order-detail.blade.php):
- Display order summary with status, type, customer, date
- Render QR code SVG for shop-floor access
- Conditionally include state-specific action forms
- Show order items, packing details, shipment info

Action Templates:
- ops/actions/packing.blade.php: Form to input weight/dimensions
- ops/actions/inspection.blade.php: Pass/Fail inspection with optional notes
- ops/actions/read-only.blade.php: Status display for read-only states

Error Pages:
- ops/order-not-found.blade.php: Invalid/expired QR token
- ops/unauthorized.blade.php: Insufficient permissions

All forms use AJAX submission with confirmation dialogs
2026-01-02 18:57:21 +02:00
twotalesanimation 74525e8955 feat: Implement QR code generation and ops interface scaffold
Phase 1: QR Code Generation
- Create GenerateQrCodeOnOrderCreated listener
- Generate secure random token on order creation
- Create SVG QR codes pointing to /ops/orders/{token}
- Store QR token and generation timestamp on order record
- Register listener in EventServiceProvider

Phase 2: Ops Controller & Routes
- Create OpsController with showOrder() landing page
- Implement confirmPacking() to capture dimensions via form
- Add markInspectionPassed() and flagInspectionIssue() methods
- Implement getAvailableActions() state machine for UI
- Add routes: GET /ops/orders/{token}, POST /ops/orders/{id}/pack, etc.
- Token-gated access, requires auth middleware

Next: Create Blade templates for state-driven UI
2026-01-02 18:55:53 +02:00
twotalesanimation db0454d102 refactor: Move shipment creation logic to CourierService for DRY principle
- Move all validation (packing, payment, status, duplicates) to CourierService::createShipmentForOrder()
- Move database updates to service layer
- Move document fetching to service layer
- Simplify ShippingController to just handle HTTP concerns (request/response)
- Simplify CreateShipmentOnReadyToShip listener to just dispatch events
- Single source of truth for business logic in CourierService
- Eliminates duplicate validation between controller and listener
2026-01-02 18:13:16 +02:00
twotalesanimation 2ddb3dc19f fix: Refactor CreateShipmentOnReadyToShip listener to directly call service
- Remove controller dependency and call CourierService directly
- Add proper validation before attempting shipment creation
- Log failures instead of silently succeeding
- Emit ShipmentCreated/ShipmentCreationFailed events properly
- Prevents false success logs when validation fails
2026-01-02 17:42:12 +02:00
twotalesanimation aa30111841 fix: Fix CreateShipmentOnReadyToShip listener syntax and add guard logging 2026-01-02 17:37:54 +02:00
twotalesanimation 1c68fff301 feat: Add CreateShipmentOnReadyToShip listener to handle webhook-triggered shipments
- Create new listener that fires on ReadyToShipIntent event
- Register listener in EventServiceProvider
- Fix log statements in ShippingController to use order UUID instead of ID
- Now when Trello card moves to 'Ready to Ship', backend automatically creates shipment
2026-01-02 17:26:11 +02:00
twotalesanimation 291393d497 feat: Implement full Trello webhook handling and event dispatch
- Parse incoming Trello webhook payloads for card movement actions
- Extract order number from card names (supports both numeric and full formats)
- Look up orders in database (Order or CustomOrder models)
- Emit ReadyToShipIntent event when cards moved to 'Ready to Ship' list
- Validate shipment exists before allowing 'Awaiting Collection' transition
- Add comprehensive logging for all Trello actions
- Handle validation pings and real events identically (both return 200)
- Card moves now trigger backend order processing workflows
2026-01-02 16:33:41 +02:00
twotalesanimation 986f12f685 fix: Correct print stock relationship accessor
- Change print_stocks (plural) to printStock (singular)
- Properly accesses the PrintStock model via OrderItem's belongsTo relationship
- Gets the correct print stock name from the print_stocks table
2026-01-02 16:25:18 +02:00
twotalesanimation 9fd138582a feat: Enhance Slack notification with design and size details
- Add emoji () and design name to order creation Slack message
- Include print size information (wallpaper length or mural dimensions)
- Extract design/size info once and reuse for both Slack and Trello
- Message format: 'New [type] order: Order #[number]\\nDesign: [name]\\nSize: [dimensions]'
2026-01-02 16:15:57 +02:00
twotalesanimation d29f08bd98 fix: Extract design_name and print_size from order items for Trello
- design_name: Get product name from first order item
- print_size: Calculate from item dimensions (wallpaper: length in m, mural: width x height in cm)
- Add fallback values ('') when items/dimensions unavailable
- Prevents null errors when populating Trello custom fields
2026-01-02 16:11:15 +02:00
twotalesanimation ca7d16da16 feat: Add Trello configuration fallback and test command
- Add fallback env parsing directly from .env file in config/trello.php
- Fixes issue where cached config prevents env() from reading .env values
- Add artisan trello:test command to diagnose Trello configuration
- Test command checks API credentials, board/list IDs, and connectivity
- Test successfully creates and moves a test Trello card
2026-01-02 15:36:27 +02:00
twotalesanimation 783cc88c6d feat: Update Order/CustomOrder models and controllers with event integration
- Add integration fields to Order model: packing_*, courier_*, trello_card_id, qr_token
- Add integration fields to CustomOrder model: packing_*, proof_approved_*, courier_*, trello_card_id, qr_token
- Update Order model fillable array and add relationships (packedBy)
- Update CustomOrder model fillable array, casts, and add relationships (packedBy)
- Add isCustomOrder() method to both models for type checking
- Update OrderController to emit OrderCreated and DepositPaid events on successful payment
- For standard orders: full payment -> prep status, emit events
- For custom orders: deposit -> design status, balance -> printing status, emit respective events
- Add approveProof() method to CustomOrderController (POST /custom-orders/{id}/approve-proof)
- Add requestChanges() method to CustomOrderController (POST /custom-orders/{id}/request-changes)
- Add markBalancePaid() method to CustomOrderController (POST /custom-orders/{id}/pay-balance)
- All new methods emit appropriate events (ProofApproved, ProofRevisionRequested, BalancePaid)
- Add database migration for proof_approved and proof_approved_at fields on custom_orders
- Add routes for new custom order endpoints with UUID binding
- Import all required event classes in both controllers
2026-01-02 14:35:01 +02:00
twotalesanimation 12aadfd917 feat: Implement Slack, Trello, and Courier (ShipLogic) integration
- Add 14 domain events for order lifecycle (OrderCreated, OrderPacked, ShipmentCreated, ParcelDelivered, etc.)
- Create SlackNotifierService with channels for orders, design, production, shipping, ops-alerts
- Create TrelloService to create cards, move cards between lists, attach files, check items
- Create CourierService to integrate with ShipLogic API for shipment creation and document retrieval
- Create PackingController to explicitly capture packing dimensions and weight
- Create ShippingController with multi-layer guards: packing validation, payment/approval verification, idempotency
- Create TrelloWebhookController to handle incoming Trello webhooks as intent signals
- Create CourierWebhookController to handle Shiplogic status updates
- Create event listeners for Slack notifications and Trello card updates
- Create EventServiceProvider to register all events and listeners
- Add database migration for packing, courier, and Trello data columns
- Create config files for slack, trello, and courier integration
- Update .env with integration secrets placeholders
- Add routes for /orders/{id}/pack, /orders/{id}/ship, /api/webhooks/trello, /api/webhooks/courier

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

Some files were not shown because too many files have changed in this diff Show More