Магазин на Laravel 7, часть 24. Фильтр товаров категории по цене, новинкам и лидерам продаж
16.11.2020
Теги: Laravel • MySQL • PHP • Web-разработка • БазаДанных • ИнтернетМагазин • КаталогТоваров • Практика • Форма • Фреймворк • ШаблонСайта
Фильтр для товаров категории
Поскольку у нас теперь товары имеют атрибуты new
, hit
и sale
, мы можем их отбирать по этим атрибутам. Другими словами — реализовать фильтр товаров. Давайте создадим шаблон filter.blade.php
в директрии views/catalog/part
и подключим его в шаблоне catalog.category
.
@extends('layout.site', ['title' => $category->name]) @section('content') <h1>{{ $category->name }}</h1> <p>{{ $category->content }}</p> <div class="row"> @foreach ($category->children as $child) @include('catalog.part.category', ['category' => $child]) @endforeach </div> <div class="bg-info p-2 mb-4"> <!-- Фильтр для товаров категории --> <form method="get" action="{{ route('catalog.category', ['category' => $category->slug]) }}"> @include('catalog.part.filter') <a href="{{ route('catalog.category', ['category' => $category->slug]) }}" class="btn btn-light">Сбросить</a> </form> </div> <div class="row"> @foreach ($products as $product) @include('catalog.part.product', ['product' => $product]) @endforeach </div> {{ $products->links() }} @endsection
<!-- цена (руб) --> <select name="price" class="form-control d-inline w-25 mr-4" title="Цена"> <option value="0">Выберите цену</option> <option value="min"@if(request()->price == 'min') selected @endif>Дешевые товары</option> <option value="max"@if(request()->price == 'max') selected @endif>Дорогие товары</option> </select> <!-- новинка --> <div class="form-check form-check-inline"> <input type="checkbox" name="new" class="form-check-input" id="new-product" @if(request()->has('new')) checked @endif value="yes"> <label class="form-check-label" for="new-product">Новинка</label> </div> <!-- лидер продаж --> <div class="form-check form-check-inline"> <input type="checkbox" name="hit" class="form-check-input" id="hit-product" @if(request()->has('hit')) checked @endif value="yes"> <label class="form-check-label" for="hit-product">Лидер продаж</label> </div> <!-- распродажа --> <div class="form-check form-check-inline "> <input type="checkbox" name="sale" class="form-check-input" id="sale-product" @if(request()->has('sale')) checked @endif value="yes"> <label class="form-check-label" for="sale-product">Распродажа</label> </div> <button type="submit" class="btn btn-light">Фильтровать</button>
Изменим метод category()
контроллера CatalogController
:
class CatalogController extends Controller { /* ... */ public function category(Request $request, Category $category) { $descendants = $category->getAllChildren($category->id); $descendants[] = $category->id; $builder = Product::whereIn('category_id', $descendants); // дешевые или дорогие товары if ($request->has('price') && in_array($request->price, ['min', 'max'])) { $products = $builder->get(); $count = $products->count(); if ($count > 1) { $max = $builder->get()->max('price'); // цена самого дорогого товара $min = $builder->get()->min('price'); // цена самого дешевого товара $avg = ($min + $max) * 0.5; if ($request->price == 'min') { $builder->where('price', '<=', $avg); } else { $builder->where('price', '>=', $avg); } } } // отбираем только новинки if ($request->has('new')) { $builder->where('new', true); } // отбираем только лидеров продаж if ($request->has('hit')) { $builder->where('hit', true); } // отбираем только со скидкой if ($request->has('sale')) { $builder->where('sale', true); } $products = $builder->paginate(6)->withQueryString(); return view('catalog.category', compact('category', 'products')); } /* ... */ }
Фильтр по цене вычисляет среднюю цену товара в категории как среднее арифметическое максимальной и минимальной цены. Если выбраны дешевые товары — будут показаны товары, у которых цена меньше или равна средней арифметической, если выбраны дорогие товары — будут показаны товары, у которых цена больше или равна средней арифметической.
У этого алгоритма есть недостаток — если в категории большинство товаров дешевые (например — 100.00, 200.00, 300.00 и 400.рублей), а дорогих товаров мало (например — только один 1000.00 рублей), то средняя цена будет 550.00 рублей. Когда выбраны дешевые товары — будут показано четыре товара, а когда дорогие — только один.
Возможно, есть смысл вычислить среднюю цену как среднее арифметическре всех цен в разделе каталога, тогда распределение по дешевым и дорогим будет более равномерным. Еще один алгоритм отбора дешевых и дорогих товаров — делить их ровно пополам. То есть 50% товаров попадают в дешевые, а еще 50% — попадают в дорогие.
class CatalogController extends Controller { /* ... */ public function category(Request $request, Category $category) { $descendants = $category->getAllChildren($category->id); $descendants[] = $category->id; $builder = Product::whereIn('category_id', $descendants); // дешевые или дорогие товары if ($request->has('price') && in_array($request->price, ['min', 'max'])) { $products = $builder->get()->sortBy('price')->values(); $count = $products->count(); if ($count > 1) { $half = intdiv($count, 2); if ($count % 2) { // нечетное кол-во товаров, надо найти цену товара, который ровно посередине $avg = $products[$half]['price']; } else { // четное количество, надо найти такую цену, которая поделит товары пополам $avg = 0.5 * ($products[$half - 1]['price'] + $products[$half]['price']); } if ($request->price == 'min') { $builder->where('price', '<=', $avg); } else { $builder->where('price', '>=', $avg); } } } // отбираем только новинки if ($request->has('new')) { $builder->where('new', true); } // отбираем только лидеров продаж if ($request->has('hit')) { $builder->where('hit', true); } // отбираем только со скидкой if ($request->has('sale')) { $builder->where('sale', true); } $products = $builder->paginate(6)->withQueryString(); return view('catalog.category', compact('category', 'products')); } /* ... */ }
Рефакторинг кода
Теперь метод category()
контроллера выглядит запутанно, и выполняет слишком много работы. Давайте вынесем фильтрацию товаров в отдельный класс app/Helpers/ProductFilter
:
namespace App\Helpers; use Illuminate\Http\Request; use Illuminate\Database\Eloquent\Builder; class ProductFilter { private $builder; private $request; public function __construct(Builder $builder, Request $request) { $this->builder = $builder; $this->request = $request; } public function apply() { foreach ($this->request->query() as $filter => $value) { if (method_exists($this, $filter)) { $this->$filter($value); } } return $this->builder; } private function price($value) { if (in_array($value, ['min', 'max'])) { $products = $this->builder->get(); $count = $products->count(); if ($count > 1) { $max = $this->builder->get()->max('price'); // цена самого дорогого товара $min = $this->builder->get()->min('price'); // цена самого дешевого товара $avg = ($min + $max) * 0.5; if ($value == 'min') { $this->builder->where('price', '<=', $avg); } else { $this->builder->where('price', '>=', $avg); } } } } private function new($value) { if ('yes' == $value) { $this->builder->where('new', true); } } private function hit($value) { if ('yes' == $value) { $this->builder->where('hit', true); } } private function sale($value) { if ('yes' == $value) { $this->builder->where('sale', true); } } }
class CatalogController extends Controller { /* ... */ public function category(Request $request, Category $category) { $descendants = $category->getAllChildren($category->id); $descendants[] = $category->id; $builder = Product::whereIn('category_id', $descendants); $products = (new ProductFilter($builder, $request))->apply()->paginate(6)->withQueryString(); return view('catalog.category', compact('category', 'products')); } /* ... */ }
Используем скоупы
Сейчас, чтобы получить товары категории, мы используем следующий код:
class CatalogController extends Controller { /* ... */ public function category(Request $request, Category $category) { $descendants = $category->getAllChildren($category->id); $descendants[] = $category->id; $builder = Product::whereIn('category_id', $descendants); $products = (new ProductFilter($builder, $request))->apply()->paginate(6)->withQueryString(); return view('catalog.category', compact('category', 'products')); } /* ... */ }
При использовании локального скоупа (scope), получаем уже такой код:
class CatalogController extends Controller { /* ... */ public function category(Request $request, Category $category) { $descendants = $category->getAllChildren($category->id); $descendants[] = $category->id; $builder = Product::categoryProducts($descendants); $products = (new ProductFilter($builder, $request))->apply()->paginate(6)->withQueryString(); return view('catalog.category', compact('category', 'products')); } /* ... */ }
class Product extends Model { /** * Позволяет выбирать товары категории и всех ее потомков * * @param \Illuminate\Database\Eloquent\Builder $builder * @param array $parents * @return \Illuminate\Database\Eloquent\Builder */ public function scopeCategoryProducts($builder, $parents) { return $builder->whereIn('category_id', $parents); } /* ... */ }
Кажется, что в этом нет особого смысла, но давайте добавим еще один скоуп:
class CatalogController extends Controller { /* ... */ public function category(Category $category, ProductFilter $filters) { $descendants = $category->getAllChildren($category->id); $descendants[] = $category->id; // получаем товары категории и ее потомков, потом применяем фильтры $builder = Product::categoryProducts($descendants)->filterProducts($filters); $products = $builder->paginate(6)->withQueryString(); return view('catalog.category', compact('category', 'products')); } /* ... */ }
class Product extends Model { /** * Позволяет выбирать товары категории и всех ее потомков * * @param \Illuminate\Database\Eloquent\Builder $builder * @param array $parents * @return \Illuminate\Database\Eloquent\Builder */ public function scopeCategoryProducts($builder, $parents) { return $builder->whereIn('category_id', $parents); } /** * Позволяет фильтровать товары по нескольким условиям * * @param \Illuminate\Database\Eloquent\Builder $builder * @param $filters * @return \Illuminate\Database\Eloquent\Builder */ public function scopeFilterProducts($builder, $filters) { return $filters->apply($builder); } /* ... */ }
Мы внедряем экземпляр класса ProductFilter
в метод category()
контроллера. Создаем экземпляр построителя запроса, используя скоуп scopeCategoryProducts()
и отбираем товары категории. Но скоуп scopeFilterProducts()
используем немного не так, как принято делать. Вместо того, чтобы добавить условие where()
— вызываем метод apply()
класса ProductFilter
. Который вернет нам все тот же объект построителя запроса, к которому добавлено несколько условий where()
— это наши фильтры.
class ProductFilter { private $builder; private $request; public function __construct(Request $request) { $this->request = $request; } public function apply($builder) { $this->builder = $builder; foreach ($this->request->query() as $filter => $value) { if (method_exists($this, $filter)) { $this->$filter($value); } } return $this->builder; } /* ... */ }
Только вызов метода Category::getAllChildren()
все портит — давайте сделаем этот метод статическим. И будем его вызывать в методе scopeCategoryProducts()
модели Product
, а не в методе category()
контроллера CatalogController
.
class Category extends Model { /** * Возвращает всех потомков категории с идентификатором $id */ public static function getAllChildren($id) { // получаем прямых потомков категории с идентификатором $id $children = self::where('parent_id', $id)->with('children')->get(); $ids = []; foreach ($children as $child) { $ids[] = $child->id; // для каждого прямого потомка получаем его прямых потомков if ($child->children->count()) { $ids = array_merge($ids, self::getAllChildren($child->id)); } } return $ids; } }
class Product extends Model { /** * Позволяет выбирать товары категории и всех ее потомков * * @param \Illuminate\Database\Eloquent\Builder $builder * @param integer $id * @return \Illuminate\Database\Eloquent\Builder */ public function scopeCategoryProducts($builder, $id) { $descendants = Category::getAllChildren($id); $descendants[] = $id; return $builder->whereIn('category_id', $descendants); } /** * Позволяет фильтровать товары по нескольким условиям * * @param \Illuminate\Database\Eloquent\Builder $builder * @param \App\Helpers\ProductFilter $filters * @return \Illuminate\Database\Eloquent\Builder */ public function scopeFilterProducts($builder, $filters) { return $filters->apply($builder); } /* ... */ }
Вот теперь метод category()
контроллера выглядит вполне прилично:
class CatalogController extends Controller { /* ... */ public function category(Category $category, ProductFilter $filters) { $products = Product::categoryProducts($category->id) // товары категории и всех ее потомков ->filterProducts($filters) // фильтруем товары категории и всех ее потомков ->paginate(6) ->withQueryString(); return view('catalog.category', compact('category', 'products')); } /* ... */ }
Фильтр для товаров бренда
Аналогичный фильтр можем сделать для товаров бренда, для этого добавляем форму в шаблон catalog.brand
:
@extends('layout.site', ['title' => $brand->name]) @section('content') <h1>{{ $brand->name }}</h1> <p>{{ $brand->content }}</p> <div class="bg-info p-2 mb-4"> <!-- Фильтр для товаров бренда --> <form method="get" action="{{ route('catalog.brand', ['brand' => $brand->slug]) }}"> @include('catalog.part.filter') <a href="{{ route('catalog.brand', ['brand' => $brand->slug]) }}" class="btn btn-light">Сбросить</a> </form> </div> <div class="row"> @foreach ($products as $product) @include('catalog.part.product', ['product' => $product]) @endforeach </div> {{ $products->links() }} @endsection
И изменяем метод brand()
контроллера CatalogController
:
class CatalogController extends Controller { /* ... */ public function brand(Brand $brand, ProductFilter $filters) { $products = $brand ->products() // возвращает построитель запроса ->filterProducts($filters) ->paginate(6) ->withQueryString(); return view('catalog.brand', compact('brand', 'products')); } /* ... */ }
Приводим в порядок маршруты
Это файл routes/web.php
— чтобы сразу было понятно, какой маршрут за что отвечает:
/* * Главная страница интернет-магазина */ Route::get('/', 'IndexController')->name('index'); /* * Страницы «Доставка», «Контакты» и прочие */ Route::get('/page/{page:slug}', 'PageController')->name('page.show'); /* * Каталог товаров: категория, бренд и товар */ Route::group([ 'as' => 'catalog.', // имя маршрута, например catalog.index 'prefix' => 'catalog', // префикс маршрута, например catalog/index ], function () { // главная страница каталога Route::get('index', 'CatalogController@index') ->name('index'); // категория каталога товаров Route::get('category/{category:slug}', 'CatalogController@category') ->name('category'); // бренд каталога товаров Route::get('brand/{brand:slug}', 'CatalogController@brand') ->name('brand'); // страница товара каталога Route::get('product/{product:slug}', 'CatalogController@product') ->name('product'); }); /* * Корзина покупателя */ Route::group([ 'as' => 'basket.', // имя маршрута, например basket.index 'prefix' => 'basket', // префикс маршрута, например bsaket/index ], function () { // список всех товаров в корзине Route::get('index', 'BasketController@index') ->name('index'); // страница с формой оформления заказа Route::get('checkout', 'BasketController@checkout') ->name('checkout'); // получение данных профиля для оформления Route::post('profile', 'BasketController@profile') ->name('profile'); // отправка данных формы для сохранения заказа в БД Route::post('saveorder', 'BasketController@saveOrder') ->name('saveorder'); // страница после успешного сохранения заказа в БД Route::get('success', 'BasketController@success') ->name('success'); // отправка формы добавления товара в корзину Route::post('add/{id}', 'BasketController@add') ->where('id', '[0-9]+') ->name('add'); // отправка формы изменения кол-ва отдельного товара в корзине Route::post('plus/{id}', 'BasketController@plus') ->where('id', '[0-9]+') ->name('plus'); // отправка формы изменения кол-ва отдельного товара в корзине Route::post('minus/{id}', 'BasketController@minus') ->where('id', '[0-9]+') ->name('minus'); // отправка формы удаления отдельного товара из корзины Route::post('remove/{id}', 'BasketController@remove') ->where('id', '[0-9]+') ->name('remove'); // отправка формы для удаления всех товаров из корзины Route::post('clear', 'BasketController@clear') ->name('clear'); }); /* * Регистрация, вход в ЛК, восстановление пароля */ Route::name('user.')->prefix('user')->group(function () { Auth::routes(); }); /* * Личный кабинет зарегистрированного пользователя */ Route::group([ 'as' => 'user.', // имя маршрута, например user.index 'prefix' => 'user', // префикс маршрута, например user/index 'middleware' => ['auth'] // один или несколько посредников ], function () { // главная страница личного кабинета пользователя Route::get('index', 'UserController@index')->name('index'); // CRUD-операции над профилями пользователя Route::resource('profile', 'ProfileController'); // просмотр списка заказов в личном кабинете Route::get('order', 'OrderController@index')->name('order.index'); // просмотр отдельного заказа в личном кабинете Route::get('order/{order}', 'OrderController@show')->name('order.show'); }); /* * Панель управления магазином для администратора сайта */ Route::group([ 'as' => 'admin.', // имя маршрута, например admin.index 'prefix' => 'admin', // префикс маршрута, например admin/index 'namespace' => 'Admin', // пространство имен контроллера 'middleware' => ['auth', 'admin'] // один или несколько посредников ], function () { // главная страница панели управления Route::get('index', 'IndexController')->name('index'); // CRUD-операции над категориями каталога Route::resource('category', 'CategoryController'); // CRUD-операции над брендами каталога Route::resource('brand', 'BrandController'); // CRUD-операции над товарами каталога Route::resource('product', 'ProductController'); // доп.маршрут для показа товаров категории Route::get('product/category/{category}', 'ProductController@category') ->name('product.category'); // просмотр и редактирование заказов Route::resource('order', 'OrderController', ['except' => [ 'create', 'store', 'destroy' ]]); // просмотр и редактирование пользователей Route::resource('user', 'UserController', ['except' => [ 'create', 'store', 'show', 'destroy' ]]); // CRUD-операции над страницами сайта Route::resource('page', 'PageController'); // загрузка изображения из wysiwyg-редактора Route::post('page/upload/image', 'PageController@uploadImage') ->name('page.upload.image'); // удаление изображения в wysiwyg-редакторе Route::delete('page/remove/image', 'PageController@removeImage') ->name('page.remove.image'); });
- Магазин на Laravel 7, часть 13. Панель управления, обрезка изображения и валидация данных
- Магазин на Laravel 7, часть 12. Панель управления, создание и редактирование категорий
- Магазин на Laravel 7, часть 10. Форма оформления, сохранение заказа в базу данных
- Магазин на Laravel 7, часть 25. Поиск по каталогу товаров, деплой проекта на хостинг TimeWeb
- Магазин на Laravel 7, часть 23. Главная страница сайта, новинки, лидеры продаж и распродажа
- Магазин на Laravel 7, часть 21. Добавляем профили и используем их при оформлении заказа
- Магазин на Laravel 7, часть 20. Показ отдельной страницы и верхнее меню всех страниц
Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Интернет магазин • Каталог товаров • Практика • Форма • Фреймворк • Шаблон сайта • Фильтр