Личный кабинет пользователя

Товары есть, заказы есть, оплата есть... Осталось только вывести пользователю историю его заказов со статусом оплаты.
Заодно можно и отобразить оплату в админке. На этом, полагаю, с основным функционалом мы закончим.
В этой заметке не будет практически никакой новой информации, потому что всё это мы уже делали много раз: создадим контроллеры, пропишем маршруты, сделаем странички на сайте и выведем нужные данные.
Но, тем не менее, коротенько расскажу про создание личного кабинета для пользователя.

Контроллеры

Для вывода заказов пользователя мы создадим новый класс в Controllers/User/Orders. В эту директорию я кладу контроллеры, работающие с текущим пользователем, такие как Profile. Так сказать, личные контроллеры.
<?php

namespace App\Controllers\User;

// ...

class Orders extends ModelGetController
{
    protected $scope = 'profile';
    protected $model = Order::class;

    protected function beforeGet(Builder $c): Builder
    {
        // Вывод одного заказа со всеми свойствами
        $c->where('user_id', $this->user->id);
        $c->with('address');
        $c->with('payment');
        $c->with('orderProducts', 'orderProducts.product');

        return $c;
    }

    protected function beforeCount(Builder $c): Builder
    {
        // Выборка списка заказов
        $c->where('user_id', $this->user->id);
        // Возможность поиска по номеру
        if ($query = trim($this->getProperty('query', ''))) {
            $c->where('num', 'LIKE', "$query%");
        }

        return $c;
    }

    protected function afterCount(Builder $c): Builder
    {
        // Добор к списку количества товаров и оплаты
        $c->withCount('orderProducts');
        $c->with('payment:order_id,paid');

        return $c;
    }

    protected function addSorting(Builder $c): Builder
    {
        // Исправление сортировки по номеру
        // Я не придумал, как правильно сделать натуральную сортировку в MySQL, 
        // поэтому просто меняю её на дату создания  - они всё равно совпадают
        if ($sort = $this->getProperty('sort')) {
            if ($sort === 'num') {
                $dir = strtolower($this->getProperty('dir', '')) === 'desc' ? 'desc' : 'asc';
                $c->orderBy('created_at', $dir);
            } else {
                $c = parent::addSorting($c);
            }
        }

        return $c;
    }

    protected function getPrimaryKey(): ?array
    {
        // Замена обращения по id на uuid
        if ($uuid = $this->getProperty('uuid')) {
            return ['uuid' => $uuid];
        }

        return null;
    }
}
Выборку платежа и замену сортировки по номеру я также внёс и в контроллер заказов админки.
Маршрут для нового контроллера вот такой:
$group->group(
    '/user',
    static function (RouteCollectorProxy $group) {
        $group->any('/profile', App\Controllers\User\Profile::class);
        $group->any('/addresses', App\Controllers\User\Addresses::class);
        $group->any('/orders[/{uuid}]', App\Controllers\User\Orders::class);
    }
);
Контроллер выводит как список заказов юзера, так и отдельный заказ со всеми свойствами.
На этом с бэкендом всё.

Пользовательный раздел

Раз у нас есть авторизация, давайте выделим для пользователей и собственный раздел в директории pages/user/.
Там будут находиться внутренние странички личного кабинета, а корнем для них станет файл pages/user.vue:
<template>
  <div>
    <!-- Отображаем 2 вкладки для вложенных страничек-->
    <b-nav tabs class="mt-4">
      <b-nav-item :to="{name: 'user-orders'}">{{ $t('models.order.title_many') }}</b-nav-item>
      <b-nav-item :to="{name: 'user-profile'}">{{ $t('security.profile') }}</b-nav-item>
    </b-nav>
    <div class="mt-4">
      <!-- Здесь будет содержимое страничек-->
      <nuxt-child />
    </div>
  </div>
</template>

<script>
export default {
  name: 'UserPage',
  // Загружать раздел можно только юзерам
  validate({app}) {
    return app.$auth.loggedIn
  },
  watch: {
    // Если юзер выходит прямо в разделе - редирект на главную
    '$auth.loggedIn'(newValue) {
      if (!newValue) {
        this.$router.replace({name: 'index'})
      }
    },
  },
}
</script>
Это файл как-бы роутер для раздела, он отобразит все странички своей директории на месте компонента <nuxt-child />. Имейте в виду, что эти странички являются вложенными маршрутами, поэтому метод validate работает для них всех.
Чтобы при заходе на адрес /user мы не видели только неактивные вкладки с пустотой (подраздел-то не выбран), создаём еще страничку pages/user/index.vue - она сделает редирект на заказы.
<script>
export default {
  name: 'UserIndexPage',
  asyncData({redirect}) {
    redirect({name: 'user-orders'})
  },
  render() {
    return null
  },
}
</script>

Страница заказа

Здесь вообще всё по образу и подобию админки. Выводим табличку заказов и показываем их в модальном окошке.
В общем виде таблица выглядит вот так:
<template>
  <div>
    <vesp-table :url="url" :fields="fields" :filters="filters" :table-actions="tableActions" :sort="sort" :dir="dir">
      <template #cell(payment)="{value}">
        <div v-if="value.order_id" class="text-center">
          <fa v-if="value.paid === null" icon="minus" class="text-muted" :title="$t('models.payment.paid_null')" />
          <fa v-else-if="value.paid" icon="check" class="text-success" :title="$t('models.payment.paid_true')" />
          <fa v-else icon="times" class="text-danger" :title="$t('models.payment.paid_false')" />
        </div>
      </template>
    </vesp-table>
    <nuxt-child />
  </div>
</template>
В единственном слоте я добавил отображение статуса оплаты. Такое же изменение внёс и в таблицу админки.
Формы я так же просто скопировал из админки с небольними изменениями: убрал смену юзера, адреса, да и вообще возможность редактирования.
Конечно, я подключил все требуемые компоненты, иконки и стили в nuxt.config.js. Посмотрите изменения в исходниках.

Страница профиля

На страницу профиля я просто перенёс форму из модалки, которую мы раньше использовали.
Конечно, пришлось добавить кнопку отправки и обработку формы, потому что раньше за это отвечал компонент <vesp-modal />, а теперь нужно делать самостоятельно.
Но как вы понимаете, вообще ничегошеньки сложного нет:
<template>
  <div>
    <b-form @submit.prevent="onSubmit">
      <b-overlay :show="loading" opacity="0.5">
        <form-user v-model="record" :show-group="false" :show-comment="false" :show-status="false" />
      </b-overlay>
      <div class="text-center mt-3">
        <b-button variant="primary" type="submit" :disabled="loading">
          {{ $t('actions.submit') }}
        </b-button>
      </div>
    </b-form>
  </div>
</template>

<script>
import FormUser from '../../../admin/components/forms/user'

export default {
  name: 'UserProfilePage',
  components: {FormUser},
  data() {
    return {
      loading: false,
      url: 'user/profile',
      record: {},
    }
  },
  mounted() {
    this.record = {...this.$auth.user}
  },
  methods: {
    async onSubmit() {
      this.loading = true
      try {
        const {data} = await this.$axios.patch('user/profile', this.record)
        this.record = {...data.user}
        this.$auth.setUser(this.record)
      } catch (e) {
      } finally {
        this.loading = false
      }
    },
  },
}
</script>
Работа с профилем пользователя переехала уже в третий раз!

Изменение навигационной панели

Тут я добавил вывод аватарки юзера, если она есть, и убрал модальный профиль юзера - заменил его на ссылку. Сам компонент <user-profile /> удалил.
<b-dropdown v-else variant="light">
  <template #button-content>
    <b-img
      v-if="$auth.user.file"
      :src="$image($auth.user.file, {w: 50, h: 50})"
      class="rounded-circle"
      width="25"
      height="25"
    />
    <fa v-else icon="user" />
  </template>
  <b-dropdown-text>{{ $auth.user.fullname }}</b-dropdown-text>
  <b-dropdown-divider />
  <b-dropdown-item :to="{name: 'user-orders'}">{{ $t('models.order.title_many') }}</b-dropdown-item>
  <b-dropdown-item :to="{name: 'user-profile'}">{{ $t('security.profile') }}</b-dropdown-item>
  <b-dropdown-divider />
  <b-dropdown-item link-class="d-flex justify-content-between align-items-center" @click="onLogout">
    {{ $t('security.logout') }}
    <fa icon="right-from-bracket" />
  </b-dropdown-item>
</b-dropdown>
Получилось очень симпатично.

Заключение

Как и предупреждал в начале заметки - сегодня ничего нового. Понятно, что дальше в новый раздел вы можете добавлять любой функционал.
В админке я так же добавил работу с оплатой, но там её можно немного редактировать:
Осталась всего одна заметка - запуск в продакшен с использованием Docker.
Итоговые изменения одним коммитом.

2 комментария

Дмитрий П.
А какая разница между переопределением метода getPrimaryKey в контроллере, например, Orders, и указанием свойства $primaryKey = ['uuid']?
Василий Наумкин
Никакой разницы, только в количестве строк.
Не знаю, почему я так написал - не задумывался -)
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Ivan CR
24.10.2024, 15:20:54
С днем рождения!!! Класс, что в твоей жизни есть такие интересные достижения.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!