27 Commits

Author SHA1 Message Date
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
Valentin Lab
cc8dfa29b4 fix: display only delivery types that have a price and auto-select first 2025-10-05 10:09:03 +02:00
Valentin Lab
62bce92d6d fix: make delivery option on checkout stick to the current sale channel 2025-10-05 09:56:33 +02:00
Valentin Lab
8d130b9741 new: add channel management 2025-10-05 09:39:27 +02:00
Valentin Lab
2d7436a12b fix: make sale channel description field editable 2025-10-05 05:26:20 +02:00
Valentin Lab
f25a62ed26 new: make admin delivery edition can toggle off public and active states 2025-10-05 03:32:08 +02:00
Valentin Lab
36764f2647 fix: make save button avoid error 500 in delivery method admin page 2025-10-05 03:28:03 +02:00
Valentin Lab
e37cad6699 new: make the eye icon work to see an invoice in admin customer view 2025-10-04 15:37:28 +02:00
Valentin Lab
ae7f8ed2c9 fix: remove 404 about javascript file in admin console 2025-10-04 14:39:07 +02:00
Valentin Lab
a3a86f4b2f new: keep cart when login in 2025-10-04 14:13:48 +02:00
Valentin Lab
9c081574c8 new: make click in choices of search box load the page of the product 2025-10-04 13:54:21 +02:00
Valentin Lab
11edccad02 fix: make invoices creation resistant to missing address if this still happens 2025-10-04 12:55:11 +02:00
Valentin Lab
7c796802be new: make invoice still keep the old addresses when their address gets deleted in profile 2025-10-04 12:39:13 +02:00
Valentin Lab
5cc43bc889 fix: make the button to add an address unusable when the address form is open 2025-10-04 12:19:24 +02:00
Valentin Lab
f094411f10 new: add persistence of default address selection 2025-10-04 11:59:57 +02:00
Valentin Lab
ccc477f291 new: display the default address checkbox on profile load 2025-10-04 11:59:57 +02:00
Valentin Lab
7217d945a3 fix: make the address appear when added 2025-10-04 11:59:57 +02:00
Valentin Lab
9185269874 fix: prevent deleting last address for each kind 2025-10-04 11:59:57 +02:00
Valentin Lab
e42e3b4c0d fix: prevent 404 when deleting an adress 2025-10-04 11:06:43 +02:00
Valentin Lab
a7ae946797 fix: prevent error 500 on profile edition 2025-10-04 10:51:41 +02:00
Valentin Lab
7a189abf0b fix: move `build directory to resources/shop` 2025-10-04 10:13:38 +02:00
Valentin Lab
34fc1c33bf fix: repair favicon links and provide one from https://www.jardinenvie.com 2025-10-04 09:41:15 +02:00
Valentin Lab
61e34b4f4e fix: finalize payments and clear cart after Paybox success
This captures the Paybox verification flow, duplicate-payment guard, and cart cleanup.
2025-10-04 09:17:53 +02:00
Valentin Lab
7fe2770d45 fix: do not call debugbar if not available (when in prod) 2025-09-29 11:32:19 +02:00
143 changed files with 1598 additions and 220 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

@@ -55,6 +55,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
## XXXvlab: 2025-09-25 these migration files are breaking first
## install, but we had to resolve to not install from scratch and use
@@ -93,7 +94,8 @@ RUN mkdir -p /out \
--exclude=.editorconfig --exclude=phpunit.xml \
--exclude=.travis.yml --exclude=composer.lock --exclude=.styleci.yml \
--exclude=Makefile --exclude=.gitkeep --exclude=test \
artisan app build config database vendor public resources routes stubs bootstrap storage composer.json \
--exclude=resources/shop \
artisan app config database vendor public resources routes stubs bootstrap storage composer.json \
&& xz -T0 -9e /out/app.tar \
&& mv /out/app.tar.xz /out/opensem-prod.tar.xz

View File

@@ -19,8 +19,8 @@ var jsSite = [
jsBootstrap,
'node_modules/jquery-serializejson/jquery.serializejson.min.js',
'node_modules/currency.js/dist/currency.min.js',
'build/js/plugins/smooth_products/js/smoothproducts.min.js',
'build/js/site.js',
'resources/shop/js/plugins/smooth_products/js/smoothproducts.min.js',
'resources/shop/js/site.js',
]
var cssSite = [
@@ -28,8 +28,8 @@ var cssSite = [
'node_modules/@fortawesome/fontawesome-free/css/all.min.css',
'node_modules/animate.css/animate.min.css',
'node_modules/icheck-bootstrap/icheck-bootstrap.min.css',
'build/js/plugins/smooth_products/css/smoothproducts.css',
'build/css/site.css',
'resources/shop/js/plugins/smooth_products/css/smoothproducts.css',
'resources/shop/css/site.css',
]
var jsAdminLTE = [
@@ -41,15 +41,15 @@ var jsAdminLTE = [
]
var jsCoreInclude = [
'build/js/include/core/objectLength.js',
'build/js/include/core/url.js',
'build/js/include/core/user.js',
'build/js/include/form/radio.js',
'build/js/include/form/upload.js',
'build/js/include/form/validator.js',
'build/js/include/layout/animate.js',
'build/js/include/layout/scroll.js',
'build/js/include/layout/tooltip.js',
'resources/shop/js/include/core/objectLength.js',
'resources/shop/js/include/core/url.js',
'resources/shop/js/include/core/user.js',
'resources/shop/js/include/form/radio.js',
'resources/shop/js/include/form/upload.js',
'resources/shop/js/include/form/validator.js',
'resources/shop/js/include/layout/animate.js',
'resources/shop/js/include/layout/scroll.js',
'resources/shop/js/include/layout/tooltip.js',
]
var jsBundle = [
@@ -84,7 +84,7 @@ var jsMain = [
var cssPrint = [
// 'node_modules/bootstrap/dist/css/bootstrap.min.css',
'cssIcons',
'build/print.css'
'resources/shop/print.css'
]
var cssBundle = [
@@ -109,7 +109,7 @@ var cssIcons = [
var cssMain = [
cssBundle,
cssIcons,
'build/css/main.css',
'resources/shop/css/main.css',
]
var jsDataTables = [
@@ -251,31 +251,31 @@ module.exports = function(grunt) {
},
{
expand: true,
cwd: 'build/fonts',
cwd: 'resources/shop/fonts',
src: ['**'],
dest: 'public/fonts/'
},
{
expand: true,
cwd: 'build/img',
cwd: 'resources/shop/img',
src: ['**'],
dest: 'public/img/'
},
{
expand: true,
cwd: 'build/lang',
cwd: 'resources/shop/lang',
src: ['**'],
dest: 'public/assets/lang/'
},
{
expand: true,
cwd: 'build/plugins',
cwd: 'resources/shop/plugins',
src: ['**'],
dest: 'public/assets/plugins/'
},
{
expand: true,
cwd: 'build/assets/tpl',
cwd: 'resources/shop/assets/tpl',
src: ['**'],
dest: 'public/assets/tpl/'
},
@@ -395,7 +395,7 @@ module.exports = function(grunt) {
},
{
expand: true,
cwd: 'build/plugins/pdfjs/',
cwd: 'resources/shop/plugins/pdfjs/',
src: ['**'],
dest: 'public/assets/plugins/pdfjs',
},
@@ -461,7 +461,7 @@ module.exports = function(grunt) {
},
{
expand: true,
cwd: 'build/js/include/plugins/datatables_lang/',
cwd: 'resources/shop/js/include/plugins/datatables_lang/',
src: ['*.json'],
dest: 'public/assets/plugins/datatables_lang',
},
@@ -539,7 +539,7 @@ module.exports = function(grunt) {
},
{
expand: true,
cwd: 'build/js/include/',
cwd: 'resources/shop/js/include/',
src: ['boilerplate.js'],
dest: 'public/assets/plugins',
},
@@ -548,7 +548,7 @@ module.exports = function(grunt) {
},
watch: {
dist: {
files: ['build/js/*', 'build/css/*'],
files: ['resources/shop/js/*', 'resources/shop/css/*'],
// tasks: ['concat', 'copy']
tasks: ['concat']
}

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

@@ -11,7 +11,7 @@ use Yajra\DataTables\Html\Column;
class CustomerInvoicesDataTable extends DataTable
{
public $model_name = 'invoices';
public $model_name = 'customer_invoices';
public $sortedColumn = 1;

View File

@@ -12,15 +12,6 @@ class CustomerInvoiceController extends Controller
return $dataTable->render('Admin.Shop.CustomerInvoices.list');
}
public function show($id)
{
$data = [
'invoice' => Invoices::get($id),
];
return view('Admin.Shop.CustomerInvoices.view', $data);
}
public function destroy($id)
{
return Invoices::destroy($id);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Shop\Auth;
use App\Http\Controllers\Controller;
use App\Repositories\Core\User\ShopCart;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -31,6 +32,7 @@ class LoginController extends Controller
]);
if ($this->guard()->attempt($credentials, $request->get('remember'))) {
ShopCart::migrateGuestCartToUser();
$request->session()->regenerate();
if (back()->getTargetUrl() === route('Shop.Orders.store')) {
$route = 'Shop.Orders.order';

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Shop\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Shop\RegisterCustomer;
use App\Repositories\Core\User\ShopCart;
use App\Repositories\Shop\CustomerSaleChannels;
use App\Repositories\Shop\CustomerAddresses;
use App\Repositories\Shop\Customers;
@@ -33,6 +34,7 @@ class RegisterController extends Controller
$user = $this->create($request->all());
$this->guard()->login($user);
ShopCart::migrateGuestCartToUser();
return $request->wantsJson()
? new JsonResponse([], 201)

View File

@@ -2,9 +2,14 @@
namespace App\Http\Controllers\Shop;
use App\Repositories\Core\User\ShopCart;
use App\Repositories\Shop\Baskets;
use App\Repositories\Shop\CustomerAddresses;
use App\Repositories\Shop\Customers;
use App\Repositories\Shop\Offers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
class CustomerController extends Controller
{
@@ -43,8 +48,69 @@ class CustomerController extends Controller
public function storeProfileAjax(Request $request)
{
$data = $request->all();
if (array_key_exists('default_sale_channel_id', $data)) {
$customer = Customers::get(Customers::getId());
$newSaleChannelId = (int) $data['default_sale_channel_id'];
$currentSaleChannelId = (int) ($customer->default_sale_channel_id ?? 0);
if ($newSaleChannelId && $newSaleChannelId !== $currentSaleChannelId && ShopCart::count() > 0) {
$cartItems = ShopCart::getContent();
$unavailable = [];
foreach ($cartItems as $item) {
$offerId = (int) $item->id;
if (! Offers::getPrice($offerId, 1, $newSaleChannelId)) {
$offer = Offers::get($offerId, ['article']);
$unavailable[] = $offer->article->name ?? $item->name;
if (count($unavailable) >= 3) {
break;
}
}
}
if (! empty($unavailable)) {
$list = implode(', ', $unavailable);
return response()->json([
'error' => 1,
'message' => __('Certains articles de votre panier ne sont pas disponibles dans ce canal : :products. Merci de finaliser votre commande ou de retirer ces articles avant de changer de canal.', ['products' => $list]),
], 422);
}
}
}
$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) {
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]);
}
@@ -56,10 +122,89 @@ class CustomerController extends Controller
return redirect()->route('Shop.Customers.edit');
}
public function storeAddress(Request $request)
{
if (Customers::isNotConnected()) {
return response()->json(['message' => __('Authentification requise.')], 403);
}
$prefix = $request->input('prefix');
$types = ['deliveries' => 1, 'invoices' => 2];
if (! array_key_exists($prefix, $types)) {
return response()->json(['message' => __('Type d\'adresse inconnu.')], 422);
}
$addressData = $request->input($prefix, []);
$validator = Validator::make($addressData, [
'name' => ['nullable', 'string', 'max:150'],
'address' => ['required', 'string', 'max:255'],
'address2' => ['nullable', 'string', 'max:255'],
'zipcode' => ['required', 'string', 'max:30'],
'city' => ['required', 'string', 'max:255'],
], [
'address.required' => __('Merci de renseigner l\'adresse.'),
'zipcode.required' => __('Merci de renseigner le code postal.'),
'city.required' => __('Merci de renseigner la ville.'),
]);
if ($validator->fails()) {
return response()->json([
'message' => __('Merci de vérifier les informations saisies.'),
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$customerId = Customers::getId();
$data['customer_id'] = $customerId;
$data['type'] = $types[$prefix];
if (empty($data['name'])) {
$data['name'] = Customers::getName($customerId);
}
$address = CustomerAddresses::store($data);
CustomerAddresses::setDefault($customerId, $address->id, $types[$prefix]);
$html = view('Shop.Customers.partials.address_item', [
'address' => $address->toArray(),
'prefix' => $prefix,
'with_name' => true,
'selected' => $address->id,
])->render();
return response()->json([
'success' => true,
'html' => $html,
'message' => __('Adresse enregistrée.'),
'id' => $address->id,
]);
}
public function delete_address($id)
{
$ret = CustomerAddresses::destroy($id);
$address = CustomerAddresses::get($id);
return redirect()->route('Shop.Customers.edit');
if (! $address || (int) $address->customer_id !== (int) Customers::getId()) {
abort(404);
}
$remaining = CustomerAddresses::getModel()
->byCustomer($address->customer_id)
->byType($address->type)
->count();
if ($remaining <= 1) {
return redirect()->route('Shop.Customers.edit')
->with('growl', [__('Vous devez conserver au moins une adresse par type.'), 'warning']);
}
CustomerAddresses::destroy($id);
CustomerAddresses::ensureDefault($address->customer_id, $address->type);
return redirect()->route('Shop.Customers.edit')
->with('growl', [__('Adresse supprimée.'), 'success']);
}
}

View File

@@ -15,8 +15,14 @@ class InvoiceController extends Controller
public function view($uuid)
{
$invoice = Invoices::view($uuid);
if (! $invoice) {
abort(404);
}
$data = [
'invoice' => Invoices::view($uuid),
'invoice' => $invoice,
];
return view('Shop.Invoices.view', $data);
@@ -24,7 +30,9 @@ class InvoiceController extends Controller
public function pdf($uuid)
{
\Debugbar::disable();
if (app()->bound('debugbar')) {
app('debugbar')->disable();
}
return InvoicePDF::getByUUID($uuid);
}

View File

@@ -49,12 +49,22 @@ class OrderController extends Controller
{
if (ShopCart::count()) {
$customer = Customers::getWithAddresses();
$deliveries = Deliveries::getByCustomer();
$customerId = $customer ? $customer->id : false;
$defaultSaleChannelId = SaleChannels::getDefaultID($customerId);
$deliveries = $defaultSaleChannelId
? Deliveries::getBySaleChannels([$defaultSaleChannelId])
: Deliveries::getByCustomer($customerId);
$deliveries = $deliveries ? $deliveries->values() : collect();
$customerData = $customer ? $customer->toArray() : false;
if ($customerData && $defaultSaleChannelId) {
$customerData['default_sale_channel_id'] = $defaultSaleChannelId;
}
$data = [
'customer' => $customer ? $customer->toArray() : false,
'customer' => $customerData,
'basket' => Baskets::getBasketTotal(),
'deliveries' => $deliveries ? $deliveries->toArray() : [],
'deliveries' => $deliveries->toArray(),
'delivery_types' => DeliveryTypes::getWithPrice(Baskets::getWeight()),
];

View File

@@ -3,13 +3,19 @@
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Repositories\Core\User\ShopCart;
use App\Repositories\Shop\Paybox as PayboxGateway;
use App\Repositories\Shop\Contents;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class PayboxController extends Controller
{
public function accepted()
{
ShopCart::clear();
return view('paybox.paybox', ['content' => Contents::getPayboxConfirmedContent()]);
}
@@ -30,8 +36,20 @@ class PayboxController extends Controller
public function process(Request $request)
{
$data = $request->all();
$invoiceId = $request->input('order_number');
return view('paybox.send', $data);
if (! $invoiceId) {
Log::warning('Paybox callback missing order_number', ['payload' => $request->all()]);
return response('Missing order_number', 400);
}
$success = PayboxGateway::verifyPayment($invoiceId);
if (! $success) {
return response('KO', 400);
}
return response('OK');
}
}

View File

@@ -3,9 +3,11 @@
namespace App\Models\Shop;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CustomerAddress extends Model
{
use SoftDeletes;
protected $guarded = ['id'];
protected $table = 'shop_customer_addresses';

View File

@@ -39,7 +39,7 @@ class Invoice extends Model
public function address(): BelongsTo
{
return $this->belongsTo(CustomerAddress::class, 'invoice_address_id');
return $this->belongsTo(CustomerAddress::class, 'invoice_address_id')->withTrashed();
}
public function scopeByCustomer($query, $customerId)

View File

@@ -29,7 +29,7 @@ class Order extends Model
public function delivery_address(): BelongsTo
{
return $this->belongsTo(CustomerAddress::class, 'delivery_address_id');
return $this->belongsTo(CustomerAddress::class, 'delivery_address_id')->withTrashed();
}
public function delivery(): BelongsTo

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

@@ -94,11 +94,106 @@ class ShopCart
return self::get()->getContent();
}
public static function get()
public static function migrateGuestCartToUser($userId = null)
{
$userId = Auth::guard('customer')->id();
$sessionKey = 'cart_'.sha1(static::class . ($userId ?? 'guest'));
$userId = self::resolveUserId($userId);
return Cart::session($sessionKey);
if ($userId === null) {
return;
}
$guestSessionKey = self::sessionKey();
$guestItems = Cart::session($guestSessionKey)->getContent();
if ($guestItems->count() === 0) {
return;
}
$userSessionKey = self::sessionKey($userId);
foreach ($guestItems as $item) {
$existing = Cart::session($userSessionKey)->get($item->id);
if ($existing) {
Cart::session($userSessionKey)->update($item->id, [
'quantity' => [
'relative' => false,
'value' => $existing->quantity + $item->quantity,
],
]);
continue;
}
$itemData = [
'id' => $item->id,
'name' => $item->name,
'price' => $item->price,
'quantity' => $item->quantity,
'attributes' => self::extractAttributes($item),
];
if (isset($item->associatedModel)) {
$itemData['associatedModel'] = $item->associatedModel;
}
$conditions = self::extractConditions($item);
if (! empty($conditions)) {
$itemData['conditions'] = $conditions;
}
Cart::session($userSessionKey)->add($itemData);
}
Cart::session($guestSessionKey)->clear();
Cart::session($userSessionKey);
}
protected static function extractAttributes($item)
{
if (! isset($item->attributes)) {
return [];
}
if (is_object($item->attributes) && method_exists($item->attributes, 'toArray')) {
return $item->attributes->toArray();
}
return (array) $item->attributes;
}
protected static function extractConditions($item)
{
if (! isset($item->conditions)) {
return [];
}
if (is_object($item->conditions) && method_exists($item->conditions, 'toArray')) {
return $item->conditions->toArray();
}
return (array) $item->conditions;
}
protected static function resolveUserId($userId = null)
{
return $userId ?? Auth::guard('customer')->id();
}
protected static function sessionKey($userId = null)
{
$key = $userId ?? 'guest';
return 'cart_'.sha1(static::class.$key);
}
protected static function session($userId = null)
{
return Cart::session(self::sessionKey($userId));
}
public static function get($userId = null)
{
return self::session(self::resolveUserId($userId));
}
}

View File

@@ -5,6 +5,9 @@ namespace App\Repositories\Shop;
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;
@@ -70,9 +73,33 @@ 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;
}

View File

@@ -100,12 +100,19 @@ class Baskets
$offers = Offers::getWithPricesByIds(self::getIds(), $saleChannelId);
foreach ($basket as $item) {
$offer = $offers->where('id', $item->id)->first();
if (! $offer) {
continue;
}
$priceValue = Offers::getPrice($item->id, $item->quantity, $saleChannelId);
$unitPrice = $priceValue ? (float) $priceValue->price_taxed : (float) $item->price;
$article_nature = strtolower($offer->article->article_nature->name);
$data[$article_nature][] = [
'id' => (int) $item->id,
'name' => $item->name,
'quantity' => (int) $item->quantity,
'price' => $item->price,
'price' => $unitPrice,
'variation' => $offer->variation->name,
'image' => Articles::getPreviewSrc(ArticleImages::getFullImageByArticle($offer->article)),
'latin' => $offer->article->product->specie->latin ?? false,
@@ -115,6 +122,24 @@ class Baskets
return $data ?? false;
}
public static function refreshPrices($saleChannelId = false)
{
$saleChannelId = $saleChannelId ? $saleChannelId : SaleChannels::getDefaultID();
$basket = ShopCart::getContent();
foreach ($basket as $item) {
$priceValue = Offers::getPrice($item->id, $item->quantity, $saleChannelId);
if (! $priceValue) {
continue;
}
ShopCart::get()->update($item->id, [
'price' => $priceValue->price_taxed,
]);
}
}
public static function getBasketData($id, $quantity = 1)
{
$offer = Offers::get($id, ['article', 'variation']);

View File

@@ -12,17 +12,35 @@ class CustomerAddresses
public static function storeByCustomer($customer, $data)
{
$deliveries = $data['deliveries'] ?? false;
if ($deliveries && $deliveries['zipcode'] && $deliveries['city']) {
$deliveries['customer_id'] = $customer->id;
$deliveries['type'] = 1;
self::store($deliveries);
if ($deliveries) {
if (! empty($deliveries['address_id'])) {
self::setDefault($customer->id, (int) $deliveries['address_id'], 1);
}
if (! empty($deliveries['zipcode']) && ! empty($deliveries['city'])) {
$payload = $deliveries;
unset($payload['address_id']);
$payload['customer_id'] = $customer->id;
$payload['type'] = 1;
$newAddress = self::store($payload);
self::setDefault($customer->id, $newAddress->id, 1);
}
}
$invoices = $data['invoices'] ?? false;
if ($invoices && $invoices['zipcode'] && $invoices['city']) {
$invoices['customer_id'] = $customer->id;
$invoices['type'] = 2;
self::store($invoices);
if ($invoices) {
if (! empty($invoices['address_id'])) {
self::setDefault($customer->id, (int) $invoices['address_id'], 2);
}
if (! empty($invoices['zipcode']) && ! empty($invoices['city'])) {
$payload = $invoices;
unset($payload['address_id']);
$payload['customer_id'] = $customer->id;
$payload['type'] = 2;
$newAddress = self::store($payload);
self::setDefault($customer->id, $newAddress->id, 2);
}
}
}
@@ -70,14 +88,24 @@ class CustomerAddresses
public static function getInvoiceAddress($customerId)
{
$addresses = CustomerAddress::byCustomer($customerId)->byInvoicing()->get();
return count($addresses) ? $addresses->first() : self::getByCustomer($customerId);
$address = CustomerAddress::byCustomer($customerId)
->byInvoicing()
->orderByDesc('priority')
->orderBy('id')
->first();
return $address ?? self::getByCustomer($customerId);
}
public static function getDeliveryAddress($customerId)
{
$addresses = CustomerAddress::byCustomer($customerId)->byDelivery()->get();
return count($addresses) ? $addresses->first() : self::getByCustomer($customerId);
$address = CustomerAddress::byCustomer($customerId)
->byDelivery()
->orderByDesc('priority')
->orderBy('id')
->first();
return $address ?? self::getByCustomer($customerId);
}
public static function getByCustomer($customerId = false)
@@ -92,6 +120,40 @@ class CustomerAddresses
return ((int) $type === 1) ? '<i class="fa fa-fw fa-truck"></i>' : '<i class="fa fa-fw fa-file-invoice"></i>';
}
public static function setDefault($customerId, $addressId, $type)
{
if (! $addressId) {
return;
}
$address = self::get($addressId);
if (! $address || (int) $address->customer_id !== (int) $customerId || (int) $address->type !== (int) $type) {
return;
}
self::getModel()->byCustomer($customerId)->byType($type)->update(['priority' => null]);
$address->priority = 1;
$address->save();
}
public static function ensureDefault($customerId, $type)
{
$hasDefault = self::getModel()->byCustomer($customerId)->byType($type)->where('priority', 1)->exists();
if ($hasDefault) {
return;
}
$address = self::getModel()->byCustomer($customerId)->byType($type)->orderBy('id')->first();
if ($address) {
$address->priority = 1;
$address->save();
}
}
public static function toggleActive($id, $active)
{
return self::update(['active' => $active], $id);

View File

@@ -6,6 +6,7 @@ use App\Datatables\Shop\CustomerInvoicesDataTable;
use App\Datatables\Shop\CustomerOrdersDataTable;
use App\Models\Shop\Customer;
use App\Traits\Model\Basic;
use App\Repositories\Shop\CustomerAddresses;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
@@ -30,16 +31,31 @@ class Customers
public static function getSaleChannels($customerId = false)
{
$customer = $customerId ? self::get($customerId) : self::getAuth();
$saleChannels = $customer ? $customer->sale_channels : false;
$saleChannels = collect();
return $saleChannels ? $saleChannels : SaleChannels::getDefault();
if ($customer) {
$customer->loadMissing('sale_channels');
$saleChannels = $customer->sale_channels ?? collect();
if ($saleChannels instanceof \Illuminate\Support\Collection && $saleChannels->isNotEmpty()) {
return $saleChannels;
}
}
$default = SaleChannels::getDefault($customerId);
return $default ? collect([$default]) : collect();
}
public static function getSaleChannel($customerId = false)
{
$saleChannels = self::getSaleChannels($customerId);
return $saleChannels->first();
if ($saleChannels instanceof \Illuminate\Support\Collection) {
return $saleChannels->first();
}
return $saleChannels;
}
public static function getDeliveries()
@@ -57,12 +73,22 @@ class Customers
public static function editProfile($id = false)
{
return $id ? [
'customer' => self::get($id, ['addresses', 'deliveries'])->toArray(),
'deliveries' => Deliveries::getAllWithSaleChannel()->toArray(),
if (! $id) {
abort('403');
}
$customer = self::get($id, ['addresses', 'deliveries', 'sale_channels']);
$saleChannels = self::getSaleChannels($id);
return [
'customer' => $customer->toArray(),
'sale_channels' => $saleChannels->toArray(),
'deliveries' => Deliveries::getByCustomer($id)->toArray(),
'sale_channel_checks' => Shop::getSaleChannelAvailabilitySummary($saleChannels->pluck('id')->toArray()),
'orders' => (new CustomerOrdersDataTable())->html(),
'invoices' => (new CustomerInvoicesDataTable())->html(),
] : abort('403');
];
}
public static function getAddresses($id = false)
@@ -95,6 +121,16 @@ class Customers
$data = $customer->toArray();
$data['sale_channels'] = $customer->sale_channels->pluck('id')->toArray();
$data['deliveries'] = Deliveries::getBySaleChannels($data['sale_channels'])->toArray();
$data['delivery_address_id'] = optional(CustomerAddresses::getDeliveryAddress($id))->id;
$data['invoice_address_id'] = optional(CustomerAddresses::getInvoiceAddress($id))->id;
if (! $data['delivery_address_id'] && ! empty($data['delivery_addresses'])) {
$data['delivery_address_id'] = $data['delivery_addresses'][0]['id'] ?? null;
}
if (! $data['invoice_address_id'] && ! empty($data['invoice_addresses'])) {
$data['invoice_address_id'] = $data['invoice_addresses'][0]['id'] ?? null;
}
return $data;
}
@@ -102,8 +138,8 @@ class Customers
public static function storeFull($data)
{
$data2 = $data;
if ($data['sale_channels'] ?? false) {
$saleChannels = $data['sale_channels'] ?? false;
$saleChannels = array_key_exists('sale_channels', $data) ? $data['sale_channels'] : null;
if ($saleChannels !== null) {
unset($data['sale_channels']);
}
if ($data['deliveries'] ?? false) {
@@ -113,7 +149,9 @@ class Customers
unset($data['invoices']);
}
$customer = self::store($data);
$customer->sale_channels()->sync($saleChannels);
if ($saleChannels !== null) {
$customer->sale_channels()->sync($saleChannels);
}
CustomerAddresses::storeByCustomer($customer, $data2);
return $customer->id;
@@ -141,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

@@ -21,12 +21,12 @@ class Deliveries
$customer = $customerId ? Customers::get($customerId) : Customers::getAuth();
$saleChannels = $customer ? $customer->sale_channels->pluck('id')->toArray() : [SaleChannels::getDefaultID()];
return $saleChannels ? self::getBySaleChannels($saleChannels) : false;
return $saleChannels ? self::getBySaleChannels($saleChannels) : collect();
}
public static function getBySaleChannels($saleChannels)
{
return Delivery::bySaleChannels($saleChannels)->with('sale_channel')->get();
return Delivery::bySaleChannels($saleChannels)->active()->with('sale_channel')->get();
}
public static function getSaleChannelId($deliveryId)
@@ -41,7 +41,7 @@ class Deliveries
public static function getAllWithSaleChannel()
{
return Delivery::orderBy('name', 'asc')->active()->public()->with('sale_channel')->get();
return Delivery::orderBy('name', 'asc')->active()->with('sale_channel')->get();
}
public static function toggleActive($id, $active)

View File

@@ -15,9 +15,15 @@ class DeliveryTypes
$types = self::getAll();
foreach ($types as $type) {
$price = self::getPrice($type->id, $weight);
if ($price === false) {
continue;
}
$data[$type->id] = [
'name' => $type->name,
'price' => self::getPrice($type->id, $weight),
'price' => $price,
];
}

View File

@@ -17,12 +17,15 @@ class InvoicePDF
public static function get($id)
{
$invoice = Invoices::getFull($id);
$customFields = [];
if ($orderRef = optional($invoice->order)->ref) {
$customFields['order number'] = $orderRef;
}
$customer = new Party([
'name' => $invoice->customer->name,
'name' => optional($invoice->customer)->name ?? __('Client inconnu'),
'address' => self::makeAddress($invoice->address),
'custom_fields' => [
'order number' => $invoice->order->ref,
],
'custom_fields' => $customFields,
]);
$items = self::makeItems($invoice->order->detail);
@@ -48,7 +51,17 @@ class InvoicePDF
public static function makeAddress($address)
{
return $address->address.'<br>'.$address->zipcode.' '.$address->city;
if (! $address) {
return '';
}
$lines = array_filter([
$address->address ?? '',
$address->address2 ?? '',
trim(($address->zipcode ?? '').' '.($address->city ?? '')),
]);
return implode('<br>', $lines);
}
public static function makeItems($details)

View File

@@ -36,7 +36,13 @@ class Invoices
public static function view($uuid)
{
$data = self::getFullByUUID($uuid)->toArray();
$invoice = self::getFullByUUID($uuid);
if (! $invoice) {
return false;
}
$data = $invoice->toArray();
$data['payment_type'] = InvoicePayments::getPaymentType($data['payment_type']);
$data['status'] = self::getStatus($data['status']);

View File

@@ -3,6 +3,8 @@
namespace App\Repositories\Shop;
use App\Models\Shop\Offer;
use App\Models\Shop\PriceListValue;
use App\Models\Shop\SaleChannel;
use App\Traits\Model\Basic;
class Offers
@@ -166,4 +168,60 @@ class Offers
{
return Offer::query();
}
public static function getSaleChannelsForArticle($articleId)
{
$channels = SaleChannel::query()
->whereHas('price_lists', function ($query) use ($articleId) {
$query->whereHas('tariff.offers', function ($subQuery) use ($articleId) {
$subQuery->byArticle($articleId);
})->whereHas('price_list_values');
})
->orderBy('name')
->get();
$offers = Offer::query()
->byArticle($articleId)
->with([
'article',
'tariff:id,status_id',
])
->get();
return $channels->map(function ($channel) use ($offers) {
$priceValue = null;
$candidateOffer = null;
foreach ($offers as $offer) {
$priceCandidate = self::getPrice($offer->id, 1, $channel->id);
if ($priceCandidate && (float) $priceCandidate->price_taxed > 0) {
$priceValue = $priceCandidate;
$candidateOffer = $offer;
break;
}
}
$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;
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,
];
})->toArray();
}
}

View File

@@ -2,12 +2,15 @@
namespace App\Repositories\Shop;
use App\Models\Shop\Invoice;
use App\Models\Shop\InvoicePayment;
use App\Repositories\Core\DateTime;
use Bnb\PayboxGateway\Requests\Paybox\AuthorizationWithCapture;
use Bnb\PayboxGateway\Requests\PayboxDirect\Capture;
use Bnb\PayboxGateway\Responses\Exceptions\InvalidSignature;
use Bnb\PayboxGateway\Responses\Paybox\Verify;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class Paybox
@@ -23,17 +26,141 @@ class Paybox
public static function verifyPayment($invoiceId)
{
$invoice = Invoices::get($invoiceId);
$payboxVerify = App::make(Verify::class);
try {
$success = $payboxVerify->isSuccess($invoice->total_shipped);
if ($success) {
// process order here after making sure it was real payment
}
echo 'OK';
} catch (InvalidSignature $e) {
Log::alert('Invalid payment signature detected');
$invoice = Invoices::get($invoiceId, ['order']);
if (! $invoice) {
Log::warning('Paybox callback received for unknown invoice', [
'invoice_id' => $invoiceId,
'payload' => request()->all(),
]);
return false;
}
$payboxVerify = App::make(Verify::class);
try {
$isSuccessful = $payboxVerify->isSuccess($invoice->total_shipped);
} catch (InvalidSignature $e) {
Log::alert('Invalid payment signature detected', [
'invoice_id' => $invoiceId,
'payload' => request()->except('signature'),
]);
return false;
}
if (! $isSuccessful) {
Log::warning('Paybox payment verification failed', [
'invoice_id' => $invoiceId,
'response_code' => $payboxVerify->getResponseCode(),
'payload' => request()->except('signature'),
]);
return false;
}
return self::finalizeInvoicePayment($invoice);
}
protected static function finalizeInvoicePayment(Invoice $invoice)
{
$order = $invoice->order;
if (! $order) {
Log::error('Paybox payment cannot be finalized: missing related order', [
'invoice_id' => $invoice->id,
]);
return false;
}
$request = request();
$referenceParts = array_filter([
$request->input('call_number'),
$request->input('transaction_number'),
]);
$reference = $referenceParts ? implode('-', $referenceParts) : $request->input('authorization_number');
if (! $reference) {
$reference = 'paybox-'.$invoice->id;
}
$payload = $request->except('signature');
$existingPayment = InvoicePayment::where('invoice_id', $invoice->id)
->where('reference', $reference)
->first();
$shouldNotify = false;
$validatedTotal = InvoicePayment::where('invoice_id', $invoice->id)
->validated()
->sum('amount');
if (! $existingPayment && (float) $validatedTotal >= (float) $invoice->total_shipped) {
Log::info('Paybox payment ignored: invoice already fully settled', [
'invoice_id' => $invoice->id,
'order_id' => $order->id,
'reference' => $reference,
]);
return true;
}
DB::transaction(function () use ($invoice, $order, $reference, $payload, $existingPayment, &$shouldNotify) {
$attributes = [
'payment_type' => 1,
'amount' => $invoice->total_shipped,
'date' => DateTime::getDate(),
'data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
'validated' => 1,
];
if ($existingPayment) {
$previousValidationState = (int) ($existingPayment->validated ?? 0);
$existingPayment->fill($attributes);
if ($existingPayment->isDirty()) {
$existingPayment->save();
}
if ($previousValidationState !== 1 && (int) $existingPayment->validated === 1) {
$shouldNotify = true;
}
} else {
InvoicePayment::create($attributes + [
'invoice_id' => $invoice->id,
'reference' => $reference,
]);
$shouldNotify = true;
}
Invoices::checkPayments($invoice->id);
$paidStatus = Orders::getStatusByName('Préparation');
if ($paidStatus !== '' && (int) $order->status !== (int) $paidStatus) {
$order->status = $paidStatus;
$order->save();
}
});
if ($shouldNotify) {
try {
OrderMails::sendOrderConfirmed($order->id);
} catch (\Throwable $exception) {
Log::error('Unable to send order confirmation email after Paybox payment', [
'order_id' => $order->id,
'invoice_id' => $invoice->id,
'exception' => $exception->getMessage(),
]);
}
}
Log::info('Paybox payment finalized successfully', [
'invoice_id' => $invoice->id,
'order_id' => $order->id,
'reference' => $reference,
'notified' => $shouldNotify,
]);
return true;
}
public static function getPreviousAuthorizedRequest($request)

View File

@@ -3,21 +3,52 @@
namespace App\Repositories\Shop;
use App\Models\Shop\SaleChannel;
use App\Repositories\Shop\Customers;
use App\Traits\Model\Basic;
class SaleChannels
{
use Basic;
public static function getDefaultID()
public static function getDefaultID($customerId = false)
{
$default = self::getDefault();
$default = self::getDefault($customerId);
return $default ? self::getDefault()->id : false;
return $default ? $default->id : false;
}
public static function getDefault()
public static function getDefault($customerId = false)
{
$sessionChannelId = session('shop.default_sale_channel_id');
if ($sessionChannelId) {
$sessionChannel = SaleChannel::find($sessionChannelId);
if ($sessionChannel) {
return $sessionChannel;
}
}
$customer = $customerId ? Customers::get($customerId) : Customers::getAuth();
if ($customer) {
$customer->loadMissing('sale_channels');
if ($customer->default_sale_channel_id) {
$preferred = $customer->sale_channels->firstWhere('id', $customer->default_sale_channel_id);
if (! $preferred) {
$preferred = SaleChannel::find($customer->default_sale_channel_id);
}
if ($preferred) {
session(['shop.default_sale_channel_id' => $preferred->id]);
return $preferred;
}
}
if ($customer->sale_channels->isNotEmpty()) {
session(['shop.default_sale_channel_id' => $customer->sale_channels->first()->id]);
return $customer->sale_channels->first();
}
}
return self::getByCode('EXP');
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Repositories\Shop;
use App\Repositories\Core\User\ShopCart;
class Shop
{
public static function getSaleChannelAvailabilitySummary(array $saleChannelIds): array
{
if (empty($saleChannelIds) || ShopCart::count() === 0) {
return [];
}
$cartItems = ShopCart::getContent();
$summary = [];
foreach ($saleChannelIds as $saleChannelId) {
$saleChannelId = (int) $saleChannelId;
$issues = [];
$issueCount = 0;
foreach ($cartItems as $item) {
$offerId = (int) $item->id;
if (! Offers::getPrice($offerId, 1, $saleChannelId)) {
$offer = Offers::get($offerId, ['article']);
$issues[] = $offer->article->name ?? $item->name;
$issueCount++;
if (count($issues) >= 3) {
continue;
}
}
}
if (! empty($issues)) {
$summary[$saleChannelId] = [
'full_count' => $issueCount,
'names' => array_slice($issues, 0, 3),
];
}
}
return $summary;
}
}

View File

@@ -1,17 +0,0 @@
// Prevent closing from click inside dropdown
$(document).on('click', '.dropdown-menu', function (e) {
e.stopPropagation();
});
// 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();
}
$('.dropdown').on('hide.bs.dropdown', function () {
$(this).find('.submenu').hide();
});
});
}

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('shop_deliveries')) {
return;
}
$columns = ['created_by', 'updated_by', 'deleted_by'];
$columnsToDrop = [];
foreach ($columns as $column) {
if (Schema::hasColumn('shop_deliveries', $column)) {
$columnsToDrop[] = $column;
}
}
if ($columnsToDrop) {
Schema::table('shop_deliveries', function (Blueprint $table) use ($columnsToDrop) {
$table->dropColumn($columnsToDrop);
});
}
Schema::table('shop_deliveries', function (Blueprint $table) {
$table->unsignedBigInteger('created_by')->nullable()->after('event_date_end');
$table->unsignedBigInteger('updated_by')->nullable()->after('created_by');
$table->unsignedBigInteger('deleted_by')->nullable()->after('updated_by');
});
}
public function down(): void
{
if (! Schema::hasTable('shop_deliveries')) {
return;
}
Schema::table('shop_deliveries', function (Blueprint $table) {
$table->dropColumn(['created_by', 'updated_by', 'deleted_by']);
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('shop_customers', function (Blueprint $table) {
if (! Schema::hasColumn('shop_customers', 'default_sale_channel_id')) {
$table->unsignedInteger('default_sale_channel_id')->nullable()->after('settings');
$table->index('default_sale_channel_id', 'shop_customers_default_sale_channel_id_index');
}
});
}
public function down()
{
Schema::table('shop_customers', function (Blueprint $table) {
if (Schema::hasColumn('shop_customers', 'default_sale_channel_id')) {
$table->dropIndex('shop_customers_default_sale_channel_id_index');
$table->dropColumn('default_sale_channel_id');
}
});
}
};

View File

@@ -223,6 +223,9 @@ return [
'successmod' => 'Le canal de vente a été correctement modifié',
'successdel' => 'Le canal de vente a été correctement effacé',
'confirmdelete' => 'Confirmez-vous la suppression du canal de vente ?',
'missing_offers' => '{1} Ce canal de vente n\'a pas d\'offre pour :count produit.|[2,*] Ce canal de vente n\'a pas d\'offre pour :count produits.',
'missing_offers_all' => 'Ce canal de vente n\'a aucune offre pour tous les produits de votre panier.',
'cannot_select_with_cart' => 'Vous ne pouvez pas sélectionner ce mode d\'achat tant que votre panier contient des produits non disponibles dans ce mode.',
],
'shelves' => [
'title' => 'Rayons',

View File

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

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