Друзья, приветствую! Наконец-то дошли руки до описания второй части нашего большого проекта по работе с выдуманной клиникой «Здоровье Плюс».

Я напоминаю, что в рамках этой небольшой серии мы создаем телеграм-бота с MiniApp, основная задача которого — дать пользователям возможность записаться к врачу в удобный день и время.

В прошлой части мы полностью закрыли вопрос логики нашего бота, и теперь он умеет:

  • Регистрировать пользователей в базе данных

  • Автоматически отправлять напоминания о записи к врачу

  • Реализовывать достаточно сложную систему бронирования

В реализации логики мы использовали: SQLAlchemy для взаимодействия с базой данных, APScheduler для обеспечения логики автоматической отправки напоминаний, FastAPI для реализации основной логики бэкенда (API методы, хуки для бота и прочее) и HTTPX для поддержки работы с серверами Telegram.

То есть, для написания самого телеграм-бота мы не использовали фреймворки, такие как Aiogram, а реализовали все самостоятельно и с нуля.

В завершении прошлой статьи мы развернули наш API удаленно, используя сервис Amvera Cloud. Теперь у нас есть независимое приложение — бэкенд, который осталось только визуализировать при помощи нашего MiniApp (ранее известного как Telegram Web App), чем мы сегодня и займемся.

На чем будем писать фронтенд?

К сожалению, для каждого бэкенд-разработчика при создании фронтенда для Telegram нам не обойтись без использования JavaScript-технологий. Поэтому сегодня мы не напишем ни одной строки кода на Python или других языках бэкенда — только JavaScript.

Для того чтобы было комфортнее писать фронтенд, я решил использовать JavaScript-фреймворк Vue.js 3. Выбрал его за простоту и гибкость синтаксиса, что нам сегодня очень пригодится.

Сразу скажу бэкендерам переживать не нужно. Я постараюсь доступно объяснить те шаги, которые мы сегодня пройдем, и даже абсолютные новички в мире фронтенд-технологий поймут, о чем идет речь и как это работает.

Однако стоит отметить, что эта статья не призвана обучить вас азам Vue.js 3. Я не буду делать подробный и доскональный разбор, но сделаю все возможное, чтобы после прочтения вы смогли самостоятельно разобраться во всем.

Стек технологий на сегодня

Стилизация фронтенда:

  • Tailwind CSS — с его помощью мы создадим стильный интерфейс без написания CSS вручную.

  • FontAwesome — для иконок.

JavaScript:

  • Vue.js 3 — основа для создания фронтенда.

  • VueRouter — создание многостраничного приложения.

  • useFetch — для работы с API.

  • VueTG — упрощает интеграцию telegram MiniApp.

Этапы разработки

  1. Настройка проекта Vue.js 3 для написания кода

  2. Организация и настройка проекта

  3. Реализации компонентов и представлений приложения

  4. Деплой

Что касается деплоя (удаленного запуска приложения), в прошлой статье мы уже запустили удаленно бэкенд, используя сервис Amvera Cloud. Сегодня там же и всего за пару минут мы опубликуем приложение фронтенда.

Принцип публикации будет похож на деплой бэкенда; единственное, мы напишем сегодня конфигурационный файл не через amvera.yml, а через Dockerfile. Но не переживайте: настройки будут стандартными и такими же простыми, как в прошлом примере с файлом amvera.yml.

Результат будет следующим:

Дисклеймер

Прежде чем приступим к написанию кода, давайте обсудим некоторые важные моменты.

Начнем с того, что писать мы сегодня будем учебный проект, а не боевой. Следовательно, я намеренно буду упрощать некоторые моменты, в частности в блоке интеграции TypeScript. Не воспринимайте этот код как некое подобие Best Practice.

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

Поэтому перед прочтением далее настоятельно рекомендую ознакомиться с такими понятиями как HTML, стили CSS, Vue.js 3 и VueRouter самостоятельно. Обычное базовое понимание этих технологий сильно упростит понимание сегодняшнего материала.

Что касается хейта: друзья, если вам кажется, что вы знаете что-то лучше меня или понимаете что-то лучше и прочее — пожалуйста, пишите не только негативные и токсичные комментарии. Как минимум подтверждайте их реальным кодом и более лучшими практиками и решениями, чем те которые я буду сегодня демонстрировать.

Речь тут не только про отношение к автору, но и по отношению к аудитории. Часто бывает так, что «умники», не разобравшись в контексте, пишут негативные комментарии, а новички читают это и уходят. Материал написан для новичков. Спасибо за понимание.

Надеюсь, что у вас реализовано API для нашего проекта. Если это не так, то прочитайте первую статью «FastAPI и Vue.js 3: телеграм-бот с MiniApp для записи и автоматических уведомлений. Пишем бэкенд» и повторите за мной или как минимум заберите полный исходный код бэкенда в моем бесплатном телеграм-канале «Легкий путь в Python». В этом-же канале вы найдете и полный искодный код фронтенда.

Начинаем!

Настройка и запуск проекта

Начнем мы с базовой настройки приложения. Чтобы сэкономить время, я подготовил для вас стартовую универсальную сборку Vue.js 3 приложения.

Эта сборка представляет собой готовый стартовый шаблон для создания современных веб-приложений с использованием Vue 3, Vite, TypeScript, Vue Router и Tailwind CSS. Вам нужно всего лишь клонировать репозиторий, чтобы получить полностью настроенный проект Vue.js 3 с интегрированными инструментами. В шаблоне уже предусмотрены базовые роутеры и компоненты, что делает структуру проекта понятной и доступной для каждого разработчика.

Клонируем репозиторий

Для начала клонируем репозиторий:

git clone https://github.com/Yakvenalex/vue-typescript-tailwind-starter.git

Далее переходим в созданную папку:

cd vue-typescript-tailwind-starter

Установка зависимостей

Установите зависимости с помощью следующей команды:

npm install

Сразу установим библиотеку VueTG, которая значительно упростит процесс интеграции Telegram MiniApp в наше приложение на Vue.js:

npm install vue-tg

Настройка index.html

Теперь сделаем небольшие правки в файле index.html, а именно интегрируем в него иконки FontAwesome и добавим библиотеку Telegram MiniApp через CDN.

Получилось у меня так:

<!DOCTYPE html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/logo.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
    />
    <script src="https://telegram.org/js/telegram-web-app.js"></script>
    <title>Клиника ЗдоровьеПлюс</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Настройка main.ts

Теперь в главном файле приложения (main.ts) сразу подключим библиотеку VueTG и добавим переменную с ссылкой на наш API (надеюсь, API у вас готов). Получилось так:

import { createApp } from "vue";
import App from "./App.vue";
import "./assets/styles/index.css";
import router from "./router";
import { VueTelegramPlugin } from "vue-tg";

const app = createApp(App);
app.use(router);
app.use(VueTelegramPlugin);
app.provide("BASE_SITE", "https://vue3fastapi-yakvenalex.amvera.io");
app.mount("#app");

Запуск проекта

В целом, базовая настройка проекта завершена, и сейчас мы можем выполнить его первый запуск:

npm run dev

В результате, если мы перейдем по адресу http://localhost:3000/ мы должны увидеть простое приложения со стилями, которые подтянулись через Tailwind. Это значит, что этап первичной настройки и первого запуска мы завершили успешно.

Структура проекта

В рамках данного проекта нам предстоит использовать такие сущности, как компоненты (components), представления (views) и роутеры (routers). Давайте разберемся коротко, что это такое и зачем оно нам нужно.

Компоненты

Компоненты в Vue.js — это автономные блоки, которые инкапсулируют структуру шаблона, логику JavaScript и стили CSS. Они позволяют разбивать интерфейс на независимые части, что упрощает разработку и повторное использование кода. Каждый компонент может быть использован в различных частях приложения, что делает его гибким и масштабируемым.

Представления

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

Роутеры

Vue Router — это библиотека для управления маршрутизацией в приложениях на Vue.js. Она позволяет определять маршруты и связывать их с соответствующими представлениями. Благодаря этому пользователи могут перемещаться между различными страницами приложения без перезагрузки. Это реализует подход Single Page Application (SPA), где вся логика приложения загружается один раз, а переходы между страницами происходят мгновенно.

Как работает Vue Router?

Vue Router работает по принципу сопоставления URL с компонентами представлений. Каждый маршрут определяет путь и компонент, который будет отображаться при переходе по этому пути.

В нашем примере, который мы скоро реализуем:

const routes = [
  { path: "/", name: "Home", component: Home },
  { path: "/doctors/:specialId", name: "Doctors", component: Doctors },
  { path: "/booking/:doctorId", name: "Booking", component: Booking },
];

В этом примере при переходе на корневой путь (/) будет отображен компонент Home и так далее (подробнее разберем когда доберемся до настройки роутеров).

Подход Single Page Application

Использование подхода SPA позволяет значительно улучшить пользовательский опыт. Поскольку приложение загружается один раз, пользователи могут быстро перемещаться между страницами без задержек на загрузку. Это особенно важно для приложений с большим количеством взаимодействий, таких как наш телеграм-бот с MiniApp.

Настраиваем роутеры

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

Если вы используете мою сборку, файл с роутерами находится в папке:

src/router/index.ts

Заполним его следующим образом и затем разберем настройки:

import { createRouter, createWebHistory } from "vue-router";
import Doctors from "../views/Doctors.vue";
import Booking from "../views/DoctorDetail.vue";
import Home from "../views/Home.vue";

const routes = [
  { path: "/", name: "Home", component: Home },
  { path: "/doctors/:specialId", name: "Doctors", component: Doctors },
  { path: "/booking/:doctorId", name: "Booking", component: Booking },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior() {
    // Всегда прокручивать к верху страницы
    return { top: 0 };
  },
});

export default router;

Давайте подробно рассмотрим каждый элемент этого кода.

Импорт необходимых модулей

import { createRouter, createWebHistory } from "vue-router";
import Doctors from "../views/Doctors.vue";
import Booking from "../views/DoctorDetail.vue";
import Home from "../views/Home.vue";

В этом блоке мы импортируем функции и компоненты, необходимые для настройки роутера.

  • createRouter – функция для создания экземпляра роутера.

  • createWebHistory – функция, которая позволяет использовать HTML5 History API для управления историей навигации.

  • Компоненты Doctors, Booking и Home – это Vue-компоненты, которые будут отображаться на различных маршрутах (сами компоненты нам предстоит создать, поэтому, часть кода можете пока закомментировать, сейчас у нас существует только представление Home).

Определение маршрутов

const routes = [
  { path: "/", name: "Home", component: Home },
  { path: "/doctors/:specialId", name: "Doctors", component: Doctors },
  { path: "/booking/:doctorId", name: "Booking", component: Booking },
];

Здесь мы создаем массив маршрутов. Каждый маршрут представляет собой объект с тремя основными свойствами:

  • path: URL-адрес, по которому будет доступен маршрут.

  • name: уникальное имя маршрута, которое может использоваться для навигации.

  • component: компонент Vue, который будет отображаться при переходе на указанный маршрут.

Например:

  • Путь / соответствует главной странице и отображает компонент Home.

  • Путь /doctors/:specialId позволяет отображать список врачей по специальности (где specialId – это параметр маршрута).

  • Путь /booking/:doctorId отвечает за отображение страницы записи к врачу (где doctorId – это идентификатор врача).

Создание экземпляра роутера

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior() {
    return { top: 0 };
  },
});

В этом блоке мы создаем экземпляр роутера с помощью функции createRouter. Мы передаем объект с настройками:

  • history: указывает на использование HTML5 History API для управления историей.

  • routes: массив маршрутов, который мы определили ранее.

  • scrollBehavior: функция, которая управляет поведением прокрутки при навигации. В данном случае мы указываем, что при переходе на новый маршрут страница всегда будет прокручиваться к верху.

Экспорт роутера

export default router;

В конце мы экспортируем созданный экземпляр роутера. Это позволяет использовать его в других частях приложения, например, в основном файле приложения для подключения роутера к Vue.

Подготавливаем основной компонент приложения

Теперь подготовим основной компонент приложения, файл, который находится по пути src/App.vue.

<script setup lang="ts"></script>

<template>
  <div class="container mx-auto p-4">
    <router-view />
  </div>
</template>

<style scoped></style>

Код получился максимально лаконичным. Давайте разберемся с тем, как он работает.

В файле `App.vue` мы видим три основных блока: `<script>`, `<template>` и `<style>`. Каждый из этих блоков выполняет свою уникальную роль в компоненте.

Блок `<script>`

<script setup lang="ts"></script>

- <script setup>: Это синтаксис Composition API, который позволяет более лаконично и удобно писать компоненты Vue. В данном случае мы используем `lang='ts'`, что указывает на использование TypeScript. Это дает возможность использовать статическую типизацию, что может помочь избежать ошибок и улучшить читаемость кода.

- В этом блоке мы не добавили никакого кода, но здесь вы можете импортировать компоненты, определять реактивные переменные и функции, если это потребуется в будущем.

Блок `<template>`

<template>
  <div class="container mx-auto p-4">
    <router-view />
  </div>
</template>
  • <template>: Этот блок определяет разметку компонента. В нашем случае он содержит один элемент – <div> с классами для стилизации и контейнером для отображения маршрутов, который мы взяли от Tailwind CSS.

  • <router-view />: Это специальный компонент Vue Router, который служит местом для отображения компонентов, соответствующих текущему маршруту. Когда пользователь переходит по различным маршрутам, содержимое этого элемента будет обновляться в зависимости от того, какой маршрут активен. Это позволяет создавать одностраничные приложения (SPA), где страницы загружаются динамически без перезагрузки.

Блок <style>

<style scoped></style>

- <style scoped>: Этот блок предназначен для стилизации компонента. Атрибут scoped гарантирует, что стили будут применяться только к этому компоненту, а не ко всем элементам на странице. В данном случае он пустой, но вы можете добавить стили в будущем для улучшения визуального оформления вашего приложения.

Опишем первый компонент

Теперь приступим к реальной практике. Сейчас мы реализуем компонент Specialization, который будет использоваться на нашей главной странице (в представлении Home.vue). Основной смысл этого компонента — описать карточку со специализацией. Речь тут про направления в медицине, такие как терапия, стоматология и прочее.

Описывать его мы будем в папке src/components в файле Specialization.vue. Опишем полный код и после с ним разберемся:

<script setup lang="ts">
import { useRouter } from "vue-router";

defineProps(["specialization", "description", "icon", "specialId"]);

const router = useRouter();
</script>

<template>
  <div
    class="bg-white rounded-xl shadow-lg overflow-hidden transition-transform duration-300 hover:scale-105 flex flex-col items-center justify-center p-6"
  >
    <div
      class="bg-blue-100 rounded-full p-6 w-28 h-28 flex items-center justify-center"
    >
      <i :class="icon + ' text-blue-600 text-4xl'" aria-hidden="true"></i>
    </div>
    <h3 class="text-xl font-bold text-gray-800 m-2">{{ specialization }}</h3>
    <p
      class="text-gray-600 mb-4 text-center h-30 overflow-hidden"
      title="{{ description }}"
    >
      {{ description }}
    </p>
    <button
      @click="() => router.push(`/doctors/${specialId}`)"
      class="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition-colors"
    >
      Записаться
    </button>
  </div>
</template>

<style scoped></style>

Тут мы уже начали взаимодействовать с блоком <script setup lang='ts'>, поэтому подходы разберем более подробно.

Блок `<script>`

<script setup lang="ts">
import { useRouter } from "vue-router";

defineProps(["specialization", "description", "icon", "specialId"]);

const router = useRouter();
</script>
  1. Импорт useRouter:

    • Мы импортируем функцию useRouter из библиотеки vue-router. Это необходимо для работы с маршрутизацией внутри нашего компонента. С помощью этой функции мы получаем доступ к экземпляру роутера, что позволяет нам программно управлять навигацией.

  2. Определение Props:

    • С помощью defineProps мы определяем свойства (props), которые компонент будет принимать. В нашем случае это:

      • specialization: название специализации.

      • description: описание специализации.

      • icon: иконка, ассоциированная со специализацией.

      • specialId: уникальный идентификатор специализации, который будет использоваться для навигации.

  3. Инициализация роутера:

    • Мы создаем константу router, которая хранит экземпляр роутера, полученный с помощью useRouter(). Это позволит нам использовать методы роутера для навигации по маршрутам.

Блок <template>

<template>
  <div
    class="bg-white rounded-xl shadow-lg overflow-hidden transition-transform duration-300 hover:scale-105 flex flex-col items-center justify-center p-6"
  >
    <div
      class="bg-blue-100 rounded-full p-6 w-28 h-28 flex items-center justify-center"
    >
      <i :class="icon + ' text-blue-600 text-4xl'" aria-hidden="true"></i>
    </div>
    <h3 class="text-xl font-bold text-gray-800 m-2">{{ specialization }}</h3>
    <p
      class="text-gray-600 mb-4 text-center h-30 overflow-hidden"
      title="{{ description }}"
    >
      {{ description }}
    </p>
    <button
      @click="() => router.push(`/doctors/${specialId}`)"
      class="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition-colors"
    >
      Записаться
    </button>
  </div>
</template>
  1. Структура карточки:

    • Внутри блока <template> мы создаем карточку, которая содержит информацию о специализации. Используются классы Tailwind для стилизации

  2. Иконка специализации:

    • Мы используем элемент <i> для отображения иконки. Класс иконки передается через пропс icon, что позволяет динамически менять иконки в зависимости от переданных данных.

  3. Название и описание:

    • Название специализации отображается в заголовке <h3>, а описание — в параграфе <p>. Мы используем двойные фигурные скобки для вставки значений пропсов, что делает код чистым и понятным.

  4. Кнопка записи:

    • Кнопка "Записаться" использует директиву @click для обработки клика. При нажатии на кнопку вызывается метод, который перенаправляет пользователя на страницу с врачами по выбранной специализации, используя метод router.push().

Когда мы реализуем представление, которое будет использовать данный компонент, вам станет более понятно зачем мы вынесли этот код в отдельный файл.

Опишем первое представление

Работать сейчас мы будем с файлом src/views/Home.vue. Тут код пойдет немного сложнее, поэтому, надеюсь, что вы хорошо разобрались с предыдущим материалом.

Представление Home.vue будет связано не только с недавно созданным компонентом, но и напрямую завязано на API-методах, которые мы реализовали в прошлой части, так как информация о специализациях будет поступать через запросы к нашему API.

Подробный разбор блока <script>

Начнем с импортов:

import { useFetch } from "@vueuse/core";
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { inject } from "vue";
import Specialization from "../components/Specialization.vue";
  1. Импорт useFetch:

    • Мы используем useFetch из библиотеки @vueuse/core. Если вы использовали мою сборку, то эта библиотека уже установлена. Если это не так, выполните установку командой:

      npm i @vueuse/core
    • Эта библиотека будет использоваться для взаимодействия с созданным ранее API. Она позволяет нам легко и удобно работать с запросами к API. В нашем случае нас интересует именно useFetch, который упрощает процесс получения данных.

  2. Импорт из Vue:

    • Мы импортируем несколько полезных функций из самого Vue:

      • ref: используется для создания реактивных переменных.

      • computed: позволяет создавать вычисляемые свойства, которые будут автоматически обновляться при изменении зависимостей.

      • onMounted и onBeforeUnmount: это хуки жизненного цикла компонента, которые позволяют выполнять код при монтировании и размонтировании компонента.

  3. Импорт компонента Specialization:

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

Работа с API

Теперь давайте посмотрим на то, как мы будем получать данные о врачах и специализациях:

interface Doctor {
    id: number;
    specialization: string;
    description: string;
    icon: string;
}

const BASE_SITE = inject('BASE_SITE') as string;
const searchQuery = ref('');

const {
    data: doctors,
    isFetching,
    error
} = useFetch(`${BASE_SITE}/specialists`).get().json();
  1. Интерфейс Doctor:

    • Мы определяем интерфейс Doctor, который описывает структуру данных о врачах. Это помогает нам понимать, какие поля будут возвращаться из API и обеспечивает статическую типизацию при работе с данными.

  2. Получение базового URL:

    • С помощью функции inject мы получаем базовый URL нашего API (BASE_SITE). Это позволяет нам использовать его в запросах без необходимости дублировать адрес в разных местах кода.

  3. Реактивная переменная для поиска:

    • Мы создаем реактивную переменную searchQuery, которая будет хранить текст запроса для поиска врачей по специализации или описанию.

  4. Запрос к API:

    • Используя useFetch, мы выполняем GET-запрос к нашему API по адресу ${BASE_SITE}/specialists. Результаты запроса будут храниться в переменной doctors, а также у нас есть флаги isFetching (для отслеживания состояния загрузки) и error (для обработки ошибок).

Фильтрация данных

Далее мы создаем вычисляемое свойство для фильтрации списка врачей:

const filteredDoctors = computed(() => {
    if (!doctors.value) return [] as Doctor[];

    const query = searchQuery.value.toLowerCase().trim();
    if (!query) return doctors.value;

    return doctors.value.filter((doctor: Doctor) => {
        return (
            doctor.specialization.toLowerCase().includes(query) ||
            doctor.description.toLowerCase().includes(query)
        );
    });
});
  • Здесь мы проверяем, есть ли данные о врачах. Если нет — возвращаем пустой массив.

  • Затем мы берем текст из поля поиска (searchQuery), приводим его к нижнему регистру и убираем лишние пробелы.

  • Если текст пустой, возвращаем весь список врачей.

  • В противном случае фильтруем список по специализации и описанию врача, проверяя, содержится ли текст запроса в этих полях.

Обработка кликов вне компонента

Чтобы улучшить взаимодействие с пользователем, добавим обработчик кликов вне поля ввода:

const handleClickOutside = (event: MouseEvent) => {
    const inputElement = document.getElementById('search');
    if (inputElement && !inputElement.contains(event.target as Node)) {
        inputElement.blur(); // Снять фокус с поля ввода
    }
};

// Установка обработчика при монтировании компонента
onMounted(() => {
    document.addEventListener('click', handleClickOutside);
});

// Удаление обработчика при размонтировании компонента
onBeforeUnmount(() => {
    document.removeEventListener('click', handleClickOutside);
});
  • Мы создаем функцию handleClickOutside, которая снимает фокус с поля ввода при клике вне его.

  • Используем хуки жизненного цикла для установки и удаления обработчика события клика на документе.

Опишем блок <template>

Теперь мы готовы к описанию логики в блоке <template>. Код будет выглядеть следующим образом:

<template>
  <div class="mb-12 mt-5">
    <div class="relative max-w-xl mx-auto">
      <input
        v-model="searchQuery"
        type="text"
        id="search"
        placeholder="Поиск специалиста..."
        class="w-full px-4 py-3 rounded-lg shadow-sm border border-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
      />
      <button class="absolute right-3 top-3 text-gray-400">
        <i class="fas fa-search"></i>
      </button>
    </div>
  </div>

  <div>
    <div v-if="isFetching" class="text-center">
      <p>Загрузка...</p>
    </div>

    <div v-else-if="error" class="text-center text-red-500">
      <p>Ошибка: {{ error.message }}</p>
    </div>

    <div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-4">
      <div v-for="doctor in filteredDoctors" :key="doctor.id">
        <Specialization
          :specialization="doctor.specialization"
          :description="doctor.description"
          :icon="doctor.icon"
          :specialId="doctor.id"
        />
      </div>
    </div>
  </div>
</template>

Данное представление можно разделить на две условные части, каждая из которых выполняет свою уникальную задачу.

Первая часть: Реактивный поисковик

<div class="mb-12 mt-5">
    <div class="relative max-w-xl mx-auto">
        <input class="w-full px-4 py-3 rounded-lg shadow-sm border border-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" placeholder="Поиск специалиста..." id="search" type="text">
        <button class="absolute right-3 top-3 text-gray-400">
            <i class="fas fa-search"></i>
        </button>
    </div>
</div>

В этой части мы создаем интерактивное поле для поиска, которое позволяет пользователям находить специалистов по ключевым словам.

  1. Поле ввода:

    • Используя директиву v-model, мы связываем значение поля ввода с реактивной переменной searchQuery. Это значит, что при вводе текста в поле, значение переменной будет обновляться автоматически.

    • Поле имеет атрибут placeholder, который показывает пользователю, что он может ввести текст для поиска.

  2. Кнопка поиска:

    • Кнопка с иконкой поиска добавлена для улучшения пользовательского интерфейса. Она располагается поверх поля ввода и визуально указывает на возможность выполнения поиска.

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

Вторая часть: Взаимодействие с компонентом Specialization

<div>
    <div v-if="isFetching" class="text-center">
        <p>Загрузка...</p>
    </div>

    <div v-else-if="error" class="text-center text-red-500">
        <p>Ошибка: {{ error.message }}</p>
    </div>

    <div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <div v-for="doctor in filteredDoctors" :key="doctor.id">
            <Specialization :specialization="doctor.specialization" :description="doctor.description"
                :icon="doctor.icon" :specialId="doctor.id" />
        </div>
    </div>
</div>

Во второй части мы обрабатываем различные состояния загрузки данных и отображаем информацию о врачах.

  1. Состояние загрузки:

    • Если данные все еще загружаются (проверяется по переменной isFetching), отображается сообщение "Загрузка...". Это помогает пользователю понять, что процесс получения данных еще продолжается.

  2. Обработка ошибок:

    • Если при загрузке данных произошла ошибка (проверяется по переменной error), выводится сообщение об ошибке с указанием причины. Это важно для информирования пользователя о проблемах с получением данных.

  3. Отображение специалистов:

    • Если данные успешно загружены, мы используем директиву v-for для перебора массива filteredDoctors, который содержит отфильтрованные данные о врачах.

    • Для каждого врача создается компонент Specialization, которому передаются соответствующие пропсы: название специализации, описание, иконка и идентификатор.

Эта часть кода обеспечивает динамическое отображение информации о врачах в виде карточек, что делает интерфейс более структурированным и удобным для восприятия.

На данный момент, если ваше Vue приложение запущен в dev режиме и если у вас корректно работает API, то на главной странице вы должны увидеть следующее:

Версия для больших экранов (ПК)
Версия для больших экранов (ПК)
Мобильная версия
Мобильная версия

Кроме того, вы можете убедиться, что корректно работает реактивный поиск. Напоминаю, что тут логика следующая:

  1. Мы загружаем страницу

  2. Фронтенд отправляет запрос к бэкенду при помощи специального запроса

  3. Далее, реактивный поиск работает с информацией, которая уже есть на стороне фронтенда.

Таким образом мы минимизируем количество запросов, отправленное к нашему API.

Поскольку объём кода продолжает расти, предлагаю вам ознакомиться с полным исходным кодом фронтенд-приложения. Вы можете найти его в моём новом Telegram-канале «Лёгкий путь в JavaScript», который был создан совсем недавно. Буду рад видеть вас там!

Создаем компоненты для страницы с докторами

Перед тем как перейти к представлению (странице) с докторами по выбранной специализации, нам необходимо будет выполнить небольшую подготовку. А именно, мы с вами сейчас реализуем два компонента:

  • Doctor.vue: компонент, в котором мы опишем карточку с доктором на странице докторов по специализации.

  • DoctorHeader.vue: компонент, в котором мы реализуем небольшую переиспользуемую шапку, которая будет использоваться на нескольких представлениях.

Начнем с файла src/components/DoctorHeader.vue

Вот полный код:

<script setup lang="ts">
defineProps(["label"]);
</script>

<template>
  <div class="flex justify-between items-center mb-12">
    <h1 class="text-3xl font-bold text-indigo-900">{{ label }}</h1>
    <router-link
      to="/"
      class="inline-flex items-center px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
    >
      <i class="fas fa-home mr-2"></i>
      Главная
    </router-link>
  </div>
</template>

Описание компонента DoctorHeader

По своей сути данный компонент представляет собой динамическую надпись и кнопку «Главная», по клику на которую будет происходить переход на главную страницу (представление Home).

  1. Передача пропсов:

    • В качестве передачи надписи мы воспользовались технологией пропсов. Это позволяет нам динамически изменять текст заголовка в зависимости от контекста, в котором используется компонент.

  2. Структура шаблона:

    • В шаблоне мы используем Flexbox для выравнивания заголовка и кнопки. Заголовок отображает переданное значение label, а кнопка реализует переход на главную страницу.

Теперь давайте опишем файл src/components/Doctor.vue.

Описание файла src/components/Doctor.vue

В этом компоненте мы будем описывать карточку доктора на странице представлений.

Блок <script setup>

<script setup lang="ts">
import { useRouter } from "vue-router";
import { inject } from "vue";

const BASE_SITE = inject("BASE_SITE") as string;
const props = defineProps([
  "doctorId",
  "name",
  "special",
  "experience",
  "work_experience",
  "description",
  "photo",
]);

const imageSrc = new URL(
  `${BASE_SITE}/static/images/${props.photo}`,
  import.meta.url
).href; // Используем URL для получения пути к изображению

const router = useRouter();
</script>
  1. Импорт необходимых модулей:

    • Мы импортируем useRouter из vue-router для управления навигацией и inject для получения базового URL сайта.

  2. Определение пропсов:

    • Мы определяем пропсы, которые будут переданы в компонент: doctorId, name, special, experience, work_experience, description и photo.

  3. Генерация ссылки на фото:

    • Строка

      const imageSrc = new URL(
        `${BASE_SITE}/static/images/${props.photo}`,
        import.meta.url
      ).href;
      

      использует синтаксис TypeScript для генерации ссылки на изображение доктора. Это позволяет нам динамически формировать путь к изображению в зависимости от переданного имени файла.

Блок <template>

<template>
  <div
    class="bg-white rounded-xl shadow-lg overflow-hidden h-full flex flex-col"
  >
    <div class="relative h-64 flex-shrink-0">
      <img
        :src="imageSrc"
        alt="Описание фотографии"
        class="absolute w-full h-full object-cover transition-transform duration-300 ease-in-out transform hover:scale-105"
      />
    </div>

    <div class="p-6 flex flex-col flex-grow">
      <div class="flex-grow">
        <h3 class="text-xl font-bold text-gray-800 mb-2">{{ name }}</h3>
        <p class="text-gray-600 mb-2">{{ special }}</p>
        <p class="text-sm text-gray-500 mb-4">
          Стаж работы: {{ work_experience }} {{ experience }}
        </p>
        <p class="text-gray-600 mb-4">{{ description }}</p>
      </div>
      <button
        @click="() => router.push(`/booking/${doctorId}`)"
        class="w-full bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition-colors mt-auto"
      >
        Записаться на приём
      </button>
    </div>
  </div>
</template>
  1. Структура карточки доктора:

    • Карточка имеет белый фон и закругленные углы. Мы используем Flexbox для вертикального выравнивания содержимого.

  2. Изображение доктора:

    • Изображение загружается с помощью динамически сгенерированного пути (imageSrc). Мы добавили эффект увеличения при наведении, что делает интерфейс более интерактивным.

  3. Информация о докторе:

    • Внутри карточки отображается информация о докторе: имя, специализация, стаж работы и описание. Все данные берутся из переданных пропсов.

  4. Кнопка записи на приём:

    • Кнопка "Записаться на приём" использует метод router.push() для навигации к странице записи, передавая идентификатор доктора.

Создаем представление с докторами по специализации

Теперь, когда мы подготовили компоненты Doctor и DoctorHeader, мы можем описать представление с докторами по выбранной специализации. Сделаем это в файле src/views/Doctors.vue.

Начнем с блока <script>

<script setup lang="ts">
import { useRoute } from "vue-router";
import Doctor from "../components/Doctor.vue";
import DoctorHeader from "../components/DoctorHeader.vue";
import { inject, ref, computed } from "vue";
import { useFetch } from "@vueuse/core";

// Инъекция BASE_SITE из родительского контекста
const BASE_SITE = inject<string>("BASE_SITE");

// Получаем параметры маршрута
const route = useRoute();
const specialId = route.params.specialId as string; // Убедимся, что specialId обрабатывается как строка

// Запрашиваем врачей по ID специализации
const {
  data: doctors,
  isFetching,
  error,
} = useFetch(`${BASE_SITE}/doctors/${specialId}`).get().json();

// Используем ref для хранения заголовка для шапки
const label = ref<string>("Наши врачи");

// Вычисляемое свойство для безопасного доступа к метке из данных врачей
const specializationLabel = computed(() => {
  if (doctors.value && doctors.value.length > 0) {
    return doctors.value[0].specialization.label;
  }
  return label.value; // Резервный вариант, если врачи не найдены
});
</script>

Краткое описание блока <script>

  1. Импорт необходимых модулей:

    • Мы импортируем useRoute из vue-router для получения параметров маршрута, компоненты Doctor и DoctorHeader, а также функции из Vue и библиотеки VueUse.

  2. Инъекция BASE_SITE:

    • С помощью inject мы получаем базовый URL нашего API, который будет использоваться для запросов.

  3. Получение параметров маршрута:

    • Мы используем useRoute для получения параметров текущего маршрута. В данном случае нас интересует specialId, который передается в URL.

  4. Запрос данных о врачах:

    • С помощью useFetch мы выполняем GET-запрос к API для получения списка врачей по выбранной специализации. Данные о врачах будут храниться в переменной doctors, а также у нас есть флаги isFetching и error для отслеживания состояния загрузки и обработки ошибок.

  5. Реактивные переменные:

    • Мы создаем реактивную переменную label, которая будет использоваться в заголовке шапки.

    • Вычисляемое свойство specializationLabel позволяет безопасно получать название специализации из данных врачей или использовать резервный вариант, если данные не найдены.

Описание блока <template>

<template>
  <DoctorHeader :label="specializationLabel" />
  <div v-if="isFetching" class="text-center">
    <p>Загрузка...</p>
  </div>
  <!-- Ошибка -->
  <div v-else-if="error" class="text-center text-red-500">
    <p>Ошибка: {{ error.message }}</p>
  </div>
  <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
    <div v-for="doctor in doctors" :key="doctor.id">
      <Doctor
        :photo="doctor.photo"
        :description="doctor.description"
        :experience="doctor.experience"
        :doctorId="doctor.id"
        :name="`${doctor.first_name} ${doctor.last_name}`"
        :special="doctor.special"
        :work_experience="doctor.work_experience"
      />
    </div>
  </div>
</template>

Описание блока <template>

  1. Компонент шапки:

    • Мы используем компонент DoctorHeader, передавая ему вычисляемое свойство specializationLabel. Это позволяет динамически отображать название специализации в заголовке.

  2. Состояние загрузки:

    • Если данные все еще загружаются (isFetching), отображается сообщение "Загрузка...". Это помогает пользователю понять, что информация еще не готова.

  3. Обработка ошибок:

    • Если произошла ошибка при загрузке данных (error), выводится сообщение об ошибке с указанием причины. Это важно для информирования пользователя о проблемах с получением данных.

  4. Отображение списка докторов:

    • Если данные успешно загружены, мы используем директиву v-for для перебора массива doctors. Для каждого врача создается компонент Doctor, которому передаются соответствующие пропсы: фото, описание, стаж работы и имя врача (составленное из имени и фамилии).

Таким образом мы реализовали 2 из 3 представлений. Теперь мы готовы перейти к самому сложному блоку нашего фронтенд-приложения, а именно, к странице с записями к указанному доктору.

Перед прочтением далее важно, чтоб вы хорошо разобрались с предыдущим материалом!

Опишем компонент слотов на запись

Сразу хочу предупредить, что в реальной практике данный компонент можно было бы разбить ещё на 3-4 дополнительных компонента, но, с целью экономии вашего и своего времени, я решил не дробить проект так сильно. Поэтому, сейчас будет чуть больше кода, чем в предыдущих компонентах.

Логика в блоке <script>

<script setup lang="ts">
import { ref, computed } from "vue";
import { inject } from "vue";
import { useFetch } from "@vueuse/core";
import { useWebApp } from "vue-tg";

const { initDataUnsafe, close } = useWebApp();
const user = initDataUnsafe.user || {}; // Получаем данные пользователя

const BASE_SITE = inject("BASE_SITE") as string;

// Определяем пропсы для компонента
const props = defineProps([
  "dayWeek", // День недели
  "countSlot", // Количество доступных слотов
  "dayData", // Точная дата (например, 08.01.2025)
  "doctorInfo", // Полная информация о докторе
  "dayDate",
]);

// Состояния модальных окон
const isModalOpen = ref(false); // Основное модальное окно для выбора времени
const isConfirmationModalOpen = ref(false); // Модальное окно подтверждения записи
const isSuccessModalOpen = ref(false); // Модальное окно успешной записи
const selectedTime = ref(""); // Выбранное время для записи
const selectedDay = ref(""); // Выбранный день для записи
const localCount = ref(props.dayDate.total_slots); // Доступное количество слотов для выбранного дня
const localSlots = ref(props.dayDate.slots); // Доступные временные слоты

// Функция для открытия основного модального окна
const openModal = () => {
  isModalOpen.value = true;
};

// Функция для закрытия основного модального окна
const closeModal = () => {
  isModalOpen.value = false;
};

// Функция для открытия модального окна подтверждения записи
const openConfirmationModal = (time: string, day: string) => {
  selectedTime.value = time; // Устанавливаем выбранное время
  selectedDay.value = day; // Устанавливаем выбранный день
  isConfirmationModalOpen.value = true;
};

// Функция для закрытия модального окна подтверждения записи
const closeConfirmationModal = () => {
  isConfirmationModalOpen.value = false;
};

// Функция для подтверждения записи на прием
const confirmAppointment = async () => {
  const bookingData = {
    doctor_id: props.doctorInfo.id,
    user_id: user.id,
    day_booking: selectedDay.value,
    time_booking: selectedTime.value,
  };

  try {
    await useFetch(`${BASE_SITE}/book`).post(bookingData).json(); // Отправляем данные о записи на прием через API
    localCount.value--; // Уменьшаем количество доступных слотов на 1
    localSlots.value = localSlots.value.filter(
      (slot: string) => slot !== selectedTime.value
    ); // Удаляем выбранный слот из доступных
    isConfirmationModalOpen.value = false; // Закрываем окно подтверждения записи
    isModalOpen.value = false; // Закрываем основное модальное окно
    isSuccessModalOpen.value = true; // Открываем окно успешной записи
  } catch (error) {
    console.error("Ошибка при записи:", error);
    alert("Произошла ошибка при записи. Пожалуйста, попробуйте еще раз."); // Обработка ошибок при записи
  }
};

// Функция для закрытия окна успешной записи
const closeSuccessModal = () => {
  isSuccessModalOpen.value = false;
  close(); // Закрываем веб-приложение (если это требуется)
};

// Вычисляемое свойство для отображения количества доступных слотов
const slotLabel = computed(() => {
  if (localCount.value) {
    return `Доступно ${localCount.value} слотов`;
  }
  return "Бронь не доступна!"; // Сообщение о недоступности слотов
});
</script>

Для интеграции библиотеки VueTg в наш проект мы выполнили следующие шаги:

Импорт функциональности VueTg

Сначала мы импортировали объект useWebApp из библиотеки vue-tg:

import { useWebApp } from "vue-tg";

Этот объект предоставляет доступ к методам и данным, связанным с Telegram Web Apps.

Извлечение данных пользователя

Далее мы извлекли из объекта useWebApp два метода: initDataUnsafe и close:

const { initDataUnsafe, close } = useWebApp();
  • initDataUnsafe используется для получения информации о пользователе Telegram, который открыл наше приложение.

  • close позволяет закрыть веб-приложение Telegram (например, после успешного выполнения действия).

С помощью initDataUnsafe мы получили данные текущего пользователя Telegram:

const user = initDataUnsafe.user || {}; // Получаем данные пользователя

Если данные пользователя отсутствуют, переменная user будет пустым объектом.

Тестирование с использованием хардкода

На этапе тестирования мы можем подставить Telegram ID пользователя вручную. Например:

const user = initDataUnsafe.user || { id: 12345 };

Здесь вместо 12345 указывается Telegram ID пользователя, с которым ваш бот уже "знаком" и которому он может отправлять сообщения. Это упрощает процесс тестирования приложения без необходимости реального взаимодействия с Telegram API в формате MiniApp.

Альтернативный подход

Хотя Telegram ID можно было бы извлечь из параметров URL (например, передав его через клавиатуру бота), данный способ менее элегантен. Использование initDataUnsafe позволяет нам напрямую работать с данными, предоставляемыми платформой Telegram, что делает код более чистым и удобным.

Остальная логика в этом блоке кода должна быть вам понятна. Тем более, я добавили комментарии практически к каждой строке кода.

Логика в блоке <template>

<template>
  <div>
    <!-- Карточка дня -->
    <div
      class="cursor-pointer bg-white rounded-lg shadow-md p-6 text-center hover:shadow-lg transition-shadow duration-300"
      @click="openModal"
    >
      <h3 class="text-xl font-bold text-indigo-600 mb-2">{{ dayDate.day }}</h3>
      <p class="text-gray-700 text-lg">{{ slotLabel }}</p>
    </div>

    <!-- Основное модальное окно (выбор времени) -->
    <div
      v-if="isModalOpen"
      class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
      @click.self="closeModal"
    >
      <div class="bg-white rounded-lg shadow-lg w-96 p-6">
        <h2 class="text-2xl font-bold text-indigo-900 text-center">
          Запись на {{ dayDate.date }}
        </h2>
        <h3 class="font-bold text-center mb-4 text-indigo-900">
          ({{ dayDate.day }})
        </h3>
        <p class="text-gray-700 mb-4 text-center">
          Выберите удобное время для записи:
        </p>
        <div class="grid grid-cols-3 gap-3 mb-6">
          <!-- Генерация временных слотов -->
          <button
            v-for="time in localSlots"
            class="py-2 px-4 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition duration-200"
            @click="openConfirmationModal(time, dayDate.date)"
          >
            {{ time }}
          </button>
        </div>
        <button
          class="w-full py-2 px-4 bg-red-500 text-white rounded-lg hover:bg-red-600 transition duration-200"
          @click="closeModal"
        >
          Закрыть
        </button>
      </div>
    </div>

    <!-- Модальное окно подтверждения -->
    <div
      v-if="isConfirmationModalOpen"
      class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
      @click.self="closeConfirmationModal"
    >
      <div class="bg-white rounded-lg shadow-lg w-96 p-6">
        <h2 class="text-xl font-bold text-indigo-900 mb-4 text-center">
          Вы уверены?
        </h2>
        <p class="text-gray-700 text-center mb-6">
          Вы хотите записаться к
          <span class="font-semibold">{{ doctorInfo.name }}</span> на
          <span class="font-semibold">{{ dayDate.day }}</span> (<span>{{
            dayDate.date
          }}</span
          >) в <span class="font-semibold">{{ selectedTime }}</span
          >?
        </p>
        <div class="flex justify-around">
          <button
            class="py-2 px-4 bg-green-500 text-white rounded-lg hover:bg-green-600 transition duration-200"
            @click="confirmAppointment"
          >
            ДА
          </button>
          <button
            class="py-2 px-4 bg-red-500 text-white rounded-lg hover:bg-red-600 transition duration-200"
            @click="closeConfirmationModal"
          >
            НЕТ
          </button>
        </div>
      </div>
    </div>

    <!-- Модальное окно успешной записи -->
    <div
      v-if="isSuccessModalOpen"
      class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
      @click.self="closeSuccessModal"
    >
      <div class="bg-white rounded-lg shadow-lg w-96 p-6">
        <h2 class="text-xl font-bold text-green-600 mb-4 text-center">
          Успешно!
        </h2>
        <p class="text-gray-700 text-center mb-6">
          Вы успешно записались к
          <span class="font-semibold">{{ doctorInfo.name }}</span> на
          <span class="font-semibold">{{ dayDate.day }}</span> (<span>{{
            dayDate.date
          }}</span
          >) в <span class="font-semibold">{{ selectedTime }}</span
          >.
        </p>
        <button
          class="w-full py-2 px-4 bg-green-500 text-white rounded-lg hover:bg-green-600 transition duration-200"
          @click="closeSuccessModal"
        >
          Закрыть
        </button>
      </div>
    </div>
  </div>
</template>

Объяснение логики в блоке <template>

  1. Карточка дня:

    • Эта карточка отображает информацию о дате и количестве доступных слотов. При клике открывается основное модальное окно для выбора времени.

  2. Основное модальное окно:

    • Если isModalOpen истинно, отображается это окно с заголовком и кнопками для выбора времени.

    • Временные слоты генерируются с помощью директивы v-for, что позволяет пользователю выбрать удобное время.

  3. Модальное окно подтверждения:

    • После выбора времени открывается это окно с вопросом о подтверждении записи.

    • Здесь пользователь может подтвердить или отменить запись.

  4. Модальное окно успешной записи:

    • Это окно появляется после успешного завершения процедуры бронирования.

    • В нем отображается сообщение об успешной записи с возможностью закрытия окна.

Общий смысл блока

Компонент слотов на запись предоставляет пользователям удобный интерфейс для выбора времени записи к врачу. Он включает в себя несколько интерактивных элементов:

  1. Выбор даты: Пользователь может выбрать день и увидеть количество доступных временных слотов.

  2. Выбор времени: После выбора даты открывается модальное окно с доступными временными слотами.

  3. Подтверждение бронирования: Пользователь может подтвердить или отменить запись через отдельное модальное окно.

  4. Уведомление об успехе: После успешного завершения процесса бронирования пользователь получает уведомление об успешной записи.

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

Создаем представление с записью

И переходим к нашему крайнему представлению (странице), на котором пользователь сможет выполнить запись к выбранному доктору. Работать мы будем с файлом src/views/DoctorDetail.vue.

Блок <script>

<script setup lang="ts">
import DoctorHeader from "../components/DoctorHeader.vue";
import BookingSlot from "../components/BookingSlot.vue";
import { useFetch } from "@vueuse/core";
import { computed, inject, ref, watch } from "vue";
import { useRoute } from "vue-router";

// Инъекция базового URL API
const BASE_SITE = inject<string>("BASE_SITE");
if (!BASE_SITE) {
  throw new Error("BASE_SITE must be provided");
}

// Получаем параметры маршрута
const route = useRoute();
const doctorId = route.params.doctorId as string;

// Получаем информацию о докторе
const { data: doctor } = useFetch(`${BASE_SITE}/doctor/${doctorId}`)
  .get()
  .json();

const doctorInfo = computed(() => doctor.value || {});

// Формируем URL изображения доктора
const imageSrc = computed(() => {
  if (!doctorInfo.value.photo) return "";
  return new URL(
    `${BASE_SITE}/static/images/${doctorInfo.value.photo}`,
    import.meta.url
  ).href;
});

// Формируем полное имя доктора
const name = computed(() => {
  const { first_name = "", patronymic = "", last_name = "" } = doctorInfo.value;
  return `${first_name} ${patronymic} ${last_name}`.trim();
});

// Получаем текущую дату для начальных слотов
const today = new Date();
const initialDate = today.toISOString().split("T")[0];
const currentStartDate = ref(initialDate);

// Создаем реактивный запрос для получения слотов
const { data: slotsWeekInfo, execute: fetchSlots } = useFetch(
  computed(
    () =>
      `${BASE_SITE}/booking/available-slots/${doctorId}?start_date=${currentStartDate.value}`
  ),
  { immediate: true }
)
  .get()
  .json();

const label = "Запись";

// Генерируем строку с текущей неделей
const generateCurrentWeek = computed(() => {
  if (slotsWeekInfo.value?.days?.length) {
    const days = slotsWeekInfo.value.days;
    const startDate = days[0].date;
    const endDate = days[days.length - 1].date;
    return `${startDate} - ${endDate}`;
  }
  return "Нет данных";
});

// Функция навигации по неделям
const changeWeek = async (direction: number) => {
  const date = new Date(currentStartDate.value);
  date.setDate(date.getDate() + direction * 7);

  // Предотвращаем выбор даты ранее текущей
  if (date < today) {
    currentStartDate.value = initialDate;
  } else {
    currentStartDate.value = date.toISOString().split("T")[0];
  }

  // Ждем обновления слотов
  await fetchSlots();
};

// Следим за изменениями даты и обновляем слоты
watch(
  currentStartDate,
  async () => {
    await fetchSlots();
  },
  { immediate: true }
);
</script>

Пояснения к блоку <script>

  1. Импорт необходимых модулей:

    • Мы импортируем компоненты DoctorHeader и BookingSlot, а также функции из Vue и библиотеки @vueuse/core.

  2. Инъекция базового URL:

    • С помощью inject мы получаем базовый URL API. Если он не предоставлен, выбрасывается ошибка.

  3. Получение параметров маршрута:

    • Используя useRoute, мы получаем ID доктора из параметров маршрута.

  4. Запрос информации о докторе:

    • С помощью useFetch выполняется GET-запрос к API для получения данных о докторе по его ID.

  5. Формирование данных для отображения:

    • Мы используем вычисляемые свойства (computed) для формирования URL изображения доктора и полного имени на основе полученной информации.

  6. Получение доступных слотов:

    • Мы определяем текущую дату и создаем реактивный запрос для получения доступных временных слотов на неделю.

  7. Навигация по неделям:

    • Функция changeWeek позволяет пользователю переключаться между неделями, обновляя доступные слоты в зависимости от выбранной даты.

  8. Отслеживание изменений:

    • Мы используем watch, чтобы следить за изменениями в currentStartDate и автоматически обновлять слоты при изменении даты.

Блок <template>

Теперь давайте рассмотрим блок <template>:

<template>
  <DoctorHeader :label="label" />

  <div
    class="flex flex-col sm:flex-row items-center mb-8 border-b pb-6 rounded-lg 
                shadow-lg hover:shadow-xl transition-shadow duration-300"
  >
    <img
      v-if="imageSrc"
      :src="imageSrc"
      :alt="`Фото доктора ${name}`"
      class="ml-2 w-32 h-24 sm:w-40 sm:h-32 object-cover rounded-lg mr-4 cursor-pointer"
    />

    <div class="text-center sm:text-left">
      <h2 class="text-2xl sm:text-3xl font-bold text-indigo-900 mb-2">
        {{ name }}
      </h2>
      <p class="text-indigo-600 text-lg">
        {{ doctorInfo.special }} • Стаж: {{ doctorInfo.work_experience }}
        {{ doctorInfo.experience }}
      </p>
    </div>
  </div>

  <div class="flex items-center space-x-6 mb-6">
    <button
      class="w-14 h-14 flex items-center justify-center bg-indigo-500 text-white rounded-full 
                   hover:bg-indigo-600 active:bg-indigo-700 transition-all duration-300 shadow-lg 
                   hover:shadow-xl"
      @click="() => changeWeek(-1)"
      :disabled="currentStartDate === initialDate"
    >
      <i class="fas fa-chevron-left text-xl"></i>
    </button>
    <h3 class="text-xl font-semibold text-indigo-900 flex-grow text-center">
      {{ generateCurrentWeek }}
    </h3>
    <button
      class="w-14 h-14 flex items-center justify-center bg-indigo-500 text-white rounded-full 
                   hover:bg-indigo-600 active:bg-indigo-700 transition-all duration-300 shadow-lg 
                   hover:shadow-xl"
      @click="() => changeWeek(1)"
    >
      <i class="fas fa-chevron-right text-xl"></i>
    </button>
  </div>

  <!-- Слоты для записи -->
  <div
    class="grid grid-cols-1 sm:grid-cols-3 gap-6"
    v-if="slotsWeekInfo && slotsWeekInfo.days"
  >
    <BookingSlot
      v-for="dayDate in slotsWeekInfo.days"
      :key="dayDate.date"
      :doctor-info="doctorInfo"
      :dayDate="dayDate"
    />
  </div>
</template>

Пояснения к блоку <template>

  1. Компонент заголовка:

    • Мы используем компонент DoctorHeader, передавая ему метку для отображения заголовка страницы.

  2. Карточка доктора:

    • В этом блоке отображается информация о докторе, включая его фотографию и полное имя. Если фотография отсутствует, элемент не будет отображаться.

  3. Навигация по неделям:

    • Кнопки с иконками позволяют пользователю переключаться между неделями. Первая кнопка уменьшает дату на одну неделю, а вторая увеличивает.

    • Кнопка для перехода назад отключается, если текущая дата уже является начальной.

  4. Отображение доступных слотов:

    • Если данные о доступных слотах успешно загружены, мы используем компонент BookingSlot для отображения информации о каждом дне с доступными временными слотами.

Теперь посмотрим на то, что у нас получилось.

При клике на кнопку «Закрыть», если приложение было запущено с телеграмм, то закроется не только модальное окно, но и само окно Telegram MiniApp. Это произойдет из-за активации этой функции close, которую мы импортировали ранее из useWebApp().

Что дальше?

Теперь нам остается выполнить два шага, чтобы завершить интеграцию нашего веб-приложения с Telegram и сделать его доступным для пользователей.

1. Деплой проекта

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

Кроме того, важно, чтобы доменное имя поддерживало защищенный HTTPS протокол. В противном случае серверы Telegram не будут доверять вашему приложению, и вы не сможете использовать его в качестве Telegram MiniApp.

Напоминаю, что деплой мы будем выполнять на сервис Amvera Cloud. Выполнив деплой на данный сервис вы получите не только гибкость в развертывании своего приложения, но и бесплатное доменное имя с HTTPS протоколом, которого будет более чем достаточно для работы нашего проекта.

2. Привязка веб-приложения к Telegram-боту

Вторым шагом будет привязка нашего веб-приложения к Telegram-боту. Если вы внимательно читали первую часть данной мини-серии, то должны помнить, что мы уже реализовали Telegram-бота и подключили специальную инлайн-клавиатуру, которая ожидает ссылки на MiniApp.

Процесс связывания Telegram-бота и нашего Vue.js приложения будет заключаться в следующем:

  • Установим корректную ссылку на MiniApp в настройках бота (бэкенда).

  • После этого перезапустим бэкенд с новой ссылкой.

Этот процесс займет не больше минуты.

Приступим к деплою

Теперь, когда мы знаем, что нам нужно сделать, давайте перейдем к процессу деплоя и интеграции с Telegram. Это позволит нам завершить разработку и предоставить пользователям доступ к нашему приложению через мессенджер Telegram.

Первое, что мы сделаем, — это соберем проект. Для этого достаточно в терминале ввести команду:

npm run build
 Как видите, сборка заняла менее 3 секунд.
Как видите, сборка заняла менее 3 секунд.

После выполнения этой команды в папке dist будут созданы все необходимые файлы для развертывания вашего приложения.

Важно отметить, что простого запуска файла index.html в браузере будет недостаточно для того, чтобы увидеть функциональность нашего веб-приложения. Это связано с несколькими факторами:

  1. Использование ES модулей: Современные веб-приложения часто используют модульную структуру кода, которая требует специальной обработки на сервере.

  2. Безопасность: Браузеры ограничивают выполнение скриптов из локальных файлов, что может привести к проблемам при загрузке ресурсов.

  3. Отсутствие серверной обработки: Некоторые функции приложения могут требовать взаимодействия с сервером.

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

  1. Использование FastAPI: Один из вариантов - привязать полученные файлы приложения к FastAPI. В моей статье "Запускаем Vue.js 3 с FastAPI в одном приложении" я подробно описывал этот подход. Он позволяет объединить фронтенд и бэкенд в одном приложении, что удобно для разработки и деплоя.

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

Для простого варианта обработки статических файлов можно использовать Nginx в контейнере Docker. Это легковесное решение, которое отлично подходит для обслуживания статического контента Vue.js приложения.

Чтобы реализовать этот подход, выполним следующие шаги:

В корне проекта, на одном уровне со сгенерированной ранее папкой dist, создадим файл Dockerfile и заполним его следующим содержимым:

# Используем официальный образ NGINX в качестве базового
FROM nginx:alpine

# Удаляем дефолтный файл конфигурации NGINX
RUN rm /usr/share/nginx/html/index.html

# Копируем все файлы из папки dist в папку с HTML файлами NGINX
COPY dist/ /usr/share/nginx/html/

# Экспонируем порт 80
EXPOSE 80

# Запускаем NGINX
CMD ["nginx", "-g", "daemon off;"]

Пояснение к Dockerfile

  1. Базовый образ:

    • Мы используем официальный образ NGINX на базе Alpine Linux. Этот образ легковесен и идеально подходит для развертывания статических сайтов.

  2. Удаление дефолтного файла:

    • Удаляем стандартный файл index.html, который поставляется с образом NGINX, чтобы заменить его на наш собственный.

  3. Копирование файлов:

    • Копируем все файлы из папки dist в директорию NGINX, где хранятся HTML-файлы. Это позволяет серверу NGINX обслуживать наше приложение.

  4. Экспонирование порта:

    • Экспонируем порт 80, который используется по умолчанию для HTTP-трафика. Это позволит пользователям обращаться к нашему приложению через веб-браузер.

  5. Запуск NGINX:

    • Запускаем NGINX с параметром daemon off, чтобы он работал в переднем плане и не завершал свою работу при запуске контейнера.

Файл Dockerfile для сервиса Amvera Cloud теперь будет выступать в виде файла с инструкциями для запуска. То есть, теперь нам останется только доставить файлы Vue.JS3 с этим докер-файлом и ваше приложение будет запущено. Шаги будут сильно похожи на те, которые мы выполнили в прошлой статье при деплое приложения.

Запускаем фронтенд на Amvera Cloud

Теперь мы готовы выполнить удаленный запуск нашего фронтенд-приложения на сервисе Amvera Cloud. Все сводится к тому, чтобы доставить файл Dockerfile и файлы веб-приложения в созданный проект на указанном сервисе. Далее Amvera самостоятельно соберет и поднимет ваш проект.

Вот пошаговый план:

  • Регистрируемся на сайте Amvera Cloud, если ранее этого не сделали (новички получают приветственный бонус в размере 111 рублей).

  • Кликаем на кнопку «Создать проект».

  • Выбираем тип сервиса «Приложение» и нажимаем «Далее».

  • Даем название проекту и выбираем тарифный план (для учебных целей подойдет один из начальных). После этого жмем на «Далее».

  • На новом экране вам предложат загрузить файлы приложения в проект. Можно воспользоваться GIT (вы сразу увидите инструкцию на странице) или использовать загрузку файлов прямо через сайт. Я, для простоты, выбрал вариант «Через интерфейс». Необходимо загрузить папку dist вместе с ранее созданным Dockerfile. После этого кликаем на «Далее».

  • Далее у вас откроется экран с настройками. Там нужно выбрать окружение Docker и инструмент docker. Все остальное оставляем без изменений. Жмем на «Завершить».

Теперь нам необходимо выполнить процесс привязки бесплатного домена с HTTPS к нашему проекту. Для этого нужно:

  • Найти свой проект и войти в него.

  • Выбрать вкладку «Домены».

  • Кликнуть на «Добавить домен» и выбрать бесплатный HTTPS домен Amvera Cloud или, если требуется, выбрать свой домен и следовать простой инструкции по привязке домена к проекту.

  • Пересобрать проект, чтобы корректно привязался домен.

Теперь остается подождать 2-3 минуты, и ваш проект будет развернут и привязан к доменному имени с HTTPS.

Привязываем MiniApp к Telegram боту

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

FRONT_SITE=http://127.0.0.1:3000

Замените значение этой переменной на привязанное к проекту доменное имя (без слеша в конце). Далее, если деплой бэкенда был на Amvera Cloud, просто замените файл .env на файл с обновленной переменной и пересоберите проект.

На этом все. Готовый проект в моем исполнении вы можете посмотреть здесь: Vue3_FastApiBOT.

Заключение

Надеюсь, мне удалось передать ключевую мысль: даже если вы бэкенд-разработчик, используя такие технологии, как Tailwind CSS и Vue.js 3, можно не только самостоятельно создавать бэкенд-логику, но и разрабатывать вполне достойную визуальную часть приложения.

Особенно хочу подчеркнуть, что при реализации фронтенда я намеренно не писал ни одной собственной строки CSS. Это еще раз доказывает, что Tailwind CSS — мощный инструмент, который стоит изучить глубже. Этот CSS-фреймворк отлично интегрируется с Vue.js 3, что делает его еще более привлекательным выбором для разработки.

Теперь о Vue.js. Несмотря на то, что в рамках этой статьи мы реализовали всего три представления и несколько дополнительных компонентов, даже на этом уровне была задействована довольно сложная JavaScript-логика. Реализация подобного функционала на чистом JavaScript потребовала бы значительно больше усилий. Именно поэтому я и выбрал Vue.js — этот фреймворк значительно упрощает разработку.

Напоминаю, что в своих предыдущих статьях я создавал фронтенд, используя чистые HTML, CSS и JS, а рендеринг осуществлял через Jinja2 в FastAPI. Если этот материал найдет отклик у аудитории, в следующих публикациях я планирую продемонстрировать еще больше возможностей не только из мира бэкенда, но и из мира фронтенд-технологий.

Для тех, кто хочет глубже погрузиться в фронтенд, я создал новый телеграм-канал «Легкий путь в JavaScript». Канал только начал свою работу, и ваша подписка станет для меня мощным стимулом. Там уже доступен полный исходный код сегодняшнего проекта, а также эксклюзивные материалы, которых вы не найдете на других площадках.

Если же ваш интерес лежит в области бэкенда, приглашаю вас в канал «Легкий путь в Python». Это уже сформировавшееся сообщество из более чем 2200 участников, которое активно развивается. В этом канале я делюсь знаниями и опытом, касающимся Python и связанных технологий.

В рамках этих двух статей мы проделали действительно большую работу, завершив полноценное full-stack приложение. Теперь вы знаете, как:

  • писать API;

  • создавать телеграм-ботов без использования фреймворков вроде AIOgram 3;

  • разрабатывать современный и реактивный фронтенд на Vue.js 3, который легко взаимодействует с бэкендом через API.

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

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

На этом у меня все. До скорого!

Комментарии (0)