Compare commits

..

279 commits

Author SHA1 Message Date
Jérémy Munsch
cd7d5f5fe9
feat(orders): implement default sorting for order queries
Added default sorting by date in descending order for order queries.
Updated related views and tests to reflect this change.
2026-10-29 21:16:06 +01:00
Jérémy Munsch
0e05779f89
feat(sliders): enhance carousel controls and responsiveness
Added padding for carousel controls on larger screens and improved button styles for better visibility and interaction.
2026-10-29 20:59:30 +01:00
Jérémy Munsch
c8f895c759
refactor: simplify use statements and type hints
- Removed fully qualified names for better readability.
- Streamlined code by using direct class references.
2026-10-29 20:35:35 +01:00
Jérémy Munsch
c40016bba1
feat(order): implement order placement and related DTOs
- Add methods to convert OrderLine and DeliveryAddress to DTOs.
- Introduce toPlacedEvent method in Order to generate domain event.
- Enhance OrderUnit with Regate code mapping.
- Implement tests for new functionality and ensure correctness.
2026-10-29 20:35:13 +01:00
Jérémy Munsch
71e0d65f31
refactor(OrderService): simplify order creation from basket
Updated the OrderService to utilize the new fromBasket method for creating orders, enhancing code clarity and maintainability. Removed the ProductAvailabilityService as its functionality is now integrated into the ProductRepository.
2026-10-29 20:21:32 +01:00
Jérémy Munsch
da0c3afc14
refactor(MoneyServiceProvider, ViewServiceProvider): streamline boot methods
Removed unnecessary conditionals and improved method calls for clarity.
2026-10-29 20:13:15 +01:00
Jérémy Munsch
e857a58d00
test(Product): add comprehensive tests for Product entity
- Implement tests for product publication status.
- Add tests for product orderability based on various conditions.
- Enhance pricing calculation tests for different discount scenarios.
- Remove outdated PricingServiceTest and refactor ProductAvailabilityServiceTest.
2026-10-29 19:48:38 +01:00
Jérémy Munsch
cfac370e2e
feat(discount): implement published discounts filtering logic
Added logic to filter published discounts, including those with null start or end dates.
2026-10-29 19:19:22 +01:00
Jérémy Munsch
08354b44b6
style(tests): format code for better readability
Improved code formatting in ChangeOrderBladeTest for consistency.
2026-10-29 19:13:31 +01:00
Jérémy Munsch
ed593c25b0
test(UpdateShopPickupExceptionsRequest): remove redundant message test and update error messages
Updated error messages for better clarity and removed unnecessary test for custom messages.
2026-10-29 19:12:21 +01:00
Jérémy Munsch
c52b13cc51
feat(order): implement soft delete handling in order changes
Added support for soft-deleted models in ChangeModelOrder and ChangeModelOrderPosition actions.
This allows for proper order adjustments even when models are soft-deleted.
2026-10-29 19:04:46 +01:00
Jérémy Munsch
7b910c5af6
refactor(UpdateShopPickupExceptionsRequest): remove unused messages method
Cleaned up the code by removing the messages method as it was not utilized.
2026-10-29 18:43:02 +01:00
Jérémy Munsch
961829a0c7
fix(pickup-exceptions): update noReplacement logic for checkbox state
Ensure noReplacement reflects checkbox status for accurate behavior.
2026-10-29 18:24:21 +01:00
Jérémy Munsch
36faed679e
fix(pickup-exceptions): correct spelling of 'Blocked week'
Updated the text from 'Bloqued week' to 'Blocked week' for consistency in error messages.
2026-10-29 18:23:36 +01:00
Jérémy Munsch
cef7a4e4b0
style(pickup-exceptions): fix formatting for readability
Adjusted indentation for better code clarity.
2026-10-29 18:22:51 +01:00
Jérémy Munsch
a87f5756b2
fix(PickupSchedulingService): use equals method for exception ID comparison
Updated the comparison of exception IDs to use the equals method for better accuracy.
2026-10-29 18:21:22 +01:00
Jérémy Munsch
0874259eba
style(FilteredProductResource): fix indentation for nutriscore line 2026-10-29 17:10:13 +01:00
Jérémy Munsch
3ac3cbc7ff
style(products): format change-selected-order route for readability 2026-10-29 17:08:16 +01:00
Jérémy Munsch
16dfc32f73
Merge branch 'issue-82' into develop 2026-10-29 17:06:27 +01:00
Jérémy Munsch
65df9aa04e
test(SettingsController): update assertions to check forbidden access
Updated test cases to assert forbidden responses instead of successful ones for employee site settings.
2026-10-29 17:05:34 +01:00
Jérémy Munsch
584fee7e69
fix(EloquentPickupExceptionRepository): include pivot data in exception mapping
Updated the mapping function to include pivot data for pickup exceptions, ensuring accurate retrieval of associated order deadlines and replacement dates.
2026-10-29 17:02:07 +01:00
Jérémy Munsch
5d36639705
fix(lang): correct typo in French translation for replacement date
Fixes a typo in the French language file for the replacement date message.
2026-10-29 16:42:41 +01:00
Jérémy Munsch
8e42639134
feat(order): implement change selected order functionality
Add functionality to change the order of models in the product list.
This includes new actions, requests, and UI components for user interaction.
2026-10-29 16:39:23 +01:00
Jérémy Munsch
a94d8a4b49
feat(products): add discount filtering to product index
- Implement discount filtering in product queries and views.
- Update related request and response handling for discounts.
2026-10-29 16:09:41 +01:00
Jérémy Munsch
b9999caf8d
feat(discounts): add discount logic for piece pricing
Implement discount calculations that apply to the piece (sold unit) for products invoiced by weight.
2026-10-29 15:43:37 +01:00
Jérémy Munsch
4d6ff9b726
fix(SettingPolicy): enable Arcadia refresh job dispatching
The Arcadia refresh job dispatching is currently disabled and needs to be enabled when the API is available.
2026-10-29 15:26:45 +01:00
Jérémy Munsch
82697ed416
fix(products): respect is_old_price_displayed discount setting in product cards
- Condition strikethrough price display on showStrippedPrice in ProductCard.vue
- Add showStrippedPrice to product Zod schemas, TypeScript types, repositories and store
- Optimize showStrippedPrice in FilteredProductResource using eager-loaded validDiscount
- Add unit tests for productModelSchema and integration tests in ProductsControllerTest
2026-10-29 15:08:53 +01:00
Jérémy Munsch
0d5958aae6
fix(products): update discount logic for price display
Corrected the logic for displaying discounted prices in the product views.
2026-10-29 14:56:35 +01:00
Jérémy Munsch
0edef37e50
fix(orders): correct closing tag for order reference span
This change fixes a typo in the order reference display by correcting the closing tag.
2026-10-28 15:25:28 +01:00
Jérémy Munsch
0ea946a2f6
style(orders): update button alignment and styling for order references
Improve button presentation for better visual consistency.
2026-10-28 15:18:56 +01:00
Jérémy Munsch
f882b431b7
refactor(AisleViewer): update page title logic and imports
Improved the logic for displaying the page title based on search criteria.
2026-10-28 15:08:46 +01:00
Jérémy Munsch
889cc66c07
style(faq): update accordion button states for consistency
Adjusted button classes and attributes for accordion items to ensure consistent behavior and appearance.
2026-10-28 15:06:19 +01:00
Jérémy Munsch
9d28162ec4
style(logging): update comment formatting for clarity
Improved the formatting of comments in the logging configuration file for better readability.
2026-10-28 15:03:25 +01:00
Jérémy Munsch
db684cf35f
refactor(products): update column classes for filter form
Adjusted column classes from 'col-md' to 'col-xl' for better responsiveness in the product filter form.
2026-10-28 13:15:30 +01:00
Jérémy Munsch
7a5c91e55e
refactor(crud): simplify no results messages in views
Updated no results messages to remove model references for consistency across various views.
2026-10-28 13:04:46 +01:00
Jérémy Munsch
e2263911f3
refactor(products): order tags by 'order' in query
This change ensures that the tags are retrieved in a consistent order.
2026-10-28 12:59:37 +01:00
Jérémy Munsch
aef658645b
refactor(slides): update column definitions and formatting
- Removed unused 'label' and 'text' columns.
- Adjusted 'tableColSorter' parameters for clarity.
2026-10-28 12:55:44 +01:00
Jérémy Munsch
1e1172b757
feat(validation): update address attribute to use postal_address
Updated the address attribute in multiple requests and views to use the new 'postal_address' translation key for consistency.
2026-10-28 12:51:06 +01:00
Jérémy Munsch
a15cc29a61
style(faq, legal-notice, privacy, terms): add padding to section containers 2026-10-28 12:19:38 +01:00
Jérémy Munsch
b31602b040
feat(faq): implement accordion for FAQ section
Enhances the FAQ page by replacing the list with an accordion component for better user experience.
2026-10-28 12:18:54 +01:00
Jérémy Munsch
020e2f8526
test(DiscountProductDto): update tests to include picture attribute
Added picture attribute to the DiscountProductDto tests to ensure proper serialization.
2026-10-28 11:57:37 +01:00
Jérémy Munsch
c5fe5987da
feat(fontawesome): add cash register icon to library
feat(confirmation): update button with cash register icon

feat(pick-time-slot): update back button with chevron icon
2026-10-28 11:53:21 +01:00
Jérémy Munsch
4b0775b5ea
feat(ProductFilterViewModel): enhance URL parameter handling
Improve loading of search, ranges, tags, and brands from URL.
Trigger loading state and refresh list when filters are applied.
2026-10-28 11:36:40 +01:00
Jérémy Munsch
7a6c154e5f
feat(products): add price info section to product details
This update introduces a new price information section in the product details view, enhancing user experience by providing clear pricing details.
2026-10-28 11:26:23 +01:00
Jérémy Munsch
769949b3c3
feat(AisleViewer): update page title based on search state
Add computed properties to dynamically change the page title
between "Your search" and "All aisles" based on user input.
2026-10-28 11:10:05 +01:00
Jérémy Munsch
5a0cc6c519
feat(ContactFormRequest): add attributes method for validation messages
Added a method to define custom attribute names for validation.
2026-10-28 10:56:39 +01:00
Jérémy Munsch
99586179d7
feat(services): update pickup day text in services view
Updated the text for selecting the pickup day in the services view for better clarity.
2026-10-28 10:51:55 +01:00
Jérémy Munsch
87e66c1d13
style(navbar): update button styles for consistency and visibility
Updated button styles to use 'btn-tertiary' and added focus rings for better accessibility.
2026-10-28 10:43:44 +01:00
Jérémy Munsch
b3dc0ff3c4
style(BasketPanel): update button styles for better visibility
Improved button text weight for enhanced readability.
2026-10-28 10:41:08 +01:00
Jérémy Munsch
14ceb300cf
feat(ui): update button styles and add focus ring
- Added new tertiary button style with focus ring.
- Updated button classes in navbar and basket components for consistency.
2026-10-28 10:41:03 +01:00
Jérémy Munsch
44ffc4d34e
feat(DiscountProductDto): add picture property and update fromModel method
feat(ProductDragAndDropComponent): enhance product display with picture and improve layout
2026-10-28 10:02:43 +01:00
Jérémy Munsch
f9ded6d01c
refactor(ToolboxHelper): remove unused getValidatedEnum method
Cleaned up ToolboxHelper by removing the unused getValidatedEnum method to enhance code clarity.
2026-10-28 08:57:35 +01:00
Jérémy Munsch
4ff3daecab
refactor(Auth): improve reset URL handling and validation
refactor(Helpers): update parameter types and remove ignores

refactor(Models): streamline order assignment logic

refactor(Enums): clarify unused constant warnings

refactor(Services): encapsulate cutoff week offset logic
2026-10-28 08:53:34 +01:00
Jérémy Munsch
1899fd1f0e
chore: phpcs fix 2026-10-27 20:20:52 +01:00
Jérémy Munsch
8e213e2e33
fix: htmlsanitizer and contact form validation 2026-10-27 20:18:08 +01:00
Jérémy Munsch
c7535d3d04
feat(Providers): bind CsvGenerator to CsvGeneratorImpl
Add binding for CsvGenerator service to its implementation.
2026-10-27 19:59:49 +01:00
Jérémy Munsch
b8a0f16efa
fix(Crm): update customer input handling in UpdateCustomer action
Refactor input validation and data preparation for customer updates.
2026-10-27 18:57:46 +01:00
Jérémy Munsch
57e12e06b6
docs: to force rebuild 2026-10-27 17:33:42 +01:00
Jérémy Munsch
afdd6cd0a7
fix(config): correct environment variable usage for phone reception
Ensure the default phone reception number is used correctly when the environment variable is not set.
2026-10-27 17:07:15 +01:00
Jérémy Munsch
aeb8addbb8
fix(config): simplify environment variable usage for default settings
Updated the default settings in filesystems, mail, and session configurations to use a more concise syntax for environment variable retrieval.
2026-10-27 16:39:23 +01:00
Jérémy Munsch
dd05d9166c
fix(value-objects): simplify Uuid usage in OrderReference
Refactor the OrderReference class to use the Uuid alias directly for better readability.
2026-10-27 16:20:50 +01:00
Jérémy Munsch
1cf15950c6
fix: add missing CACHE_STORE environment variable
This change ensures that the CACHE_STORE environment variable is defined for the testing environment.
2026-10-27 16:17:40 +01:00
Jérémy Munsch
713c937890
fix(casts): update OrderReferenceCast to use base58 encoding
Refactor OrderReferenceCast to utilize base58 encoding for order references instead of UUID.
This change improves compatibility with the new OrderReference implementation.
2026-10-27 16:17:28 +01:00
Jérémy Munsch
91a82af969
fix: add missing logging config file 2026-10-27 16:08:23 +01:00
Jérémy Munsch
d8f419cd2c
docs: rgpd to force rebuild 2026-10-27 15:23:42 +01:00
Jérémy Munsch
c1ab6ef066
build fix docker image 2026-10-27 13:55:27 +01:00
Jérémy Munsch
0a1bfc9aed
build: fix build npm-security 2026-10-27 13:53:22 +01:00
Jérémy Munsch
fee9609907
build: fix build npm-security 2026-10-27 13:50:35 +01:00
Jérémy Munsch
cecd808a80
build: fix build npm-security 2026-10-27 13:50:27 +01:00
Jérémy Munsch
bd0cb02389
build: fix multistage 2026-10-27 13:03:28 +01:00
Jérémy Munsch
146bbf57bf
build: fix build config 2026-10-27 12:47:58 +01:00
Jérémy Munsch
c8eba4a932
fix: sentry config optimization 2026-10-27 12:25:38 +01:00
Jérémy
639a8459d2 Merge branch 'massive-bump' into 'develop'
Massive bump

See merge request ldc-web/projets/boutique-cnc-sable!84
2026-10-27 11:40:27 +01:00
Jérémy Munsch
abde0bd5b9
chore(Dockerfile): update bun version to 1.4.4-alpine
This change updates the base image for the frontend to the latest version.
2026-10-27 11:36:21 +01:00
Jérémy Munsch
0f6dc42bf2
chore(deps): update dependencies to latest versions
Updated various dependencies in package.json to improve stability and security.
2026-10-27 11:31:51 +01:00
Jérémy Munsch
49666d5364
chore: update dependencies to latest versions
This update includes various improvements and security patches.
2026-10-27 11:31:46 +01:00
Jérémy Munsch
9f99e06b7d
chore(manifest): update database host and port variables
Replaced DB_HOST and DB_PORT with global OVH MySQL variables for consistency.
2026-10-27 11:22:33 +01:00
Jérémy Munsch
09a506ccca
chore: add strict types declaration to migration file
This change enforces strict typing for better type safety.
2026-10-27 11:21:14 +01:00
Jérémy Munsch
5201aa3a5a
chore(jobs): disable Arcadia jobs until API is available and refresh 2026-10-27 11:21:13 +01:00
Jeremy Munsch
240885577d
fix: eslint fix on ImageInputComponent.vue 2026-10-27 09:59:02 +01:00
Jeremy Munsch
80716ab7f7
chore: composer update 2026-10-27 09:59:02 +01:00
Jeremy Munsch
7d5912e396
chore: bun update 2026-10-27 09:59:01 +01:00
Jeremy Munsch
2831ad972b
fix: GetFoProductIndexPageQuery was not returning brands 2026-10-27 09:59:01 +01:00
Jérémy Munsch
fa5abe7470
test(EmployeeSyncService): add logger to EmployeeSyncService instantiation
This change ensures that the logger is properly injected into the EmployeeSyncService for better logging capabilities during tests.
2026-10-27 09:59:01 +01:00
Jérémy Munsch
85f3896337
fix(commands): update command signatures to include 'uwil' prefix
Updated command signatures for MailTest, SyncProductIncosCommand, and UserCreate to maintain consistency in naming conventions.
2026-10-27 09:59:00 +01:00
Jérémy Munsch
bf087c3bff
style(jobs): fix Date facade usage for consistency
Updated Date facade usage to ensure consistency in the jobs scheduling file.
2026-10-27 09:59:00 +01:00
Jeremy Munsch
450399fd74
fix: product inco previous button to product always 2026-10-27 09:59:00 +01:00
Jeremy Munsch
46e9c58ad2
refactor: battle test employee sync 2026-10-27 09:58:59 +01:00
Jérémy Munsch
232561cb83
chore: update dependencies in composer files
- Removed deprecated dxw/cidr dependency.
- Updated uwil/admin-secure-laravel to uwil/laravel-admin-secure.
- Adjusted versioning for dxw/cidr and other packages.
2026-10-27 09:58:59 +01:00
Jérémy Munsch
a7354cb051
feat: Add StringEncoder and StringHelper for encoding utilities
- Introduced StringEncoder for converting various encodings to UTF-8.
- Added StringHelper with methods for string manipulation and transliteration.
2026-10-27 09:58:59 +01:00
Jérémy Munsch
3e4732bcb4
fix: Bind mocked CrmService to container in RegisterCustomerTest
Ensure that all mocked dependencies in RegisterCustomerTest are properly bound
to the Laravel service container using App::instance(). This prevents any code
that resolves dependencies via container injection from getting real service
instances instead of mocks.

This fix aligns the Integration test pattern with Feature tests which already
use App::instance() to bind mocks to the container.

Changes:
- Add import for Illuminate\Support\Facades\App
- Add App::instance() calls after each mock creation in all 6 test cases
- Add mockCrmService() helper method to TestCase base class for future tests

Fixes CI failures where CRM service was being called in real instead of being mocked.
2026-10-27 09:58:59 +01:00
Jérémy Munsch
f67a8c88c9
ci: update bun install command to use --no-save
Changed bun install option from --frozen-lockfile to --no-save for better dependency management.
2026-10-27 09:58:58 +01:00
Jérémy Munsch
255590a4d0
ci: update bun image version in CI configuration
Updated bun image version to 1 for consistency across linting and testing stages.
2026-10-27 09:58:58 +01:00
Jérémy Munsch
eacb445b55
refactor: phpcs fixes on StoreShopRequest 2026-10-27 09:58:58 +01:00
Jérémy Munsch
b51d409a88
refactor: phpcs fixes on ExecuteMassAction 2026-10-27 09:58:57 +01:00
Jérémy Munsch
169fe52a26
chore(vscode): clean up PHP configuration settings
Removed unused PHP executable paths and cleaned up formatting in settings.json.
2026-10-27 09:58:57 +01:00
Jérémy Munsch
bd4efb4587
fix(lang): update French translations and validation messages
- Added new translations for INCO sheet features and other UI elements.
- Improved validation messages for better clarity and consistency.
2026-10-27 09:58:57 +01:00
Jérémy Munsch
5229999365
chore: update dependencies to latest versions
This update includes various improvements and security patches.
2026-10-27 09:58:56 +01:00
Jérémy Munsch
48443e4f82
chore(dependencies): update dxw/cidr and slevomat/coding-standard versions
Updated dxw/cidr to a specific commit and slevomat/coding-standard to the latest version.
2026-10-27 09:58:56 +01:00
Jérémy Munsch
c3f4c29c1a
ci: use image uwil-ci-8.5 2026-10-27 09:58:56 +01:00
Jérémy Munsch
353b899c3f
refactor(bo): change dispatch method for employee sync job
Updated the dispatch method to use dispatchAfterResponse for the employee sync job.
2026-10-27 09:58:55 +01:00
Jérémy Munsch
8cbbefb20b
feat: flash warning message when unauthenticated guest is redirected to login 2026-10-27 09:58:55 +01:00
Jérémy Munsch
87661b2a60
refactor: migrate customer registration and mass actions to actions 2026-10-27 09:58:55 +01:00
Jérémy Munsch
17f898e3f9
feat(domain): introduce DiscountRate VO and eliminate float rounding in pricing calculations 2026-10-27 09:58:54 +01:00
Jérémy Munsch
4165bfecb5
fix(tests): use valid UUID v7 for order references in integration tests 2026-10-27 09:58:54 +01:00
Jérémy Munsch
8a9210123f
feat(env): update environment variables for Arcadia API and LDC
Added APP_CURRENCY, FILESYSTEM_DISK, and LDC_HEADQUARTERS_PHONE_RECEPTION
to support new features and configurations.
2026-10-27 09:58:54 +01:00
Jérémy Munsch
14647576df
feat(bo/product-incos): adapt BO controller, requests, routes and views for Arcadia INCO management 2026-10-27 09:58:53 +01:00
Jérémy Munsch
50df658d58
feat(product-incos): implement actions, queries, sync job and command for Arcadia INCO sheets 2026-10-27 09:58:53 +01:00
Jérémy Munsch
3de48c14fd
feat(arcadia): add ArcadiaIncoService and DTOs to fetch and preview INCO sheets via Arcadia API 2026-10-27 09:58:53 +01:00
Jérémy Munsch
ba99113776
chore(deps): update package-lock.json after package.json dependency removals 2026-10-27 09:58:53 +01:00
Jérémy Munsch
62104ebe28
Revert "chore(deps): update bun.lock after removing unused npm packages"
This reverts commit 5d966b2b4d13853d90a55fef3fe290f053f7e2ed.
2026-10-27 09:58:52 +01:00
Jérémy Munsch
f0667b7d3e
chore(database): drop legacy xml_incos table and model in favor of Arcadia API 2026-10-27 09:58:52 +01:00
Jérémy Munsch
5a9480ab93
chore(deps): update bun.lock after removing unused npm packages 2026-10-27 09:58:52 +01:00
Jérémy Munsch
5492ffe217
refactor(orders): simplify OrderReference to wrap UuidInterface directly and remove obsolete UuidConverter 2026-10-27 09:58:51 +01:00
Jérémy Munsch
bae633e312
style: fix phpcs sniffs and complexity warnings 2026-10-27 09:58:51 +01:00
Jérémy Munsch
ff831beb01
test: apply Given/When/Then structure to remaining Pest tests 2026-10-27 09:58:51 +01:00
Jérémy Munsch
d29561fe64
test: annotate Pest closures using $this with \Tests\TestCase 2026-10-27 09:58:50 +01:00
Jérémy Munsch
ae520d543d
chore(deps): remove unused npm packages fancybox, tippy.js and resolve-url-loader 2026-10-27 09:58:50 +01:00
Jérémy Munsch
9e4da0e287
fix(mass-actions): report exceptions instead of silently swallowing them 2026-10-27 09:58:50 +01:00
Jérémy Munsch
15e729cbe2
style: prefix global PHP classes with backslash instead of importing them 2026-10-27 09:58:49 +01:00
Jérémy Munsch
da0b32c7ee
refactor(views): remove orphan breadcrumb and script blade views 2026-10-27 09:58:49 +01:00
Jérémy Munsch
9a334b5ed1
refactor(models): remove unused ProductType and OrdersStatus models 2026-10-27 09:58:49 +01:00
Jérémy Munsch
b31ea9cad0
fix(phpcs): enable searchAnnotations in UnusedUses sniff configuration 2026-10-27 09:58:48 +01:00
Jérémy Munsch
e8ed520a38
feat(fontawesome): add faLock icon to library
Added the faLock icon to the FontAwesome library for enhanced UI options.
2026-10-27 09:58:48 +01:00
Jérémy Munsch
d4db0cffc6
fix(slides): allow empty text in slide requests and indicate connected visibility in BO 2026-10-27 09:58:48 +01:00
Jérémy Munsch
3c309dc356
feat(slides): indicate visibility in BO and fix conditional slider display in FO 2026-10-27 09:58:47 +01:00
Jérémy Munsch
bf3e7a93fd
refactor(i18n): harmonize translation keys to English and complete French translations
- Convert hardcoded strings and French translation keys to English keys across Blade templates, Vue components, TypeScript modules, models, and routes
- Complete and synchronize lang/fr.json and lang/en.json translation dictionaries
- Add timezone attribute in lang/fr/validation.php and lang/en/validation.php
- Add missing English lang files for parity: crud.php, models.php, search.php, and vue.php
2026-10-27 09:58:47 +01:00
Jérémy Munsch
52588ae999
fix(ckeditor): prevent null value from being converted to string 'null' on validation returns 2026-10-27 09:58:47 +01:00
Jérémy Munsch
f84a1a7b8c
fix(orders): preserve pagination and query parameters in IndexOrdersRequest 2026-10-27 09:58:46 +01:00
Jérémy Munsch
30c4483ce9
fix(database): clean orders employee service code and legacy site names
- Strip site prefix and non-digits from service code in orders.employee JSON snapshot
- Normalize legacy site names with spaces (LDC SERVICES, LDC VOLAILLES, LDC TER) in orders.employee and employees tables
2026-10-27 09:58:46 +01:00
Jérémy Munsch
e0c4b34ed8
feat(employees): implement deterministic 6-character code generation using SerialCaster
- Add kwaadpepper/serial-int-caster and SerialCasterServiceProvider
- Update CodeGenerationService to encode Matricule deterministically with length 6
- Update Code value object to support lengths between 6 and 10 with 53 non-ambiguous characters
- Add database migration to sanitize legacy ambiguous codes
- Recreate and update unit and integration tests for CodeGenerationService and Code value object
2026-10-27 09:58:46 +01:00
Jérémy Munsch
8b96ce90be
test(casts): add tests for EmployeeSitesCast functionality
test(casts): add tests for ShopperEstablishmentCodeCast functionality

test(casts): add tests for ShopperServiceCodeCast functionality

test(casts): add tests for ShopperSiteCodeCast functionality

test(dto): update ProductDtoTest to include additional fields

test(dto): update SettingDtoTest to remove arcadiaSites assertions

test(events): update AnOrderHasBeenPlacedTest to use value objects

test(entities): update OrderTest to use value objects for employee details

test(value-objects): update EmployeeUpsertDataTest to use value objects

test(pagination): update PaginationTest to access value properties

test(services): update AppCustomerAuthServiceTest to use CrmHashcode

test(services): update CrmServiceTest to use CrmHashcode for customer retrieval
2026-10-27 09:58:45 +01:00
Jérémy Munsch
55ea4b55f3
refactor(tests): update logout method to use Auth facade
Improves clarity by using the Auth facade for logout.
2026-10-27 09:58:45 +01:00
Jérémy Munsch
3398aacc86
refactor(casts): update exception handling to use fully qualified class names
Refactored exception handling in various cast classes to use fully qualified class names for InvalidArgumentException and UnexpectedValueException.
2026-10-27 09:58:45 +01:00
Jérémy Munsch
6b649a8e7d
refactor(models): update property types in Setting model
Updated the type annotations for employee_sites to allow mixed types and reordered arcadia_sites for clarity.
2026-10-27 09:58:45 +01:00
Jérémy Munsch
3b3997d547
feat(employees): automated sync with Arcadia API, BO settings, Shopper value objects, and pagination migration
- Implement automated synchronization of employees via Arcadia API (EmployeeSyncService, SynchronizeEmployeesWithArcadiaApi, FetchAndUpsertArcadiaEmployees)
- Add Back-Office application settings for employee sites (Setting singleton, GetSettingsQuery, UpdateSettings, SettingsController, EmployeeSitesInputComponent.vue)
- Strongly type Employee attributes using Domain Value Objects (ShopperEstablishmentCode, ShopperSiteCode, ShopperServiceCode) and Eloquent Casts
- Migrate pagination from Domain to Shared\Pagination namespace across queries and inputs
- Remove legacy and dead CSV employee import/export code
- Restore Domain\Ports\Services\CsvGenerator interface for Regate order generation
- Add comprehensive Pest unit, integration, and feature test coverage
2026-10-27 09:58:44 +01:00
Jérémy Munsch
e5df954cf8
chore: add composer dep uwil/arcadia-api-php-client 2026-10-27 09:58:44 +01:00
Jérémy Munsch
9ebf712170
refactor(cqs): complete Fo/PageController, BO FormRequest input mappings, and decouple Directories 2026-10-27 09:58:44 +01:00
Jérémy Munsch
aad9586ca9
refactor(tunnel): migrate Fo\TunnelController to CQS pattern 2026-10-27 09:58:43 +01:00
Jérémy Munsch
35feddfb3d
refactor(dashboard): migrate Bo\DashboardController to CQS pattern 2026-10-27 09:58:43 +01:00
Jérémy Munsch
d912d4b096
refactor(orders): migrate Bo\OrdersController and Fo\OrderController to CQS pattern 2026-10-27 09:58:43 +01:00
Jérémy Munsch
8dd54f8824
fix(bo): fix MissingAttributeException in GetProductFormDataQuery 2026-10-27 09:58:42 +01:00
Jérémy Munsch
2ae317aad6
refactor(employees): migrate Bo EmployeesController to CQS pattern
- Add ImportEmployees Action and ImportEmployeesInput
- Add GetEmployeeImportColumnsQuery
- Encapsulate toImportEmployeesInput in ImportRequest
- Use Executor in Bo\EmployeesController
- Add feature tests for Bo\EmployeesController
2026-10-27 09:58:42 +01:00
Jérémy Munsch
70600ea623
refactor(products): migrate BO and FO ProductsControllers to CQS pattern
- Refactor Bo\ProductsController to use ActionExecutor and Queries
- Refactor Fo\Pages\ProductsController and Fo\Ajax\ProductsController to CQS
- Add Actions: CreateProduct, UpdateProduct, DeleteProduct with Inputs
- Add Queries: GetBoPaginatedProductsQuery, GetProductFormDataQuery, GetFoProductIndexPageQuery, GetFoFilteredProductsQuery
- Add DTOs: ProductFormDataDto and enrich ProductDto
- Encapsulate Action/Query inputs conversion in FormRequests
- Remove obsolete ProductDirectory and EloquentProductDirectory
- Add tests for Fo\Ajax\ProductsController
2026-10-27 09:58:42 +01:00
Jérémy Munsch
668151fdaf
refactor(fo): migrate HomeController to CQS queries with DTOs 2026-10-27 09:58:41 +01:00
Jérémy Munsch
9aab392350
feat(bo): refactor ProductIncosController to strict CQS with actions, DTOs, and request mapping 2026-10-27 09:58:41 +01:00
Jérémy Munsch
274432a472
chore(env): update mail configuration for local development
Changed mail settings to use log driver and local host for testing.
2026-10-27 09:58:41 +01:00
Jérémy Munsch
0fef2cfc98
refactor(fo): refactor ContactController using GetShopQuery and CQS 2026-10-27 09:58:40 +01:00
Jérémy Munsch
6dd9ef90ae
feat(bo): add replacement date display in pickup exceptions
Display the replacement date for associated exceptions in the pickup exceptions view.
2026-10-27 09:58:40 +01:00
Jérémy Munsch
55ae6553d8
refactor(bo): refactor DiscountsController to strict CQS pattern with actions, queries and DTOs 2026-10-27 09:58:40 +01:00
Jérémy Munsch
f79f1e1494
fix(bo): enforce order deadline before replacement date in request and action input 2026-10-27 09:58:40 +01:00
Jérémy Munsch
85dc683ed9
fix(bo): fix pickup-exceptions validation regression and cryptic error messages 2026-10-27 09:58:39 +01:00
Jérémy Munsch
26992f52ff
feat(bo): refactor PickupExceptionsController to strict CQS with actions, queries, and DTOs 2026-10-27 09:58:39 +01:00
Jérémy Munsch
9f1b4242b9
refactor(bo): encapsulate pickup exception filter in IndexShopRequest, align VOs and DTOs 2026-10-27 09:58:39 +01:00
Jérémy Munsch
b2c1b6eb1d
fix(dto): align PickupExceptionDto properties with database attributes 2026-10-27 09:58:38 +01:00
Jérémy Munsch
c997b37047
feat(bo): refactor ShopsController to strict CQS with actions, queries, VOs, and DTOs 2026-10-27 09:58:38 +01:00
Jérémy Munsch
8430458882
style(sweetalert-confirm): swap confirm and cancel button styles
Updated the confirm and cancel button classes for consistency in the SweetAlert dialog.
2026-10-27 09:58:38 +01:00
Jérémy Munsch
8a5246b389
style(buttons): update delete button size for consistency
Adjusted the delete button class to use a smaller size for better UI consistency across various sections.
2026-10-27 09:58:37 +01:00
Jérémy Munsch
b171b4a280
fix(arch): allow string in CrmHashcodeCast, fix misnamed query input, and update arch tests 2026-10-27 09:58:37 +01:00
Jérémy Munsch
ea2885115d
feat(timeslots): implement GetAvailableTimeslotsQuery and related classes
Added GetAvailableTimeslotsQuery, GetAvailableTimeslotsQueryInput, and GetAvailableTimeslotsQueryOutput
to handle fetching available timeslots for a shop, improving the structure and separation of concerns.
2026-10-27 09:58:37 +01:00
Jérémy Munsch
e0bc673f41
fix(product-model): make nutriscore nullable
Allows nutriscore to be optional in product model schema.
2026-10-27 09:58:36 +01:00
Jérémy Munsch
ecc03eee72
fix(filteredProduct): update nutriscore value retrieval
Ensure the nutriscore value is correctly accessed from the product's inco property.
2026-10-27 09:58:36 +01:00
Jérémy Munsch
dafb8bfdf5
style(vscode): update status bar colors for better visibility
Adjusted the status bar debugging foreground and inactive background colors for improved contrast.
2026-10-27 09:58:36 +01:00
Jérémy Munsch
16dbdbdad1
chore(config): add fileURLToPath import for path resolution
This change introduces the fileURLToPath import to enhance path handling in the configuration files.
2026-10-27 09:58:35 +01:00
Jérémy Munsch
46b5413f07
feat(basket): implement add, remove, and empty basket actions
This commit introduces new actions for adding, removing, and emptying items in the basket, along with their corresponding input classes. It also updates the BasketController to utilize these new actions, enhancing the basket management functionality.
2026-10-27 09:58:35 +01:00
Jérémy Munsch
b36d070db1
feat(crm): implement customer password update functionality
Adds the ability for customers to update their passwords through a new action and input/output classes.
2026-10-27 09:58:35 +01:00
Jérémy Munsch
5037154108
feat(crm): implement customer update functionality
Adds UpdateCustomer action, input, and output classes to handle customer information updates.
Integrates with existing services and updates the CustomerController to utilize the new action.
2026-10-27 09:58:34 +01:00
Jérémy Munsch
e89856966d
refactor(auth): use CrmHashcode value object for hashcode assignment
This change improves the handling of hashcodes by utilizing the CrmHashcode value object.
2026-10-27 09:58:34 +01:00
Jérémy Munsch
2aa4fa8aed
feat(crm): implement unsubscribe from newsletter functionality
- Added UnsubscribeFromNewsletter action and input/output classes.
- Introduced CrmHashcode value object and its cast.
- Updated NewsletterController to use the new unsubscribe action.
- Enhanced validation in UnsubscribeNewsletterRequest.
2026-10-27 09:58:34 +01:00
Jérémy Munsch
42bdae01cc
feat: implement pagination using Pagination value object
- Updated DataTableService to accept a Pagination object for pagination parameters.
- Refactored paginateQuery method to utilize pageSize and pageNumber from Pagination.

refactor: replace ActionExecutor with Executor in controller traits

- Updated ChangesModelOrder, TogglesModelStar, and TogglesModelStatus traits to use Executor interface instead of ActionExecutor.

feat: add PictureMetadata and Slug value objects

- Introduced PictureMetadata for managing image metadata with validation.
- Added Slug value object for slug generation and validation.

feat: update breadcrumbs to support RangeDto

- Modified breadcrumbs-bo.php to accept RangeDto in the edit route.

test: add tests for RangesController

- Added tests for accessing, creating, updating, and deleting ranges.
- Included tests for toggling range status.

test: add integration tests for range actions

- Created tests for CreateRange, DeleteRange, and UpdateRange actions.
- Validated input handling and database interactions.

test: add unit tests for range input classes

- Implemented tests for CreateRangeInput and UpdateRangeInput to validate input constraints.

test: add unit tests for value objects

- Added tests for PictureMetadata and Slug to ensure proper validation and instantiation.

test: add unit tests for RangeDto

- Created tests to verify RangeDto mapping from Range model.

test: add unit tests for RangePolicy

- Implemented tests for RangePolicy to validate permissions based on user roles.
2026-10-27 09:58:34 +01:00
Jérémy Munsch
31d7a789c4
refactor(slides): use fill() in slide actions and align model phpdocs with ide-helper 2026-10-27 09:58:33 +01:00
Jérémy Munsch
e2e2a357f7
refactor(slides): implement CQS actions, query, DTO, and DatePeriod VO for slides 2026-10-27 09:58:33 +01:00
Jérémy Munsch
1fd2a3a1d8
refactor(pages): implement CQS action, query, DTO, and SeoMetadata VO for pages 2026-10-27 09:58:33 +01:00
Jérémy Munsch
8f6006e209
test(faqs): assert html content is preserved and purified in faqs controller 2026-10-27 09:58:32 +01:00
Jérémy Munsch
797d1da1b2
refactor(faqs): implement CQS actions, query, and DTO for faqs 2026-10-27 09:58:32 +01:00
Jérémy Munsch
3ac95cc6f8
refactor(common): implement generic actions for controller traits (order, status, star) 2026-10-27 09:58:32 +01:00
Jérémy Munsch
c2dd9e79c5
feat(CkEditorComponent): add dynamic navbar height adjustment
Implement dynamic adjustment of the editor's viewport offset based on the navbar height during window resize events.
2026-10-27 09:58:31 +01:00
Jérémy Munsch
6e10308f53
refactor(CkEditorComponent): attach editor to ckeditor ref 2026-10-27 09:58:31 +01:00
Jérémy Munsch
054d22cc0c
style(CkEditorComponent): remove scoped attribute from style tag 2026-10-27 09:58:31 +01:00
Jérémy Munsch
98e580d327
refactor(tags): implement CRUD actions for tags
- Added CreateTag, UpdateTag, and DeleteTag actions.
- Introduced CreateTagInput and UpdateTagInput for input validation.
- Implemented ActionExecutor for executing actions with logging.
- Added tests for all new actions and input validation.
2026-10-27 09:58:30 +01:00
Jérémy Munsch
209b7e0193
chore: update dependencies in composer files
Added "webmozart/assert" package to dependencies and updated content hash in composer.lock.
2026-10-27 09:58:30 +01:00
Jérémy Munsch
41ce20a86e
chore: update installed paths in phpcs configuration
Added the path for Slevomat coding standard to the PHPCS configuration.
2026-10-27 09:58:30 +01:00
Jérémy Munsch
e34218eb27
style(vscode): update color settings for activity and status bars
Adjusted colors for better visibility and consistency in the UI.
2026-10-27 09:58:29 +01:00
Jeremy Munsch
cf5db041db
chore: update bun packages 2026-10-27 09:58:29 +01:00
Jérémy Munsch
a9ef1b910a
ci: add npm-build job to satisfy shared docker-build dependency 2026-10-27 09:58:29 +01:00
Jérémy Munsch
c116a202ab
ci: replace deprecated qa image with composer:2.10 and php extensions 2026-10-27 09:58:29 +01:00
Jérémy Munsch
bc25ee6f3c
fix(test): explicitly logout backend guard in customer rejection tests and add phpdoc typehints 2026-10-27 09:58:28 +01:00
Jérémy Munsch
a1ad396a0e
fix(types): resolve phpstan typing issues in ContactFormRequest and Basket entity 2026-10-27 09:58:28 +01:00
Jérémy Munsch
418b4e1941
fix(test): use Customer model for unauthorized BO access tests instead of undefined enum case [AUD-TST-003] 2026-10-27 09:58:28 +01:00
Jérémy Munsch
23682def0d
refactor(migrations): use RuntimeException for irreversible uuidv7 migrations [AUD-DB-002] 2026-10-27 09:58:27 +01:00
Jérémy Munsch
30077e68ad
test(bo): add negative authorization tests for unauthenticated and unauthorized requests [AUD-TST-003] 2026-10-27 09:58:27 +01:00
Jérémy Munsch
4ce5488bae
perf(datatable): cache schema column listings to avoid repeated table introspection [AUD-PER-002] 2026-10-27 09:58:27 +01:00
Jérémy Munsch
668aaba1ca
perf(bo): optimize product selection and association queries in BO controllers [AUD-PER-012] 2026-10-27 09:58:26 +01:00
Jérémy Munsch
29fa8a1b7d
fix(order): revalidate basket prices and discounts during order finalization [AUD-DSA-001] 2026-10-27 09:58:26 +01:00
Jérémy Munsch
ad36fd4596
test(tunnel): clarify unauthenticated redirect assertions on tunnel routes [AUD-TST-001] 2026-10-27 09:58:26 +01:00
Jérémy Munsch
4676d3de67
sec(contact): persist contact form attachments to private disk before queued mail [AUD-SEC-005] 2026-10-27 09:58:25 +01:00
Jérémy Munsch
9c9d67ad37
fix(slides): fix slide expiration logic and use bulk queries in UpdateSlidesStatus [AUD-PER-011] 2026-10-27 09:58:25 +01:00
Jérémy Munsch
5a0b68496e
perf(crm): cache customer lookups and email verification with deterministic keys [AUD-PER-014] 2026-10-27 09:58:25 +01:00
Jérémy Munsch
18e2baa1a8
fix(export): use notifyNow before deleting export file [AUD-QUE-001] 2026-10-27 09:58:24 +01:00
Jérémy Munsch
507078363b
sec(rate-limiting): secure clientIp resolution using trustProxies [AUD-SEC-008] 2026-10-27 09:58:24 +01:00
Jérémy Munsch
a32e410314
sec(csrf): add PreventRequestForgery to web-ajax middleware group [AUD-SEC-011] 2026-10-27 09:58:24 +01:00
Jérémy Munsch
8db44cdd53
chore: pint pass 2026-10-27 09:58:24 +01:00
Jérémy Munsch
1da093ebd2
style(navbar): update z-index and clean up button classes 2026-10-27 09:58:23 +01:00
Jérémy Munsch
f0ff70eb0c
test(pickup-exceptions): update validation test for repeat frequency
Ensure repeat frequency resets to Weekly if not repeatable.
2026-10-27 09:58:23 +01:00
Jérémy Munsch
205865f524
style(stats): disable cyclomatic complexity check for getStats
Disable cyclomatic complexity check for the getStats method to improve code readability.
2026-10-27 09:58:23 +01:00
Jérémy Munsch
acdb3ad13e
refactor: eslint fix 2026-10-27 09:58:22 +01:00
Jérémy Munsch
284f69ec97
style(sliders): remove carousel indicators for mobile view
Simplified the carousel by removing indicators when the product list is short.
2026-10-27 09:58:22 +01:00
Jérémy Munsch
e2ae396154
style(sliders): update carousel item indexing for accessibility
Improves aria-labels for carousel items by using loop iteration instead of index.
2026-10-27 09:58:22 +01:00
Jérémy Munsch
8ab2702129
style(sliders): enhance carousel controls and indicators
Updated carousel controls and indicators for better accessibility and visual consistency.
2026-10-27 09:58:21 +01:00
Jérémy Munsch
d8d745dba9
fix(pickup-exceptions): set default repeat_frequency to WEEKLY
Ensure repeat_frequency is set to WEEKLY when repeatable is false.
2026-10-27 09:58:21 +01:00
Jérémy Munsch
6adab479e0
style(basket): update tooltip placement to bottom 2026-10-27 09:58:21 +01:00
Jérémy Munsch
e6a54a8acb
style(common): update inset values for layout consistency
Adjusted inset values to improve layout alignment across different screen sizes.
2026-10-27 09:58:20 +01:00
Jérémy Munsch
b66fc01013
fix(pickup-exceptions): update repeat_frequency validation rules
Make 'repeat_frequency' nullable and conditionally required based on 'repeatable' status.
2026-10-27 09:58:20 +01:00
Jérémy Munsch
1f97e35046
style(sliders): adjust column width for slide images
Updated the column width for slide images to improve layout consistency.
2026-10-27 09:58:20 +01:00
Jérémy Munsch
0da6a85ff8
style(navbar): update brand link visibility and button styles
Adjusted the brand link to be hidden on mobile and improved button styles for better visibility.
2026-10-27 09:58:19 +01:00
Jérémy Munsch
ce81ccd7ec
style(footer): adjust order classes for layout consistency
Updated order classes in footer layout for better alignment.
2026-10-27 09:58:19 +01:00
Jérémy Munsch
c5dcba92eb
style(sliders): adjust column width for slide images
Updated the column width for slide images to enhance layout responsiveness.
2026-10-27 09:58:19 +01:00
Jérémy Munsch
477b01247e
fix(shops): correct authorization check for shop creation
Updated the authorization check to ensure it correctly references the Shop model instead of the Slide model.
2026-10-27 09:58:19 +01:00
Jérémy Munsch
c359f8d68c
feat(routes): add product model binding in RouteServiceProvider
This change allows the product model to be resolved in route parameters.
2026-10-27 09:58:18 +01:00
Jérémy Munsch
54752d5d05
fix(translations): correct formatting in employee number message
style(forms): remove asterisk from required labels in various forms
2026-10-27 09:58:18 +01:00
Jérémy Munsch
ec14a31f72
refactor(shops): enable form inclusion in create view
Restores the inclusion of the shop form in the create view.
2026-10-27 09:58:18 +01:00
Jérémy Munsch
7e28b8cd94
refactor(sliders): update carousel structure and styles
- Changed class names for consistency and clarity.
- Improved carousel controls and indicators for better usability.
2026-10-27 09:58:17 +01:00
Jérémy Munsch
e79a62b6f3
style(home): add scroll margin to section headings
Added scroll margin to headings for better spacing and improved layout.
2026-10-27 09:58:17 +01:00
Jérémy Munsch
3d9bb99381
fix: correct French translations in various files
- Updated translations for clarity and correctness in RepeatFrequency, validation messages, and form labels.
2026-10-27 09:58:17 +01:00
Jérémy Munsch
1f6f2a47f2
fix: update browserslist to exclude Firefox versions < 100
This change ensures compatibility with modern Firefox versions.
2026-10-27 09:58:16 +01:00
Jérémy Munsch
05357b16ec
refactor: customer bs file button 2026-10-27 09:58:16 +01:00
Jérémy Munsch
ed5de2f115
refactor(slides): use trans_choice for model translation
Updated model translation for slides to use trans_choice for better localization handling.
2026-10-27 09:58:16 +01:00
Jérémy Munsch
2da418dd01
fix: ProductsController injection of DataTableService 2026-10-27 09:58:15 +01:00
Jérémy Munsch
149edff185
refactor: Closes #123 change the way stats are calculated and fixes employee radiation date 2026-10-27 09:58:15 +01:00
Jeremy Munsch
d7308fbc36
fix: database migration ordering sort column on products as unsigned integer 2026-10-27 09:58:15 +01:00
Jérémy Munsch
bc32abb4f1
reafactor(employees): add flash message for no imported or updated lines
Notify users when no lines are imported or updated during the import process.
2026-10-27 09:58:14 +01:00
Jérémy Munsch
ae50117098
ci: update docker file in order to use bun 2026-10-27 09:58:14 +01:00
Jérémy Munsch
07715c0097
chore: remove TODO.md file 2026-10-27 09:58:14 +01:00
Jérémy Munsch
924e435ff4
ci: bun audit 2026-10-27 09:58:13 +01:00
Jérémy Munsch
58af50b5c5
refactor: massive bump refactor 2026-10-27 09:58:12 +01:00
Karl M'muller
90af1eb37e Merge branch 'fix-orders-issues' into 'develop'
fix: change product order

See merge request ldc-web/projets/boutique-cnc-sable!88
2026-10-08 08:27:43 +00:00
Jérémy Munsch
10d4e9f08b
fix: change product order 2026-10-07 10:31:32 +02:00
Noix
a5f99507c2 Merge branch '116-fix-promotions-filter' into 'develop'
fix: 🐛 the promotions filter was selected by default

See merge request ldc-web/projets/boutique-cnc-sable!86
2026-05-11 09:39:13 +00:00
Corentin Huet
c9d1b56c2a fix: 🐛 the promotions filter was selected by default 2026-05-11 11:03:07 +02:00
Noah Lancien
b5c79ca5c1 fix: 🐛 Updated htaccess to redirect index.php 2026-04-21 14:50:38 +02:00
Noah Lancien
695b141053 ci: 👷 Added variable for db host 2026-04-21 14:22:18 +02:00
Noix
f7e5296a36 Merge branch '100-discount-filter' into 'develop'
fix: 🐛 update promotion filtering system

See merge request ldc-web/projets/boutique-cnc-sable!82
2026-04-16 14:00:52 +00:00
Noix
242eaa60c5 Merge branch '102-brands-filter' into 'develop'
fix: 🐛 hide brands for unpublished products

See merge request ldc-web/projets/boutique-cnc-sable!83
2026-04-16 13:59:54 +00:00
Corentin Huet
a5dbb9ebc3 fix: 🐛 hide brands for unpublished products 2026-04-09 11:54:52 +02:00
Corentin Huet
0692ec70c5 fix: 🐛 update promotion filtering system 2026-04-09 11:36:15 +02:00
Noah Lancien
caa157adaf ci: updated variable name for cidr list 2026-03-03 10:58:07 +01:00
noah.lancien
7dffb917e2 Merge branch '97-admin-secure' into 'develop'
refactor: ♻️ remove admin-secure package and integrate middleware

See merge request ldc-web/projets/boutique-cnc-sable!81
2026-03-03 08:47:16 +00:00
Corentin Huet
1a8b3cff7c refactor: ♻️ remove admin-secure package and integrate middleware 2026-03-03 09:41:26 +01:00
Noah Lancien
7c1c15460e feat: updated slider to 25/75 ratio - #83 2026-02-25 17:07:38 +01:00
Noah Lancien
f91a7d0eae fix: fixed variable name for previous commit - #96 2026-02-25 15:50:56 +01:00
Noah Lancien
ab9fce56f5 fix: hide prices in products.index <hen the user isn't logged in - #96 2026-02-25 15:45:06 +01:00
Noah Lancien
06785a3cb8 feat: added boolean to show old price on discounts - #95 2026-02-25 15:27:24 +01:00
Noah Lancien
f9e0448fb6 feat: re-order products with a select system like support-web - #94 2026-02-25 11:05:59 +01:00
Noah Lancien
6e7bbe0681 feat: show only brands with min 1 product - #91 2026-02-25 09:23:16 +01:00
noah.lancien
b23bbf8334 Merge branch '92-navbar-link' into 'develop'
feat: update store link in fo navbar #92

See merge request ldc-web/projets/boutique-cnc-sable!80
2026-02-23 11:07:37 +00:00
Noah Lancien
2d9c55ef9c feat: update store link in fo navbar #92 2026-02-23 12:04:23 +01:00
Noah Lancien
7150dc1c7a fix: addToCart policy was broken in product #86 + homepage #85 2026-02-20 15:03:28 +01:00
noah.lancien
3a332fc1c3 Merge branch 'issue-73' into 'develop'
feat ✨ : add routes static to xmpl file

See merge request ldc-web/projets/boutique-cnc-sable!79
2026-02-12 09:56:14 +00:00
System User
6328623474 fix: rename route in sitemap 2026-02-12 10:45:02 +01:00
System User
c3e37f8dec delete package deprecied 2026-02-10 16:13:30 +01:00
System User
440f5b4db3 feat ✨ : add routes static to xmpl file 2026-02-10 16:13:02 +01:00
noah.lancien
3feaa4cddf Merge branch 'issue-88' into 'develop'
fix: 🐛 set blade directives function in public #88

See merge request ldc-web/projets/boutique-cnc-sable!78
2026-02-05 16:14:18 +00:00
Alexis Gousseau
e7db2d4523 fix: 🐛 set blade directives function in public #88 2026-02-05 16:45:01 +01:00
noah.lancien
0048fcf0ab Merge branch 'issue-87' into 'develop'
feat: 🗃 add new field to show slide only for connected customers #87

See merge request ldc-web/projets/boutique-cnc-sable!77
2026-02-04 11:10:30 +00:00
Alexis Gousseau
fcd0a79251 feat: 🗃 add new field to show slide only for connected customers #87 2026-02-04 10:56:10 +01:00
Noah Lancien
d84914974d ci: added staging-resources.yaml + define pod resources + limit cronjobs 2026-02-03 09:36:35 +01:00
Noah Lancien
0ac773f523 ci: added staging-resources.yaml + define pod resources + limit cronjobs 2026-02-03 09:25:11 +01:00
noah.lancien
319e3f9706 Merge branch 'issue-76' into 'develop'
fix: 🐛 use blade directives helper to prevent blade files from being cached #76 #77

See merge request ldc-web/projets/boutique-cnc-sable!76
2026-02-03 08:13:33 +00:00
noah.lancien
ec4e0ef178 Merge branch '80-add-brands' into 'develop'
feat: ✨ adding multiple brands

See merge request ldc-web/projets/boutique-cnc-sable!74
2026-02-03 08:13:15 +00:00
noah.lancien
159a21658f Merge branch '81-mail' into 'develop'
feat: ✨ send an order reminder email

See merge request ldc-web/projets/boutique-cnc-sable!75
2026-02-03 08:12:46 +00:00
Alexis Gousseau
ae54ee559b fix: 🐛 use blade directives helper to prevent blade files from being cached #76 #77 2026-02-02 15:47:55 +01:00
Corentin Huet
b3d4d6945e feat: ✨ send an order reminder email 2026-01-30 11:43:10 +01:00
Corentin Huet
79b9b9b808 feat: ✨ adding multiple brands 2026-01-29 17:56:51 +01:00
noah.lancien
dc49a08128 Merge branch '71-price-display' into 'develop'
fix: reset view cache when updating settings

See merge request ldc-web/projets/boutique-cnc-sable!73
2025-11-12 16:14:30 +00:00
Noah Lancien
f1bb958609 fix: reset view cache when updating settings 2025-11-05 13:02:08 +01:00
noah.lancien
4485340355 Merge branch 'issue-67-export' into 'develop'
feat: export file optimization

See merge request ldc-web/projets/boutique-cnc-sable!72
2025-07-08 14:42:03 +00:00
Corentin Huet
e83b77d985 feat: export file optimization 2025-07-08 15:36:23 +02:00
Vincent BRIDIER
ae9ab52f4d Merge branch 'issue-66' into 'develop'
fix: 🐛 error on removeFromCart

See merge request ldc-web/projets/boutique-cnc-sable!71
2025-07-01 14:05:18 +00:00
Karl
dc8fa4da0b fix: 🐛 error on removeFromCart 2025-07-01 15:44:02 +02:00
1802 changed files with 106855 additions and 59927 deletions

6
.browserslistrc Normal file
View file

@ -0,0 +1,6 @@
# Browsers that we support
last 3 version
> 0.3%
not dead # no browsers without security updates
not Firefox < 100

View file

@ -1,2 +1,11 @@
.git
.git/
.gitlab/
.gitlab-ci.yml
*.md
.vscode/
auth.json
Dockerfile
manifest.yaml
node_modules/
production-resources.yaml
.env.example

View file

@ -3,13 +3,33 @@ root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_size = 2
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{php,json}]
indent_size = 4
[*.blade.php]
indent_size = 2
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 2
[*.scss]
indent_size = 2
quote_type = single
[*.css]
indent_size = 2
quote_type = single
[*.vue]
indent_size = 2

View file

@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_CURRENCY=EUR
LOG_CHANNEL=stack
LOG_LEVEL=debug
@ -15,22 +16,24 @@ DB_USERNAME=root
DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
CACHE_STORE=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
FILESYSTEM_DISK=local
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=
MAIL_FROM_ADDRESS=
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_LIST_SERVICEWEB=
MAIL_LIST_INVALIDINCO=
MAIL_TO_CONTACT=
MAIL_TO_REGATE=
# CRM
CRM_ENDPOINT=
@ -43,6 +46,20 @@ SENTRY_LARAVEL_DSN=
# Matomo
MATOMO_SITE_ID=
## ADMIN SECURE
LDC_WEB_API_KEY=
LDC_WEB_CIDR_MANUAL_ALLOWED_LIST=
# UWIL ADMIN SECURE
# - LIST SEPARATED WITH ',' (Required)
UWIL_ADMIN_SECURE_CIDR_LIST=""
# - LIST SEPARATED WITH ',' (Optional)
UWIL_ADMIN_SECURE_CIDR_PROJECT_ALLOWED_LIST=""
# Arcadia API
ARCADIA_API_ENDPOINT=
ARCADIA_API_APIKEY=
ARCADIA_API_SECRET=
ARCADIA_API_IGNORE_SELF_SIGNED_CERT=false
# Serial Caster
SERIAL_CASTER_SEED=12345
# LDC
LDC_HEADQUARTERS_PHONE_RECEPTION="02 43 62 70 00"

14
.gitattributes vendored
View file

@ -1,5 +1,11 @@
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
*.js linguist-vendored
* 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

53
.gitignore vendored
View file

@ -1,22 +1,51 @@
.env
.env.testing
.env.backup
.env.production
.phpunit.result.cache
.stylelintcache
/.fleet
/.idea
/.phpunit.cache
/.vscode
/node_modules
/bootstrap/cache
/public/build
/public/cache
/public/hot
/public/sitemap.xml
/public/storage
/public/images/content
/public/images/gammes
/public/images/produits
/public/images/slides
/public/b-o/
/public/mix-manifest.json
/storage/*.key
/vendor
.env
.env.backup
.env.dev
.env.prod
.phpunit.result.cache
coverage/
auth.json
docker-compose.override.yml
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
auth.json
.npmrc
js.save
phpstan-report.xml
# Ignore public favicon
/public/android-chrome-192x192.png
/public/android-chrome-512x512.png
/public/apple-touch-icon.png
/public/browserconfig.xml
/public/browserconfig.xml
/public/favicon-16x16.png
/public/favicon-32x32.png
/public/favicon.ico
/public/mstile-150x150.png
/public/safari-pinned-tab.svg
/public/site.webmanifest
/public/files
develop
_ide_helper.php
.phpstorm.meta.php
phpstan-report.xml
// Ignore PHPUnit cache files
cache

View file

@ -1,23 +1,328 @@
include:
- project: "ldc-web/shared-ci"
ref: "main"
file: "lint/lint7.3.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "security/security7.3.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/build7.3.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "deploy/deploy.yml"
stages:
- lint
- security
- build
- test
- deploy
- install
- lint
- test
- security
- build
- deploy
include:
- project: "ldc-web/shared-ci"
ref: "main"
file: "security/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "build/docker-multistage.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "deploy/deploy.yml"
# * LINT --------------------------------------------------------------------
.php-lint-base: &php-lint-base
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
variables:
COMPOSER_CACHE_DIR: ".composer-cache"
cache:
key:
files:
- composer.lock
prefix: "composer"
policy: pull
paths:
- .composer-cache/
- vendor/
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
parallel-lint:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/parallel-lint --exclude vendor --exclude storage --exclude bootstrap --exclude node_modules .
allow_failure: false
pint:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/pint --test
allow_failure: false
phpcs:
<<: *php-lint-base
stage: lint
script:
- vendor/bin/phpcs --standard=phpcs.xml --extensions=php --ignore=vendor/*,storage/*,bootstrap/*,node_modules/* --report=junit --report-file=phpcs-report.xml
artifacts:
when: on_failure
expose_as: 'phpcs'
paths:
- phpcs-report.xml
reports:
junit: phpcs-report.xml
allow_failure: false
phpstan:
<<: *php-lint-base
stage: lint
variables:
PHP_MEMORY_LIMIT: "512M"
COMPOSER_CACHE_DIR: ".composer-cache"
script:
- php -d memory_limit=512M vendor/bin/phpstan --error-format=junit > phpstan-report.xml
artifacts:
when: on_failure
expose_as: 'phpstan'
paths:
- phpstan-report.xml
reports:
junit: phpstan-report.xml
allow_failure: false
eslint:
stage: lint
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run eslint-ci --quiet --output-file=eslint-junit.xml
artifacts:
when: on_failure
expose_as: 'eslint'
paths:
- eslint-junit.xml
reports:
junit: eslint-junit.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
stylelint:
stage: lint
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run stylelint-ci --quiet --output-file=stylelint-junit.xml
artifacts:
when: on_failure
expose_as: 'stylelint'
paths:
- stylelint-junit.xml
reports:
junit: stylelint-junit.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "develop"
# * TESTS -------------------------------------------------------------------
bun-build-test:
stage: test
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- timeout 300 bun run prod
artifacts:
expose_as: 'front-build-test'
paths:
- public/build/manifest.json
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
tests_front:
stage: test
image: oven/bun:1
variables:
BUN_INSTALL_CACHE_DIR: ".bun-cache"
cache:
key:
files:
- bun.lock
prefix: "bun"
policy: pull
paths:
- .bun-cache/
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- bun install --no-save
script:
- bun run test:unit
artifacts:
when: always
expose_as: 'vitest'
paths:
- coverage/junit-vitest.xml
reports:
junit: coverage/junit-vitest.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
tests_laravel:
stage: test
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
allow_failure: false
variables:
COMPOSER_CACHE_DIR: ".composer-cache"
# MYSQL SERVICE VARS
MYSQL_DATABASE: testing_db
MYSQL_ROOT_PASSWORD: password
# LARAVEL VARS
DB_CONNECTION: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_DATABASE: testing_db
DB_USERNAME: root
DB_PASSWORD: password
cache:
- key:
files:
- composer.lock
prefix: "composer"
policy: pull
paths:
- .composer-cache/
- vendor/
services:
- name: mysql:8.0
alias: mysql
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- cp .env.example .env
- php artisan key:generate
- php -r "while(true){try{new PDO('mysql:host=mysql;dbname=testing_db','root','password');break;}catch(Exception){sleep(1);}}"
- php artisan config:clear
- php artisan test --coverage --colors=never --log-junit=coverage/junit.xml --coverage-cobertura=coverage/cobertura.xml --coverage-html=coverage/html
after_script:
- if [ "$CI_JOB_STATUS" == "failed" ]; then cat storage/logs/laravel.log; fi
artifacts:
when: always
expose_as: 'coverage'
paths:
- coverage/html/
reports:
junit: coverage/junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura.xml
coverage: '/^\s*Total:\s*([\d\.]+)%/'
needs:
- job: bun-build-test
optional: false
artifacts: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
# * SECURITY ----------------------------------------------------------------
bun-security:
stage: security
image: oven/bun:1
before_script:
- echo "@ldc-web:registry=https://${CI_SERVER_HOST}/api/v4/packages/npm/" >> .npmrc
- echo "//${CI_SERVER_HOST}/api/v4/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
- echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc
- echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${CI_DEPLOY_PASSWORD}" >> .npmrc
script:
- bun audit --json > bun-security-checker-report.json
artifacts:
when: on_failure
expose_as: 'bun-security-checker'
paths:
- bun-security-checker-report.json
allow_failure: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null
# * BUILD -------------------------------------------------------------------
npm-build:
stage: build
script:
- echo "Frontend is built directly within Docker multi-stage build"
npm-security:
stage: security
script:
- echo "Npm security would run bun security"
dependencies:
- bun-security
needs:
- job: bun-security
optional: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "trunk" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG != null

View file

@ -8,4 +8,4 @@
## Étapes pour la reproduction du bug
*
*

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,5 @@ php:
finder:
not-name:
- index.php
- server.php
js:
finder:
not-name:
- webpack.mix.js
js: true
css: true

43
.stylelintrc.json Normal file
View file

@ -0,0 +1,43 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-sass-guidelines",
"stylelint-config-idiomatic-order"
],
"plugins": [
"stylelint-scss"
],
"files": [
"**/*.css",
"**/*.scss"
],
"ignoreFiles": [
"**/vendor/*.css",
"**/vendor/**/*.css",
"**/vendor/*.scss",
"**/vendor/**/*.scss",
"**/vendor/*.sass",
"**/vendor/**/*.sass",
"**/vendor/*.less",
"**/vendor/**/*.less"
],
"customSyntax": "postcss-scss",
"rules": {
"declaration-property-value-no-unknown": true,
"selector-max-id": null,
"color-no-invalid-hex": true,
"order/properties-alphabetical-order": null,
"max-nesting-depth": 5,
"selector-max-compound-selectors": 5,
"scss/double-slash-comment-empty-line-before": [
"always",
{
"ignore": [
"between-comments",
"stylelint-commands",
"inside-block"
]
}
]
}
}

95
.vscode/settings.json vendored
View file

@ -1,7 +1,7 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#920c7f",
"activityBar.activeBorder": "#1a1d02",
"activityBar.activeBorder": "#e7e7e7",
"activityBar.background": "#920c7f",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
@ -11,13 +11,100 @@
"statusBar.background": "#630856",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#920c7f",
"statusBarItem.remoteBackground": "#630856",
"statusBarItem.remoteBackground": "#66760a",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#630856",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#63085699",
"titleBar.inactiveForeground": "#e7e7e799",
"commandCenter.border": "#e7e7e799"
"commandCenter.border": "#e7e7e799",
"activityBarTop.activeBackground": "#920c7f",
"activityBarTop.activeBorder": "#e7e7e7",
"activityBarTop.background": "#920c7f",
"activityBarTop.foreground": "#e7e7e7",
"activityBarTop.inactiveForeground": "#e7e7e799",
"commandCenter.foreground": "#e7e7e7",
"statusBar.debuggingBackground": "#630856",
"statusBar.debuggingForeground": "#e7e7e7",
"statusBar.inactiveBackground": "#630856"
},
"peacock.remoteColor": "#630856"
"peacock.color": "#630856",
"peacock.remoteColor": "#630856",
// "php.validate.executablePath": "/usr/local/bin/php85",
// "php.executablePath": "/usr/local/bin/php85",
"intelephense.environment.phpVersion": "8.5.0",
"intelephense.environment.includePaths": [
"./.intellipest",
"./vendor/pestphp",
"./vendor/phpunit/phpunit",
"./vendor/laravel"
],
"phpstan.binCommand": [
"${workspaceFolder}/vendor/bin/phpstan"
],
"laravel-pint.enable": true,
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "typescript", "html", "vue"],
"[html]": {
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"[blade]": {
"editor.formatOnSave": true,
"editor.formatOnType": false
},
"[javascript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": "always"
}
},
"[typescript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[php]": {
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[vue]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"conventionalCommits.scopes": [
"migration"
],
"emeraldwalk.runonsave": {
"autoClearConsole": false,
"commands": [
{
"match": "\\.php$",
"isAsync": true,
"cmd": "${workspaceFolder}/vendor/bin/pint ${file}"
}
]
},
// "LaravelExtraIntellisense.phpCommand": "docker exec -w /app/srcs -t boutique-cnc-sable-$(whoami)-apache-php-1 php -r \"{code}\"",
// "LaravelExtraIntellisense.basePathForCode": "/app/srcs"
}

View file

@ -1,3 +1,71 @@
# (2026-04-23)
### Bug Fixes
* :bug: delete ldcweb:statuschecker from the kernel 898104d
* :bug: error on removeFromCart dc8fa4d
* :bug: hide brands for unpublished products a5dbb9e
* :bug: set blade directives function in public #88 e7db2d4, closes #88
* :bug: update promotion filtering system 0692ec7
* :bug: use blade directives helper to prevent blade files from being cached #76 #77 ae54ee5, closes #76 #77
* :bug: user->id null on SalarieExportJob + add public holiday with cron + condition on public holiday's calendar 79d9061
* :chart_with_upwards_trend: update dashboard statistics #52 f454d35, closes #52
* addToCart policy was broken in product #86 + homepage #85 7150dc1, closes #86 #85
* Clean and fix cookie notice implementation 683e7fd
* Display the correct count of employee in the dashboard c07ff0d
* feedback on user basket a3b4a4b
* Fix check on salarie and customer relation 0366644
* fixed variable name for previous commit - #96 f91a7d0, closes #96
* hide prices in products.index <hen the user isn't logged in - #96 ab9fce5, closes #96
* prevent division by zero in membership rate calculation f27ed8f
* Remove piece of :poop: 2687f58
* rename route in sitemap 6328623
* reset view cache when updating settings f1bb958
* RM console.log e5e44bb
* Update matricule format 8ee5a94
### Features
* :poop: Update Matricule format on employees import and order export f156dd6
* :poop: Update SalarieSite Enum and Salarie Magasin model method 895843e
* :sparkles: add new product filter on food products no art in back-end #53 e900b85, closes #53
* :sparkles: adding multiple brands 79b9b9b
* :sparkles: Closes #46 3a3d993, closes #46
* :sparkles: re-add the stat before the date result on the dashbaord 8118d5a
* :sparkles: send an order reminder email b3d4d69
* 💬 add wording 31dac9b
* 🗃 add new field to show slide only for connected customers #87 fcd0a79, closes #87
* Add Laravel K8S 4fb9363
* Add missing newsletters folder ed5b2e0
* added admin secure fa3b38a
* added boolean to show old price on discounts - #95 06785a3, closes #95
* Authorize all BO roles for user import - ts-2165 e7baad6
* Clean and add CICD for K8S migration 9333185
* export file optimization e83b77d
* Issue #49 feedback ba6380f, closes #49
* Issue #52 feedback f6e06ec, closes #52
* Issue #55 feedback bab3bef, closes #55
* Make the fu**ing Salarie import works again c332696
* Make the fu**ing Salarie import works again 7f53f2a
* move uploads file in storage/app/public instead of public/ be69807
* re-order products with a select system like support-web - #94 f9e0448, closes #94
* Replace Regate EAI order transfert by email 184da02
* show only brands with min 1 product - #91 6e7bbe0, closes #91
* Update queue connection in manifest a93d191
* Update rules c49ca99
* update store link in fo navbar #92 2d9c55e, closes #92
* Update uploads files folder in controllers 43fc273
* updated slider to 25/75 ratio - #83 7c1c154, closes #83
### Reverts
* Revert "npm audit fix --force to solve all vulnerabilities" 02cfc19
* Revert "PHPCS correction 1" 3e9428d
## 07-10-2022
* Nouvelle UX (mise à jour et ajout de composants vue, inplémentation de l'intégration dans des blades)

View file

@ -1,5 +1,52 @@
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/ldcweb-production-7.3:latest
COPY --chown=www-data:www-data . /app/srcs
ARG PHP_8_5_TAG=latest
FROM composer:2.9 AS vendor
ARG GITLAB_USER
ARG GITLAB_PASSWORD
COPY database/ database/
COPY composer.json composer.json
COPY composer.lock composer.lock
RUN composer config gitlab-token.lab.s-web.fr "${GITLAB_USER}" "${GITLAB_PASSWORD}"
RUN composer install \
--ignore-platform-reqs \
--no-interaction \
--no-plugins \
--no-scripts \
--no-dev \
--prefer-dist
FROM oven/bun:1.4.4-alpine AS frontend
ARG GITLAB_PASSWORD
RUN mkdir -p /app/public/build
WORKDIR /app
COPY package.json bun.lock* tsconfig.json vite.config.ts vite.config.js .stylelintrc.json /app/
COPY resources/ /app/resources/
COPY --from=vendor /app/vendor/ /app/vendor/
RUN echo "@ldc-web:registry=https://lab.s-web.fr/api/v4/packages/npm/" >> .npmrc && \
echo "//lab.s-web.fr/api/v4/packages/npm/:_authToken=${GITLAB_PASSWORD}" >> .npmrc && \
echo "@uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/" >> .npmrc && \
echo "//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken=${GITLAB_PASSWORD}" >> .npmrc
RUN bun install --frozen-lockfile && bun run prod
ARG PHP_8_5_TAG
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-production-8.5:${PHP_8_5_TAG}
ARG USER=www-data
COPY --chown=${USER}:${USER} . /app/srcs
COPY --chown=${USER}:${USER} ./resources/favicon/ /app/srcs/public/
COPY --from=vendor --chown=${USER}:${USER} /app/vendor/ /app/srcs/vendor/
COPY --from=frontend --chown=${USER}:${USER} /app/public/build/ /app/srcs/public/build/
USER root
RUN chmod -R 755 /app/srcs/storage
USER www-data
USER ${USER}

View file

@ -1,37 +1,80 @@
## Boutique Click N Collect Sablé
# Boutique Click N Collect Sablé
## Table of contents
- [Boutique Click N Collect Sablé](#boutique-click-n-collect-sablé)
- [Table of contents](#table-of-contents)
- [Frameworks, Platforms and Libraries](#frameworks-platforms-and-libraries)
- [Installation](#installation)
- [Tests](#tests)
- [Deployment](#deployment)
- [Available Commands](#available-commands)
- [Changelog](#changelog)
- [Architecture](#architecture)
- [Règles d'Or (Verrouillées par les tests)](#règles-dor-verrouillées-par-les-tests)
## Frameworks, Platforms and Libraries
<!-- https://github.com/Ileriayo/markdown-badges -->
[![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)](https://sass-lang.com/)
[![TypeScript](https://img.shields.io/badge/TS-blue.svg?style=for-the-badge&logo=TYPESCRIPT&logoColor=white)](https://www.typescriptlang.org/)
[![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white)](https://vite.dev/)
[![PHP](https://img.shields.io/badge/php-%23777BB4.svg?style=for-the-badge&logo=php&logoColor=white)](https://www.php.net/)
[![Laravel](https://img.shields.io/badge/laravel-%23FF2D20.svg?style=for-the-badge&logo=laravel&logoColor=white)](https://laravel.com/docs/master)
[![Vue.js](https://img.shields.io/badge/vuejs-%234FC08D.svg?style=for-the-badge&logo=vue.js&logoColor=white)](https://vuejs.org/)
## Installation
```
```bash
cp .env.example .env
vi .env
composer install
php artisan storage:link
cd ./ressources/views/front/
mkdir <theme-folder>
cd <theme-folder>
git clone <theme-repo> .
cd ./../../../../
npm install && npm run prod
npm i
npm run prod
```
## Production
Put every min in PROD :
```php
## Tests
```bash
cp .env.example .env.testing
vi .env.testing
composer install
php artisan storage:link
composer run test
```
## Deployment
```bash
php artisan schedule:run
```
## Available Commands
```
ldcweb:admin-secure-sync
```bash
php artisan ldcweb:admin-secure-sync
```
## Back-Office
**Créer un utilisateur :**
Création de compte
```
```bash
php artisan create:admin
```
URL : /bo
## Changelog
See the [CHANGELOG.md](CHANGELOG.md) file for details.
## Architecture
Ce projet suit les principes de **l'Architecture Hexagonale (Ports et Adapteurs)** et du **Domain-Driven Design (DDD)**.
L'objectif est d'isoler au maximum la logique métier de l'infrastructure (framework Laravel, base de données, requêtes réseau) pour avoir un code robuste, pérenne et facile à tester.
### Règles d'Or (Verrouillées par les tests)
Afin de faciliter l'onboarding et d'assurer que l'architecture ne dérive pas, **Pest Arch** et **PHPStan (Level 9)** garantissent les principes suivants à chaque commit :
1. **Isolation totale du Domaine (Le Cœur)** : Le code dans `domain/` ne doit **jamais** dépendre du Framework (`Illuminate\`), de `App\`, ou de `Infrastructure\`. Il ne dépend que de PHP natif.
2. **Inversion des Dépendances** : L'Application et l'Infrastructure dépendent du Domaine, et jamais l'inverse. Le domaine expose des `Ports` (Interfaces) que l'infrastructure implémente via ses `Adapters`.
3. **Entités et Objets-Valeurs Purs** : Le domaine manipule ses propres Entités et `ValueObjects` validés et typés fortement. L'utilisation directe des modèles Eloquent est strictement interdite dans le domaine.
4. **Conventions de Nommage** : Chaque composant a une place et un suffixe stricts : `*Service`, `*Repository`, `*Exception`, `*Dto`. Les tests échoueront si la convention n'est pas respectée.

33
REGLES METIERS.md Normal file
View file

@ -0,0 +1,33 @@
# Règles Métiers du projet
Les règles reposent sur le code de la partie **Domaine**.
## 1. Planification des Retraits
La sélection du retrait se fait désormais sur une journée complète et non plus par tranches horaires via `PickupSchedulingService`.
- **Jour de retrait standard** : Uniquement le **Vendredi** (`PICKUP_DAY = WeekDay::Friday`).
- **Heure de clôture (Cutoff)** : **Lundi à 14h00** de la même semaine (`CUTOFF_DAY = Monday`, `CUTOFF_HOUR = 14`).
- *Comportement* : Une commande validée avant Lundi 14h00 est préparée pour le vendredi en cours. Passé ce délai, le premier jour disponible bascule automatiquement sur le vendredi de la semaine suivante.
- **Gestion des Exceptions** :
- Les fermetures et jours fériés sont gérés via des `ShopPickupException`.
- Une exception peut **fermer le retrait**, ou proposer un **jour de remplacement**. Ce jour de remplacement peut posséder sa propre limite de validation (`orderDeadlineAt`).
- Une tâche planifiée `UpsertShopsPickupExceptionsUsingApiForYear` est lancée pour provisionner les jours fériés de l'année pour toutes les boutiques
## 2. Limites de Montants
Les montants pouvant être commandés sont gérés par un service dédié `CustomerOrderLimitPolicy`
- **Plafond par commande** : **200€ max** (`maxOrderAmountCents = 20000`). Lève une `OrderLimitExceededException`.
- **Plafond hebdomadaire** : **200€ max par client** sur l'ensemble de la semaine (`maxWeeklyAmountCents = 20000`). Lève une `WeeklyLimitExceededException`.
## 3. Disponibilité des Produits
Elle est géré par le service `ProductAvailabilityService`.
- **Conditions d'ajout au panier** :
- Le produit est actif (`status = true`).
- Il est éligible INCO (`hasInco = true`).
- La date actuelle est supérieure ou égale à `orderable_start` (si défini).
- La date actuelle est inférieure à `orderable_end` (si défini).

61
RGPD.md Normal file
View file

@ -0,0 +1,61 @@
# RGPD
## Site
### En base de données
| Table | Colonne | Contenu | Utilisation |
| ----------- | -------------------------- | ----------------------------------------------- | -------------------------------------------------------- |
| customers | email | Adresse email | Identifiant du client |
| customers | hashcode | Code unique de l'utilisateur en CRM | Identifiant du client en CRM |
| customers | created_at | Date | Suivi d'activité |
| customers | updated_at | Date | Suivi d'activité |
| customers | last_login_at | Date | Suivi de la dernière connexion au compte client |
| --- | --- | --- | --- |
| orders | employee.matricule | Matricule de l'employé | Identifiant unique de l'employé lié à la commande |
| orders | employee.firstname | Prénom de l'employé | Affichage et personnalisation de la commande |
| orders | employee.lastname | Nom de l'employé | Affichage et personnalisation de la commande |
| orders | employee.facility | Établissement de l'employé | Localisation de l'employé pour la gestion de la commande |
| orders | employee.site | Site de l'employé | Localisation du site de l'employé |
| orders | employee.service | Service de l'employé | Service d'appartenance de l'employé |
| orders | delivery_address.name | Nom du lieu de livraison | Identification du contact ou lieu de livraison |
| orders | delivery_address.address | Adresse de livraison | Expédition et livraison de la commande |
| orders | delivery_address.postcode | Code postal de livraison | Routage de la livraison |
| orders | delivery_address.city | Ville de livraison | Routage de la livraison |
| orders | delivery_address.phone | Téléphone de contact pour la livraison | Contact en cas de problème de livraison |
| orders | delivery_address.latitude | Latitude de l'adresse de livraison | Géolocalisation pour optimiser la livraison |
| orders | delivery_address.longitude | Longitude de l'adresse de livraison | Géolocalisation pour optimiser la livraison |
| orders | employee_matricule | Matricule de l'employé (colonne virtuelle) | Indexation et recherche rapide des commandes par employé |
| --- | --- | --- | --- |
| order_lines | product_id | Identifiant du produit présent dans la commande | Détail de la commande |
| order_lines | quantité | Quantité du produit présent dans la commande | Détail de la commande |
| --- | --- | --- | --- |
| employees | customer_id | Identifiant client | Permet de relier un salarié à un compte client du site |
| employees | matricule | Matricule | Matricule du salarié |
| employees | nom | Nom | Nom du salarié |
| employees | prenom | Prénom | Prénom du salarié |
| employees | start_date | Date de début du contrat | Activation du compte |
| employees | end_date | Date de fin du contrat | Désactivation du compte |
| employees | etablissement | Identifiant établissement | Localiser l'employé pour le dépôt de la commande |
| employees | site | Identifiant site | Localiser l'employé pour le dépôt de la commande |
| employees | service | Identifiant service | Localiser l'employé pour le dépôt de la commande |
| --- | --- | --- | --- |
| users | first_name | Prénom | Affichage à différents endroits du BO |
| users | last_name | Nom | Affichage à différents endroits du BO |
| users | email | Adresse mail | Identifiant, sert à la connexion au BO |
### Formulaire de contact (marketing)
| Donnée | Contenu |
| -------------- | -------------------------------- |
| Nom | Nom de la personne |
| Prénom | Prénom de la personne |
| Adresse E-mail | Adresse email de la personne |
| Pièce jointe | Photo renseignée par la personne |
## Cookies
| Service | Utilité |
| ------- | ----------------- |
| Matomo | Mesure d'audience |

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\BasketService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Basket\AddToBasketInput, void>
*/
final class AddToBasket implements Action
{
public function __construct(
private readonly BasketService $basketService,
) {
}
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, AddToBasketInput::class);
$this->basketService->add(
$input->productId,
$input->qty
);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
use Webmozart\Assert\Assert;
final readonly class AddToBasketInput implements ActionInput
{
public function __construct(
public ProductId $productId,
public int $qty,
) {
Assert::greaterThan($qty, 0);
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\ActionInputEmpty;
use Domain\Services\BasketService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Contracts\ActionInputEmpty, void>
*/
final class EmptyBasket implements Action
{
public function __construct(
private readonly BasketService $basketService,
) {
}
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf(
$input,
ActionInputEmpty::class
);
$this->basketService->empty();
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\BasketService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Basket\RemoveQuantityFromBasketInput|\App\Actions\Basket\RemoveProductFromBasketInput, void>
*/
final class RemoveFromBasket implements Action
{
public function __construct(
private readonly BasketService $basketService,
) {
}
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOfAny(
$input,
[
RemoveQuantityFromBasketInput::class,
RemoveProductFromBasketInput::class,
]
);
if ($input instanceof RemoveQuantityFromBasketInput) {
$this->basketService->remove(
$input->productId,
$input->qty
);
} elseif ($input instanceof RemoveProductFromBasketInput) {
$this->basketService->removeLine(
$input->productId
);
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
final readonly class RemoveProductFromBasketInput implements ActionInput
{
public function __construct(
public ProductId $productId,
) {
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\Basket;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ProductId;
use Webmozart\Assert\Assert;
final readonly class RemoveQuantityFromBasketInput implements ActionInput
{
public function __construct(
public ProductId $productId,
public int $qty,
) {
Assert::greaterThan($qty, 0);
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use SortDirection;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ChangeModelOrderInput, bool>
*/
final class ChangeModelOrder implements Action
{
private const int MAX_ORDER_VALUE = 4294967295;
/**
* @param \App\Actions\Common\ChangeModelOrderInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, ChangeModelOrderInput::class);
$model = $input->model;
$goUp = $input->direction === SortDirection::Ascending;
$currentOrder = $model->getOrder();
$usesSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true)
|| method_exists($model, 'trashed');
/** @var Builder<Model&ModelWithOrder> $baseQuery */
$baseQuery = $usesSoftDeletes
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();
/** @var (Model&ModelWithOrder)|null $adjacentModel */
$adjacentModel = $baseQuery
->where('order', $goUp ? '<' : '>', $currentOrder)
->orderBy('order', $goUp ? SortDirection::Descending : SortDirection::Ascending)
->first();
if ($adjacentModel === null) {
return false;
}
$this->permuteModelOrders($model, $adjacentModel);
return true;
}
private function permuteModelOrders(Model & ModelWithOrder $model1, Model & ModelWithOrder $model2): void
{
$order1 = $model1->getOrder();
$order2 = $model2->getOrder();
// Deactivate timestamps to avoid updating updated_at
$model1->timestamps = false;
$model2->timestamps = false;
$model1->setOrder(self::MAX_ORDER_VALUE);
$model1->saveQuietly();
$model2->setOrder($order1);
$model2->saveQuietly();
$model1->setOrder($order2);
$model1->saveQuietly();
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Model;
use SortDirection;
use Webmozart\Assert\Assert;
final readonly class ChangeModelOrderInput implements ActionInput
{
public function __construct(
public Model & ModelWithOrder $model,
public SortDirection $direction,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithOrder::class);
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ChangeModelOrderPositionInput, bool>
*/
final class ChangeModelOrderPosition implements Action
{
private const int MAX_ORDER_VALUE = 4294967295;
/**
* @param \App\Actions\Common\ChangeModelOrderPositionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, ChangeModelOrderPositionInput::class);
$model = $input->model;
$currentOrder = $model->getOrder();
$targetOrder = $input->targetOrder;
if ($currentOrder === $targetOrder) {
return true;
}
$usesSoftDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true)
|| method_exists($model, 'trashed');
/** @var Builder<Model&ModelWithOrder> $baseQuery */
$baseQuery = $usesSoftDeletes
// @phpstan-ignore method.notFound (withTrashed)
? $model::query()->withTrashed()
: $model::query();
/** @var int|null $maxOrder */
$maxOrder = (clone $baseQuery)->max('order');
if ($maxOrder !== null && $targetOrder > $maxOrder) {
$targetOrder = (int) $maxOrder;
}
if ($currentOrder === $targetOrder) {
return true;
}
// Deactivate timestamps
$model->timestamps = false;
// Step 1: Temporarily park the moving model at MAX_ORDER_VALUE
$model->setOrder(self::MAX_ORDER_VALUE);
$model->saveQuietly();
if ($targetOrder < $currentOrder) {
// Moving UP
/** @var \Illuminate\Database\Eloquent\Collection<int, Model&ModelWithOrder> $itemsToShift */
$itemsToShift = (clone $baseQuery)
->where('order', '>=', $targetOrder)
->where('order', '<', $currentOrder)
->whereKeyNot($model->getKey())
->orderBy('order', 'desc')
->get();
foreach ($itemsToShift as $item) {
$item->timestamps = false;
$item->setOrder($item->getOrder() + 1);
$item->saveQuietly();
}
} else {
// Moving DOWN
/** @var \Illuminate\Database\Eloquent\Collection<int, Model&ModelWithOrder> $itemsToShift */
$itemsToShift = (clone $baseQuery)
->where('order', '>', $currentOrder)
->where('order', '<=', $targetOrder)
->whereKeyNot($model->getKey())
->orderBy('order', 'asc')
->get();
foreach ($itemsToShift as $item) {
$item->timestamps = false;
$item->setOrder($item->getOrder() - 1);
$item->saveQuietly();
}
}
// Step 2: Set the moving model to the target order
$model->setOrder($targetOrder);
$model->saveQuietly();
return true;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithOrder;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ChangeModelOrderPositionInput implements ActionInput
{
public function __construct(
public Model & ModelWithOrder $model,
public int $targetOrder,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithOrder::class);
Assert::greaterThanEq($this->targetOrder, 1);
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Enums\MassActionType;
use App\Interfaces\ModelWithStar;
use App\Interfaces\ModelWithStatus;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ExecuteMassActionInput, int>
*/
final class ExecuteMassAction implements Action
{
/**
* @param \App\Actions\Common\ExecuteMassActionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): int
{
Assert::isInstanceOf($input, ExecuteMassActionInput::class);
$this->assertModelMatchesInput($input);
return $this->updateModel($input);
}
private function assertModelMatchesInput(ExecuteMassActionInput $input): void
{
$modelClass = $input->modelClass;
switch ($input->action) {
case MassActionType::Star:
case MassActionType::Unstar:
Assert::isAOf($modelClass, ModelWithStar::class);
break;
case MassActionType::Publish:
case MassActionType::Unpublish:
Assert::isAOf($modelClass, ModelWithStatus::class);
break;
default:
throw new \UnexpectedValueException('Unsupported mass action type: ' . $input->action->value);
}
}
private function updateModel(ExecuteMassActionInput $input): int
{
$modelClass = $input->modelClass;
/** @var class-string<Model> $modelClass */
$model = new $modelClass();
$models = $modelClass::query()
->whereIn($model->getKeyName(), $input->modelKeyList)
->get();
$updated = 0;
foreach ($models as $foundModel) {
if (! $foundModel instanceof Model) {
continue;
}
switch ($input->action) {
case MassActionType::Star:
/** @var Model&ModelWithStar $foundModel */
$foundModel->setStar(true);
break;
case MassActionType::Unstar:
/** @var Model&ModelWithStar $foundModel */
$foundModel->setStar(false);
break;
case MassActionType::Publish:
/** @var Model&ModelWithStatus $foundModel */
$foundModel->setStatus(true);
break;
case MassActionType::Unpublish:
/** @var Model&ModelWithStatus $foundModel */
$foundModel->setStatus(false);
break;
default:
throw new \UnexpectedValueException('Unsupported mass action type: ' . $input->action->value);
}
$foundModel->saveOrFail();
$updated++;
}
return $updated;
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Enums\MassActionType;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ExecuteMassActionInput implements ActionInput
{
/**
* @param class-string<Model> $modelClass
* @param array<int|string> $modelKeyList
*/
public function __construct(
public string $modelClass,
public MassActionType $action,
public array $modelKeyList,
) {
Assert::classExists($this->modelClass);
Assert::isAOf($this->modelClass, Model::class);
Assert::notEmpty($this->modelKeyList);
Assert::allScalar($this->modelKeyList);
foreach ($this->modelKeyList as $modelKey) {
Assert::stringNotEmpty((string) $modelKey);
}
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStar;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ToggleModelStarInput, \Illuminate\Database\Eloquent\Model&\App\Interfaces\ModelWithStar>
*/
final class ToggleModelStar implements Action
{
/**
* @param \App\Actions\Common\ToggleModelStarInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Model & ModelWithStar
{
Assert::isInstanceOf($input, ToggleModelStarInput::class);
$model = $input->model;
$model->toggleStar();
$model->saveOrFail();
return $model;
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStar;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ToggleModelStarInput implements ActionInput
{
public function __construct(
public Model & ModelWithStar $model,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithStar::class);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStatus;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Common\ToggleModelStatusInput, \Illuminate\Database\Eloquent\Model&\App\Interfaces\ModelWithStatus>
*/
final class ToggleModelStatus implements Action
{
/**
* @param \App\Actions\Common\ToggleModelStatusInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Model & ModelWithStatus
{
Assert::isInstanceOf($input, ToggleModelStatusInput::class);
$model = $input->model;
$model->toggleStatus();
$model->saveOrFail();
return $model;
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Actions\Common;
use App\Contracts\ActionInput;
use App\Interfaces\ModelWithStatus;
use Illuminate\Database\Eloquent\Model;
use Webmozart\Assert\Assert;
final readonly class ToggleModelStatusInput implements ActionInput
{
public function __construct(
public Model & ModelWithStatus $model,
) {
Assert::isInstanceOf($this->model, Model::class);
Assert::isInstanceOf($this->model, ModelWithStatus::class);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Services\CrmService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Crm\UnsubscribeFromNewsletterInput, \App\Actions\Crm\UnsubscribeFromNewsletterOutput>
*/
final class UnsubscribeFromNewsletter implements Action
{
public function __construct(
private readonly CrmService $crmService
) {
}
#[\Override]
public function __invoke(ActionInput $input): UnsubscribeFromNewsletterOutput
{
Assert::isInstanceOf($input, UnsubscribeFromNewsletterInput::class);
try {
$isUnsubscribed = $this->crmService->unsubscribeCustomer(
$input->hashcode
);
return $isUnsubscribed ? UnsubscribeFromNewsletterOutput::SUCCESS : UnsubscribeFromNewsletterOutput::FAILURE;
} catch (\Throwable $e) {
report($e);
return UnsubscribeFromNewsletterOutput::FAILURE;
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\ActionInput;
use Shared\ValueObjects\CrmHashcode;
final readonly class UnsubscribeFromNewsletterInput implements ActionInput
{
public function __construct(
public CrmHashcode $hashcode
) {
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
enum UnsubscribeFromNewsletterOutput
{
case SUCCESS;
case INVALIDLINK;
case FAILURE;
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Services\CrmService;
use Infrastructure\Adapters\Services\AppCustomerAuthService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Crm\UpdateCustomerInput, \App\Actions\Crm\UpdateCustomerOutput>
*/
final class UpdateCustomer implements Action
{
public function __construct(
private readonly AppCustomerAuthService $authService,
private readonly CrmService $crmService
) {
}
#[\Override]
public function __invoke(ActionInput $input): UpdateCustomerOutput
{
Assert::isInstanceOf($input, UpdateCustomerInput::class);
$customer = $this->authService->getCustomerAsMandatory();
try {
/** @var array{firstname:string,lastname:string,birth_date:string,phone?:string,address?:string,postcode:string,city:string,opt_in?:bool} $data */
$data = [
'firstname' => $input->firstname,
'lastname' => $input->lastname,
'birth_date' => $input->birthDate,
'postcode' => $input->postcode,
'city' => $input->city,
'opt_in' => $input->optIn,
];
if ($input->phone !== null) {
$data['phone'] = $input->phone;
}
if ($input->address !== null) {
$data['address'] = $input->address;
}
$result = $this->crmService->updateCustomerInfos(
$customer,
$data
);
return new UpdateCustomerOutput($result);
} catch (\Throwable $e) {
report($e);
return new UpdateCustomerOutput(false);
}
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\ActionInput;
final readonly class UpdateCustomerInput implements ActionInput
{
public function __construct(
public string $firstname,
public string $lastname,
public string $birthDate,
public ?string $phone,
public ?string $address,
public string $postcode,
public string $city,
public bool $optIn,
) {
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use Domain\Models\ValueObjects\CrmCustomer;
use Illuminate\Validation\ValidationException;
final readonly class UpdateCustomerOutput
{
public function __construct(
public CrmCustomer | ValidationException | false $result
) {
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Services\CrmService;
use Domain\Models\ValueObjects\CrmCustomer;
use Infrastructure\Adapters\Services\AppCustomerAuthService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Crm\UpdateCustomerPasswordInput, \App\Actions\Crm\UpdateCustomerPasswordOutput>
*/
final class UpdateCustomerPassword implements Action
{
public function __construct(
private readonly AppCustomerAuthService $authService,
private readonly CrmService $crmService
) {
}
#[\Override]
public function __invoke(ActionInput $input): UpdateCustomerPasswordOutput
{
Assert::isInstanceOf($input, UpdateCustomerPasswordInput::class);
$hashcode = $this->authService->getCustomerAsMandatory()->hashcode;
$crmCustomer = $this->crmService->getCustomerFromHashCode(
$hashcode
);
if ($crmCustomer instanceof CrmCustomer === false) {
return new UpdateCustomerPasswordOutput(false);
}
try {
$result = $this->crmService->changePassword(
$crmCustomer,
$input->currentPassword,
$input->newPassword,
);
return new UpdateCustomerPasswordOutput($result);
} catch (\Throwable $e) {
report($e);
return new UpdateCustomerPasswordOutput(false);
}
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
final readonly class UpdateCustomerPasswordInput implements ActionInput
{
public function __construct(
#[\SensitiveParameter]
public string $currentPassword,
#[\SensitiveParameter]
public string $newPassword,
) {
Assert::notEmpty($currentPassword);
Assert::notEmpty($newPassword);
Assert::notSame($currentPassword, $newPassword);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Crm;
use Domain\Models\ValueObjects\CrmCustomer;
use Illuminate\Validation\ValidationException;
final readonly class UpdateCustomerPasswordOutput
{
public function __construct(
public CrmCustomer | ValidationException | false $result
) {
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Actions\Customers;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\Repositories\EmployeeRepository;
use App\Exceptions\Crm\CrmCustomerExistsException;
use App\Exceptions\CrmServiceException;
use App\Models\Customer;
use App\Services\CrmService;
use Domain\Enums\Country;
use Domain\Models\ValueObjects\CrmCustomer;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Validation\ValidationException;
use Infrastructure\Adapters\Services\AppCustomerAuthService;
use Ramsey\Uuid\Uuid;
use Shared\ValueObjects\CrmHashcode;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Customers\RegisterCustomerInput, \App\Models\Customer>
*/
final class RegisterCustomer implements Action
{
public function __construct(
private readonly AppCustomerAuthService $authService,
private readonly CrmService $crmService,
private readonly EmployeeRepository $employeeRepository,
) {
}
/**
* @param \App\Actions\Customers\RegisterCustomerInput $input
*
* @throws ValidationException
* @throws \UnexpectedValueException
* @throws ModelNotFoundException
*/
#[\Override]
public function __invoke(ActionInput $input): Customer
{
Assert::isInstanceOf($input, RegisterCustomerInput::class);
// * Persist and associate Customer + Employee
$customer = new Customer([
'email' => $input->email,
]);
// * Temp hashcode to satisfy constraint
$customer->hashcode = new CrmHashcode(Uuid::uuid4());
$customer->saveOrFail();
$employee = $this->employeeRepository->findByMatriculeAndCode(
$input->matricule,
$input->code
);
if (! $employee) {
throw new ModelNotFoundException('Employee not found');
}
$employee->customer()->associate($customer);
$employee->saveOrFail();
$crmCustomer = new CrmCustomer(
hashcode: null,
email: $input->email->value,
password: $input->password,
firstname: $input->firstname,
lastname: $input->lastname,
birthDate: $input->birthDate->format('Y-m-d'),
phone: $input->phone?->value,
address: $input->address,
address2: null,
city: $input->city,
postcode: $input->postcode,
country: Country::france,
optIn: $input->optIn,
status: false,
lastConnection: null,
);
try {
$result = $this->crmService->persist($crmCustomer);
if (! $result->hashcode) {
throw new \UnexpectedValueException(
'CRM did not return a hashcode for the created customer'
);
}
$customer->hashcode = CrmHashcode::fromString($result->hashcode);
$customer->saveOrFail();
$customer->sendEmailVerificationNotification();
$this->authService->logIn($customer);
return $customer;
} catch (CrmCustomerExistsException) {
throw ValidationException::withMessages([
'error' => trans('An account is already associated with this email address'),
]);
} catch (CrmServiceException $e) {
report($e);
throw ValidationException::withMessages([
'error' => trans('An error has occurred. Please try again later'),
]);
}
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Actions\Customers;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Code;
use Domain\Models\ValueObjects\Email;
use Domain\Models\ValueObjects\Matricule;
use Domain\Models\ValueObjects\PhoneNumber;
use Webmozart\Assert\Assert;
final readonly class RegisterCustomerInput implements ActionInput
{
public function __construct(
public Matricule $matricule,
public Code $code,
public string $firstname,
public string $lastname,
public Email $email,
public string $password,
public \DateTimeImmutable $birthDate,
public ?PhoneNumber $phone,
public ?string $address,
public string $postcode,
public string $city,
public bool $optIn = false,
) {
Assert::stringNotEmpty(trim($this->firstname));
Assert::maxLength($this->firstname, 255);
Assert::stringNotEmpty(trim($this->lastname));
Assert::maxLength($this->lastname, 255);
Assert::stringNotEmpty(trim($this->password));
Assert::stringNotEmpty(trim($this->postcode));
Assert::maxLength($this->postcode, 20);
Assert::stringNotEmpty(trim($this->city));
Assert::maxLength($this->city, 255);
if ($this->address !== null) {
Assert::maxLength($this->address, 255);
}
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Discount;
use App\Models\Product;
use Illuminate\Support\Carbon;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Discounts\CreateDiscountInput, \App\Models\Discount>
*/
final class CreateDiscount implements Action
{
/**
* @param \App\Actions\Discounts\CreateDiscountInput $input
*/
public function __invoke(ActionInput $input): Discount
{
Assert::isInstanceOf($input, CreateDiscountInput::class);
$discount = new Discount();
$discount->type = $input->type;
$discount->value = $input->value;
$discount->is_old_price_displayed = $input->is_old_price_displayed;
$discount->date_start = $input->period->start !== null ? Carbon::instance($input->period->start) : null;
$discount->date_end = $input->period->end !== null ? Carbon::instance($input->period->end) : null;
$discount->saveOrFail();
$this->syncAssociatedProducts($discount, $input->associatedProductsIds);
return $discount;
}
/**
* @param array<string> $productIdList
*/
private function syncAssociatedProducts(Discount $discount, array $productIdList): void
{
Product::query()
->where('discount_id', $discount->id)
->whereNotIn('id', $productIdList)
->update(['discount_id' => null]);
if (! empty($productIdList)) {
Product::query()
->whereIn('id', $productIdList)
->update(['discount_id' => $discount->id]);
}
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\ActionInput;
use Domain\Enums\DiscountType;
use Domain\Models\ValueObjects\DatePeriod;
use Domain\Models\ValueObjects\DiscountValue;
use Webmozart\Assert\Assert;
final readonly class CreateDiscountInput implements ActionInput
{
/**
* @param array<string> $associatedProductsIds
*/
public function __construct(
public DiscountType $type,
public DiscountValue $value,
public bool $is_old_price_displayed,
public DatePeriod $period = new DatePeriod(),
public array $associatedProductsIds = [],
) {
Assert::same($this->type, $this->value->type, 'The discount value type must match the discount type.');
Assert::allString($this->associatedProductsIds);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Discounts\DeleteDiscountInput, bool>
*/
final class DeleteDiscount implements Action
{
/**
* @param \App\Actions\Discounts\DeleteDiscountInput $input
*/
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteDiscountInput::class);
return (bool) $input->discount->deleteOrFail();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\ActionInput;
use App\Models\Discount;
final readonly class DeleteDiscountInput implements ActionInput
{
public function __construct(
public Discount $discount,
) {
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Discount;
use App\Models\Product;
use Illuminate\Support\Carbon;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Discounts\UpdateDiscountInput, \App\Models\Discount>
*/
final class UpdateDiscount implements Action
{
/**
* @param \App\Actions\Discounts\UpdateDiscountInput $input
*/
public function __invoke(ActionInput $input): Discount
{
Assert::isInstanceOf($input, UpdateDiscountInput::class);
$discount = $input->discount;
$discount->type = $input->type;
$discount->value = $input->value;
$discount->is_old_price_displayed = $input->is_old_price_displayed;
$discount->date_start = $input->period->start !== null ? Carbon::instance($input->period->start) : null;
$discount->date_end = $input->period->end !== null ? Carbon::instance($input->period->end) : null;
$discount->saveOrFail();
$this->syncAssociatedProducts($discount, $input->associatedProductsIds);
return $discount;
}
/**
* @param array<string> $productIdList
*/
private function syncAssociatedProducts(Discount $discount, array $productIdList): void
{
Product::query()
->where('discount_id', $discount->id)
->whereNotIn('id', $productIdList)
->update(['discount_id' => null]);
if (! empty($productIdList)) {
Product::query()
->whereIn('id', $productIdList)
->update(['discount_id' => $discount->id]);
}
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discounts;
use App\Contracts\ActionInput;
use App\Models\Discount;
use Domain\Enums\DiscountType;
use Domain\Models\ValueObjects\DatePeriod;
use Domain\Models\ValueObjects\DiscountValue;
use Webmozart\Assert\Assert;
final readonly class UpdateDiscountInput implements ActionInput
{
/**
* @param array<string> $associatedProductsIds
*/
public function __construct(
public Discount $discount,
public DiscountType $type,
public DiscountValue $value,
public bool $is_old_price_displayed,
public DatePeriod $period = new DatePeriod(),
public array $associatedProductsIds = [],
) {
Assert::same($this->type, $this->value->type, 'The discount value type must match the discount type.');
Assert::allString($this->associatedProductsIds);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Actions\Employee;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\ActionInputEmpty;
use App\Contracts\Repositories\EmployeeRepository;
use App\Services\EmployeeSyncService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Contracts\ActionInputEmpty, void>
*/
final class SynchronizeEmployeesWithArcadiaApi implements Action
{
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeSyncService $syncService
) {
}
/** @param \App\Contracts\ActionInputEmpty $input */
public function __invoke(ActionInput $input): void
{
Assert::isAOf($input, ActionInputEmpty::class);
$wasLastSyncedAt = $this->employeeRepository->getMostRecentSyncDate();
$this->syncService->synchronize($wasLastSyncedAt);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Faq;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Faqs\CreateFaqInput, \App\Models\Faq>
*/
final class CreateFaq implements Action
{
/**
* @param \App\Actions\Faqs\CreateFaqInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Faq
{
Assert::isInstanceOf($input, CreateFaqInput::class);
$faq = new Faq();
$faq->fill([
'question' => $input->question,
'answer' => $input->answer,
]);
$faq->saveOrFail();
return $faq;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
final readonly class CreateFaqInput implements ActionInput
{
public function __construct(
public string $question,
public string $answer,
) {
Assert::stringNotEmpty(trim($this->question), 'Question cannot be empty.');
Assert::minLength(trim($this->question), 4);
Assert::maxLength($this->question, 255);
Assert::stringNotEmpty(trim($this->answer), 'Answer cannot be empty.');
Assert::minLength(trim($this->answer), 4);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Faqs\DeleteFaqInput, void>
*/
final class DeleteFaq implements Action
{
/**
* @param \App\Actions\Faqs\DeleteFaqInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, DeleteFaqInput::class);
$input->faq->deleteOrFail();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\ActionInput;
use App\Models\Faq;
final readonly class DeleteFaqInput implements ActionInput
{
public function __construct(
public Faq $faq,
) {
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Faq;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Faqs\UpdateFaqInput, \App\Models\Faq>
*/
final class UpdateFaq implements Action
{
/**
* @param \App\Actions\Faqs\UpdateFaqInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Faq
{
Assert::isInstanceOf($input, UpdateFaqInput::class);
$faq = $input->faq;
$faq->fill([
'question' => $input->question,
'answer' => $input->answer,
]);
$faq->saveOrFail();
return $faq;
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Actions\Faqs;
use App\Contracts\ActionInput;
use App\Models\Faq;
use Webmozart\Assert\Assert;
final readonly class UpdateFaqInput implements ActionInput
{
public function __construct(
public Faq $faq,
public string $question,
public string $answer,
) {
Assert::stringNotEmpty(trim($this->question), 'Question cannot be empty.');
Assert::minLength(trim($this->question), 4);
Assert::maxLength($this->question, 255);
Assert::stringNotEmpty(trim($this->answer), 'Answer cannot be empty.');
Assert::minLength(trim($this->answer), 4);
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\PickupTimeSlot;
use Domain\Services\OrderTunnelService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\ApplyPickTimeSlotInput, ?\Domain\Models\ValueObjects\PickupTimeSlot>
*/
final class ApplyPickTimeSlot implements Action
{
public function __construct(
private readonly OrderTunnelService $orderTunnelService,
) {
}
/**
* @param \App\Actions\Orders\ApplyPickTimeSlotInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): ?PickupTimeSlot
{
Assert::isInstanceOf($input, ApplyPickTimeSlotInput::class);
$builder = $this->orderTunnelService->getBuilderFromBasketOrFail();
$shop = $this->orderTunnelService->getShopForCustomer($input->shopperId);
$timeSlots = $this->orderTunnelService->getAvailableTimeSlots($shop);
return $this->orderTunnelService->applyTimeSlot(
$builder,
$shop,
$input->timeslot,
$timeSlots
);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use Carbon\CarbonInterface;
use Domain\Models\ValueObjects\Entities\ShopperId;
final readonly class ApplyPickTimeSlotInput implements ActionInput
{
public function __construct(
public ShopperId $shopperId,
public CarbonInterface $timeslot,
) {
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\OrderService as DomainOrderService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\CancelOrderInput, void>
*/
final class CancelOrder implements Action
{
public function __construct(
private readonly DomainOrderService $domainOrderService,
) {
}
/**
* @param \App\Actions\Orders\CancelOrderInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, CancelOrderInput::class);
$this->domainOrderService->cancelOrder($input->order->getId());
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use App\Models\Order;
use Webmozart\Assert\Assert;
final readonly class CancelOrderInput implements ActionInput
{
public function __construct(
public Order $order,
) {
Assert::stringNotEmpty(trim($this->order->id));
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\OrderReference;
use Domain\Services\OrderTunnelService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\FinalizeOrderInput, \Domain\Models\ValueObjects\OrderReference>
*/
final class FinalizeOrder implements Action
{
public function __construct(
private readonly OrderTunnelService $orderTunnelService,
) {
}
/**
* @param \App\Actions\Orders\FinalizeOrderInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): OrderReference
{
Assert::isInstanceOf($input, FinalizeOrderInput::class);
$builder = $this->orderTunnelService->getBuilderCompletedOrFail();
$order = $this->orderTunnelService->finalizeOrder($builder);
return $order->reference;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Entities\ShopperId;
final readonly class FinalizeOrderInput implements ActionInput
{
public function __construct(
public ?ShopperId $shopperId = null,
) {
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Domain\Services\OrderTunnelService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Orders\InitializeOrderTunnelInput, void>
*/
final class InitializeOrderTunnel implements Action
{
public function __construct(
private readonly OrderTunnelService $orderTunnelService,
) {
}
/**
* @param \App\Actions\Orders\InitializeOrderTunnelInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): void
{
Assert::isInstanceOf($input, InitializeOrderTunnelInput::class);
$this->orderTunnelService->initializeFromBasket(
$input->basket,
$input->shopperId
);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Contracts\ActionInput;
use Domain\Models\Entities\Basket;
use Domain\Models\ValueObjects\Entities\ShopperId;
final readonly class InitializeOrderTunnelInput implements ActionInput
{
public function __construct(
public Basket $basket,
public ShopperId $shopperId,
) {
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Actions\Pages;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Page;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Pages\UpdatePageInput, \App\Models\Page>
*/
final class UpdatePage implements Action
{
/**
* @param \App\Actions\Pages\UpdatePageInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Page
{
Assert::isInstanceOf($input, UpdatePageInput::class);
$page = $input->page;
$page->fill([
'seo_title' => $input->seo->title,
'seo_description' => $input->seo->description,
'title' => $input->title,
'content' => $input->content,
]);
$page->saveOrFail();
return $page;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Actions\Pages;
use App\Contracts\ActionInput;
use App\Models\Page;
use Domain\Models\ValueObjects\SeoMetadata;
use Webmozart\Assert\Assert;
final readonly class UpdatePageInput implements ActionInput
{
public function __construct(
public Page $page,
public SeoMetadata $seo,
public ?string $title,
public string $content,
) {
Assert::stringNotEmpty(trim($this->content));
if ($this->title !== null) {
Assert::stringNotEmpty(trim($this->title));
Assert::maxLength($this->title, 255);
}
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\PickupException;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\PickupExceptions\CreatePickupExceptionInput, \App\Models\PickupException>
*/
final class CreatePickupException implements Action
{
/**
* @param \App\Actions\PickupExceptions\CreatePickupExceptionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): PickupException
{
Assert::isInstanceOf($input, CreatePickupExceptionInput::class);
$pickupException = new PickupException();
$pickupException->fill([
'date' => $input->date,
'label' => $input->label,
'repeatable' => $input->repeatable,
'repeat_frequency' => $input->repeat_frequency,
]);
$pickupException->setAsManuallySaved();
$pickupException->saveOrFail();
return $pickupException;
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\ActionInput;
use Domain\Enums\RepeatFrequency;
use Webmozart\Assert\Assert;
final readonly class CreatePickupExceptionInput implements ActionInput
{
public function __construct(
public string $date,
public string $label,
public bool $repeatable,
public RepeatFrequency $repeat_frequency,
) {
Assert::regex($this->date, '/^\d{4}-\d{2}-\d{2}$/', 'Date must be in Y-m-d format.');
Assert::stringNotEmpty(trim($this->label), 'Label cannot be empty.');
Assert::maxLength(trim($this->label), 255, 'Label cannot exceed 255 characters.');
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\PickupExceptions\DeletePickupExceptionInput, bool>
*/
final class DeletePickupException implements Action
{
/**
* @param \App\Actions\PickupExceptions\DeletePickupExceptionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeletePickupExceptionInput::class);
return (bool) $input->pickupException->deleteOrFail();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\ActionInput;
use App\Models\PickupException;
final readonly class DeletePickupExceptionInput implements ActionInput
{
public function __construct(
public PickupException $pickupException,
) {
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\PickupException;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\PickupExceptions\UpdatePickupExceptionInput, \App\Models\PickupException>
*/
final class UpdatePickupException implements Action
{
/**
* @param \App\Actions\PickupExceptions\UpdatePickupExceptionInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): PickupException
{
Assert::isInstanceOf($input, UpdatePickupExceptionInput::class);
$input->pickupException->fill([
'date' => $input->date,
'label' => $input->label,
'repeatable' => $input->repeatable,
'repeat_frequency' => $input->repeat_frequency,
]);
$input->pickupException->setAsManuallySaved();
$input->pickupException->saveOrFail();
return $input->pickupException;
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Actions\PickupExceptions;
use App\Contracts\ActionInput;
use App\Models\PickupException;
use Domain\Enums\RepeatFrequency;
use Webmozart\Assert\Assert;
final readonly class UpdatePickupExceptionInput implements ActionInput
{
public function __construct(
public PickupException $pickupException,
public string $date,
public string $label,
public bool $repeatable,
public RepeatFrequency $repeat_frequency,
) {
Assert::regex($this->date, '/^\d{4}-\d{2}-\d{2}$/', 'Date must be in Y-m-d format.');
Assert::stringNotEmpty(trim($this->label), 'Label cannot be empty.');
Assert::maxLength(trim($this->label), 255, 'Label cannot exceed 255 characters.');
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\ProductInco;
use App\Services\ArcadiaIncoService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\ProductIncos\AssociateProductIncoInput, \App\Models\ProductInco>
*/
final class AssociateProductInco implements Action
{
public function __construct(
private readonly ArcadiaIncoService $arcadiaIncoService,
) {
}
/**
* @param \App\Actions\ProductIncos\AssociateProductIncoInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): ProductInco
{
Assert::isInstanceOf($input, AssociateProductIncoInput::class);
$incoData = $this->arcadiaIncoService->fetchInco($input->no_art);
if ($incoData === null) {
throw new \RuntimeException(sprintf('INCO sheet #%d was not found on Arcadia API.', $input->no_art));
}
// * Remove existing product_inco for this product if present
$existing = ProductInco::query()->where('product_id', $input->product->id)->first();
if ($existing !== null) {
$existing->delete();
}
$inco = new ProductInco();
$inco->product_id = (string) $input->product->id;
$inco->no_art = $incoData->noArt;
$inco->ingredients = $incoData->ingredients;
$inco->preparation = $incoData->preparation;
$inco->servings = $incoData->servings;
$inco->allergens = $incoData->allergens;
$inco->selling_argument = $incoData->sellingArgument;
$inco->nutriscore = $incoData->nutriscore;
$inco->nutriscore_args = $incoData->nutriscoreArgs;
$inco->nutrition = $incoData->nutrition;
$inco->saveOrFail();
return $inco;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\ActionInput;
use App\Models\Product;
use Webmozart\Assert\Assert;
final readonly class AssociateProductIncoInput implements ActionInput
{
public function __construct(
public Product $product,
public int $no_art,
) {
Assert::greaterThanEq($this->no_art, 1, 'The INCO article number must be greater than or equal to 1.');
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\ProductInco;
use App\Services\ArcadiaIncoService;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\ProductIncos\SyncProductIncoInput, \App\Models\ProductInco>
*/
final class SyncProductInco implements Action
{
public function __construct(
private readonly ArcadiaIncoService $arcadiaIncoService,
) {
}
/**
* @param \App\Actions\ProductIncos\SyncProductIncoInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): ProductInco
{
Assert::isInstanceOf($input, SyncProductIncoInput::class);
$inco = $input->product->inco;
if ($inco === null) {
throw new \RuntimeException('Product does not have an associated INCO sheet to sync.');
}
$incoData = $this->arcadiaIncoService->fetchInco($inco->no_art);
if ($incoData === null) {
throw new \RuntimeException(sprintf('INCO sheet #%d was not found on Arcadia API.', $inco->no_art));
}
$inco->ingredients = $incoData->ingredients;
$inco->preparation = $incoData->preparation;
$inco->servings = $incoData->servings;
$inco->allergens = $incoData->allergens;
$inco->selling_argument = $incoData->sellingArgument;
$inco->nutriscore = $incoData->nutriscore;
$inco->nutriscore_args = $incoData->nutriscoreArgs;
$inco->nutrition = $incoData->nutrition;
$inco->saveOrFail();
return $inco;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\ActionInput;
use App\Models\Product;
final readonly class SyncProductIncoInput implements ActionInput
{
public function __construct(
public Product $product,
) {
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Actions\ProductIncos;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Contracts\ActionInputEmpty;
use App\Models\ProductInco;
use App\Services\ArcadiaIncoService;
use Psr\Log\LoggerInterface;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Contracts\ActionInputEmpty, int>
*/
final class SynchronizeProductIncosWithArcadiaApi implements Action
{
public function __construct(
private readonly ArcadiaIncoService $arcadiaIncoService,
private readonly LoggerInterface $logger,
) {
}
/**
* @param \App\Contracts\ActionInputEmpty $input
*/
#[\Override]
public function __invoke(ActionInput $input): int
{
Assert::isInstanceOf($input, ActionInputEmpty::class);
$this->logger->info('Starting synchronization of all ProductIncos with Arcadia API.');
$updatedCount = 0;
/** @var \Illuminate\Database\Eloquent\Collection<int, ProductInco> $incos */
$incos = ProductInco::query()->get();
foreach ($incos as $inco) {
$incoData = $this->arcadiaIncoService->fetchInco($inco->no_art);
if ($incoData === null) {
$this->logger->warning("Could not sync ProductInco #{$inco->id} (no_art: {$inco->no_art}): not found on Arcadia.");
continue;
}
$inco->ingredients = $incoData->ingredients;
$inco->preparation = $incoData->preparation;
$inco->servings = $incoData->servings;
$inco->allergens = $incoData->allergens;
$inco->selling_argument = $incoData->sellingArgument;
$inco->nutriscore = $incoData->nutriscore;
$inco->nutriscore_args = $incoData->nutriscoreArgs;
$inco->nutrition = $incoData->nutrition;
$inco->saveOrFail();
$updatedCount++;
}
$this->logger->info("Completed synchronization of ProductIncos: {$updatedCount} updated.");
return $updatedCount;
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Product;
use App\Models\Tag;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Products\CreateProductInput, \App\Models\Product>
*/
final class CreateProduct implements Action
{
/**
* @param \App\Actions\Products\CreateProductInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Product
{
Assert::isInstanceOf($input, CreateProductInput::class);
$product = new Product();
$product->name = $input->name;
$product->short_description = $input->shortDescription;
$product->description = $input->description;
$product->price = $input->price;
$product->invoicing_unit = $input->invoicingUnit;
$product->stock = $input->stock;
$product->range_id = $input->rangeId;
$product->sell_unit = $input->sellUnit;
$product->order_unit = $input->orderUnit;
$product->tax_rate = $input->taxRate;
$product->brand = $input->brand;
$product->weight = $input->weight;
$product->weight_variable = $input->weightVariable;
$product->orderable_start = $input->orderableStart !== null ? Date::instance($input->orderableStart) : null;
$product->orderable_end = $input->orderableEnd !== null ? Date::instance($input->orderableEnd) : null;
$product->star = $input->star;
$product->status = $input->status;
if ($input->picture !== null) {
$product->setAttribute('picture', $input->picture);
}
$product->picture_alt = (string) $input->pictureAlt;
$product->picture_title = (string) $input->pictureTitle;
if ($input->pictureLarge !== null) {
$product->setAttribute('picture_large', $input->pictureLarge);
}
$product->picture_large_alt = (string) $input->pictureLargeAlt;
$product->picture_large_title = (string) $input->pictureLargeTitle;
$product->saveOrFail();
$this->syncTags($input->tags, $product);
$this->syncAssociatedProducts($input->associatedProductsIds, $product);
return $product;
}
/**
* @param array<array{id?: int|string, label: string}> $tags
*/
private function syncTags(array $tags, Product $product): void
{
$tagIdList = [];
foreach ($tags as $tag) {
$tagIdList[] = empty($tag['id'])
? $this->storeTag($tag)
: (int) $tag['id'];
}
$product->tags()->sync($tagIdList);
}
/**
* @param array{id?: int|string, label: string} $tag
*/
private function storeTag(array $tag): int
{
$slug = Str::slug($tag['label']);
$tagModel = Tag::query()
->where('slug', $slug)
->firstOrNew();
$tagModel->setAttribute('label', $tag['label']);
$tagModel->saveOrFail();
return (int) $tagModel->id;
}
/**
* @param list<string> $productIdList
*/
private function syncAssociatedProducts(array $productIdList, Product $product): void
{
$syncData = [];
foreach ($productIdList as $index => $id) {
$syncData[$id] = ['sort_order' => $index + 1];
}
$product->products()->sync($syncData);
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\ActionInput;
use Carbon\CarbonInterface;
use Domain\Enums\ProductBrand;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Money\Money;
use Webmozart\Assert\Assert;
final readonly class CreateProductInput implements ActionInput
{
/**
* @param array<array{id?: int|string, label: string}> $tags
* @param list<string> $associatedProductsIds
*/
public function __construct(
public string $name,
public string $shortDescription,
public string $description,
public Money $price,
public InvoicingUnit $invoicingUnit,
public int $stock,
public int $rangeId,
public SellUnit $sellUnit,
public OrderUnit $orderUnit,
public TaxRate $taxRate,
public ProductBrand $brand,
public ?int $weight = null,
public bool $weightVariable = false,
public ?CarbonInterface $orderableStart = null,
public ?CarbonInterface $orderableEnd = null,
public bool $star = false,
public bool $status = false,
public mixed $picture = null,
public ?string $pictureAlt = null,
public ?string $pictureTitle = null,
public mixed $pictureLarge = null,
public ?string $pictureLargeAlt = null,
public ?string $pictureLargeTitle = null,
public array $tags = [],
public array $associatedProductsIds = [],
) {
Assert::stringNotEmpty(trim($this->name));
Assert::maxLength($this->name, 255);
Assert::greaterThanEq($this->stock, 0);
Assert::greaterThanEq($this->rangeId, 1);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Products\DeleteProductInput, bool>
*/
final class DeleteProduct implements Action
{
/**
* @param \App\Actions\Products\DeleteProductInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteProductInput::class);
return (bool) $input->product->deleteOrFail();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\ActionInput;
use App\Models\Product;
final readonly class DeleteProductInput implements ActionInput
{
public function __construct(
public Product $product,
) {
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Product;
use App\Models\Tag;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Products\UpdateProductInput, \App\Models\Product>
*/
final class UpdateProduct implements Action
{
/**
* @param \App\Actions\Products\UpdateProductInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Product
{
Assert::isInstanceOf($input, UpdateProductInput::class);
$product = $input->product;
$product->name = $input->name;
$product->short_description = $input->shortDescription;
$product->description = $input->description;
$product->price = $input->price;
$product->invoicing_unit = $input->invoicingUnit;
$product->stock = $input->stock;
$product->range_id = $input->rangeId;
$product->sell_unit = $input->sellUnit;
$product->order_unit = $input->orderUnit;
$product->tax_rate = $input->taxRate;
$product->brand = $input->brand;
$product->weight = $input->weight;
$product->weight_variable = $input->weightVariable;
$product->orderable_start = $input->orderableStart !== null ? Date::instance($input->orderableStart) : null;
$product->orderable_end = $input->orderableEnd !== null ? Date::instance($input->orderableEnd) : null;
$product->star = $input->star;
$product->status = $input->status;
if ($input->picture !== null) {
$product->setAttribute('picture', $input->picture);
}
$product->picture_alt = (string) $input->pictureAlt;
$product->picture_title = (string) $input->pictureTitle;
if ($input->pictureLarge !== null) {
$product->setAttribute('picture_large', $input->pictureLarge);
}
$product->picture_large_alt = (string) $input->pictureLargeAlt;
$product->picture_large_title = (string) $input->pictureLargeTitle;
$product->saveOrFail();
$this->syncTags($input->tags, $product);
$this->syncAssociatedProducts($input->associatedProductsIds, $product);
return $product;
}
/**
* @param array<array{id?: int|string, label: string}> $tags
*/
private function syncTags(array $tags, Product $product): void
{
$tagIdList = [];
foreach ($tags as $tag) {
$tagIdList[] = empty($tag['id'])
? $this->storeTag($tag)
: (int) $tag['id'];
}
$product->tags()->sync($tagIdList);
}
/**
* @param array{id?: int|string, label: string} $tag
*/
private function storeTag(array $tag): int
{
$slug = Str::slug($tag['label']);
$tagModel = Tag::query()
->where('slug', $slug)
->firstOrNew();
$tagModel->setAttribute('label', $tag['label']);
$tagModel->saveOrFail();
return (int) $tagModel->id;
}
/**
* @param list<string> $productIdList
*/
private function syncAssociatedProducts(array $productIdList, Product $product): void
{
$syncData = [];
foreach ($productIdList as $index => $id) {
$syncData[$id] = ['sort_order' => $index + 1];
}
$product->products()->sync($syncData);
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Contracts\ActionInput;
use App\Models\Product;
use Carbon\CarbonInterface;
use Domain\Enums\ProductBrand;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Money\Money;
use Webmozart\Assert\Assert;
final readonly class UpdateProductInput implements ActionInput
{
/**
* @param array<array{id?: int|string, label: string}> $tags
* @param list<string> $associatedProductsIds
*/
public function __construct(
public Product $product,
public string $name,
public string $shortDescription,
public string $description,
public Money $price,
public InvoicingUnit $invoicingUnit,
public int $stock,
public int $rangeId,
public SellUnit $sellUnit,
public OrderUnit $orderUnit,
public TaxRate $taxRate,
public ProductBrand $brand,
public ?int $weight = null,
public bool $weightVariable = false,
public ?CarbonInterface $orderableStart = null,
public ?CarbonInterface $orderableEnd = null,
public bool $star = false,
public bool $status = false,
public mixed $picture = null,
public ?string $pictureAlt = null,
public ?string $pictureTitle = null,
public mixed $pictureLarge = null,
public ?string $pictureLargeAlt = null,
public ?string $pictureLargeTitle = null,
public array $tags = [],
public array $associatedProductsIds = [],
) {
Assert::stringNotEmpty(trim($this->name));
Assert::maxLength($this->name, 255);
Assert::greaterThanEq($this->stock, 0);
Assert::greaterThanEq($this->rangeId, 1);
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Range;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Ranges\CreateRangeInput, \App\Models\Range>
*/
final class CreateRange implements Action
{
/**
* @param \App\Actions\Ranges\CreateRangeInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Range
{
Assert::isInstanceOf($input, CreateRangeInput::class);
$attributes = [
'name' => $input->name,
'description' => $input->description,
'picture_alt' => $input->pictureMetadata->alt ?? '',
'picture_title' => $input->pictureMetadata->title ?? '',
'status' => $input->status,
];
if ($input->picture !== null) {
$attributes['picture'] = $input->picture;
}
$range = new Range();
$range->fill($attributes);
$range->saveOrFail();
return $range;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\PictureMetadata;
use Illuminate\Http\UploadedFile;
use Webmozart\Assert\Assert;
final readonly class CreateRangeInput implements ActionInput
{
public function __construct(
public string $name,
public ?string $description,
public UploadedFile | string | null $picture,
public PictureMetadata $pictureMetadata,
public bool $status,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'The range name cannot be empty.');
Assert::minLength($trimmedName, 2, 'The range name must be at least 2 characters.');
Assert::maxLength($trimmedName, 45, 'The range name cannot exceed 45 characters.');
if ($this->picture !== null) {
Assert::notNull($this->pictureMetadata->alt, 'Picture alt is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->alt), 'Picture alt cannot be empty when picture is provided.');
Assert::notNull($this->pictureMetadata->title, 'Picture title is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->title), 'Picture title cannot be empty when picture is provided.');
}
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Ranges\DeleteRangeInput, bool>
*/
final class DeleteRange implements Action
{
/**
* @param \App\Actions\Ranges\DeleteRangeInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteRangeInput::class);
return (bool) $input->range->deleteOrFail();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\ActionInput;
use App\Models\Range;
final readonly class DeleteRangeInput implements ActionInput
{
public function __construct(
public Range $range,
) {
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Range;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Ranges\UpdateRangeInput, \App\Models\Range>
*/
final class UpdateRange implements Action
{
/**
* @param \App\Actions\Ranges\UpdateRangeInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Range
{
Assert::isInstanceOf($input, UpdateRangeInput::class);
$attributes = [
'name' => $input->name,
'description' => $input->description,
'picture_alt' => $input->pictureMetadata->alt ?? '',
'picture_title' => $input->pictureMetadata->title ?? '',
'status' => $input->status,
];
if ($input->picture !== null) {
$attributes['picture'] = $input->picture;
}
$range = $input->range;
$range->fill($attributes);
$range->saveOrFail();
return $range;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions\Ranges;
use App\Contracts\ActionInput;
use App\Models\Range;
use Domain\Models\ValueObjects\PictureMetadata;
use Illuminate\Http\UploadedFile;
use Webmozart\Assert\Assert;
final readonly class UpdateRangeInput implements ActionInput
{
public function __construct(
public Range $range,
public string $name,
public ?string $description,
public UploadedFile | string | null $picture,
public PictureMetadata $pictureMetadata,
public bool $status,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'The range name cannot be empty.');
Assert::minLength($trimmedName, 2, 'The range name must be at least 2 characters.');
Assert::maxLength($trimmedName, 45, 'The range name cannot exceed 45 characters.');
if ($this->picture !== null) {
Assert::notNull($this->pictureMetadata->alt, 'Picture alt is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->alt), 'Picture alt cannot be empty when picture is provided.');
Assert::notNull($this->pictureMetadata->title, 'Picture title is required when picture is provided.');
Assert::stringNotEmpty(trim($this->pictureMetadata->title), 'Picture title cannot be empty when picture is provided.');
}
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Actions\Settings;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Setting;
use Domain\Models\ValueObjects\ShopperSiteCode;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Settings\UpdateSettingsInput, \App\Models\Setting>
*/
final class UpdateSettings implements Action
{
/**
* @param \App\Actions\Settings\UpdateSettingsInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Setting
{
Assert::isInstanceOf($input, UpdateSettingsInput::class);
$setting = Setting::getSingleton();
$setting->fill([
'employee_sites' => array_values(
array_map(
static fn (ShopperSiteCode $siteCode): string => $siteCode->value,
$input->employeeSites
)
),
]);
$setting->saveOrFail();
return $setting;
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Actions\Settings;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\ShopperSiteCode;
use Webmozart\Assert\Assert;
final readonly class UpdateSettingsInput implements ActionInput
{
/**
* @param array<int, \Domain\Models\ValueObjects\ShopperSiteCode> $employeeSites
*/
public function __construct(
public array $employeeSites = [],
) {
Assert::allIsInstanceOf($this->employeeSites, ShopperSiteCode::class);
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\CreateShopInput, \App\Models\Shop>
*/
final class CreateShop implements Action
{
/**
* @param \App\Actions\Shops\CreateShopInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Shop
{
Assert::isInstanceOf($input, CreateShopInput::class);
$attributes = [
'name' => $input->name,
'address' => $input->address,
'postcode' => $input->postcode,
'city' => $input->city,
'phone' => $input->phone?->value,
'latitude' => $input->coordinates !== null ? (string) $input->coordinates->latitude : null,
'longitude' => $input->coordinates !== null ? (string) $input->coordinates->longitude : null,
];
$shop = new Shop();
$shop->fill($attributes);
$shop->default = $input->default;
$shop->saveOrFail();
return $shop;
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use Domain\Models\ValueObjects\Coordinates;
use Domain\Models\ValueObjects\PhoneNumber;
use Webmozart\Assert\Assert;
final readonly class CreateShopInput implements ActionInput
{
public function __construct(
public string $name,
public string $address,
public string $postcode,
public string $city,
public ?PhoneNumber $phone,
public ?Coordinates $coordinates,
public bool $default,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'Shop name cannot be empty.');
Assert::maxLength($trimmedName, 255, 'Shop name cannot exceed 255 characters.');
$trimmedAddress = trim($this->address);
Assert::stringNotEmpty($trimmedAddress, 'Shop address cannot be empty.');
Assert::maxLength($trimmedAddress, 255, 'Shop address cannot exceed 255 characters.');
$trimmedPostcode = trim($this->postcode);
Assert::stringNotEmpty($trimmedPostcode, 'Shop postcode cannot be empty.');
Assert::maxLength($trimmedPostcode, 255, 'Shop postcode cannot exceed 255 characters.');
$trimmedCity = trim($this->city);
Assert::stringNotEmpty($trimmedCity, 'Shop city cannot be empty.');
Assert::maxLength($trimmedCity, 255, 'Shop city cannot exceed 255 characters.');
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\DeleteShopInput, bool>
*/
final class DeleteShop implements Action
{
/**
* @param \App\Actions\Shops\DeleteShopInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, DeleteShopInput::class);
return (bool) $input->shop->deleteOrFail();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use App\Models\Shop;
final readonly class DeleteShopInput implements ActionInput
{
public function __construct(
public Shop $shop,
) {
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\UpdateShopInput, \App\Models\Shop>
*/
final class UpdateShop implements Action
{
/**
* @param \App\Actions\Shops\UpdateShopInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): Shop
{
Assert::isInstanceOf($input, UpdateShopInput::class);
$attributes = [
'name' => $input->name,
'address' => $input->address,
'postcode' => $input->postcode,
'city' => $input->city,
'phone' => $input->phone?->value,
'latitude' => $input->coordinates !== null ? (string) $input->coordinates->latitude : null,
'longitude' => $input->coordinates !== null ? (string) $input->coordinates->longitude : null,
];
$shop = $input->shop;
$shop->fill($attributes);
$shop->default = $input->default;
$shop->saveOrFail();
return $shop;
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Domain\Models\ValueObjects\Coordinates;
use Domain\Models\ValueObjects\PhoneNumber;
use Webmozart\Assert\Assert;
final readonly class UpdateShopInput implements ActionInput
{
public function __construct(
public Shop $shop,
public string $name,
public string $address,
public string $postcode,
public string $city,
public ?PhoneNumber $phone,
public ?Coordinates $coordinates,
public bool $default,
) {
$trimmedName = trim($this->name);
Assert::stringNotEmpty($trimmedName, 'Shop name cannot be empty.');
Assert::maxLength($trimmedName, 255, 'Shop name cannot exceed 255 characters.');
$trimmedAddress = trim($this->address);
Assert::stringNotEmpty($trimmedAddress, 'Shop address cannot be empty.');
Assert::maxLength($trimmedAddress, 255, 'Shop address cannot exceed 255 characters.');
$trimmedPostcode = trim($this->postcode);
Assert::stringNotEmpty($trimmedPostcode, 'Shop postcode cannot be empty.');
Assert::maxLength($trimmedPostcode, 255, 'Shop postcode cannot exceed 255 characters.');
$trimmedCity = trim($this->city);
Assert::stringNotEmpty($trimmedCity, 'Shop city cannot be empty.');
Assert::maxLength($trimmedCity, 255, 'Shop city cannot exceed 255 characters.');
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\Action;
use App\Contracts\ActionInput;
use Webmozart\Assert\Assert;
/**
* @implements \App\Contracts\Action<\App\Actions\Shops\UpdateShopPickupExceptionsInput, bool>
*/
final class UpdateShopPickupExceptions implements Action
{
/**
* @param \App\Actions\Shops\UpdateShopPickupExceptionsInput $input
*/
#[\Override]
public function __invoke(ActionInput $input): bool
{
Assert::isInstanceOf($input, UpdateShopPickupExceptionsInput::class);
$input->shop->pickupExceptions()->sync($input->syncData);
return true;
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Actions\Shops;
use App\Contracts\ActionInput;
use App\Models\Shop;
use Webmozart\Assert\Assert;
final readonly class UpdateShopPickupExceptionsInput implements ActionInput
{
/**
* @param array<array-key, array{replacement_date: string|null, order_deadline_at: string|null}> $syncData
*/
public function __construct(
public Shop $shop,
public array $syncData,
) {
foreach ($this->syncData as $item) {
Assert::isArray($item, 'Each sync item must be an array.');
Assert::keyExists($item, 'replacement_date', 'Sync item must contain replacement_date key.');
Assert::keyExists($item, 'order_deadline_at', 'Sync item must contain order_deadline_at key.');
Assert::nullOrString($item['replacement_date'], 'replacement_date must be a string or null.');
Assert::nullOrString($item['order_deadline_at'], 'order_deadline_at must be a string or null.');
if ($item['order_deadline_at'] !== null) {
Assert::notNull(
$item['replacement_date'],
'A replacement date is required when an order deadline is set.'
);
}
if ($item['replacement_date'] !== null && $item['order_deadline_at'] !== null) {
$replacementDate = new \DateTimeImmutable($item['replacement_date']);
$orderDeadlineAt = new \DateTimeImmutable($item['order_deadline_at']);
Assert::true(
$orderDeadlineAt < $replacementDate,
'Order deadline must be strictly before the replacement date.'
);
}
}
}
}

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