Магазин на Laravel 7, часть 22. Рефакторинг кода, работа над каталогом товаров и корзиной

04.11.2020

Теги: LaravelMySQLPHPWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровКорзинаПрактикаШаблонСайта

Рефакторинг кода

Фреймворк Laravel имеет в своём арсенале много полезных функций, и одна из них — привязка модели к маршруту (Route Model Binding). Привязка модели к маршруту — это механизм внедрения экземпляра модели по ключу маршрута. Звучит сложно, но на самом деле все просто. Мы уже использовали привязку в панели управления много раз.

Сейчас наш контроллер CatalogController, который отвечает за страницы каталога товаров, выглядит так:

namespace App\Http\Controllers;

use App\Models\Brand;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;

class CatalogController extends Controller {
    public function index() {
        $roots = Category::where('parent_id', 0)->get();
        return view('catalog.index', compact('roots'));
    }

    public function category($slug) {
        $category = Category::where('slug', $slug)->firstOrFail();
        return view('catalog.category', compact('category'));
    }

    public function brand($slug) {
        $brand = Brand::where('slug', $slug)->firstOrFail();
        return view('catalog.brand', compact('brand'));
    }

    public function product($slug) {
        $product = Product::where('slug', $slug)->firstOrFail();
        return view('catalog.product', compact('product'));
    }
}

И у нас есть маршруты в файле routes/web.php:

Route::get('/catalog/index', 'CatalogController@index')->name('catalog.index');
Route::get('/catalog/category/{slug}', 'CatalogController@category')->name('catalog.category');
Route::get('/catalog/brand/{slug}', 'CatalogController@brand')->name('catalog.brand');
Route::get('/catalog/product/{slug}', 'CatalogController@product')->name('catalog.product');

Мы можем упростить контроллер, чтобы не самим получать экземпляр модели категории, бренда и товара, а доверить это Laravel. Фреймворк будет внедрять экземпляры моделей в методы контроллера, если мы используем «type hinting».

namespace App\Http\Controllers;

use App\Models\Brand;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;

class CatalogController extends Controller {
    public function index() {
        $roots = Category::where('parent_id', 0)->get();
        return view('catalog.index', compact('roots'));
    }

    public function category(Category $category) {
        return view('catalog.category', compact('category'));
    }

    public function brand(Brand $brand) {
        return view('catalog.brand', compact('brand'));
    }

    public function product(Product $product) {
        return view('catalog.product', compact('product'));
    }
}

Важно, что название параметра /catalog/brand/{brand} должно соответствовать имени переменной аргумента метода brand(Brand $brand). Только так фреймворк будет понимать, что надо создать экземпляр модели App\Models\Brand и внедрить его в метод brand().

Route::get('/catalog/brand/{brand}', 'CatalogController@brand')->name('catalog.brand');
class CatalogController extends Controller {
    public function brand(Brand $brand) {
        return view('catalog.brand', compact('brand'));
    }
}

Давайте так и сделаем:

Route::get('/catalog/index', 'CatalogController@index')->name('catalog.index');
Route::get('/catalog/category/{category}', 'CatalogController@category')->name('catalog.category');
Route::get('/catalog/brand/{brand}', 'CatalogController@brand')->name('catalog.brand');
Route::get('/catalog/product/{product}', 'CatalogController@product')->name('catalog.product');

Фреймворк по умолчанию берёт параметр {brand} из маршрута и ищет запись в таблице БД brands по полю id. Чтобы изменить поле, по которому идет поиск записи в таблице БД, можно поступить так:

Route::get('/catalog/index', 'CatalogController@index')->name('catalog.index');
Route::get('/catalog/category/{category:slug}', 'CatalogController@category')->name('catalog.category');
Route::get('/catalog/brand/{brand:slug}', 'CatalogController@brand')->name('catalog.brand');
Route::get('/catalog/product/{product:slug}', 'CatalogController@product')->name('catalog.product');

Теперь поиск записи будет по уникальному полю slug. Но осталось еще исправить имя параметра в шаблонах — заменить slug на category, brand или product. Сильно жалею, что не сделал с самого начала, так что теперь много муторной работы. Надо найти все вызовы функции route() и заменить имя параметра.

<a href="{{ route('catalog.brand', ['slug' => $brand->slug]) }}">{{ $brand->name }}</a>
<a href="{{ route('catalog.brand', ['brand' => $brand->slug]) }}">{{ $brand->name }}</a>
<a href="{{ route('catalog.category', ['slug' => $category->slug]) }}">{{ $category->name }}</a>
<a href="{{ route('catalog.category', ['category' => $category->slug]) }}">{{ $category->name }}</a>
<a href="{{ route('catalog.product', ['slug' => $product->slug]) }}">{{ $product->name }}</a>
<a href="{{ route('catalog.product', ['product' => $product->slug]) }}">{{ $product->name }}</a>

Еще лучше — вообще не указывать имя параметра, чтобы больше с этим никогда не сталкиваться, если возникнет необходимость изменить имя параметра маршрута.

<a href="{{ route('catalog.brand', [$brand->slug]) }}">{{ $brand->name }}</a>
<a href="{{ route('catalog.category', [$category->slug]) }}">{{ $category->name }}</a>
<a href="{{ route('catalog.product', [$product->slug]) }}">{{ $product->name }}</a>

Работа над каталогом

У нас каталог в публичной части пока что толком не реализован, есть лишь некоторые общие черты — как это должно работать. Например, нет постраничной навигации, не показываются дочерние категории (что затрудняет навигацию), нет хлебных крошек и так далее.

1. Дочерние категории

Для каждой категории каталога будем показывать дочерние категории для удобства навигации. Кроме того, для каждой категории будем показывать товары не только этой категории, но и товары, принадлежащие потомкам этой категории. Для этого изменим метод category() контроллера CatalogController.

class CatalogController extends Controller {
    public function category(Category $category) {
        // получаем всех потомков этой категории
        $descendants = $category->getAllChildren($category->id);
        $descendants[] = $category->id;
        // товары этой категории и всех потомков
        $products = Product::whereIn('category_id', $descendants)->paginate(6);
        return view('catalog.category', compact('category', 'products'));
    }
}

Шаблон для показа категории views/catalog/category.blade.php:

@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>
    <h5 class="bg-info text-white p-2 mb-4">Товары раздела</h5>
    <div class="row">
        @foreach ($products as $product)
            @include('catalog.part.product', ['product' => $product])
        @endforeach
    </div>
    {{ $products->links() }}
@endsection

Вспомогательный шаблон views/catalog/part/category.blade.php:

<div class="col-md-4 mb-4">
    <div class="card list-item">
        <div class="card-header">
            <h3 class="mb-0">{{ $category->name }}</h3>
        </div>
        <div class="card-body p-0">
            @if ($category->image)
                @php $url = url('storage/catalog/category/thumb/' . $category->image) @endphp
                <img src="{{ $url }}" class="img-fluid" alt="">
            @else
                <img src="https://via.placeholder.com/300x150" class="img-fluid" alt="">
            @endif
        </div>
        <div class="card-footer">
            <a href="{{ route('catalog.category', ['category' => $category->slug]) }}"
               class="btn btn-dark">Товары раздела</a>
        </div>
    </div>
</div>

Вспомогательный шаблон views/catalog/part/product.blade.php:

<div class="col-md-4 mb-4">
    <div class="card list-item">
        <div class="card-header">
            <h3 class="mb-0">{{ $product->name }}</h3>
        </div>
        <div class="card-body p-0">
            @if ($product->image)
                @php($url = url('storage/catalog/product/thumb/' . $product->image))
                <img src="{{ $url }}" class="img-fluid" alt="">
            @else
                <img src="https://via.placeholder.com/300x150" class="img-fluid" alt="">
            @endif
        </div>
        <div class="card-footer">
            <!-- Форма для добавления товара в корзину -->
            <form action="{{ route('basket.add', ['id' => $product->id]) }}"
                  method="post" class="d-inline">
                @csrf
                <button type="submit" class="btn btn-success">В корзину</button>
            </form>
            <a href="{{ route('catalog.product', ['product' => $product->slug]) }}"
               class="btn btn-dark float-right">Смотреть</a>
        </div>
    </div>
</div>

2. Главная страница каталога

Кроме корневых категорий будем показывать популярные бренды. Для этого изменим метод index() контроллера CatalogController.

class CatalogController extends Controller {
    public function index() {
        // корневые категории
        $roots = Category::where('parent_id', 0)->get();
        // популярные бренды
        $brands = Brand::popular();
        return view('catalog.index', compact('roots', 'brands'));
    }
}

Шаблон для показа главной страницы каталога views/catalog/category.blade.php:

@extends('layout.site', ['title' => 'Каталог товаров'])

@section('content')
    <h1>Каталог товаров</h1>

    <p>
    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque ducimus, eligendi exercitationem expedita,
    iure iusto laborum magnam qui quidem repellat similique tempora tempore ullam! Deserunt doloremque impedit
    quis repudiandae voluptas.
    </p>

    <h2 class="mb-4">Разделы каталога</h2>
    <div class="row">
        @foreach ($roots as $root)
            @include('catalog.part.category', ['category' => $root])
        @endforeach
    </div>

    <h2 class="mb-4">Популярные бренды</h2>
    <div class="row">
        @foreach ($brands as $brand)
            @include('catalog.part.brand', ['brand' => $brand])
        @endforeach
    </div>
@endsection

Вспомогательный шаблон views/catalog/part/brand.blade.php:

<div class="col-md-4 mb-4">
    <div class="card list-item">
        <div class="card-header px-1">
            <h3 class="mb-0">{{ $brand->name }}</h3>
        </div>
        <div class="card-body p-0">
            @if ($brand->image)
                @php($url = url('storage/catalog/brand/thumb/' . $brand->image))
                <img src="{{ $url }}" class="img-fluid" alt="">
            @else
                <img src="https://via.placeholder.com/300x150" class="img-fluid" alt="">
            @endif
        </div>
        <div class="card-footer px-1">
            <a href="{{ route('catalog.brand', ['brand' => $brand->slug]) }}"
               class="btn btn-dark">Товары бренда</a>
        </div>
    </div>
</div>

3. Товары бренда

Добавим постраничную навигацию для просмотра товаров бренда. Для этого изменим метод brand() контроллера CatalogController.

class CatalogController extends Controller {
    public function brand(Brand $brand) {
        $products = $brand->products()->paginate(6);
        return view('catalog.brand', compact('brand', 'products'));
    }
}

Шаблон для показа товаров бренда views/catalog/brand.blade.php:

@extends('layout.site', ['title' => $brand->name])

@section('content')
    <h1>{{ $brand->name }}</h1>
    <p>{{ $brand->content }}</p>
    <h5 class="bg-info text-white p-1 mb-4">Товары бренда</h5>
    <div class="row">
        @foreach ($products as $product)
            @include('catalog.part.product', ['product' => $product])
        @endforeach
    </div>
    {{ $products->links() }}
@endsection

Добавление в корзину

Мы сейчас добавляем товар в корзину с перезагрузкой страницы, что выглядит совсем уж архаично. Давайте это изменим и будем отправлять на сервер ajax-запрос. Для этого во всех формах добавления в корзину добавим css-класс add-to-basket.

<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product->id]) }}"
      method="post" class="form-inline add-to-basket">
    @csrf
    <label for="input-quantity">Количество</label>
    <input type="text" name="quantity" id="input-quantity" value="1"
           class="form-control mx-2 w-25">
    <button type="submit" class="btn btn-success">Добавить в корзину</button>
</form>
<!-- Форма для добавления товара в корзину -->
<form action="{{ route('basket.add', ['id' => $product->id]) }}"
      method="post" class="d-inline add-to-basket">
    @csrf
    <button type="submit" class="btn btn-success">В корзину</button>
</form>

Теперь редактируем файл js-скрипта site.js:

jQuery(document).ready(function($) {
    /*
     * Общие настройки ajax-запросов, отправка на сервер csrf-токена
     */
    $.ajaxSetup({
        headers: {
            'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
        }
    });
    /*
     * Раскрытие и скрытие пунктов меню каталога в левой колонке
     */
    $('#catalog-sidebar > ul ul').hide();
    $('#catalog-sidebar .badge').on('click', function () {
        /* ... */
    });
    /*
     * Получение данных профиля пользователя при оформлении заказа
     */
    $('form#profiles button[type="submit"]').hide();
    // при выборе профиля отправляем ajax-запрос, чтобы получить данные
    $('form#profiles select').change(function () {
        /* ... */
    });
    /*
     * Добавление товара в корзину с помощью ajax-запроса без перезагрузки
     */
    $('form.add-to-basket').submit(function (e) {
        // отменяем отправку формы стандартным способом
        e.preventDefault();
        // получаем данные этой формы добавления в корзину
        var $form = $(this);
        var data = new FormData($form[0]);
        $.ajax({
            url: $form.attr('action'),
            data: data,
            processData: false,
            contentType: false,
            type: 'POST',
            dataType: 'HTML',
            beforeSend: function () {
                var spinner = ' <span class="spinner-border spinner-border-sm"></span>';
                $form.find('button').append(spinner);
            },
            success: function(html) {
                $form.find('.spinner-border').remove();
                $('#top-basket').html(html);
            }
        });
    });
});

И изменяем метод add() контроллера BasketController, чтобы он мог обрабатывать и ajax-запросы.

class BasketController extends Controller {
    /**
     * Добавляет товар с идентификатором $id в корзину
     */
    public function add(Request $request, $id) {
        $quantity = $request->input('quantity') ?? 1;
        $this->basket->increase($id, $quantity);
        if ( ! $request->ajax()) {
            // выполняем редирект обратно на ту страницу,
            // где была нажата кнопка «В корзину»
            return back();
        }
        // в случае ajax-запроса возвращаем html-код корзины в правом
        // верхнем углу, чтобы заменить исходный html-код, потому что
        // теперь количество позиций будет другим
        $positions = $this->basket->products->count();
        return view('basket.part.basket', compact('positions'));
    }
}

Нам еще потребуется малюсенький шаблон views/basket/part/basket.blade.php:

<a class="nav-link @if ($positions) text-success @endif"
   href="{{ route('basket.index') }}">
    Корзина
    @if ($positions) ({{ $positions }}) @endif
</a>

Этот фрагмент html-кода будет отправляться клиенту при ajax-запросе. А наш js-код этот фрагмент html-кода будет вставлять внутрь элемента #top-basket. Как нетрудно догадаться, идентификатор #top-basket надо прописать в layout-шаблоне site.blade.php.

<!-- Этот блок расположен справа -->
<ul class="navbar-nav ml-auto">
    <li class="nav-item" id="top-basket">
        <a class="nav-link @if ($positions) text-success @endif"
           href="{{ route('basket.index') }}">
            Корзина
            @if ($positions) ({{ $positions }}) @endif
        </a>
    </li>
    @guest
        <!-- ..... -->
    @else
        <!-- ..... -->
    @endif
</ul>

Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Интернет магазин • Каталог товаров • Корзина • Практика • Шаблон сайта

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.