51 Commits

Author SHA1 Message Date
Valentin Lab
6fee40c55c new: make all forms have a cancel/save button on the top also 2025-12-13 21:17:07 +01:00
Valentin Lab
f5d21fe083 fix: prevent error 500 upon displaying empty 'Rayons' 2025-12-13 21:05:08 +01:00
Valentin Lab
2fe818e8b4 fix: make 'Rayons' title adaptable to screen width 2025-12-13 20:55:07 +01:00
Valentin Lab
0ec13e802e new: make the menu visible on mobile 2025-12-13 20:10:42 +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
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
167 changed files with 2274 additions and 363 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 \
@@ -55,6 +56,8 @@ 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
@@ -83,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 \
@@ -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

@@ -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

@@ -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

@@ -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

@@ -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,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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;
@@ -16,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];
@@ -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;
}
@@ -108,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:
}
@@ -148,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) {

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,9 @@
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;
class Offers
@@ -166,4 +169,87 @@ 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',
'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) {
// 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

@@ -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

@@ -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

@@ -3,22 +3,53 @@
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)
{
return self::getByCode('EXP');
$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('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

@@ -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

@@ -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,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

@@ -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

@@ -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

@@ -112,3 +112,17 @@ 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;
}

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,40 @@ div.megamenu ul.megamenu li.megamenu.level1
.dropdown-menu > li:hover > .submenu{
display: block;
}
}
}
@media (max-width: 991.98px){
#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

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