Магазин на Laravel 7, часть 21. Добавляем профили и используем их при оформлении заказа

02.11.2020

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

Нехорошо заставлять постоянного покупателя, который зарегистрирован на сайте, вводить одинаковые данные при каждом оформлении заказа. Давайте добавим возможность создания профилей в личном кабинете. Профиль содержит все данные, которые необходимы, чтобы оформить заказ в магазине, то есть имя и фамилию, адрес почты, номер телефона, адрес доставки. Тогда при оформлении заказа пользователь будет просто выбирать нужный профиль, вместо заполнения всех этих полей.

Добавляем профили

Создаем модель и миграцию с помощью artisan-команды:

> php artisan make:model Models/Profile -m
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateProfilesTable extends Migration {
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up() {
        Schema::create('profiles', function (Blueprint $table) {
            $table->id();
            $table->bigInteger('user_id')->unsigned()->nullable(false);
            $table->string('title')->nullable(false); // название профиля
            $table->string('name')->nullable(false); // имя пользователя
            $table->string('email')->nullable(false); // почта пользователя
            $table->string('phone')->nullable(false); // телефон пользователя
            $table->string('address')->nullable(false); // адрес доставки заказа
            $table->string('comment')->nullable(); // комментарий к заказу
            $table->timestamps();

            // внешний ключ, ссылается на поле id таблицы users
            $table->foreign('user_id')
                ->references('id')
                ->on('users')
                ->cascadeOnDelete();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down() {
        Schema::dropIfExists('profiles');
    }
}

Запускаем миграцию, чтобы создать таблицу базы данных:

> php artisan migrate

Создаем ресурсный контроллер для CRUD-операций над профилями:

> php artisan make:controller ProfileController --resource --model=Models/Profile

Создадим маршртуты для CRUD-операций над профилями:

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');
});

Маршрут главной страницы личного кабинета теперь в другой группе. Эта группа использует посредник auth, то есть все маршруты в ней доступны только аутентифицированным пользователям. А посредник в конструкторе контроллера UserController уберем.

namespace App\Http\Controllers;

class UserController extends Controller {
    public function __construct() {
        $this->middleware('auth');
    }

    public function index() {
        return view('user.index');
    }
}
namespace App\Http\Controllers;

class UserController extends Controller {
    public function index() {
        return view('user.index');
    }
}

Добавим в модель User связи между таблицами:

class User extends Authenticatable {
    /**
     * Связь «один ко многим» таблицы `users` с таблицей `profiles`
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function profiles() {
        return $this->hasMany(Profile::class);
    }
}

Добавим в модель Profile связи между таблицами:

class Profile extends Model {
    /**
     * Связь «профиль принадлежит» таблицы `profiles` с таблицей `users`
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function user() {
        return $this->belongsTo(User::class);
    }
}

Теперь поработаем над контроллером ProfileController:

namespace App\Http\Controllers;

use App\Models\Profile;
use Illuminate\Http\Request;

class ProfileController extends Controller {

    /**
     * Показывает список всех профилей
     *
     * @return \Illuminate\Http\Response
     */
    public function index() {
        $profiles = auth()->user()->profiles()->paginate(4);
        return view('user.profile.index', compact('profiles'));
    }

    /**
     * Показывает форму для создания профиля
     *
     * @return \Illuminate\Http\Response
     */
    public function create() {
        return view('user.profile.create');
    }

    /**
     * Сохраняет новый профиль в базу данных
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request) {
        // проверяем данные формы профиля
        $this->validate($request, [
            'user_id' => 'in:' . auth()->user()->id,
            'title' => 'required|max:255',
            'name' => 'required|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'required|max:255',
            'address' => 'required|max:255',
        ]);
        // валидация пройдена, создаем профиль
        $profile = Profile::create($request->all());
        return redirect()
            ->route('user.profile.show', ['profile' => $profile->id])
            ->with('success', 'Новый профиль успешно создан');
    }

    /**
     * Показывает информацию о профиле
     *
     * @param  \App\Models\Profile  $profile
     * @return \Illuminate\Http\Response
     */
    public function show(Profile $profile) {
        if ($profile->user_id !== auth()->user()->id) {
            abort(404); // это чужой профиль
        }
        return view('user.profile.show', compact('profile'));
    }

    /**
     * Показывает форму для редактирования профиля
     *
     * @param  \App\Models\Profile  $profile
     * @return \Illuminate\Http\Response
     */
    public function edit(Profile $profile) {
        if ($profile->user_id !== auth()->user()->id) {
            abort(404); // это чужой профиль
        }
        return view('user.profile.edit', compact('profile'));
    }

    /**
     * Обновляет профиль (запись в таблице БД)
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Profile  $profile
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Profile $profile) {
        // проверяем данные формы профиля
        $this->validate($request, [
            'user_id' => 'in:' . auth()->user()->id,
            'title' => 'required|max:255',
            'name' => 'required|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'required|max:255',
            'address' => 'required|max:255',
        ]);
        // валидация пройдена, обновляем профиль
        $profile->update($request->all());
        return redirect()
            ->route('user.profile.show', ['profile' => $profile->id])
            ->with('success', 'Профиль был успешно отредактирован');
    }

    /**
     * Удаляет профиль (запись в таблице БД)
     *
     * @param  \App\Models\Profile  $profile
     * @return \Illuminate\Http\Response
     */
    public function destroy(Profile $profile) {
        if ($profile->user_id !== auth()->user()->id) {
            abort(404); // это чужой профиль
        }
        $profile->delete();
        return redirect()
            ->route('user.profile.index')
            ->with('success', 'Профиль был успешно удален');
    }
}

Поскольку мы используем «mass assignment», добавляем свойство fillable:

class Profile extends Model {
    protected $fillable = [
        'user_id',
        'title',
        'name',
        'email',
        'phone',
        'address',
        'comment',
    ];
    /* ... */
}

Шаблон списка всех профилей пользователя views/user/profile/index.blade.php:

@extends('layout.site', ['title' => 'Ваши профили'])

@section('content')
    <h1>Ваши профили</h1>

    <a href="{{ route('user.profile.create') }}" class="btn btn-success mb-4">
        Создать профиль
    </a>

    @if (count($profiles))
        <table class="table table-bordered">
            <tr>
                <th></th>
                <th width="22%">Наименование</th>
                <th width="22%">Имя, Фамилия</th>
                <th width="22%">Адрес почты</th>
                <th width="22%">Номер телефона</th>
                <th><i class="fas fa-edit"></i></th>
                <th><i class="fas fa-trash-alt"></i></th>
            </tr>
            @foreach($profiles as $profile)
                <tr>
                    <td>{{ $loop->iteration }}</td>
                    <td>
                        <a href="{{ route('user.profile.show', ['profile' => $profile->id]) }}">
                            {{ $profile->title }}
                        </a>
                    </td>
                    <td>{{ $profile->name }}</td>
                    <td><a href="mailto:{{ $profile->email }}">{{ $profile->email }}</a></td>
                    <td>{{ $profile->phone }}</td>
                    <td>
                        <a href="{{ route('user.profile.edit', ['profile' => $profile->id]) }}">
                            <i class="far fa-edit"></i>
                        </a>
                    </td>
                    <td>
                        <form action="{{ route('user.profile.destroy', ['profile' => $profile->id]) }}"
                              method="post" onsubmit="return confirm('Удалить этот профиль?')">
                            @csrf
                            @method('DELETE')
                            <button type="submit" class="m-0 p-0 border-0 bg-transparent">
                                <i class="far fa-trash-alt text-danger"></i>
                            </button>
                        </form>
                    </td>
                </tr>
            @endforeach
        </table>
        {{ $profiles->links() }}
    @endif
@endsection

Шаблон просмотра профиля пользователя views/user/profile/show.blade.php:

@extends('layout.site', ['title' => 'Данные профиля'])

@section('content')
    <h1>Данные профиля</h1>

    <p><strong>Название профиля:</strong> {{ $profile->title }}</p>
    <p><strong>Имя, фамилия:</strong> {{ $profile->name }}</p>
    <p>
        <strong>Адрес почты:</strong>
        <a href="mailto:{{ $profile->email }}">{{ $profile->email }}</a>
    </p>
    <p><strong>Номер телефона:</strong> {{ $profile->phone }}</p>
    <p><strong>Адрес доставки:</strong> {{ $profile->address }}</p>
    @isset ($profile->comment)
        <p><strong>Комментарий:</strong> {{ $profile->comment }}</p>
    @endisset

    <a href="{{ route('user.profile.edit', ['profile' => $profile->id]) }}"
       class="btn btn-success">
        Редактировать профиль
    </a>
    <form method="post" class="d-inline" onsubmit="return confirm('Удалить этот профиль?')"
          action="{{ route('user.profile.destroy', ['profile' => $profile->id]) }}">
        @csrf
        @method('DELETE')
        <button type="submit" class="btn btn-danger">
            Удалить профиль
        </button>
    </form>
@endsection

Шаблон создания профиля пользователя views/user/profile/create.blade.php:

@extends('layout.admin', ['title' => 'Создание профиля'])

@section('content')
    <h1>Создание профиля</h1>
    <form method="post" action="{{ route('user.profile.store') }}">
        @include('user.profile.part.form')
    </form>
@endsection

Шаблон редактирования профиля пользователя views/user/profile/edit.blade.php:

@extends('layout.site', ['title' => 'Редактирование профиля'])

@section('content')
    <h1>Редактирование профиля</h1>
    <form method="post" action="{{ route('user.profile.update', ['profile' => $profile->id]) }}">
        @method('PUT')
        @include('user.profile.part.form')
    </form>
@endsection

Вспомогательный шаблон с формой создания-редактирования views/user/profile/part/form.blade.php:

@csrf
<input type="hidden" name="user_id" value="{{ auth()->user()->id }}">
<div class="form-group">
    <input type="text" class="form-control" name="title" placeholder="Название профиля"
           required maxlength="255" value="{{ old('title') ?? $profile->title ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="name" placeholder="Имя, Фамилия"
           required maxlength="255" value="{{ old('name') ?? $profile->name ?? '' }}">
</div>
<div class="form-group">
    <input type="email" class="form-control" name="email" placeholder="Адрес почты"
           required maxlength="255" value="{{ old('email') ?? $profile->email ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="phone" placeholder="Номер телефона"
           required maxlength="255" value="{{ old('phone') ?? $profile->phone ?? '' }}">
</div>
<div class="form-group">
    <input type="text" class="form-control" name="address" placeholder="Адрес доставки"
           required maxlength="255" value="{{ old('address') ?? $profile->address ?? '' }}">
</div>
<div class="form-group">
    <textarea class="form-control" name="comment" placeholder="Комментарий"
              maxlength="255" rows="2">{{ old('comment') ?? $profile->comment ?? '' }}</textarea>
</div>
<div class="form-group">
    <button type="submit" class="btn btn-success">Сохранить</button>
</div>

Используем профили

Теперь при оформлении заказа нам нужно предоставить аутентифицированному пользователю возможность выбрать подходящий профиль. Начнем с метода checkout() контроллера BasketController.

class BasketController extends Controller {
    /**
     * Форма оформления заказа
     *
     * @param Request $request
     * @return \Illuminate\Http\Response
     */
    public function checkout(Request $request) {
        $profile = null;
        $profiles = null;
        if (auth()->check()) { // если пользователь аутентифицирован
            $user = auth()->user();
            // ...и у него есть профили для оформления
            $profiles = $user->profiles;
            // ...и был запрошен профиль для оформления
            $prof_id = (int)$request->input('profile_id');
            if ($prof_id) {
                $profile = $user->profiles()->whereIdAndUserId($prof_id, $user->id)->first();
            }
        }
        return view('basket.checkout', compact('profiles', 'profile'));
    }
}

Если пользователь залогинен, мы получаем его профили и передаем в шаблон формы оформления заказа. На переменную $profile пока не обращаем внимание, о ней чуть позже.

@extends('layout.site', ['title' => 'Оформить заказ'])

@section('content')
    <h1 class="mb-4">Оформить заказ</h1>
    @if ($profiles && $profiles->count())
        @include('basket.select', ['current' => $profile->id ?? 0])
    @endif
    <form method="post" action="{{ route('basket.saveorder') }}" id="checkout">
    <!-- ..... -->
    </form>
@endsection

Как видите, в форме подключаем еще один шаблон views/basket/select.blade.php, который позволяет выбрать подходящий профиль.

<form action="{{ route('basket.checkout') }}" method="get" id="profiles">
    <div class="form-group">
        <select name="profile_id" class="form-control">
            <option value="0">Выберите профиль</option>
            @foreach($profiles as $profile)
                <option value="{{ $profile->id }}"@if($profile->id == $current) selected @endif>
                    {{ $profile->title }}
                </option>
            @endforeach
        </select>
    </div>
    <div class="form-group">
        <button type="submit" class="btn btn-primary">Выбрать</button>
    </div>
</form>

При отправке этой формы передается GET-параметр profile_id, данные отправляются на роут basket.checkout. За показ страницы по этому роуту отвечает метод checkout() контроллера. Показывается все та же форма оформления заказа, но мы уже можем получить данные профиля, поскольку есть идентификатор $profile_id. И передать данные профиля в шаблон, чтобы заполнить поля формы.

@extends('layout.site', ['title' => 'Оформить заказ'])

@section('content')
    <h1 class="mb-4">Оформить заказ</h1>
    @isset ($profiles)
        @include('basket.select', ['current' => $profile->id ?? 0])
    @endisset
    <form method="post" action="{{ route('basket.saveorder') }}" id="checkout">
        @csrf
        <div class="form-group">
            <input type="text" class="form-control" name="name" placeholder="Имя, Фамилия"
                   required maxlength="255" value="{{ old('name') ?? $profile->name ?? '' }}">
        </div>
        <div class="form-group">
            <input type="email" class="form-control" name="email" placeholder="Адрес почты"
                   required maxlength="255" value="{{ old('email') ?? $profile->email ?? '' }}">
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="phone" placeholder="Номер телефона"
                   required maxlength="255" value="{{ old('phone') ?? $profile->phone ?? '' }}">
        </div>
        <div class="form-group">
            <input type="text" class="form-control" name="address" placeholder="Адрес доставки"
                   required maxlength="255" value="{{ old('address') ?? $profile->address ?? '' }}">
        </div>
        <div class="form-group">
            <textarea class="form-control" name="comment" placeholder="Комментарий"
                      maxlength="255" rows="2">{{ old('comment') ?? $profile->comment ?? '' }}</textarea>
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-success">Оформить</button>
        </div>
    </form>
@endsection

Практически все готово, только давайте будем получать данные профиля с помощью ajax-запроса:

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 () {
        // если выбран элемент «Выберите профиль»
        if ($(this).val() == 0) {
            // очищаем все поля формы оформления заказа
            $('#checkout').trigger('reset');
            return;
        }
        var data = new FormData($('form#profiles')[0]);
        $.ajax({
            url: '/basket/profile',
            data: data,
            processData: false,
            contentType: false,
            type: 'POST',
            dataType: 'JSON',
            success: function(data) {
                $('input[name="name"]').val(data.profile.name);
                $('input[name="email"]').val(data.profile.email);
                $('input[name="phone"]').val(data.profile.phone);
                $('input[name="address"]').val(data.profile.address);
                $('textarea[name="comment"]').val(data.profile.comment);
            },
            error: function (reject) {
                alert(reject.responseJSON.error);
            }
        });
    });
});

Данные мы отправляем методом POST по маршруту basket/profile. Соответственно, надо добавить маршрут:

Route::post('/basket/profile', 'BasketController@profile')
    ->name('basket.profile');

И добавляем метод profile() в контроллер BasketController, который будет возвращать данные профиля в json-формате.

class BasketController extends Controller {
    /**
     * Возвращает профиль пользователя в формате JSON
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function profile(Request $request) {
        if ( ! $request->ajax()) {
            abort(404);
        }
        if ( ! auth()->check()) {
            return response()->json(['error' => 'Нужна авторизация!'], 404);
        }
        $user = auth()->user();
        $profile_id = (int)$request->input('profile_id');
        if ($profile_id) {
            $profile = $user->profiles()->whereIdAndUserId($profile_id, $user->id)->first();
            if ($profile) {
                return response()->json(['profile' => $profile]);
            }
        }
        return response()->json(['error' => 'Профиль не найден!'], 404);
    }
}

И последнее — добавляем в layout-шаблон мета-тег c csrf-токеном, чтобы использовать в ajax-запросах:

<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ $title ?? 'Интернет-магазин' }}</title>
    <link rel="stylesheet" href="{{ asset('css/app.css') }}">
    <link rel="stylesheet" href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css"/>
    <link rel="stylesheet" href="{{ asset('css/site.css') }}">
    <script src="{{ asset('js/app.js') }}"></script>
    <script src="{{ asset('js/site.js') }}"></script>
</head>

Поиск: 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.