diff --git a/02-basics-2/50-selected-meetup/README.md b/02-basics-2/50-selected-meetup/README.md
new file mode 100644
index 0000000..63d3359
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/README.md
@@ -0,0 +1,31 @@
+# Выбранный митап
+
+🔥 _Задача повышенной сложности_\
+📚 _Закрепление материала_
+
+
+
+Требуется создать Vue приложение, которое выводит заголовок выбранного митапа:
+- На странице отображается заголовок выбранного митапа
+- Данные митапа запрашиваются с API функцией `getMeetup`
+- Выбор митапа осуществляется радио кнопками с выбором ID от 1 до 5
+- Кнопки "Предыдущий" и "Следующий" позволяют соответственно менять выбранный ID
+- Кнопки должны быть отключены с `disabled` по достижении крайних значений
+- Изначально выбран митап с ID = 1
+
+Требуется минимальное работающее решение. Обрабатывать потенциальную ошибку получения данных или отображать индикацию загрузки не требуется.
+
+
+
+
+
+---
+
+### Инструкция
+
+📝 Для решения задачи отредактируйте файл: `SelectedMeetupApp.js`.
+
+🚀 Команда запуска для ручного тестирования: `npm run dev`\
+Приложение будет доступно на [http://localhost:5173/02-basics-2/50-selected-meetup/](http://localhost:5173/02-basics-2/50-selected-meetup/).
+
+✅ Доступно автоматическое тестирование: `npm test selected-meetup`
diff --git a/02-basics-2/50-selected-meetup/SelectedMeetupApp.css b/02-basics-2/50-selected-meetup/SelectedMeetupApp.css
new file mode 100644
index 0000000..4401e67
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/SelectedMeetupApp.css
@@ -0,0 +1,16 @@
+.meetup-selector {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 10px;
+}
+
+.meetup-selector__control {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+
+.meetup-selector__cover {
+}
diff --git a/02-basics-2/50-selected-meetup/SelectedMeetupApp.js b/02-basics-2/50-selected-meetup/SelectedMeetupApp.js
new file mode 100644
index 0000000..dad23b8
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/SelectedMeetupApp.js
@@ -0,0 +1,78 @@
+import { defineComponent } from 'vue'
+// import { getMeetup } from './meetupsService.ts'
+
+export default defineComponent({
+ name: 'SelectedMeetupApp',
+
+ setup() {},
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
Some Meetup Title
+
+
+
+
+ `,
+})
diff --git a/02-basics-2/50-selected-meetup/__tests__/selected-meetup.test.ts b/02-basics-2/50-selected-meetup/__tests__/selected-meetup.test.ts
new file mode 100644
index 0000000..eca9ad9
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/__tests__/selected-meetup.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import type { MockedFunction } from 'vitest'
+import { createWrapperError, flushPromises, mount } from '@vue/test-utils'
+import type { VueWrapper, DOMWrapper } from '@vue/test-utils'
+import SelectedMeetupApp from '@/SelectedMeetupApp.js'
+import { getMeetup } from '@/meetupsService.ts'
+import type { MeetupDTO } from '@/meetups.types.ts'
+
+vi.mock('@/meetupsService.ts')
+
+const mockMeetups = {
+ 1: { id: 1, title: 'Meetup 1' },
+ 2: { id: 2, title: 'Meetup Second' },
+ 3: { id: 3, title: 'Meetup III' },
+ 4: { id: 4, title: '4th meetup' },
+ 5: { id: 5, title: 'Meetup 5' },
+} as const as unknown as Record
+
+const mockGetMeetup = getMeetup as MockedFunction
+
+mockGetMeetup.mockImplementation((meetupId: number) => Promise.resolve(mockMeetups[meetupId]))
+
+function findByText(wrapper: VueWrapper, selector: string, text: string): DOMWrapper {
+ return wrapper.findAll(selector).find(el => el.text() === text) ?? createWrapperError>('DOMWrapper')
+}
+
+describe('SelectedMeetupApp', () => {
+ let wrapper: VueWrapper
+ let prevButton: DOMWrapper
+ let nextButton: DOMWrapper
+ let meetupIdRadioButtons: DOMWrapper[]
+ let meetupIdRadioLabels: DOMWrapper[]
+
+ beforeEach(() => {
+ wrapper = mount(SelectedMeetupApp)
+ prevButton = findByText(wrapper, 'button', 'Предыдущий')
+ nextButton = findByText(wrapper, 'button', 'Следующий')
+ meetupIdRadioLabels = wrapper.findAll('[role="radiogroup"] label')
+ meetupIdRadioButtons = meetupIdRadioLabels.map(label => wrapper.find(`input#${label.attributes('for')}`))
+ })
+
+ it('должно отображать 5 радио кнопок со значениями от 1 до 5, связанных по ID и FOR, а также кнопки "Предыдущий", "Следующий"', () => {
+ expect(prevButton.exists()).toBe(true)
+ expect(nextButton.exists()).toBe(true)
+ expect(meetupIdRadioLabels).toHaveLength(5)
+ expect(meetupIdRadioButtons).toHaveLength(5)
+ for (let i = 1; i <= 5; i++) {
+ expect(meetupIdRadioLabels[i - 1].text()).toBe(i.toString())
+ expect(meetupIdRadioButtons[i - 1].exists()).toBeTruthy()
+ }
+ })
+
+ it('должно изначально отображать первый митап выбранным и выводить его заголовок из данных, полученных функцией getMeetup', async () => {
+ await flushPromises()
+ expect(meetupIdRadioButtons[0].element.checked).toBeTruthy()
+ expect(wrapper.text()).toContain('Meetup 1')
+ })
+
+ it('должно отображать кнопку "Предыдущий" отключенной при изначально выбранном первом митапе', async () => {
+ expect(meetupIdRadioButtons[0].element.checked).toBeTruthy()
+ expect(prevButton.attributes('disabled')).toBeDefined()
+ expect(nextButton.attributes('disabled')).not.toBeDefined()
+ })
+
+ it('должно выводить заголовок 2-го митапа выбранным после выбора 2-ой радио кнопки и выводить его заголовок из данных, полученных функцией getMeetup', async () => {
+ await meetupIdRadioButtons[1].setValue(true)
+ await flushPromises()
+ expect(wrapper.text()).toContain('Meetup Second')
+ })
+
+ it('должно выводить заголовок 5-го митапа после выбора 5-й радио кнопки', async () => {
+ await meetupIdRadioButtons[4].setValue(true)
+ await flushPromises()
+ expect(wrapper.text()).toContain('Meetup 5')
+ })
+
+ it('должно отображать кнопку "Следующий" отключенной при выборе последнего митапа', async () => {
+ await meetupIdRadioButtons[4].setValue(true)
+ await flushPromises()
+ expect(prevButton.attributes('disabled')).not.toBeDefined()
+ expect(nextButton.attributes('disabled')).toBeDefined()
+ })
+
+ it('должно переключать с 3-го на 4-ый митап кнопкой "Следующий", когда был выбран 3-ий, и выводить его заголовок из данных, полученных функцией getMeetup', async () => {
+ await meetupIdRadioButtons[2].setValue(true)
+ await flushPromises()
+ await nextButton.trigger('click')
+ await flushPromises()
+ expect(prevButton.attributes('disabled')).not.toBeDefined()
+ expect(nextButton.attributes('disabled')).not.toBeDefined()
+ expect(meetupIdRadioButtons[2].element.checked).toBeFalsy()
+ expect(meetupIdRadioButtons[3].element.checked).toBeTruthy()
+ expect(wrapper.text()).toContain('4th meetup')
+ })
+
+ it('должно переключать с 4-го на 3-ий митап кнопкой "Предыдущий", когда выбран 4-ый, и выводить его заголовок из данных, полученных функцией getMeetup', async () => {
+ await meetupIdRadioButtons[3].setValue(true)
+ await flushPromises()
+ await prevButton.trigger('click')
+ await flushPromises()
+ expect(prevButton.attributes('disabled')).not.toBeDefined()
+ expect(nextButton.attributes('disabled')).not.toBeDefined()
+ expect(meetupIdRadioButtons[3].element.checked).toBeFalsy()
+ expect(meetupIdRadioButtons[2].element.checked).toBeTruthy()
+ expect(wrapper.text()).toContain('Meetup III')
+ })
+})
diff --git a/02-basics-2/50-selected-meetup/index.html b/02-basics-2/50-selected-meetup/index.html
new file mode 100644
index 0000000..cb13e76
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+ selected-meetup
+
+
+
+
+
+
diff --git a/02-basics-2/50-selected-meetup/main.js b/02-basics-2/50-selected-meetup/main.js
new file mode 100644
index 0000000..adc8f06
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/main.js
@@ -0,0 +1,6 @@
+import '@shgk/vue-course-ui/meetups/style.css'
+import { createApp } from 'vue'
+import SelectedMeetupApp from './SelectedMeetupApp.js'
+import './SelectedMeetupApp.css'
+
+createApp(SelectedMeetupApp).mount('#app')
diff --git a/02-basics-2/50-selected-meetup/meetups.types.ts b/02-basics-2/50-selected-meetup/meetups.types.ts
new file mode 100644
index 0000000..a27e7a1
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/meetups.types.ts
@@ -0,0 +1,160 @@
+export type AgendaItemsTypes =
+ | 'registration'
+ | 'opening'
+ | 'talk'
+ | 'break'
+ | 'coffee'
+ | 'closing'
+ | 'afterparty'
+ | 'other'
+
+export type MeetupAgendaItemDTO = {
+ /** ID пункта программы */
+ id: number
+
+ /** Время начала в формате HH:MM */
+ startsAt: string
+
+ /** Время окончания в формате HH:MM */
+ endsAt: string
+
+ /** Тип пункта программы */
+ type: AgendaItemsTypes
+
+ /** Заголовок при наличии */
+ title: string | null
+
+ /** Описание при наличии */
+ description: string | null
+
+ /** Имя докладчика для типа talk */
+ speaker: string | null
+
+ /** Язык доклада для типа talk */
+ language: string | null
+}
+
+export type MeetupDTO = {
+ /** ID митапа */
+ id: string
+
+ /** Название митапа */
+ title: string
+
+ /** Описание митапа */
+ description: string
+
+ /**
+ * ID изображения митапа
+ * @deprecated
+ */
+ imageId: number
+
+ /** Ссылка на изображение митапа */
+ image: string
+
+ /** Место проведения митапа */
+ place: string
+
+ /** Организатор митапа */
+ organizer: string
+
+ /** Дата митапа в формате UNIX Timestamp в 00:00:00.000 по UTC */
+ date: number
+
+ /** Дата митапа в формате YYYY-MM-DD по UTC */
+ dateIso: string
+
+ /** Для авторизованного пользователя - является ли текущий пользователь организатором митапа */
+ organizing?: boolean
+
+ /** Для авторизованного пользователя - является ли текущий пользователь участником митапа */
+ attending?: boolean
+
+ /** Программа митапа */
+ agenda: MeetupAgendaItemDTO[]
+}
+
+export type CreateAgendaItemDTO = {
+ /** Время начала в формате HH:MM */
+ startsAt: string
+
+ /** Время окончания в формате HH:MM */
+ endsAt: string
+
+ /** Тип пункта программы */
+ type: AgendaItemsTypes
+
+ /** Заголовок при наличии */
+ title?: string | null
+
+ /** Описание при наличии */
+ description?: string | null
+
+ /** Имя докладчика для типа talk */
+ speaker?: string
+
+ /** Язык доклада для типа talk */
+ language?: string
+}
+
+export type CreateMeetupDTO = {
+ /** Название митапа */
+ title: string
+
+ /** Описание митапа */
+ description: string
+
+ /** Место проведения митапа */
+ place: string
+
+ /** Дата митапа строкой в формате YYYY-MM-DD или числом UNIX timestamp в полночь по UTC */
+ date: string | number
+
+ /** ID загруженного изображения митапа */
+ imageId: number
+
+ /** Программа митапа */
+ agenda: CreateAgendaItemDTO[]
+}
+
+export type UserDTO = {
+ /** ID пользователя */
+ id: number
+
+ /** Полное имя */
+ fullname: string
+
+ /** Email */
+ email: string
+
+ /** Ссылка на аватар в сервисе gravatar */
+ avatar: string
+}
+
+export type LoginDTO = {
+ /** Email */
+ email: string
+
+ /** Пароль */
+ password: string
+}
+
+export type RegisterDTO = {
+ /** Email */
+ email: string
+
+ /** Пароль */
+ password: string
+
+ /** Полное имя */
+ fullname: string
+}
+
+export type ImageDTO = {
+ /** ID изображения */
+ id: number
+
+ /** Ссылка на изображение */
+ url: string
+}
diff --git a/02-basics-2/50-selected-meetup/meetupsService.ts b/02-basics-2/50-selected-meetup/meetupsService.ts
new file mode 100644
index 0000000..a26efa0
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/meetupsService.ts
@@ -0,0 +1,17 @@
+import type { MeetupDTO } from './meetups.types.ts'
+
+const API_URL = 'https://course-vue.javascript.ru/api'
+
+/**
+ * Получить данные митапа по его ID
+ * @param meetupId - ID митапа
+ * @returns Данные митапа
+ */
+export async function getMeetup(meetupId: number): Promise {
+ const response = await fetch(`${API_URL}/meetups/${meetupId}`)
+ const result = await response.json()
+ if (!response.ok) {
+ throw new Error(result.message)
+ }
+ return result
+}
diff --git a/02-basics-2/50-selected-meetup/tsconfig.json b/02-basics-2/50-selected-meetup/tsconfig.json
new file mode 100644
index 0000000..8a8b469
--- /dev/null
+++ b/02-basics-2/50-selected-meetup/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@shgk/vue-course-taskbook/configs/tsconfig.json",
+ "include": ["**/*", "**/*.vue"],
+ "files": [],
+ "compilerOptions": {
+ "outDir": "dist",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ }
+}