48 Commits

Author SHA1 Message Date
Valentin Lab
f6eb686fcd fix: display « tarif appliqué » on checkout page
The ``basketTotal`` partial expects ``$sale_channel`` as a top-level
view variable.  The cart page passed it correctly, but the checkout
page and the AJAX basket refresh only passed it nested inside
``$basket``.
2026-02-09 10:00:39 +01:00
Valentin Lab
2771a09a90 chg: use rich address partial in checkout with add/delete support
Replaces the simple address radio list in the checkout page with the
richer ``Shop.Customers.partials.addresses`` partial already used on
the profile edit page.  Customers can now choose among existing
addresses, add a new one on-the-fly, or delete an address directly
from the checkout flow.
2026-02-09 09:51:39 +01:00
Valentin Lab
4f3ab05757 fix: use absolute URL for logo in all mail templates
The logo ``src`` was ``/storage/photos/shares/logo.png`` — a relative
path to a file that doesn't exist.  Email clients cannot resolve
relative URLs.  Replaces with the absolute URL to the actual logo at
``https://boutique.jardinenvie.com/img/logo.png`` across all 4 mail
templates.
2026-02-09 09:37:49 +01:00
Valentin Lab
bf8e948ff3 new: display dynamic password rules checklist on registration and profile edit
Each rule (length, lowercase, uppercase, number, special character)
shows a live check/cross icon as the user types.  Also aligns
``handlePasswordChange`` server-side validation with the boilerplate
``Password`` rule (was only enforcing min 8 chars).
2026-02-09 09:29:29 +01:00
Valentin Lab
3d4496b253 fix: make phone number mandatory on registration and profile edit 2026-02-09 09:16:37 +01:00
Valentin Lab
b763915211 fix: enable scrolling in mobile navigation menu
The mobile category menu (``#navbarContentMobile``) could not scroll
when its content exceeded the viewport height, because no overflow
or height constraint was set on the collapse container.

Add ``max-height: calc(100vh - 60px)``, ``overflow-y: auto`` and
``-webkit-overflow-scrolling: touch`` to allow touch-scrolling
through the full category list on mobile devices.
2026-02-09 09:12:07 +01:00
Valentin Lab
1bf920c123 fix: correct PDF invoice address separator and translate label
- Replace ``<br>`` with ``\n`` in ``InvoicePDF::makeAddress()`` so
  dompdf renders line breaks instead of showing raw HTML tags.
- Translate ``order number`` to ``Numéro de commande`` in the custom
  fields passed to the invoice builder.

The amount-in-words was already in English because the container
lacked French ICU data (``icu-data-full``); that was fixed at
runtime, not in code.
2026-02-09 08:53:38 +01:00
Valentin Lab
ed3909782b fix: implement password change for shop customers
The password change form on the profile page (``Mes coordonnées``)
was scaffolded but never wired to any backend logic. The fields
``current-password``, ``new-password`` and ``new-password_confirmation``
were silently ignored by ``Customers::storeFull()``.

- Add ``handlePasswordChange()`` in ``CustomerController`` that
  validates current password, confirmation match, and 8-char minimum
  before hashing and saving.
- Remove ``required`` attribute from password fields so the form can
  submit for profile-only updates without filling password fields.
- Strip password fields from request data before passing to
  ``storeFull()`` to avoid Eloquent mass-assignment noise.
2026-02-09 08:36:29 +01:00
Valentin Lab
4fbbe991d9 fix: order confirmation email shows wrong payment method and incomplete address
The email template had "Carte de crédit" hardcoded regardless of the
actual payment method. The address blocks were also missing the
``address2`` and ``name`` fields.

- Add ``mode_paiement``, ``livraison_nom``, ``facturation_nom``,
  ``livraison_adresse2``, ``facturation_adresse2`` to
  ``ConfirmationCommande`` Mailable
- Migration to replace hardcoded payment label with
  ``{{mode_paiement}}`` and add ``address2`` fields in DB template
- Migration to add ``name`` fields before each address block
2026-02-09 07:23:50 +01:00
Valentin Lab
41d3294f74 chg: show "en attente de règlement" for check/wire orders in customer order list
When an order has status 0 ("En attente") and payment type is check
or wire transfer, the customer-facing order list now displays
"En attente de règlement" instead of the generic "En attente".
2026-02-09 06:53:55 +01:00
Valentin Lab
9c1f3dfed2 new: show payment-specific confirmation for check/wire orders
Display a tailored confirmation message when the customer pays by
check or wire transfer, including a warning about the 30-day
cancellation policy. The payment type is passed as a query parameter
so the message survives page reloads.

- Add ``getOrderConfirmedByCheckContent()`` and
  ``getOrderConfirmedByWireContent()`` to ``Contents`` repository
- Flash ``payment_type`` through redirect query parameter
- Add migration inserting content rows (id 10, 11)
- Update confirmed view with green checkmark and warning icon
2026-02-09 06:47:18 +01:00
Valentin Lab
e774113110 fix: prevent error 500 on search with no results
``getArticlesToSell()`` returned ``false`` when no articles matched,
causing ``collect(false)`` to produce ``[false]``. The view then
iterated over that single-element array and tried to access array
offsets on a boolean value.
2026-02-09 05:00:27 +01:00
Valentin Lab
1f4177cdb3 fix: missing translation for article's deletion confirmation modal 2025-12-14 21:43:46 +01:00
Valentin Lab
66c035ef9a new: add `duplicate` button for articles 2025-12-14 21:25:42 +01:00
Valentin Lab
fefd6209ac new: add tooltip to the existing links towards "offre" and "tarif" 2025-12-13 22:26:40 +01:00
Valentin Lab
f5ec254c0e new: add a direct link toward article's admin edit form from the public article page 2025-12-13 22:18:42 +01:00
Valentin Lab
a43e82f3d9 new: remove the "previsualisation" side pane from the "offre" admin edit form 2025-12-13 22:07:16 +01:00
Valentin Lab
65460fd9f1 new: add a link to public article page from article and offres admin edit form 2025-12-13 22:04:56 +01:00
Valentin Lab
d9ae84310d fix: avoid sharing route to JSON data to offers
No paths leads there, and there are no reason to keep this route.
2025-12-13 22:02:46 +01:00
Valentin Lab
2fc091d754 chg: make the herited info pane closed by default in admin edit form for articles 2025-12-13 21:46:45 +01:00
Valentin Lab
7887e2d532 new: make all forms have a cancel/save button on the top also 2025-12-13 21:43:40 +01:00
Valentin Lab
f92e175731 fix: prevent error 500 upon displaying empty 'Rayons' 2025-12-13 21:43:40 +01:00
Valentin Lab
6bb910bb54 fix: make 'Rayons' title adaptable to screen width 2025-12-13 21:43:40 +01:00
Valentin Lab
1db3725fb2 new: make the menu visible on mobile 2025-12-13 21:43:40 +01:00
Valentin Lab
ebdf0c0d8e fix: repair tinymce implementation 2025-12-13 20:10:19 +01:00
Valentin Lab
22ebcb102f fix: prevent error message about missing css 2025-12-13 20:09:56 +01:00
Valentin Lab
cc3d4d3e32 chg: put article description after variety description on product public page 2025-12-13 18:21:37 +01:00
Valentin Lab
ef1964d472 fix: prevent error 500 on article pages 2025-11-03 11:35:16 +01:00
Valentin Lab
abb32e32b9 fix: add "Bientôt disponible" box on public product page without prices 2025-11-03 11:27:47 +01:00
Valentin Lab
8c29459489 new: make the debug info available to all backoffice users with helpful links 2025-11-03 11:23:58 +01:00
Valentin Lab
accb052f5c fix: repair price appearance on all articles 2025-11-03 11:23:24 +01:00
Valentin Lab
d5f095b5e5 fix: remove non-visible article from research results 2025-11-03 09:20:53 +01:00
Valentin Lab
fd628f3f95 fix: prevent error 500 on creation of new backoffice user 2025-11-03 09:09:36 +01:00
Valentin Lab
a10f0b35d9 fix: focus invalid field on error in article form 2025-10-15 14:49:41 +02:00
Valentin Lab
858421a9eb fix: prevent broken link upon thumbnail in variety list when having uploaded a PNG file 2025-10-15 14:48:51 +02:00
Valentin Lab
158bc4fd57 fix: provide correct temporary directory outside of `vendor/` 2025-10-15 14:08:16 +02:00
Valentin Lab
b7e3eefed6 new: allow to delete seuil lines in price-list's pice modal 2025-10-15 13:17:54 +02:00
Valentin Lab
67e4346c68 fix: pkg: do not create bogus `{cache,views,sessions}` directory in prod export 2025-10-15 12:46:01 +02:00
Valentin Lab
9ce62e82e5 fix: allow saving list-price's price seuil if seuil is unset or 0 2025-10-15 12:22:36 +02:00
Valentin Lab
7e93219774 fix: allow to re-use a deleted ref in articles 2025-10-15 12:05:16 +02:00
Valentin Lab
29f46b7287 fix: enable saving in price-list's price edit modal 2025-10-15 11:57:38 +02:00
Valentin Lab
1f02c932a0 fix: make varieties creation form avoid error 500 on save 2025-10-15 11:57:38 +02:00
Valentin Lab
7d8bd8c372 fix: make form submit apply modification on existing article 2025-10-15 11:57:38 +02:00
Valentin Lab
f4bd4ddf24 fix: prevent err 500 upon species edit form opening 2025-10-15 11:57:28 +02:00
Valentin Lab
1f7098d55b fix: repair link made by `asset() in blade template when working on http`
The forcing is useless, we are forcing links through many other
ways. I need to test aspects of deployments on my laptop to mimic
production deployment without this hassle.
2025-10-11 05:32:01 +02:00
Valentin Lab
1867e75177 new: doc: added `AGENTS.md` and small addition of a french paragraph 2025-10-10 08:20:13 +02:00
Valentin Lab
d502882052 fix: add delivery cost on load if delivery is selected 2025-10-05 12:38:19 +02:00
Valentin Lab
a5b2196b32 fix: make the selected channel apply changes to product each time 2025-10-05 12:33:08 +02:00
76 changed files with 1596 additions and 291 deletions

74
AGENTS.md Normal file
View File

@@ -0,0 +1,74 @@
# Repository Guidelines
## Project Structure & Module Organization
OpenSem builds on Laravel 9.
Core application code lives in `app/`, while HTTP routes reside in
`routes/` and Blade views in `resources/views/`. Reusable front-end
assets (JS, SCSS, images) sit under `resources/` and are compiled into
`public/` via Laravel Mix.
Database blueprints are versioned in `database/migrations/` with seeds
in `database/seeders/`.
Tests are organised in `tests/Unit/` and `tests/Feature/`; keep large
fixtures in `tests/Fixtures/` to avoid polluting source directories.
## Build, Test, and Development Commands
- `composer install` — install PHP dependencies defined in
`composer.json`.
- `php artisan serve` — start a local HTTP server on port 8000.
- `npm install && npm run dev` — install Node tooling and build UI
assets for development.
- `npm run prod` — generate minified production assets in `public/`.
- `php artisan migrate --seed` — apply database schema and load
default data for demo instances.
- `./build.sh` — builds a `.tar.xz` that contains the production and
deployement ready source to be deployed.
## Coding Style & Naming Conventions
Follow PSR-12 with four-space indentation and `snake_case` database
columns. Controllers, models, and Livewire components use StudlyCase
class names; private methods remain `camelCase`. Run `composer run
inspect` before opening a PR to execute `phpcs` and `phpstan`. For
front-end changes, keep Blade sections in lowercase kebab IDs (for
example, `@section('order-summary')`).
## Testing Guidelines
Use PHPUnit via `php artisan test`; target deterministic tests with
clear Arrange/Act/Assert blocks. Feature tests should mirror top-level
route names (e.g., `OrdersTest.php`). Unit tests belong in
`tests/Unit/` and should stub external services. When adding
migrations or service integrations, include coverage that exercises
failure paths. For granular checks, `./vendor/bin/phpunit --filter
FooTest` is acceptable, but always run the full suite before pushing.
## Commit & Pull Request Guidelines
Commits in this repo mix Conventional Commit prefixes (`new:`, `fix:`,
`chg:`); `fix: prevent null totals`. Keep messages in the imperative
mood and reference ticket IDs when available.
Pull requests must describe scope, list schema or configuration
changes, and note any manual follow-up (cron, storage links,
queues).
Attach screenshots or terminal logs when touching UI or console
output, and ensure CI scripts (when available) pass.
## Environment & Security Notes
Copy `.env.example` to `.env` and run `php artisan key:generate`
before local work. Never commit `.env`, `storage/`, or database dumps
containing sensitive data. Use the Docker resources in `docker/` only
for reproducible environments; keep secrets in your host overrides,
not in version control.

View File

@@ -43,6 +43,7 @@ COPY . /app
WORKDIR /app
RUN mkdir -p /app/bootstrap/cache \
/app/storage/media-library/temp \
/app/storage/framework/cache \
/app/storage/framework/views \
/app/storage/framework/sessions \
@@ -56,6 +57,7 @@ RUN chmod +x artisan
RUN ./artisan vendor:publish --tag=public --force ## creates public/vendor/jsvalidation
RUN ./artisan vendor:publish --tag=boilerplate-public --force --ansi ## creates public/vendor/boilerplate
RUN ./artisan vendor:publish --tag=datatables-buttons --force --ansi ## creates public/vendor/datatables/buttons
RUN ./artisan vendor:publish --tag=lfm_public --force --ansi
## XXXvlab: 2025-09-25 these migration files are breaking first
## install, but we had to resolve to not install from scratch and use
@@ -84,8 +86,6 @@ RUN apk add --no-cache xz
# bring PHP app with vendor
COPY --from=phpdeps /app /app
# ensure required runtime dirs exist (empty is fine)
RUN mkdir -p storage/framework/{cache,views,sessions} bootstrap/cache
# create artifact (use tar + xz so we don't depend on GNU tar -J)
RUN mkdir -p /out \
&& tar -C /app -cf /out/app.tar \

View File

@@ -1,10 +1,19 @@
## A propos de OpenSem
OpenSem est une solution de commerce électronique et un ERP développé pour les besoins exprimés.
OpenSem est une solution de commerce électronique et un ERP développé
pour les besoins exprimés.
Développée par Ludovic Candellier en étroite relation avec
Jardin'Envie.
L'application est écrite en PHP et est basée sur Laravel.
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
Laravel is a web application framework with expressive, elegant
syntax. We believe development must be an enjoyable and creative
experience to be truly fulfilling. Laravel takes the pain out of
development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
@@ -14,12 +23,21 @@ Laravel is a web application framework with expressive, elegant syntax. We belie
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
Laravel is accessible, powerful, and provides tools required for
large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
Laravel has the most extensive and thorough
[documentation](https://laravel.com/docs) and video tutorial library
of all modern web application frameworks, making it a breeze to get
started with the framework.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 1500 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
If you don't feel like reading, [Laracasts](https://laracasts.com) can
help. Laracasts contains over 1500 video tutorials on a range of
topics including Laravel, modern PHP, unit testing, and
JavaScript. Boost your skills by digging into our comprehensive video
library.
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
The Laravel framework is open-sourced software licensed under the [MIT
license](https://opensource.org/licenses/MIT).

View File

@@ -47,6 +47,10 @@ class CustomerOrdersDataTable extends DataTable
{
$datatables
->editColumn('status', function (Order $order) {
if ($order->status == 0 && in_array($order->payment_type, [2, 3])) {
return 'En attente de règlement';
}
return Orders::getStatus($order->status);
})
->editColumn('created_at', function (Order $order) {

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Admin\Botanic;
use App\Datatables\Botanic\SpeciesDataTable;
use App\Repositories\Botanic\Genres;
use App\Repositories\Botanic\Species;
use Illuminate\Http\Request;
@@ -21,7 +20,7 @@ class SpecieController extends Controller
public function create()
{
$data = Genres::init();
$data = Species::init();
return view('Admin.Botanic.Species.create', $data);
}
@@ -36,7 +35,7 @@ class SpecieController extends Controller
public function edit($id)
{
$data = Genres::init();
$data = Species::init();
$data['specie'] = Species::getFull($id);
return view('Admin.Botanic.Species.edit', $data);

View File

@@ -63,6 +63,17 @@ class ArticleController extends Controller
return view('Admin.Shop.Articles.edit', $data);
}
public function duplicate($id)
{
$data = Articles::getFull($id);
// Prepare for creation: blank id/slug, tweak name to indicate copy
$data['article']['id'] = null;
$data['article']['slug'] = null;
$data['article']['name'] = ($data['article']['name'] ?? '').' (copie)';
return view('Admin.Shop.Articles.create', $data);
}
public function destroy($id)
{
return Articles::destroy($id);

View File

@@ -71,7 +71,10 @@ class PriceListValueController extends Controller
public function addPrice($index)
{
$data['index'] = $index;
$data = [
'index' => $index,
'taxes' => Taxes::getOptions(),
];
return view('Admin.Shop.PriceListValues.partials.row_price', $data);
}

View File

@@ -68,8 +68,10 @@ class BasketController extends Controller
public function getBasketTotal($deliveryId = false, $deliveryTypeId = false)
{
$basket = Baskets::getBasketTotal($deliveryId, $deliveryTypeId);
$data = [
'basket' => Baskets::getBasketTotal($deliveryId, $deliveryTypeId),
'basket' => $basket,
'sale_channel' => $basket['sale_channel'] ?? null,
];
return view('Shop.Baskets.partials.basketTotal', $data);

View File

@@ -8,6 +8,8 @@ use App\Repositories\Shop\CustomerAddresses;
use App\Repositories\Shop\Customers;
use App\Repositories\Shop\Offers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
class CustomerController extends Controller
@@ -81,14 +83,33 @@ class CustomerController extends Controller
}
}
$customerId = $data['id'] ?? Customers::getId();
$requestedDefaultSaleChannelId = $data['default_sale_channel_id'] ?? null;
$hasDefaultSaleChannelColumn = Schema::hasColumn('shop_customers', 'default_sale_channel_id');
if (! $hasDefaultSaleChannelColumn) {
unset($data['default_sale_channel_id']);
}
$customer = Customers::store($data);
if ($customer) {
Customers::guard()->setUser($customer->fresh(['sale_channels']));
if (array_key_exists('default_sale_channel_id', $data)) {
session(['shop.default_sale_channel_id' => $data['default_sale_channel_id']]);
Baskets::refreshPrices((int) $data['default_sale_channel_id']);
}
if (! $customer) {
return response()->json([
'error' => 1,
'message' => __('Impossible de mettre à jour votre profil pour le moment.'),
], 422);
}
if ($hasDefaultSaleChannelColumn && $requestedDefaultSaleChannelId !== null) {
Customers::setDefaultSaleChannel($customerId, $requestedDefaultSaleChannelId);
}
$freshCustomer = Customers::get($customerId, ['sale_channels']);
Customers::guard()->setUser($freshCustomer);
if ($requestedDefaultSaleChannelId !== null) {
session(['shop.default_sale_channel_id' => $requestedDefaultSaleChannelId]);
Baskets::refreshPrices((int) $requestedDefaultSaleChannelId);
}
return response()->json(['error' => 0]);
@@ -97,9 +118,82 @@ class CustomerController extends Controller
public function store(Request $request)
{
$data = $request->all();
$validator = Validator::make($data, [
'phone' => 'required|max:30',
], [
'phone.required' => __('Le numéro de téléphone est obligatoire.'),
]);
if ($validator->fails()) {
return redirect()->route('Shop.Customers.edit')
->withInput()
->withErrors($validator->errors(), 'registration');
}
$passwordError = $this->handlePasswordChange($request);
if ($passwordError) {
return redirect()->route('Shop.Customers.edit')
->with('growl', [$passwordError, 'danger']);
}
unset($data['current-password'], $data['new-password'], $data['new-password_confirmation']);
$customer = Customers::storeFull($data);
return redirect()->route('Shop.Customers.edit');
$growl = $request->filled('new-password')
? [__('Profil et mot de passe mis à jour.'), 'success']
: [__('Profil mis à jour.'), 'success'];
return redirect()->route('Shop.Customers.edit')->with('growl', $growl);
}
protected function handlePasswordChange(Request $request)
{
if (! $request->filled('new-password')) {
return null;
}
$customer = Customers::get(Customers::getId());
if (! $customer) {
return __('Impossible de modifier le mot de passe.');
}
if (! Hash::check($request->input('current-password'), $customer->password)) {
return __('Le mot de passe actuel est incorrect.');
}
if ($request->input('new-password') !== $request->input('new-password_confirmation')) {
return __('Les mots de passe ne correspondent pas.');
}
$newPassword = $request->input('new-password');
if (strlen($newPassword) < 8) {
return __('Le mot de passe doit contenir au moins 8 caractères.');
}
if (! preg_match('/[a-z]/', $newPassword)) {
return __('Le mot de passe doit contenir au moins une lettre minuscule.');
}
if (! preg_match('/[A-Z]/', $newPassword)) {
return __('Le mot de passe doit contenir au moins une lettre majuscule.');
}
if (! preg_match('/[0-9]/', $newPassword)) {
return __('Le mot de passe doit contenir au moins un chiffre.');
}
if (! preg_match('/[^A-Za-z0-9]/', $newPassword)) {
return __('Le mot de passe doit contenir au moins un caractère spécial.');
}
$customer->password = Hash::make($request->input('new-password'));
$customer->save();
return null;
}
public function storeAddress(Request $request)
@@ -151,6 +245,7 @@ class CustomerController extends Controller
$html = view('Shop.Customers.partials.address_item', [
'address' => $address->toArray(),
'prefix' => $prefix,
'inputName' => $request->input('input_name'),
'with_name' => true,
'selected' => $address->id,
])->render();

View File

@@ -9,6 +9,7 @@ use App\Repositories\Core\User\ShopCart;
use App\Repositories\Shop\Baskets;
use App\Repositories\Shop\Contents;
use App\Repositories\Shop\Customers;
use App\Repositories\Shop\CustomerAddresses;
use App\Repositories\Shop\Deliveries;
use App\Repositories\Shop\DeliveryTypes;
use App\Repositories\Shop\OrderMails;
@@ -57,8 +58,21 @@ class OrderController extends Controller
$deliveries = $deliveries ? $deliveries->values() : collect();
$customerData = $customer ? $customer->toArray() : false;
if ($customerData && $defaultSaleChannelId) {
$customerData['default_sale_channel_id'] = $defaultSaleChannelId;
if ($customerData) {
$customerData['delivery_address_id'] = optional(CustomerAddresses::getDeliveryAddress($customerId))->id;
$customerData['invoice_address_id'] = optional(CustomerAddresses::getInvoiceAddress($customerId))->id;
if (! $customerData['delivery_address_id'] && ! empty($customerData['delivery_addresses'])) {
$customerData['delivery_address_id'] = $customerData['delivery_addresses'][0]['id'] ?? null;
}
if (! $customerData['invoice_address_id'] && ! empty($customerData['invoice_addresses'])) {
$customerData['invoice_address_id'] = $customerData['invoice_addresses'][0]['id'] ?? null;
}
if ($defaultSaleChannelId) {
$customerData['default_sale_channel_id'] = $defaultSaleChannelId;
}
}
$data = [
@@ -88,7 +102,9 @@ class OrderController extends Controller
}
OrderMails::sendOrderConfirmed($order->id);
return redirect()->route('Shop.Orders.confirmed');
return redirect()->route('Shop.Orders.confirmed', [
'payment_type' => $data['payment_type'],
]);
}
return view('Shop.Orders.order');
@@ -97,9 +113,18 @@ class OrderController extends Controller
public function confirmed()
{
ShopCart::clear();
$paymentType = request('payment_type');
$content = Contents::getOrderConfirmedContent();
$paymentLabel = match ($paymentType) {
'2' => 'chèque',
'3' => 'virement',
default => null,
};
return view('Shop.Orders.confirmed', ['content' => $content]);
return view('Shop.Orders.confirmed', [
'content' => $content,
'payment_label' => $paymentLabel,
]);
}
public function getPdf($uuid)

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin\Shop;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreArticlePost extends FormRequest
{
@@ -13,8 +14,13 @@ class StoreArticlePost extends FormRequest
public function rules()
{
$articleId = $this->input('id');
return [
'ref' => 'required|unique:shop_articles',
'ref' => [
'required',
Rule::unique('shop_articles', 'ref')->ignore($articleId)->whereNull('deleted_at'),
],
'product_type' => 'required',
'product_id' => 'required',
'article_nature_id' => 'required',

View File

@@ -20,6 +20,7 @@ class RegisterCustomer extends FormRequest
'last_name' => 'required|max:255',
'first_name' => 'required|max:255',
'email' => 'required|email|max:255|unique:shop_customers,email,NULL,id,deleted_at,NULL',
'phone' => 'required|max:30',
'password' => ['required', 'confirmed', new Password()],
];
}

View File

@@ -3,6 +3,7 @@
namespace App\Mail;
use App\Models\Core\Mail\MailTemplate;
use App\Repositories\Shop\Orders;
use App\Repositories\Shop\Traits\MailCustomers;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
@@ -32,29 +33,44 @@ class ConfirmationCommande extends TemplateMailable
public $facturation_ville;
public $livraison_nom;
public $livraison_adresse;
public $livraison_adresse2;
public $livraison_cp;
public $livraison_ville;
public $facturation_nom;
public $facturation_adresse2;
public $mode_paiement;
protected static $templateModelClass = MailTemplate::class;
public function __construct($order)
{
$facturation_address = $order->invoice->address;
$facturation_address = $order->invoice->address;
$delivery_address = $order->delivery_address;
$this->prenom = $order->customer->first_name;
$this->nom = $order->customer->last_name;
$this->facturation_nom = $facturation_address->name;
$this->facturation_adresse = $facturation_address->address;
$this->facturation_adresse2 = $facturation_address->address2;
$this->facturation_cp = $facturation_address->zipcode;
$this->facturation_ville = $facturation_address->city;
$this->livraison_nom = $delivery_address->name;
$this->livraison_adresse = $delivery_address->address;
$this->livraison_adresse2 = $delivery_address->address2;
$this->livraison_cp = $delivery_address->zipcode;
$this->livraison_ville = $delivery_address->city;
$this->societe = $order->customer->company;
$this->email = $order->customer->email;
$this->numero_commande = $order->ref;
$this->date_commande = $order->created_at;
$this->mode_paiement = Orders::getPaymentType($order->payment_type);
}
}

View File

@@ -11,9 +11,6 @@ class AppServiceProvider extends ServiceProvider
{
public function boot()
{
if (config('app.env') === 'production') {
\URL::forceScheme('https');
}
Schema::defaultStringLength(191);
View::composer('Shop.layout.layout', LayoutComposer::class);
}

View File

@@ -65,6 +65,9 @@ class Varieties
{
$images = $data['images'] ?? false;
$tags = $data['tags'] ?? false;
if (! array_key_exists('plus', $data) || $data['plus'] === null) {
$data['plus'] = '';
}
unset($data['images']);
unset($data['tags']);
$variety = self::store($data);

View File

@@ -2,6 +2,8 @@
namespace App\Repositories\Core;
use Spatie\MediaLibrary\MediaCollections\Models\Media as MediaModel;
class Medias
{
public static function getImage($model, $conversion = 'normal', $collection = 'images')
@@ -79,13 +81,9 @@ class Medias
public static function getImageSrc($image)
{
if (! $image) {
return null;
}
$id = $image['id'];
$filename = self::getFilename($image);
$media = self::resolveMedia($image);
return "/storage/{$id}/{$filename}";
return $media ? $media->getUrl() : null;
}
public static function getThumbSrc($image)
@@ -110,6 +108,12 @@ class Medias
public static function getSrcByType($image, $type)
{
$media = self::resolveMedia($image);
if ($media) {
return $type ? $media->getUrl($type) : $media->getUrl();
}
return $image ? '/storage/'.$image['id'].'/conversions/'.self::getFilename($image, $type) : false;
}
@@ -124,4 +128,48 @@ class Medias
{
return str_replace(['#', '/', '\\', ' '], '-', $name);
}
protected static function resolveMedia($image): ?MediaModel
{
if ($image instanceof MediaModel) {
return $image;
}
if (is_null($image)) {
return null;
}
if (is_array($image)) {
return self::hydrateMedia($image);
}
if (is_object($image)) {
if ($image instanceof \ArrayAccess) {
return self::hydrateMedia((array) $image);
}
$array = method_exists($image, 'toArray') ? $image->toArray() : (array) $image;
return self::hydrateMedia($array);
}
$id = data_get($image, 'id');
return $id ? MediaModel::query()->withoutGlobalScopes()->find($id) : null;
}
protected static function hydrateMedia(array $attributes): ?MediaModel
{
$id = data_get($attributes, 'id');
if (! $id) {
return null;
}
$media = new MediaModel();
$media->forceFill($attributes);
$media->exists = true;
return $media;
}
}

View File

@@ -31,7 +31,11 @@ class ArticleImages
public static function getFullImagesByArticle($article)
{
$images = count($article->images) ? $article->images : collect([]);
if (! $article) {
return collect([]);
}
$images = count($article->images ?? []) ? $article->images : collect([]);
switch ($article->product_type) {
case 'App\Models\Botanic\Variety':
$variety = $article->product ?? false;

View File

@@ -27,21 +27,34 @@ class ArticleTags
switch ($article->product_type) {
case 'App\Models\Botanic\Variety':
$data += $article->product->tags->toArray();
if ($article->product->specie ?? false) {
$data += $article->product->specie->tags->toArray();
$variety = $article->product;
if ($variety && $variety->tags) {
$data = array_merge($data, $variety->tags->toArray());
}
if ($variety && $variety->specie && $variety->specie->tags) {
$data = array_merge($data, $variety->specie->tags->toArray());
}
break;
case 'App\Models\Botanic\Specie':
$data += $article->product->tags->toArray();
$specie = $article->product;
if ($specie && $specie->tags) {
$data = array_merge($data, $specie->tags->toArray());
}
break;
case 'App\Models\Shop\Merchandise':
$data += $article->product->tags->toArray();
$data += $article->product->producer->tags->toArray();
$merchandise = $article->product;
if ($merchandise && $merchandise->tags) {
$data = array_merge($data, $merchandise->tags->toArray());
}
if ($merchandise && $merchandise->producer && $merchandise->producer->tags) {
$data = array_merge($data, $merchandise->producer->tags->toArray());
}
break;
default:
}
$data += $article->tags->toArray();
if ($article->tags) {
$data = array_merge($data, $article->tags->toArray());
}
foreach ($data as $tag) {
if (! isset($tags[$tag['group']][$tag['name']])) {

View File

@@ -6,6 +6,8 @@ use App\Models\Shop\Article;
use App\Repositories\Botanic\Species;
use App\Repositories\Botanic\Varieties;
use App\Repositories\Shop\SaleChannels;
use App\Repositories\Shop\Customers;
use Illuminate\Support\Facades\Schema;
use App\Repositories\Core\Comments;
use App\Traits\Model\Basic;
use App\Traits\Repository\Imageable;
@@ -17,7 +19,7 @@ class Articles
public static function autocomplete($str)
{
$data = Article::byAutocomplete($str)->orderBy('name')->limit(20)->pluck('name', 'id');
$data = Article::byAutocomplete($str)->visible()->orderBy('name')->limit(20)->pluck('name', 'id');
$export = [];
foreach ($data as $key => $name) {
$export[] = ['value' => $key, 'text' => $name];
@@ -72,12 +74,31 @@ class Articles
public static function getArticleToSell($id, $saleChannelId = false)
{
$saleChannelId = $saleChannelId ?: SaleChannels::getDefaultID();
$sessionSaleChannelId = session('shop.default_sale_channel_id');
$customer = Customers::getAuth();
$hasDefaultSaleChannelColumn = Schema::hasColumn('shop_customers', 'default_sale_channel_id');
$customerDefaultSaleChannelId = ($customer && $hasDefaultSaleChannelColumn)
? $customer->default_sale_channel_id
: null;
$customerSaleChannelIds = [];
if ($customer) {
$customer->loadMissing('sale_channels:id');
$customerSaleChannelIds = $customer->sale_channels->pluck('id')->toArray();
}
$data = self::getArticle($id);
$data['offers'] = self::getOffersGroupedByNature($id, $saleChannelId);
$currentSaleChannel = $saleChannelId ? SaleChannels::get($saleChannelId) : null;
$data['current_sale_channel'] = $currentSaleChannel ? $currentSaleChannel->toArray() : null;
$data['available_sale_channels'] = Offers::getSaleChannelsForArticle($id);
$data['debug_sale_channel'] = [
'session_default_sale_channel_id' => $sessionSaleChannelId,
'customer_default_sale_channel_id' => $customerDefaultSaleChannelId,
'customer_linked_sale_channel_ids' => $customerSaleChannelIds,
'resolved_sale_channel_id' => $saleChannelId,
'has_default_sale_channel_column' => $hasDefaultSaleChannelColumn,
];
return $data;
}
@@ -114,8 +135,11 @@ class Articles
$data['specie'] = $article->product ? $article->product->description : '';
break;
case 'App\Models\Shop\Merchandise':
$data['merchandise'] = $article->product ? $article->product->description : '';
$data['producer'] = $article->product->producer->description;
$merchandise = $article->product;
$data['merchandise'] = $merchandise ? ($merchandise->description ?? '') : '';
if ($merchandise && $merchandise->producer) {
$data['producer'] = $merchandise->producer->description ?? '';
}
break;
default:
}
@@ -154,10 +178,18 @@ class Articles
$articles = self::getArticlesWithOffers($options);
$searchOrder = $options['ids'] ?? false ? array_flip($options['ids']->toArray()) : false;
foreach ($articles as $article) {
// Skip articles without an offer/tariff/price list for the resolved sale channel
if (!isset($article->offers[0]) || ! $article->offers[0]->tariff) {
continue;
}
$price_lists = $article->offers[0]->tariff->price_lists->toArray();
if (! count($price_lists)) {
continue;
}
if (empty($price_lists[0]['price_list_values'][0] ?? null)) {
continue;
}
if (! is_array($data[$article->name] ?? false)) {
$data[$article->name] = self::getDataForSale($article);
if ($searchOrder) {
@@ -172,7 +204,7 @@ class Articles
ksort($data);
}
return $data ?? false;
return $data ?? [];
}
public static function getDataForSale($article)

View File

@@ -41,6 +41,16 @@ class Contents
return self::get(5)->text ?? 'Votre commande a été confirmée';
}
public static function getOrderConfirmedByCheckContent()
{
return self::get(10)->text ?? 'Votre commande a bien été enregistrée, elle vous sera expédiée dès réception de votre chèque.';
}
public static function getOrderConfirmedByWireContent()
{
return self::get(11)->text ?? 'Votre commande a bien été enregistrée, elle vous sera expédiée dès réception de votre virement.';
}
public static function getPayboxConfirmedContent()
{
return self::get(6)->text ?? 'Merci pour votre règlement. Votre commande sera traitée sous peu.';

View File

@@ -179,6 +179,24 @@ class Customers
return $customer->sale_channels()->sync($saleChannels);
}
public static function setDefaultSaleChannel($customerId, $saleChannelId)
{
if (! $customerId) {
return false;
}
$customer = self::get($customerId);
if (! $customer) {
return false;
}
$customer->default_sale_channel_id = $saleChannelId ?: null;
$customer->save();
return $customer->fresh(['sale_channels']);
}
public static function create($data)
{
$data['uuid'] = Str::uuid();

View File

@@ -19,7 +19,7 @@ class InvoicePDF
$invoice = Invoices::getFull($id);
$customFields = [];
if ($orderRef = optional($invoice->order)->ref) {
$customFields['order number'] = $orderRef;
$customFields['Numéro de commande'] = $orderRef;
}
$customer = new Party([
@@ -61,7 +61,7 @@ class InvoicePDF
trim(($address->zipcode ?? '').' '.($address->city ?? '')),
]);
return implode('<br>', $lines);
return implode("\n", $lines);
}
public static function makeItems($details)

View File

@@ -3,6 +3,7 @@
namespace App\Repositories\Shop;
use App\Models\Shop\Offer;
use App\Models\Shop\PriceList;
use App\Models\Shop\PriceListValue;
use App\Models\Shop\SaleChannel;
use App\Traits\Model\Basic;
@@ -182,27 +183,72 @@ class Offers
$offers = Offer::query()
->byArticle($articleId)
->with('article')
->with([
'article',
'tariff:id,status_id',
'variation',
])
->get();
return $channels->map(function ($channel) use ($offers) {
$priceValue = null;
$candidateOffer = null;
$allOffersForChannel = [];
foreach ($offers as $offer) {
$priceCandidate = self::getPrice($offer->id, 1, $channel->id);
if ($priceCandidate && (float) $priceCandidate->price_taxed > 0) {
$priceValue = $priceCandidate;
break;
// Get price list name
$priceListName = null;
if ($priceCandidate) {
$priceListModel = PriceList::find($priceCandidate->price_list_id);
$priceListName = $priceListModel ? $priceListModel->name : null;
}
// Collect all offers with their details
$allOffersForChannel[] = [
'id' => $offer->id,
'variation_name' => $offer->variation ? $offer->variation->name : null,
'stock_current' => (int) $offer->stock_current,
'status_id' => (int) $offer->status_id,
'is_active' => (int) $offer->status_id === 1,
'tariff_id' => $offer->tariff_id ? (int) $offer->tariff_id : null,
'price_taxed' => (float) $priceCandidate->price_taxed,
'quantity' => (int) $priceCandidate->quantity,
'price_list_name' => $priceListName,
];
// Keep first valid offer as the main candidate
if (!$candidateOffer) {
$priceValue = $priceCandidate;
$candidateOffer = $offer;
}
}
}
$offerId = $candidateOffer ? $candidateOffer->id : null;
$offerStock = $candidateOffer ? (int) $candidateOffer->stock_current : null;
$offerIsActive = $candidateOffer ? (int) $candidateOffer->status_id === 1 : false;
$offerTariffStatus = $candidateOffer && $candidateOffer->tariff ? (int) $candidateOffer->tariff->status_id : null;
$offerHasStock = $candidateOffer && $candidateOffer->stock_current !== null
? (float) $candidateOffer->stock_current > 0
: null;
$offerTariffId = $candidateOffer && $candidateOffer->tariff_id ? (int) $candidateOffer->tariff_id : null;
return [
'id' => $channel->id,
'name' => $channel->name,
'code' => $channel->code,
'price_taxed' => $priceValue ? (float) $priceValue->price_taxed : null,
'quantity' => $priceValue ? (int) $priceValue->quantity : null,
'offer_id' => $offerId,
'offer_is_active' => $offerIsActive,
'offer_stock_current' => $offerStock,
'offer_has_stock' => $offerHasStock,
'tariff_status_id' => $offerTariffStatus,
'tariff_id' => $offerTariffId,
'all_offers' => $allOffersForChannel,
];
})->toArray();
}

View File

@@ -46,12 +46,34 @@ class PriceListValues
{
foreach ($values as $value) {
$value['price_list_id'] = $price_list_id;
if ($value['price']) {
if (self::hasPrice($value)) {
self::store($value);
}
}
}
public static function purgeRemovedValues($price_list_id, array $ids)
{
if (! count($ids)) {
return;
}
PriceListValue::byPriceList($price_list_id)
->whereIn('id', $ids)
->delete();
}
protected static function hasPrice($value): bool
{
if (! array_key_exists('price', $value)) {
return false;
}
$price = $value['price'];
return $price !== null && $price !== '';
}
public static function getModel()
{
return PriceListValue::query();

View File

@@ -17,7 +17,7 @@ class PriceLists
'taxes' => Taxes::getOptions(),
'price_list' => [
'tariff_id' => $tariffId,
'price_list_values' => array_fill(0, 3, ''),
'price_list_values' => [],
],
];
}
@@ -50,9 +50,8 @@ class PriceLists
public static function edit($id)
{
$price_list = self::getFull($id)->toArray();
$n = count($price_list['price_list_values']);
if ($n <= 3) {
$price_list['price_list_values'] += array_fill($n, 3 - $n, '');
if (count($price_list['price_list_values']) === 0) {
$price_list['price_list_values'][] = [];
}
return $price_list;
@@ -71,9 +70,14 @@ class PriceLists
public static function store($data)
{
$id = $data['id'] ?? false;
$price_list_values = $data['price_list_values'] ?? false;
$price_list_values = $data['price_list_values'] ?? [];
$deleted_values = array_map('intval', array_filter($data['deleted_price_list_value_ids'] ?? [], function ($value) {
return $value !== null && $value !== '';
}));
unset($data['price_list_values']);
unset($data['deleted_price_list_value_ids']);
$price_list = $id ? self::update($data) : self::create($data);
PriceListValues::purgeRemovedValues($price_list->id, $deleted_values);
PriceListValues::storePrices($price_list->id, $price_list_values);
return $price_list;

View File

@@ -49,7 +49,7 @@ class SaleChannels
}
}
return self::getByCode('EXP');
return self::getByCode('POSTE');
}
public static function getByCode($code)

View File

@@ -8,8 +8,14 @@ class Searches
{
public static function search($options)
{
// Get article IDs from Scout search
$searchResults = Article::search($options['search_name'])->get()->pluck('id');
// Filter to only include visible articles
$visibleArticleIds = Article::whereIn('id', $searchResults)->visible()->pluck('id');
return collect(Articles::getArticlesToSell([
'ids' => Article::search($options['search_name'])->get()->pluck('id'),
'ids' => $visibleArticleIds,
]))->sortBy('searchOrder')->toArray();
}

View File

@@ -1,23 +1,35 @@
<?php
return [
// --- The default avatar size
'size' => 80,
// Default configuration group for Gravatar
'default' => [
// --- The default avatar size
'size' => 80,
// --- The default avatar to display if we have no results
// (bool) false
// (string) 404
// (string) mm: (mystery-man) a simple, cartoon-style silhouetted outline of a person (does not vary by email hash).
// (string) identicon: a geometric pattern based on an email hash.
// (string) monsterid: a generated 'monster' with different colors, faces, etc.
// (string) wavatar: generated faces with differing features and backgrounds.
// (string) retro: awesome generated, 8-bit arcade-style pixelated faces.
'default' => 'identicon',
// --- The default avatar to display if we have no results
// (bool) false
// (string) 404
// (string) mm: (mystery-man) a simple, cartoon-style silhouetted outline of a person (does not vary by email hash).
// (string) identicon: a geometric pattern based on an email hash.
// (string) monsterid: a generated 'monster' with different colors, faces, etc.
// (string) wavatar: generated faces with differing features and backgrounds.
// (string) retro: awesome generated, 8-bit arcade-style pixelated faces.
'fallback' => 'identicon',
// --- Set the type of avatars we allow to show
// - g: suitable for display on all websites with any audience type.
// - pg: may contain rude gestures, provocatively dressed individuals, the lesser swear words, or mild violence.
// - r: may contain such things as harsh profanity, intense violence, nudity, or hard drug use.
// - x: may contain hardcore sexual imagery or extremely disturbing violence.
'maxRating' => 'g',
// --- Whether to use HTTPS protocol
'secure' => false,
// --- Set the type of avatars we allow to show
// - g: suitable for display on all websites with any audience type.
// - pg: may contain rude gestures, provocatively dressed individuals, the lesser swear words, or mild violence.
// - r: may contain such things as harsh profanity, intense violence, nudity, or hard drug use.
// - x: may contain hardcore sexual imagery or extremely disturbing violence.
'maximumRating' => 'g',
// --- Force default image to always display
'forceDefault' => false,
// --- Optional file extension appended to URL
'forceExtension' => 'jpg',
]
];

View File

@@ -126,7 +126,7 @@ return [
* The path where to store temporary files while performing image conversions.
* If set to null, storage_path('media-library/temp') will be used.
*/
'temporary_directory_path' => null,
'temporary_directory_path' => env('MEDIA_LIBRARY_TEMP_PATH', storage_path('media-library/temp')),
/*
* The engine that should perform the image conversions.

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('shop_articles', function (Blueprint $table) {
$table->dropUnique('ref');
$table->unique(['ref', 'deleted_at'], 'shop_articles_ref_deleted_at_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shop_articles', function (Blueprint $table) {
$table->dropUnique('shop_articles_ref_deleted_at_unique');
$table->unique('ref');
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('shop_contents')->insert([
[
'id' => 10,
'text' => '<p>Votre commande a bien été enregistrée, elle vous sera expédiée dès réception de votre chèque.</p><p class="mt-3 text-warning"><i class="fa fa-exclamation-triangle mr-1"></i> Sans réception de votre paiement au bout de 30 jours, votre commande sera annulée.</p>',
'created_at' => now(),
'updated_at' => now(),
],
[
'id' => 11,
'text' => '<p>Votre commande a bien été enregistrée, elle vous sera expédiée dès réception de votre virement.</p><p class="mt-3 text-warning"><i class="fa fa-exclamation-triangle mr-1"></i> Sans réception de votre paiement au bout de 30 jours, votre commande sera annulée.</p>',
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('shop_contents')->whereIn('id', [10, 11])->delete();
}
};

View File

@@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$this->transformTemplate(function ($html) {
// Replace hardcoded "Carte de crédit" with the template variable
$html = str_replace(
'Carte de cr&eacute;dit',
'{{mode_paiement}}',
$html
);
// Add address2 to delivery address
$html = str_replace(
'{{livraison_adresse}}<br />{{livraison_cp}} {{livraison_ville}}',
'{{livraison_adresse}}{{#livraison_adresse2}}<br />{{livraison_adresse2}}{{/livraison_adresse2}}<br />{{livraison_cp}} {{livraison_ville}}',
$html
);
// Add address2 to billing address
$html = str_replace(
'{{facturation_adresse}}<br />{{facturation_cp}} {{facturation_ville}}',
'{{facturation_adresse}}{{#facturation_adresse2}}<br />{{facturation_adresse2}}{{/facturation_adresse2}}<br />{{facturation_cp}} {{facturation_ville}}',
$html
);
return $html;
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$this->transformTemplate(function ($html) {
$html = str_replace(
'{{mode_paiement}}',
'Carte de cr&eacute;dit',
$html
);
$html = str_replace(
'{{livraison_adresse}}{{#livraison_adresse2}}<br />{{livraison_adresse2}}{{/livraison_adresse2}}<br />{{livraison_cp}} {{livraison_ville}}',
'{{livraison_adresse}}<br />{{livraison_cp}} {{livraison_ville}}',
$html
);
$html = str_replace(
'{{facturation_adresse}}{{#facturation_adresse2}}<br />{{facturation_adresse2}}{{/facturation_adresse2}}<br />{{facturation_cp}} {{facturation_ville}}',
'{{facturation_adresse}}<br />{{facturation_cp}} {{facturation_ville}}',
$html
);
return $html;
});
}
private function transformTemplate(callable $transform): void
{
$template = DB::table('mail_templates')
->where('mailable', 'App\\Mail\\ConfirmationCommande')
->first();
if (! $template) {
return;
}
$translations = json_decode($template->html_template, true);
foreach ($translations as $lang => $html) {
$translations[$lang] = $transform($html);
}
DB::table('mail_templates')
->where('id', $template->id)
->update(['html_template' => json_encode($translations, JSON_UNESCAPED_UNICODE)]);
}
};

View File

@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$this->transformTemplate(function ($html) {
// Add name before delivery address
$html = str_replace(
'{{livraison_adresse}}',
'{{#livraison_nom}}{{livraison_nom}}<br />{{/livraison_nom}}{{livraison_adresse}}',
$html
);
// Add name before billing address
$html = str_replace(
'{{facturation_adresse}}',
'{{#facturation_nom}}{{facturation_nom}}<br />{{/facturation_nom}}{{facturation_adresse}}',
$html
);
return $html;
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$this->transformTemplate(function ($html) {
$html = str_replace(
'{{#livraison_nom}}{{livraison_nom}}<br />{{/livraison_nom}}{{livraison_adresse}}',
'{{livraison_adresse}}',
$html
);
$html = str_replace(
'{{#facturation_nom}}{{facturation_nom}}<br />{{/facturation_nom}}{{facturation_adresse}}',
'{{facturation_adresse}}',
$html
);
return $html;
});
}
private function transformTemplate(callable $transform): void
{
$template = DB::table('mail_templates')
->where('mailable', 'App\\Mail\\ConfirmationCommande')
->first();
if (! $template) {
return;
}
$translations = json_decode($template->html_template, true);
foreach ($translations as $lang => $html) {
$translations[$lang] = $transform($html);
}
DB::table('mail_templates')
->where('id', $template->id)
->update(['html_template' => json_encode($translations, JSON_UNESCAPED_UNICODE)]);
}
};

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private $oldSrc = '/storage/photos/shares/logo.png';
private $newSrc = 'https://boutique.jardinenvie.com/img/logo.png';
/**
* Run the migrations.
*/
public function up(): void
{
$this->replaceLogoInAllTemplates($this->oldSrc, $this->newSrc);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$this->replaceLogoInAllTemplates($this->newSrc, $this->oldSrc);
}
private function replaceLogoInAllTemplates(string $from, string $to): void
{
$templates = DB::table('mail_templates')->get();
foreach ($templates as $template) {
$translations = json_decode($template->html_template, true);
if (! $translations) {
continue;
}
$changed = false;
foreach ($translations as $lang => $html) {
$updated = str_replace($from, $to, $html);
if ($updated !== $html) {
$translations[$lang] = $updated;
$changed = true;
}
}
if ($changed) {
DB::table('mail_templates')
->where('id', $template->id)
->update(['html_template' => json_encode($translations, JSON_UNESCAPED_UNICODE)]);
}
}
}
};

9
resources/lang/en/fg.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
return [
'confirmdelete' => 'Do you confirm the deletion?',
'deletesuccess' => 'Deleted successfully.',
'mail_the_selection' => 'Mail the selection',
'mail_the_complete_list' => 'Mail the complete list',
];

9
resources/lang/fr/fg.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
return [
'confirmdelete' => 'Confirmez-vous la suppression ?',
'deletesuccess' => 'La suppression a été effectuée.',
'mail_the_selection' => 'Envoyer la sélection',
'mail_the_complete_list' => 'Envoyer toute la liste',
];

View File

@@ -112,3 +112,33 @@ body {
.bg-darker {
background-color: rgba(0,0,0,0.05)!important;
}
/* Header action buttons aligned with page title */
.content-header .form-buttons {
margin-left: 12px;
}
.content-header .form-buttons .btn {
height: 32px;
display: inline-flex;
align-items: center;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1.1;
}
@media (max-width: 575.98px) {
.content-header .form-buttons {
margin-left: 0;
margin-top: 8px;
}
.content-header .form-buttons .btn {
height: 28px;
padding-top: 2px;
padding-bottom: 2px;
padding-left: 8px;
padding-right: 8px;
font-size: 0.75rem;
}
}

View File

@@ -311,6 +311,52 @@ div.megamenu ul.megamenu li.megamenu.level1
}
.category-title {
font-size: 2em;
}
.category-description {
font-size: 1.2em;
}
.breadcrumb-title {
font-size: 1.6em;
}
.breadcrumb-current {
font-size: 1em;
}
@media (max-width: 767.98px){
.category-title {
font-size: 1.5em;
}
.category-description {
font-size: 1.05em;
}
.breadcrumb-title {
font-size: 1.4em;
}
.breadcrumb-current {
font-size: 0.95em;
}
}
@media (max-width: 575.98px){
.category-title {
font-size: 1.35em;
}
.category-description {
font-size: 0.95em;
}
.breadcrumb-title {
font-size: 1.2em;
}
.breadcrumb-current {
font-size: 0.9em;
}
}
@font-face {
font-family: 'noto_sanscondensed';
src: url('/fonts/notosans-condensed/notosans-condensed-webfont.eot');
@@ -348,4 +394,45 @@ div.megamenu ul.megamenu li.megamenu.level1
.dropdown-menu > li:hover > .submenu{
display: block;
}
}
}
@media (max-width: 991.98px){
#navbarContentMobile {
max-height: calc(100vh - 60px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
#navbarContentMobile .navbar-nav {
flex-direction: column;
}
#navbarContentMobile .navbar-nav .col {
flex: 0 0 100%;
max-width: 100%;
}
#navbarContentMobile .dropdown-menu {
display: block;
position: static;
float: none;
box-shadow: none;
background: transparent;
}
#navbarContentMobile .dropdown-toggle::after {
display: none;
}
#navbarContentMobile .dropdown-menu .container,
#navbarContentMobile .dropdown-menu .row {
margin: 0;
}
#navbarContentMobile .dropdown-menu .shadow {
box-shadow: none !important;
}
.category-card .card-body {
font-size: 0.75rem;
}
/* Supprimer les grandes marges du container en affichage mobile/tablette */
.container {
max-width: 100%;
}
}

View File

@@ -32,12 +32,14 @@ $(document).on('click', '.dropdown-menu', function (e) {
// make it as accordion for smaller screens
if ($(window).width() < 992) {
$('.dropdown-menu a').click(function(e) {
e.preventDefault();
if ($(this).next('.submenu').length) {
$(this).next('.submenu').toggle();
var $submenu = $(this).next('.submenu');
if ($submenu.length) {
e.preventDefault();
$submenu.toggle();
}
$('.dropdown').on('hide.bs.dropdown', function () {
$(this).find('.submenu').hide();
});
});
$('.dropdown').on('hide.bs.dropdown', function () {
$(this).find('.submenu').hide();
});
}

View File

@@ -5,5 +5,7 @@
])
@section('content')
@include('Admin.Shop.Articles.form')
@include('Admin.Shop.Articles.form', [
'cancel_url' => route('Admin.Shop.Articles.index'),
])
@endsection

View File

@@ -5,5 +5,13 @@
])
@section('content')
@include('Admin.Shop.Articles.form')
@php
$duplicateUrl = \Route::has('Admin.Shop.Articles.duplicate')
? route('Admin.Shop.Articles.duplicate', $article['id'] ?? null)
: null;
@endphp
@include('Admin.Shop.Articles.form', [
'duplicate_url' => $duplicateUrl,
'cancel_url' => route('Admin.Shop.Articles.index'),
])
@endsection

View File

@@ -5,10 +5,29 @@
'files' => true,
]) }}
<input type="hidden" name="id" id="id" value="{{ $article['id'] ?? null }}">
@php
$articlePublicUrl = null;
if (!empty($article['slug'] ?? null)) {
$articlePublicUrl = route('Shop.Articles.slug', ['slug' => $article['slug']]);
} elseif (!empty($article['id'] ?? null)) {
$articlePublicUrl = route('Shop.Articles.show', ['id' => $article['id']]);
}
@endphp
@if ($articlePublicUrl)
<div class="d-flex justify-content-end mb-3">
<a href="{{ $articlePublicUrl }}" class="btn btn-outline-primary" target="_blank" rel="noopener">
Voir la page publique
<i class="fa fa-external-link"></i>
</a>
</div>
@endif
@include('Admin.Shop.Articles.partials.characteristics')
{{ Form::close() }}
<x-save />
<x-save :cancel-url="$cancel_url ?? null" :duplicate-url="$duplicate_url ?? null" />
@include('load.form.appender')
@include('load.form.editor')

View File

@@ -2,7 +2,7 @@
@component('components.layout.box-collapse', [
'id' => 'product_description_box',
'title' => 'Informations héritées',
'collapsed' => $collapsed ?? false,
'collapsed' => $collapsed ?? true,
])
@foreach ($article['inherited'] as $inherited)
@component('components.card', ['title' => $inherited['name'], 'class' => 'mb-3'])

View File

@@ -1,8 +1,17 @@
{{ Form::open(['route' => 'Admin.Shop.Offers.store', 'id' => 'offer-form', 'autocomplete' => 'off']) }}
<input type="hidden" name="id" value="{{ $offer['id'] ?? false }}">
@if (($offer['id'] ?? false) && ($offer['article_id'] ?? false))
<div class="d-flex justify-content-end mb-3">
<a href="{{ route('Shop.Articles.show', ['id' => $offer['article_id']]) }}" class="btn btn-outline-primary" target="_blank" rel="noopener">
Voir la page publique de l'article
<i class="fa fa-external-link"></i>
</a>
</div>
@endif
<div class="row mb-3">
<div class="col-8">
<div class="col-12">
<div class="row mb-3">
<div class="col-12">
@include('components.form.select', [
@@ -96,13 +105,6 @@
</div>
@endcomponent
</div>
<div class="col-4">
@component('components.card', ['title' => 'Previsualisation'])
<div id="preview-article"></div>
<div id="preview-variation"></div>
<div id="preview-tariff"></div>
@endcomponent
</div>
</div>
@@ -117,59 +119,8 @@
{!! JsValidator::formRequest('App\Http\Requests\Admin\Shop\StoreOfferPost', '#offer-form') !!}
<script>
function handleArticle() {
$('.select_article').change(function() {
previewArticle($(this).val());
})
}
function previewArticle(id) {
var url = '{{ route('Admin.Shop.Offers.previewArticle') }}/' + id;
$('#preview-article').load(url, function() {
initChevron();
});
}
function handleVariation() {
$('.select_variation').change(function() {
previewVariation($(this).val());
})
}
function previewVariation(id) {
var url = '{{ route('Admin.Shop.Offers.previewVariation') }}/' + id;
$('#preview-variation').load(url, function() {
initChevron();
});
}
function handleTariff() {
$('.select_tariffs').change(function() {
previewTariff($(this).val());
})
}
function previewTariff(id) {
var url = '{{ route('Admin.Shop.Offers.previewTariff') }}/' + id;
$('#preview-tariff').load(url, function() {
initChevron();
});
}
function initPreview() {
previewArticle("{{ $offer['article_id'] ?? null }}");
previewVariation("{{ $offer['variation_id'] ?? null }}");
previewTariff("{{ $offer['tariff_id'] ?? null }}");
}
handleArticle();
handleVariation();
handleTariff();
initChevron();
initSaveForm('#offer-form');
initSelect2();
@if ($offer['id'] ?? false)
initPreview();
@endif
</script>
@endpush

View File

@@ -15,4 +15,9 @@
<td>
@include('components.form.inputs.money', ['name' => 'price_list_values[' . $index . '][price_taxed]', 'value' => $price_list_value['price_taxed'] ?? null, 'required' => true, 'class' => 'price_taxed'])
</td>
</tr>
<td class="text-center align-middle">
<button type="button" class="btn btn-outline-danger btn-xs remove-price" title="{{ __('Supprimer') }}">
<i class="fa fa-trash"></i>
</button>
</td>
</tr>

View File

@@ -30,12 +30,18 @@
<th>Unit. HT</th>
<th>TVA</th>
<th>Unit. TTC</th>
<th class="text-center" style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach ($price_list['price_list_values'] as $price_list_value)
@include('Admin.Shop.PriceListValues.partials.row_price', ['index' => $loop->index])
@php($priceListValues = $price_list['price_list_values'] ?? [])
@php($nextIndex = count($priceListValues))
@foreach ($priceListValues as $index => $price_list_value)
@include('Admin.Shop.PriceListValues.partials.row_price', ['index' => $index, 'price_list_value' => $price_list_value])
@endforeach
@include('Admin.Shop.PriceListValues.partials.row_price', ['index' => $nextIndex, 'price_list_value' => []])
</tbody>
<tfoot>
<tr>
@@ -49,21 +55,30 @@
</tfoot>
</table>
@endcomponent
<div id="deleted-price-list-values"></div>
</form>
<script>
var priceRowIndex = 0;
var lastRemovedIndex = null;
function handleAddPrice() {
$('#add_price').click( function () {
var index = $('#prices-table tbody tr').length;
$.get("{{ route('Admin.Shop.PriceListValues.addPrice') }}/" + index, function(data) {
$("#prices-table").append(data);
})
handlePrices();
addEmptyPriceRow();
})
}
function addEmptyPriceRow() {
var index = nextPriceRowIndex();
$.get("{{ route('Admin.Shop.PriceListValues.addPrice') }}/" + index, function(data) {
$("#prices-table tbody").append(data);
handlePrices();
handleRemovePrice();
lastRemovedIndex = null;
});
}
function handle_prices() {
$('.price').change(function() {
$col_tax = $(this).parent().parent().find('.tax');
@@ -101,9 +116,82 @@
handle_prices_taxed();
}
function handleRemovePrice() {
$('#prices-table').off('click', '.remove-price').on('click', '.remove-price', function() {
var $row = $(this).closest('tr');
var idx = extractRowIndex($row);
var id = $row.find('input[name$="[id]"]').val();
if (id) {
registerDeletedPrice(id);
}
$row.remove();
lastRemovedIndex = idx;
ensureAtLeastOneRow();
});
}
function registerDeletedPrice(id) {
var $container = $('#deleted-price-list-values');
if ($container.find('input[value="' + id + '"]').length === 0) {
$container.append('<input type="hidden" name="deleted_price_list_value_ids[]" value="' + id + '">');
}
}
function ensureAtLeastOneRow() {
if ($('#prices-table tbody tr').length === 0) {
if (lastRemovedIndex !== null) {
$.get("{{ route('Admin.Shop.PriceListValues.addPrice') }}/" + lastRemovedIndex, function(data) {
$("#prices-table tbody").append(data);
handlePrices();
handleRemovePrice();
});
} else {
addEmptyPriceRow();
}
}
}
function computeStartingIndex() {
var maxIndex = -1;
$('#prices-table tbody tr').each(function() {
var $idInput = $(this).find('input[name$="[id]"]');
var name = $idInput.attr('name');
if (name) {
var matches = name.match(/price_list_values\[(\d+)\]\[id\]/);
if (matches && matches[1]) {
var idx = parseInt(matches[1], 10);
if (!isNaN(idx) && idx > maxIndex) {
maxIndex = idx;
}
}
}
});
return maxIndex + 1;
}
function nextPriceRowIndex() {
return priceRowIndex++;
}
function extractRowIndex($row) {
var $idInput = $row.find('input[name$="[id]"]');
var name = $idInput.attr('name');
if (!name) {
return priceRowIndex;
}
var matches = name.match(/price_list_values\[(\d+)\]\[id\]/);
return matches && matches[1] ? parseInt(matches[1], 10) : priceRowIndex;
}
$(function() {
priceRowIndex = computeStartingIndex();
handleAddPrice();
handlePrices();
handleRemovePrice();
ensureAtLeastOneRow();
});
</script>

View File

@@ -1,37 +1,59 @@
@if ($article['offers']['semences'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['semences'],
'title' => 'Semences',
'model' => 'semences',
'bgClass' => 'bg-green-light',
])
@endif
@php
// Check if article is not visible OR has no offers at all
$hasNoOffers = empty($article['offers']['semences'] ?? false)
&& empty($article['offers']['plants'] ?? false)
&& empty($article['offers']['legumes'] ?? false)
&& empty($article['offers']['marchandise'] ?? false);
$shouldShowComingSoon = !($article['visible'] ?? true) || $hasNoOffers;
@endphp
@if ($article['offers']['plants'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['plants'],
'title' => 'Plants',
'model' => 'plants',
'bgClass' => 'bg-green-light',
])
@endif
@if ($shouldShowComingSoon)
{{-- Display "Coming Soon" box when article is not visible or has no offers --}}
<div class="card border-info">
<div class="card-body text-center p-4">
<h4 class="text-info mb-3">
<i class="fas fa-clock"></i>
</h4>
<h5 class="card-title mb-0">Bientôt disponible</h5>
</div>
</div>
@else
{{-- Display normal offers for visible articles with available offers --}}
@if ($article['offers']['semences'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['semences'],
'title' => 'Semences',
'model' => 'semences',
'bgClass' => 'bg-green-light',
])
@endif
@if ($article['offers']['legumes'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['legumes'],
'title' => 'Légumes',
'model' => 'legumes',
'bgClass' => 'bg-green-light',
])
@endif
@if ($article['offers']['plants'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['plants'],
'title' => 'Plants',
'model' => 'plants',
'bgClass' => 'bg-green-light',
])
@endif
@if ($article['offers']['marchandise'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['marchandise'],
'title' => 'Marchandises',
'model' => 'marchandise',
'bgClass' => 'bg-green-light',
])
@if ($article['offers']['legumes'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['legumes'],
'title' => 'Légumes',
'model' => 'legumes',
'bgClass' => 'bg-green-light',
])
@endif
@if ($article['offers']['marchandise'] ?? false)
@include('Shop.Articles.partials.addBasket', [
'data' => $article['offers']['marchandise'],
'title' => 'Marchandises',
'model' => 'marchandise',
'bgClass' => 'bg-green-light',
])
@endif
@endif
@include('load.basket')

View File

@@ -18,9 +18,9 @@
</div>
</div>
<div class="col-lg-5 col-xs-12 text-justify">
{!! $article['description']['variety'] ?? null !!}
{!! $article['description']['semences'] ?? null !!}
{!! $article['description']['plants'] ?? null !!}
{!! $article['description']['variety'] ?? null !!}
{!! $article['description']['merchandise'] ?? null !!}
@if ($article['description']['plus'] ?? false)
@@ -48,35 +48,107 @@
</div>
<div class="col-lg-3 col-xs-12">
@if (config('app.debug') && ($article['current_sale_channel'] ?? false))
<div class="alert alert-info p-2 mb-3">
<strong>Canal actif :</strong>
{{ $article['current_sale_channel']['name'] ?? 'N/A' }}
<span class="d-block small text-muted">
ID {{ $article['current_sale_channel']['id'] ?? '' }} · Code {{ $article['current_sale_channel']['code'] ?? '' }}
</span>
@if (!empty($article['available_sale_channels']))
<hr class="my-2">
<strong class="d-block">Offres disponibles dans :</strong>
<ul class="list-unstyled mb-0 small">
@foreach ($article['available_sale_channels'] as $channel)
<li class="d-flex justify-content-between align-items-start">
<span>
@if (auth('web')->check() && !empty($article['available_sale_channels']))
<div id="article-admin-offers" class="alert alert-info p-2 mb-3">
<div class="d-flex justify-content-between align-items-center">
<strong class="d-block mb-0">Offres :</strong>
<a href="{{ route('Admin.Shop.Articles.edit', $article['id']) }}" class="text-dark d-inline-flex align-items-center gap-1" style="font-size: 0.95rem;" title="Ouvrir la fiche article en admin" target="_blank" rel="noopener">
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
<span class="sr-only">Éditer l'article</span>
</a>
</div>
<ul class="list-unstyled mb-0 small">
@php
$currentSaleChannelId = $article['current_sale_channel']['id'] ?? null;
@endphp
@foreach ($article['available_sale_channels'] as $channel)
@php
$isCurrentChannel = $currentSaleChannelId === $channel['id'];
$priceTaxed = $channel['price_taxed'] ?? null;
$quantity = $channel['quantity'] ?? null;
$offerStock = $channel['offer_stock_current'] ?? null;
$offerIsActive = $channel['offer_is_active'] ?? false;
$offerHasStock = $channel['offer_has_stock'] ?? null;
$highlightStyle = $isCurrentChannel ? 'background-color: rgba(0, 0, 0, 0.06);' : '';
$nameClass = ($offerIsActive && $offerHasStock !== false) ? '' : 'text-muted';
$flags = [];
if (! $offerIsActive) {
$flags[] = 'inactive';
}
if ($offerHasStock === false) {
$flags[] = 'no-stock';
}
@endphp
<li style="{{ $highlightStyle }}">
<div class="d-flex justify-content-between align-items-start">
<span class="{{ $nameClass }}">
• {{ $channel['name'] }}
<span class="d-block text-muted" style="font-size: 0.85em; padding-left: 0.9em;">code {{ $channel['code'] }}</span>
</span>
@if (isset($channel['price_taxed']))
<span class="ml-2 text-nowrap text-right">
{{ number_format($channel['price_taxed'], 2, ',', ' ') }} TTC
@if (! empty($channel['quantity']))
<span class="d-block text-muted" style="font-size: 0.85em;">Qté min. {{ $channel['quantity'] }}</span>
@endif
<span class="d-block text-muted" style="font-size: 0.85em; padding-left: 0.9em;">
Code {{ $channel['code'] }}{!! $flags ? ' · <strong class="text-dark">'.implode('</strong> · <strong class="text-dark">', $flags).'</strong>' : '' !!}
</span>
</span>
@if ($priceTaxed !== null)
@php
$tariffId = $channel['tariff_id'] ?? null;
@endphp
@if ($tariffId)
<a href="{{ route('Admin.Shop.Tariffs.edit', $tariffId) }}" target="_blank" rel="noopener" title="Ouvrir le tarif" class="ml-2 text-nowrap text-right {{ $nameClass }} text-decoration-none text-reset d-inline-block admin-link-group admin-price-link">
{{ number_format($priceTaxed, 2, ',', ' ') }} € TTC
@if (! empty($quantity))
<span class="d-block text-muted" style="font-size: 0.85em;">Qté min. {{ $quantity }}</span>
@endif
</a>
@else
<span class="ml-2 text-nowrap text-right {{ $nameClass }}">
{{ number_format($priceTaxed, 2, ',', ' ') }} € TTC
@if (! empty($quantity))
<span class="d-block text-muted" style="font-size: 0.85em;">Qté min. {{ $quantity }}</span>
@endif
</span>
@endif
@else
<span class="ml-2 text-muted"></span>
@endif
</li>
@endforeach
</ul>
@endif
</div>
@if (!empty($channel['all_offers']))
<ul class="list-unstyled mb-0 mt-1" style="padding-left: 0.75em;">
@foreach ($channel['all_offers'] as $offer)
@php
$isSelectedOffer = $offer['id'] === $channel['offer_id'];
$offerClass = $offer['is_active'] ? 'text-dark' : 'text-muted';
$stockClass = $offer['stock_current'] > 0 ? 'text-success' : 'text-danger';
@endphp
<li class="small {{ $offerClass }}" style="font-size: 0.85em;">
<a href="{{ route('Admin.Shop.Offers.edit', $offer['id']) }}" target="_blank" rel="noopener" title="Ouvrir l'offre" class="text-decoration-none {{ $offerClass }} admin-link-group admin-offer-link">
<div class="d-flex justify-content-between align-items-start">
<div>
<span style="opacity: 0.5;">{{ $isSelectedOffer ? '▸' : '○' }}</span>
@if ($offer['variation_name'])
{{ $offer['variation_name'] }}
@endif
- Stock: <strong class="{{ $stockClass }}">{{ $offer['stock_current'] }}</strong>
@if (!$offer['is_active'])
<span class="text-muted">(inactive)</span>
@endif
</div>
<div class="text-right text-nowrap ml-2">
{{ number_format($offer['price_taxed'], 2, ',', ' ') }} €
@if ($offer['quantity'] > 1)
<span class="text-muted">(min {{ $offer['quantity'] }})</span>
@endif
</div>
</div>
</a>
</li>
@endforeach
</ul>
@endif
</li>
@endforeach
</ul>
</div>
@endif
@include('Shop.Articles.partials.ArticleAddBasket')
@@ -85,3 +157,67 @@
@endsection
@include('load.layout.modal')
@if (auth('web')->check() && !empty($article['available_sale_channels']))
@push('styles')
<style>
#article-admin-offers .admin-link-group {
transition: background-color 0.15s ease;
border-radius: 3px;
}
#article-admin-offers .admin-price-link {
display: inline-block;
padding: 2px 4px;
margin: -2px -4px;
}
#article-admin-offers .admin-offer-link {
display: block;
padding: 2px 4px;
margin: -2px -4px;
}
#article-admin-offers .admin-link-group:hover,
#article-admin-offers .admin-link-group:focus,
#article-admin-offers .admin-link-group.linked-hover {
background-color: rgba(0, 123, 255, 0.1);
text-decoration: none;
}
</style>
@endpush
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('article-admin-offers');
if (!container) {
return;
}
const links = Array.from(container.querySelectorAll('a.admin-link-group[href]'));
const grouped = new Map();
links.forEach((link) => {
const href = link.getAttribute('href');
if (!grouped.has(href)) {
grouped.set(href, []);
}
grouped.get(href).push(link);
});
grouped.forEach((group) => {
group.forEach((link) => {
const addHighlight = () => group.forEach((item) => item.classList.add('linked-hover'));
const removeHighlight = () => group.forEach((item) => item.classList.remove('linked-hover'));
link.addEventListener('mouseenter', addHighlight);
link.addEventListener('mouseleave', removeHighlight);
link.addEventListener('focus', addHighlight);
link.addEventListener('blur', removeHighlight);
});
});
});
</script>
@endpush
@endif

View File

@@ -1,7 +1,7 @@
<div class="row mt-3 address-row" data-address-id="{{ $address['id'] }}">
<div class="col-1">
@php
$inputName = isset($prefix) && $prefix ? $prefix.'[address_id]' : 'address_id';
$inputName = $inputName ?? (isset($prefix) && $prefix ? $prefix.'[address_id]' : 'address_id');
$currentValue = $selected ?? null;
@endphp
<x-form.radios.icheck name="{{ $inputName }}" val="{{ $address['id'] }}"

View File

@@ -3,6 +3,7 @@
@include('Shop.Customers.partials.address_item', [
'address' => $address,
'prefix' => $prefix ?? null,
'inputName' => $inputName ?? null,
'with_name' => $with_name ?? false,
'selected' => $selected ?? null,
])
@@ -44,6 +45,7 @@
<script>
(function() {
var prefix = '{{ $prefix }}';
var inputName = '{{ $inputName ?? '' }}';
var $formContainer = $('#add_address_container_{{ $prefix }}');
var $list = $('#addresses_list_{{ $prefix }}');
var storeUrl = '{{ route('Shop.Customers.address.store') }}';
@@ -69,7 +71,7 @@
$.ajax({
url: storeUrl,
method: 'POST',
data: data + '&prefix=' + prefix,
data: data + '&prefix=' + prefix + (inputName ? '&input_name=' + inputName : ''),
success: function(response) {
if (response.html) {
$list.append(response.html);

View File

@@ -157,6 +157,12 @@
? xhr.responseJSON.message
: "{{ __('Une erreur est survenue lors de l\'enregistrement du canal de vente préféré.') }}";
console.error('Sale channel update failed', {
status: xhr.status,
response: xhr.responseJSON || xhr.responseText,
selectedSaleChannel
});
alert(message);
if (currentSaleChannelId) {

View File

@@ -57,6 +57,7 @@
'name' => 'phone',
'value' => $customer['phone'] ?? (old('phone') ?? ''),
'label' => 'Téléphone',
'required' => true,
])
</div>
</div>

View File

@@ -4,8 +4,20 @@
@section('content')
<div class="row">
<div class="col-12">
{!! $content !!}
<div class="col-12 text-center py-5">
<i class="fa fa-check-circle text-success" style="font-size: 5rem;"></i>
<div class="mt-4" style="font-size: 1.2rem;">
{!! $content !!}
</div>
@if($payment_label ?? false)
<div class="mt-3" style="font-size: 1.1rem;">
Votre commande a bien été enregistrée, elle vous sera expédiée dès réception de votre {{ $payment_label }}.
</div>
<div class="mt-3" style="font-size: 1.1rem;">
<i class="fa fa-exclamation-triangle text-warning mr-1"></i>
Sans réception de votre paiement au bout de 30 jours, votre commande sera annulée.
</div>
@endif
</div>
</div>
@endsection

View File

@@ -40,7 +40,10 @@
<div class="col-sm-12 col-lg-4">
<x-card class='shadow'>
<div id="basketTotal">
@include('Shop.Baskets.partials.basketTotal', ['basket' => $basket])
@include('Shop.Baskets.partials.basketTotal', [
'basket' => $basket,
'sale_channel' => $basket['sale_channel'] ?? null,
])
</div>
</x-card>
</div>
@@ -50,7 +53,7 @@
@include('load.layout.chevron')
@push('js')
@prepend('js')
<script>
$('#customer').click(function() {
$(".personal_data").addClass('d-none');
@@ -65,7 +68,9 @@
});
function refreshBasketTotal(deliveryId, deliveryTypeId) {
options = deliveryId + '/' + deliveryTypeId;
var safeDeliveryId = deliveryId !== undefined && deliveryId !== null ? deliveryId : '';
var safeDeliveryTypeId = deliveryTypeId !== undefined && deliveryTypeId !== null ? deliveryTypeId : '';
var options = safeDeliveryId + '/' + safeDeliveryTypeId;
$.get("{{ Route('Shop.Basket.getBasketTotal') }}/" + options, function(data) {
$('#basketTotal').html(data);
});
@@ -73,4 +78,4 @@
initChevron();
</script>
@endpush
@endprepend

View File

@@ -1,9 +1,11 @@
<div id="registred">
<x-layout.collapse id="invoice_addresses" title="Adresse de facturation" class="rounded-lg mb-3" uncollapsed=true>
@include('Shop.Orders.partials.addresses', [
'addresses' => $customer['invoice_addresses'] ?? false,
'prefix' => 'invoice',
'name' => 'invoice[invoice_address_id]',
@include('Shop.Customers.partials.addresses', [
'addresses' => $customer['invoice_addresses'] ?? [],
'prefix' => 'invoices',
'inputName' => 'invoice[invoice_address_id]',
'with_name' => true,
'selected' => $customer['invoice_address_id'] ?? null,
])
</x-layout.collapse>
@@ -13,10 +15,12 @@
<x-layout.collapse id="delivery_addresses" title="Adresse de livraison" class="rounded-lg mb-3 d-none"
uncollapsed=true>
@include('Shop.Orders.partials.addresses', [
'addresses' => $customer['delivery_addresses'] ?? false,
'prefix' => 'delivery',
'name' => 'delivery_address_id',
@include('Shop.Customers.partials.addresses', [
'addresses' => $customer['delivery_addresses'] ?? [],
'prefix' => 'deliveries',
'inputName' => 'delivery_address_id',
'with_name' => true,
'selected' => $customer['delivery_address_id'] ?? null,
])
@include('Shop.Orders.partials.shipping')
</x-layout.collapse>

View File

@@ -1,8 +1,6 @@
<h1 style="font-size: 1.5em;">
<h1 class="breadcrumb-title">
@foreach($breadcrumb ?? [] as $parent)
<a href="{{ route('Shop.Categories.show', ['id' => $parent['id']]) }}" style="text-decoration: none; color: inherit;">{{ $parent['name'] }}</a> /
<a href="{{ route('Shop.Categories.show', ['id' => $parent['id']]) }}" class="breadcrumb-link">{{ $parent['name'] }}</a> /
@endforeach
<span style="font-size: 1.4em;">
{{ $category['name'] }}
</span>
<span class="breadcrumb-current">{{ $category['name'] }}</span>
</h1>

View File

@@ -1,7 +1,7 @@
<div class="row">
<div class="col-8">
<h1 style="font-size: 2em;">{{ $category['name'] }}</h1>
<h3 style="font-size: 1.2em;">{!! $category['description'] !!}</h3>
<h1 class="category-title">{{ $category['name'] }}</h1>
<h3 class="category-description">{!! $category['description'] !!}</h3>
</div>
<div class="col-4">
@include('Shop.layout.partials.category_add')
@@ -12,4 +12,4 @@
<div class="col-12">
@include('Shop.layout.partials.category_articles')
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<div class="row">
<div class="row mx-n1">
@if ($articles ?? false)
@foreach ($articles as $product_name => $article)
<div class="col-lg-3 col-xs-12 mb-3">
<div class="category-card col-6 col-md-4 col-lg-3 mb-2 px-1">
@include('Shop.Articles.partials.article')
</div>
@endforeach
@@ -46,4 +46,3 @@
});
</script>
@endpush

View File

@@ -0,0 +1,62 @@
@php
$passwordInputId = $passwordInputId ?? 'password';
@endphp
<ul class="list-unstyled small mt-1 mb-0 password-rules" data-input="#{{ $passwordInputId }}" style="display: none;">
<li data-rule="length"><i class="fa fa-fw fa-times"></i> Au moins 8 caractères</li>
<li data-rule="lowercase"><i class="fa fa-fw fa-times"></i> Au moins une lettre minuscule</li>
<li data-rule="uppercase"><i class="fa fa-fw fa-times"></i> Au moins une lettre majuscule</li>
<li data-rule="number"><i class="fa fa-fw fa-times"></i> Au moins un chiffre</li>
<li data-rule="special"><i class="fa fa-fw fa-times"></i> Au moins un caractère spécial</li>
</ul>
@once
@push('css')
<style>
.password-rules li {
color: #dc3545;
transition: all 0.3s;
}
.password-rules li.valid {
display: none;
}
</style>
@endpush
@push('js')
<script>
$(function() {
$('.password-rules').each(function() {
var $rules = $(this);
var inputSelector = $rules.data('input');
var $input = $(inputSelector);
if (!$input.length) return;
var checks = {
length: function(v) { return v.length >= 8; },
lowercase: function(v) { return /[a-z]/.test(v); },
uppercase: function(v) { return /[A-Z]/.test(v); },
number: function(v) { return /[0-9]/.test(v); },
special: function(v) { return /[^A-Za-z0-9]/.test(v); }
};
$input.on('input keyup', function() {
var val = $(this).val();
if (val.length === 0) {
$rules.hide();
return;
}
$rules.find('li').each(function() {
var rule = $(this).data('rule');
if (checks[rule]) {
$(this).toggleClass('valid', checks[rule](val));
}
});
$rules.show();
});
});
});
</script>
@endpush
@endonce

View File

@@ -11,10 +11,12 @@
<label>Mot de passe *</label>
{{ Form::password('password', [
'class' => 'form-control',
'id' => 'password',
'placeholder' => __('boilerplate::auth.fields.password'),
'required',
]) }}
{!! $errors->registration->first('password', '<p class="text-danger"><strong>:message</strong></p>') !!}
@include('Shop.auth.partials.password_rules', ['passwordInputId' => 'password'])
</div>
</div>
<div class="col-6">

View File

@@ -13,7 +13,8 @@
<label for="new-password" class="col-md-6 control-label text-right">Nouveau mot de passe</label>
<div class="col-md-6">
<input id="new-password" type="password" class="form-control" name="new-password" required>
<input id="new-password" type="password" class="form-control" name="new-password">
@include('Shop.auth.partials.password_rules', ['passwordInputId' => 'new-password'])
</div>
</div>
@@ -21,6 +22,6 @@
<label for="new-password-confirm" class="col-md-6 control-label text-right">Confirmez votre mot de passe</label>
<div class="col-md-6">
<input id="new-password-confirm" type="password" class="form-control" name="new-password_confirmation" required>
<input id="new-password-confirm" type="password" class="form-control" name="new-password_confirmation">
</div>
</div>

View File

@@ -1,18 +1,28 @@
<div class="row bg-light">
<div class="row bg-light align-items-center">
<div class="col-sm-12 col-lg-5">
<div class="col-6 col-lg-5 d-flex align-items-center">
<a href="/"><img src="/img/logo.png" height="52" alt="Jardin'Envie"></a>
<span class="green ml-3">Variétés Paysannes de la Semence à l'Assiette</span>
<span class="green ml-3 d-none d-md-inline">Variétés Paysannes de la Semence à l'Assiette</span>
</div>
<div class="col-sm-12 col-lg-4 pt-2">
<div class="col-12 col-lg-4 pt-2 order-3 order-lg-2">
@include('Shop.layout.partials.search')
</div>
<div class="col-sm-12 col-lg-3 pt-2 text-right">
<div class="col-6 col-lg-3 pt-2 text-right order-2 order-lg-3 d-flex justify-content-end align-items-center">
@include('Shop.layout.partials.header-catalog')
@include('Shop.layout.partials.header-profile')
@include('Shop.layout.partials.header-basket')
</div>
</div>
<div class="row d-lg-none bg-green-dark">
<div class="col-12 p-0">
<div class="collapse" id="navbarContentMobile">
<nav class="navbar navbar-dark p-0">
@include('Shop.layout.partials.sections-menu-list')
</nav>
</div>
</div>
</div>

View File

@@ -10,6 +10,13 @@
<div class="input-group-append">
<span class="input-group-text"><i class="btn btn-sm fa fa-search"></i></span>
</div>
<div class="input-group-append d-lg-none">
<button class="navbar-toggler navbar-light" type="button" data-toggle="collapse"
data-target="#navbarContentMobile" aria-controls="navbarContentMobile" aria-expanded="false"
aria-label="Menu catégories">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
</form>

View File

@@ -0,0 +1,19 @@
<ul class="navbar-nav w-100">
@foreach ($categories as $menu)
<li class="nav-item dropdown megamenu p-2 col
@if (in_array($menu['id'], [$category['id'] ?? false, $category['parent_id'] ?? false, $category['parent']['parent_id'] ?? false])) active @endif">
@if ($menu['children'] ?? false)
<a id="megamenu_{{ $menu['id'] }}" href="{{ route('Shop.Categories.show', ['id' => $menu['id']]) }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="nav-link dropdown-toggle text-uppercase">
{{ $menu['name'] }}
</a>
<div aria-labelledby="megamenu_{{ $menu['id'] }}" class="dropdown-menu border-0 p-0 m-0">
@include('Shop.layout.partials.megamenu')
</div>
@else
<a href="{{ route('Shop.Categories.show', ['id' => $menu['id']]) }}" class="nav-link text-uppercase text-white">
{{ $menu['name'] }}
</a>
@endif
</li>
@endforeach
</ul>

View File

@@ -1,27 +1,9 @@
<div class="row mb-3 bg-green-dark">
<div class="row mb-3 bg-green-dark d-none d-lg-block">
<div class="col-12 pl-0 pr-0">
<nav class="navbar navbar-expand-lg p-0">
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav w-100">
@foreach ($categories as $menu)
<li class="nav-item dropdown megamenu p-2 col
@if (in_array($menu['id'], [$category['id'] ?? false, $category['parent_id'] ?? false, $category['parent']['parent_id'] ?? false])) active @endif">
@if ($menu['children'] ?? false)
<a id="megamenu_{{ $menu['id'] }}" href="{{ route('Shop.Categories.show', ['id' => $menu['id']]) }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="nav-link dropdown-toggle text-uppercase">
{{ $menu['name'] }}
</a>
<div aria-labelledby="megamenu_{{ $menu['id'] }}" class="dropdown-menu border-0 p-0 m-0">
@include('Shop.layout.partials.megamenu')
</div>
@else
<a href="{{ route('Shop.Categories.show', ['id' => $menu['id']]) }}" class="nav-link text-uppercase text-white">
{{ $menu['name'] }}
</a>
@endif
</li>
@endforeach
</ul>
<nav class="navbar navbar-expand-lg navbar-dark p-0">
<div class="navbar-collapse show" id="navbarContent">
@include('Shop.layout.partials.sections-menu-list')
</div>
</nav>
</div>
</div>
</div>

View File

@@ -15,7 +15,20 @@
@isset($trigger) data-trigger="{{ $trigger }}" @endisset
@isset($container) data-container="{{ $container }}" @endisset
@isset($html) data-html="true" @endisset
@isset($metadata) {{ $metadata }} @endisset>
@isset($metadata) {{ $metadata }} @endisset
@isset($attr)
@if (is_array($attr))
@foreach ($attr as $key => $value)
@if ($value === true)
{{ $key }}
@elseif ($value !== false && $value !== null)
{{ $key }}="{{ $value }}"
@endif
@endforeach
@else
{{ $attr }}
@endif
@endisset>
<i class="fa fa-fw {{ $icon ?? '' }}"></i>
{{ $txt ?? '' }}
</button>

View File

@@ -0,0 +1,6 @@
@include('components.form.button', [
'class' => 'btn-info duplicate ' . ($class ?? ''),
'icon' => 'fa-copy',
'txt' => __('Dupliquer'),
'attr' => ['data-url' => $duplicate_url ?? $duplicateUrl ?? null],
])

View File

@@ -1,7 +1,33 @@
@php
$cancelUrl = $cancel_url ?? $cancelUrl ?? null;
$duplicateUrl = $duplicate_url ?? $duplicateUrl ?? null;
@endphp
@push('header-actions')
<div class="form-buttons d-flex align-items-center ml-3">
@include('components.form.buttons.button-cancel', [
'class' => 'btn-sm mr-2',
'url' => $cancelUrl,
])
@if($duplicateUrl)
@include('components.form.buttons.button-duplicate', [
'class' => 'btn-sm mr-2',
'duplicate_url' => $duplicateUrl,
])
@endif
@include('components.form.buttons.button-save', [
'class' => 'btn-sm',
])
</div>
@endpush
<div class="row pt-0 pb-3">
<div class="col-12">
<div class="text-right form-buttons">
@include('components.form.buttons.button-cancel')
@include('components.form.buttons.button-cancel', ['url' => $cancelUrl])
@if($duplicateUrl)
@include('components.form.buttons.button-duplicate', ['duplicate_url' => $duplicateUrl])
@endif
@include('components.form.buttons.button-save')
</div>
</div>

View File

@@ -1,15 +1,18 @@
<div class="content-header pt-2 pb-1">
<div class="container-fluid">
<div class="row mb-2 align-items-end">
<div class="col-sm-6">
<h1 class="m-0 text-dark">
{{ $title ?? null}}
@isset($subtitle)
<small class="font-weight-light ml-1 text-md">{{ $subtitle }}</small>
@endisset
</h1>
<div class="row mb-2 align-items-center">
<div class="col-sm-8">
<div class="d-flex align-items-center flex-wrap">
<h1 class="m-0 text-dark d-flex align-items-center flex-grow-1">
{{ $title ?? null}}
@isset($subtitle)
<small class="font-weight-light ml-1 text-md">{{ $subtitle }}</small>
@endisset
</h1>
@stack('header-actions')
</div>
</div>
<div class="col-sm-6">
<div class="col-sm-4">
<ol class="breadcrumb float-sm-right text-sm">
<li class="breadcrumb-item">
<a href="{{ route('boilerplate.dashboard') }}">

View File

@@ -94,6 +94,18 @@
@stack('scripts')
@stack('js')
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.form-buttons .duplicate').forEach(function(btn) {
btn.addEventListener('click', function() {
var url = this.dataset.url || this.getAttribute('data-url');
if (url) {
window.location = url;
}
});
});
});
</script>
</body>

View File

@@ -39,7 +39,7 @@
''); // Empty tags
},
skin: "oxide",
content_css: 'oxide',
content_css: 'default',
language: '{{ App::getLocale() }}',
file_picker_callback: function(callback, value, meta) {
var x = window.innerWidth || document.documentElement.clientWidth || document.getElementsByTagName(

View File

@@ -9,14 +9,16 @@
var selector = (typeof(sel) == 'undefined') ? '.save' : sel;
$(selector).off().click(function(e) {
if (typeof initValidator === 'function') {
console.log('click');
e.preventDefault();
if ($(form).valid()) {
$(this).prop("disabled", true);
$(this).html($(this).data('loading-text'));
$(form).submit();
} else {
console.log('erreur');
const $firstInvalid = $(form).find(':invalid, .error').first();
if ($firstInvalid.length) {
$firstInvalid.focus();
}
}
} else {
$(form).submit();

View File

@@ -75,12 +75,12 @@
if (typeof(tinyMCE) != 'undefined') {
tinyMCE.triggerSave();
}
$('form ' + form_id).submit();
getModalForm(form_id).trigger('submit');
status = 1;
}
function handlePostModal(form_id, url_save, callback) {
$('form ' + form_id).submit(function(e) {
getModalForm(form_id).off('submit').on('submit', function(e) {
e.preventDefault();
var formData = new FormData(this);
$.ajax({
@@ -98,8 +98,17 @@
});
});
}
function getModalForm(form_id) {
var $form = $(form_id);
if (! $form.length) {
$form = $('form' + form_id);
}
return $form;
}
</script>
@endpush
@php(define('LOAD_MODAL', true))
@endif
@endif

View File

@@ -14,4 +14,5 @@ Route::prefix('Articles')->name('Articles.')->group(function () {
Route::get('getProductImages/{product_id?}/{model?}', 'ArticleController@getProductImages')->name('getProductImages');
Route::post('toggleVisible', 'ArticleController@toggleVisible')->name('toggleVisible');
Route::post('toggleHomepage', 'ArticleController@toggleHomepage')->name('toggleHomepage');
Route::get('duplicate/{id}', 'ArticleController@duplicate')->name('duplicate');
});

View File

@@ -1,8 +1,8 @@
<?php
Route::middleware('auth')->prefix('Admin')->namespace('Admin')->name('Admin.')->group(function () {
Route::get('{period?}', 'HomeController@index')->name('home');
include __DIR__.'/Botanic/route.php';
include __DIR__.'/Core/route.php';
include __DIR__.'/Shop/route.php';
Route::get('{period?}', 'HomeController@index')->name('home');
});

View File

@@ -1,5 +1,8 @@
<?php
Route::prefix('Offres')->name('Offers.')->group(function () {
Route::get('show/{id}', 'OfferController@show')->name('show');
// Public offer pages are not exposed; keep the route returning 404 to avoid leaking data.
Route::get('show/{id}', function () {
abort(404);
})->name('show');
});