Как создать веб-приложение на базе VK Mini Apps
В панель

Как создать веб-приложение на базе VK Mini Apps

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

Мы написали эту инструкцию на базе инфраструктуры Selectel. Чтобы повторить ее по шагам, зарегистрируйтесь или авторизуйтесь в панели управления.

Введение

VK Mini Apps — это открытая платформа мини‑приложений и игр, которые доступны ВКонтакте, в ОК, Почте Mail.ru, браузере Atom и RuStore. Ими можно пользоваться без установки на устройства, а число уникальных пользователей достигает 45  миллионов в месяц. 

На базе VK Mini Apps можно легко создать приложение и поделиться им с друзьями. А если разработка окажется успешной — загрузить в каталог мини-приложений ВКонтакте. Об этом всем подробнее поговорим в статье. Создадим приложение для мониторинга погоды, задеплоим на сервер и загрузим в VK Mini Apps.

Создание мини-приложения

Для начала создадим приложение в панели администратора. Оставляем тип «мини-приложение», придумываем название, указываем соответствующую категорию и нажимаем кнопку Создать. После подтверждаем создание мини-приложения удобным способом.

Создание мини-приложения в панели разработчика Вконтакте.
Создание мини-приложения в панели разработчика ВКонтакте.

Разработка бэкенда

Для получения прогнозы погоды будем использовать бесплатное API OpenWeatherMap

Так как для работы с этим сервисом нужно передать секретный ключ авторизации, мы не можем отправлять запросы прямо с фронтенда — это слишком небезопасно. Чтобы сохранить ключ в секрете, напишем небольшой сервис на Python и фреймворке FastAPI. У нас будет всего две «ручки»: первая будет «геокодировать» название города, а вторая — получать прогноз погоды на сегодня и следующие пять дней.


    # Импортируем необходимые модули и библиотеки
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from aiohttp import ClientSession
from datetime import datetime
from collections import defaultdict, Counter
import uvicorn

# Создаем экземпляр приложения FastAPI с корневым путем '/api'
app = FastAPI(root_path='/api')

# Настраиваем CORS, разрешая делать запросы к нашему API из любых источников, используя любые методы и заголовки
app.add_middleware(
   CORSMiddleware,
   allow_origins=["*"],
   allow_credentials=True,
   allow_methods=["*"],
   allow_headers=["*"],
)

# Задаем константы для работы с API OpenWeatherMap
TOKEN = 'b2520e207e84b3cfb1ccbbdecbe1812e'  # Наш API ключ

# Объявляем эндпоинты, по которым мы будем запрашивать данные у OpenWeatherMap
BASE_FORECAST_URL = f'https://api.openweathermap.org/data/2.5/forecast?appid={TOKEN}&units=metric&lang=ru&'
BASE_WEATHER_URL = f'https://api.openweathermap.org/data/2.5/weather?appid={TOKEN}&units=metric&lang=ru&'
BASE_GEOCODER_URL = f'https://api.openweathermap.org/geo/1.0/direct?appid={TOKEN}&lang=ru&'

session = ClientSession()


# Функция для группировки данных о погоде по дням
def group_weather_data_by_day(data):
   grouped_data = defaultdict(list)
   for entry in data['list']:
       date = datetime.utcfromtimestamp(entry['dt']).strftime('%Y-%m-%d')
       grouped_data[date].append(entry)

   return dict(grouped_data)


# Функция для получения сгруппированных погодных условий
def get_grouped_weather_conditions(data):
   grouped_data = group_weather_data_by_day(data)
   data = []

   for date, entries in grouped_data.items():
       max_temp = max(entry['main']['temp_max'] for entry in entries)
       min_temp = min(entry['main']['temp_min'] for entry in entries)
       humidity = max(entry['main']['humidity'] for entry in entries)
       pressure = max(entry['main']['pressure'] for entry in entries)
       weather_statuses = [(entry['weather'][0]['description'], entry['weather'][0]['icon']) for entry in entries]
       most_common_status = Counter(weather_statuses).most_common(1)[0][0]
       data.append({
           'date': date,
           'max_temp': max_temp,
           'min_temp': min_temp,
           'humidity': humidity,
           'pressure': pressure,
           'status': most_common_status[0],
           # Мы не будем использовать ночные иконки погодных условий в нашем приложении, поэтому заменяем n (night) на d (day)
           'icon': most_common_status[1].replace('n', 'd'),
       })

   return data[1:]


# Эндпоинт для получения информации о городе по его названию
@app.get("/getCity")
async def getCity(city: str, limit: int = 5):
   async with session.get(BASE_GEOCODER_URL + f'q={city}&limit={limit}') as r:
       data = await r.json()
   if data:
       return [{
           "name": i['local_names']['ru'] if 'ru' in i.get('local_names', {}) else i['name'],
           "state": i.get('state', i.get('country', '')),
           "lat": i['lat'],
           "lon": i['lon']
       } for i in data]
   else:
       raise HTTPException(404)


# Эндпоинт для получения прогноза и текущей погоды по координатам
@app.get("/getWeather")
async def getWeather(lat: float, lon: float):
   # Получение прогноза погоды на 5 дней вперед
   async with session.get(BASE_FORECAST_URL + f'lat={lat}&lon={lon}') as r:
       data = await r.json()
   forecast = get_grouped_weather_conditions(data)

   # Получение текущей погоды
   async with session.get(BASE_WEATHER_URL + f'lat={lat}&lon={lon}') as r:
       data = await r.json()

   current = {
       'status': data['weather'][0]['description'].capitalize(),
       'icon': data['weather'][0]['icon'].replace('n', 'd'),
       'temp': data['main']['temp'],
       'feels_like': data['main']['feels_like'],
       'humidity': data['main']['humidity'],
       'pressure': data['main']['pressure'],
       'wind_speed': data['wind']['speed'],
       'wind_deg': data['wind']['deg']
   }

   return {
       'forecast': forecast,
       'current': current,
       'lat': data['coord']['lat'],
       'lon': data['coord']['lon'],
       'location': data['name']
   }

# Запускаем приложение с использованием Uvicorn на 5973 порту
if __name__ == "__main__":
   uvicorn.run(
       "main:app",
       host="0.0.0.0",
       port=5973,
       log_level="debug"
   )

Разработка фронтенда

Подготовка

1. Инициализируем наш будущий сервис с помощью пакета create-vk-mini-app, который помогает быстро создавать мини-приложения ВКонтакте: 


    npm init @vkontakte/vk-mini-app weather-app –- --typescript --template=vkui-bridge-router

Этой командой мы создадим шаблонное мини-приложение на TypeScript в директории weather-app. А также подключим библиотеки VKUI, VK Bridge, vk-mini-apps-router и VK Tunnel, о которых поговорим чуть позже.

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


    cd weather-app
npm install
npm run start

3. Открываем окно консоли и вводим команду:


    npm run tunnel

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

4. Переходим по ссылке в консоли, подтверждаем авторизацию, возвращаемся в консоль и копируем публичную ссылку на наш проект. Затем возвращаемся в панель администратора, переходим в раздел Размещение, включаем режим разработки во всех подразделах и вставляем публичную ссылку под каждый переключатель. Не забываем сохранить настройки.

Настройки приложения.
Настройки приложения.

Теперь мы можем нажать на название нашего приложение в правом верхнем углу экрана и перейти в него. В открывшейся вкладке увидим запущенный ранее шаблон. На этом подготовительный этап окончен — можем приступать к разработке «погодного» сервиса.

Разработка

Разрабатывать пользовательский интерфейс мы будем с помощью набора готовых адаптивных компонентов VKUI. Он позволяет быстро создавать интерфейсы, визуально схожие с нативными приложениями ВКонтакте. Благодаря этому при входе в приложение пользователь попадает в комфортную знакомую среду.

Роутинг

Далее разберемся с роутингом. Он будет базироваться на библиотеке vk-mini-apps-router, адаптированной для работы с VKUI. 

1. Открываем проект в любом удобном редакторе и переходим в каталог ./src — внутри него будем находиться почти все время. 

2. Открываем файл routes.tsx — в нем прописываются маршруты, которые должно поддерживать приложение. 

В нашем приложении будет всего одна панель, поддерживающая два модальных окна. Одно будет отвечать за выбор города, а второе — за подробный прогноз в последующие дни. Реализуем задуманное:


    import {
 createHashRouter, createModal,
 createPanel,
 createRoot,
 createView,
 RoutesConfig,
} from '@vkontakte/vk-mini-apps-router';

// Объявляем названия наших Root и View, которые будут отображаться при старте приложения
export const DEFAULT_ROOT = 'default_root';

export const DEFAULT_VIEW = 'default_view';

// Объявляем панели, которые будут использоваться в нашем DEFAULT_VIEW
export const DEFAULT_VIEW_PANELS = {
 HOME: 'home'
} as const;

export const routes = RoutesConfig.create([
 createRoot(DEFAULT_ROOT, [
   createView(DEFAULT_VIEW, [
     createPanel(DEFAULT_VIEW_PANELS.HOME, '/', [ // Создаем панель "домашней" страницы
       createModal('selectCity', '/selectCity'), // Создаем модальное окно для выбора города
       createModal('dailyForecast', '/dailyForecast') // Создаем модальное окно для отображения подробного прогноза погоды на последующие дни
     ])
   ]),
 ]),
]);

export const router = createHashRouter(routes.getRoutes());

Типизация данных

Так как мы пишем на языке TypeScript, нужно прописать типы данных, которые будем использовать. Для этого создадим и заполним файл types.ts в каталоге src:


    export type ICity = {
 name: string
 lat: number
 lon: number
 state: string
}

export type IDailyForecast = {
 date: string
 max_temp: number
 min_temp: number
 humidity: number
 pressure: number
 status: string
 icon: string
}

export type ICurrentWeather = {
 status: string
 icon: string
 temp: number
 feels_like: number
 humidity: number
 pressure: number
 wind_speed: number
 wind_deg: number
}

export type IForecast = {
 forecast: IDailyForecast[]
 current: ICurrentWeather
 lat: number
 lon: number
 location: string
}

Взаимодействие с бэкэндом

Теперь свяжем приложение с нашим бекендом. Создадим файл api.ts и создадим класс API — внутри него мы пропишем доступные на бэкенде ручки:


    import {ICity, IForecast} from "./types.ts";

export const BASE_URL = 'https://domain.ru/api/'

class API {
 // Базовая функция для выполнения запроса
 async call(path: string, signal: AbortSignal | null = null) {

   // Прокидываем параметры запуска приложения в заголовок
   const headers = {
     "Content-Type": "application/json",
     credentials: window.location.search,
   };

   return fetch(`${BASE_URL}${path}`, { headers, signal })
     .then((response) => {
       const contentType = response.headers.get("Content-Type") || "";
       if (response.ok && contentType.includes("application/json")) {
         return response.json().catch(() => {
           return Promise.reject();
         });
       }
       if (![200, 503].includes(response.status)) {
         return Promise.reject();
       }
     })
     .catch((error) => {
       if (error && error.name !== 'AbortError') {
         console.log('Request aborted')
       } else {
         return Promise.reject(error);
       }
     });
 }

 getForecastByCity(city: ICity, signal: AbortSignal | null = null) {
   return this.call(`getWeather?lat=${city.lat}&lon=${city.lon}`, signal) as Promise<IForecast>
 }

 getCityByName(query: string, limit: number, signal: AbortSignal | null = null) {
   return this.call(`getCity?city=${query}&limit=${limit}`, signal) as Promise<ICity[]>
 }
}

export const api = new API();

Глобальное состояние

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

Создадим файл store.ts и определим пару атомов:


    import {atom} from "@mntm/precoil";
import {ICity, IForecast} from "./types.ts";

export const currentForecastAtom = atom<IForecast | null>(null);

export const currentCityAtom = atom<ICity | null>(null);

App.tsx

Нам нужно получать и хранить город текущего пользователя, а также сохранять эту информацию между сессиями. Для этого будем использовать менеджер глобального состояния precoil, хранилище VK Storage и библиотеку VK Bridge, которая позволяет использовать API ВКонтакте и ОС устройства прямо из приложения.


    import { useEffect } from 'react';
import bridge from '@vkontakte/vk-bridge';
import { View, SplitLayout, SplitCol } from '@vkontakte/vkui';
import {useActiveVkuiLocation, useRouteNavigator} from '@vkontakte/vk-mini-apps-router';

import { Home } from './panels';
import { DEFAULT_VIEW_PANELS } from './routes';
import {ICity} from "./types.ts";
import {useSetAtomState} from "@mntm/precoil";
import {currentCityAtom} from "./store.ts";
import {api} from "./api.ts";

export const App = () => {
 const { panel: activePanel = DEFAULT_VIEW_PANELS.HOME } = useActiveVkuiLocation();

 const setCurrentCity = useSetAtomState(currentCityAtom);

 const router = useRouteNavigator();

 useEffect(() => {
   // Пробуем получить данные о городе из VK Storage
   getCityFromStorage().then(city => {
     // Проверяем, что данные о городе получены
     if (city) {
       setCurrentCity(city as ICity)
     } else {
       // Если данных нет, пробуем получить город из профиля пользователя
       bridge.send('VKWebAppGetUserInfo').then(user => {
         if (user.city) {
           // Запрашиваем координаты города с бэкенда и кладем в VK Storage и наш атом
           api.getCityByName(user.city.title, 1).then(data => {
             pushCityToStorage(data[0]);
             setCurrentCity(data[0]);
           })
         } else {
           // Если город получить не удалось, перенаправляем пользователя на модалку выбора города
           router.push('/selectCity')
         }
       })
     }
   })
 }, []);

 // Запрашивает данные из VK Storage
 const getCityFromStorage = (): Promise<ICity | null> => {
   return new Promise(resolve => {
     bridge.send('VKWebAppStorageGet', {'keys': ['city']}).then(data => {
       if (data.keys[0].value) {
         resolve(JSON.parse(data.keys[0].value))
       } else {
         resolve(null)
       }
     })
   })
 }

 // Кладем данные о городе пользователя в VK Storage
 const pushCityToStorage = (city: ICity) => {
   bridge.send('VKWebAppStorageSet', {'key': 'city', 'value': JSON.stringify(city)}).then(() => {})
 }

 return (
   <SplitLayout>
     <SplitCol>
       <View activePanel={activePanel}>
         <Home id="home" />
       </View>
     </SplitCol>
   </SplitLayout>
 );
};

Подключение сторонних иконок

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

Иконки из Figma.
Иконки из Figma.

Другие необходимые иконки возьмем из предустановленного пакета vk-icons.

Отображение прогноза погоды

Перейдем к домашней панели ./panels/Home.tsx. Нам нужно запрашивать данные о прогнозе погоды и отображать их на экране. В случае, если пользователь не указал свой город, будем показывать ошибку. А при загрузке данных — выводить на экран спиннер.


    import {FC, useEffect, useState} from 'react';
import {
 Button,
 Group,
 Header,
 IconButton,
 NavIdProps,
 Panel,
 PanelHeader,
 Placeholder,
 SimpleCell, Spinner,
} from '@vkontakte/vkui';
import {
 Icon20WindOutline,
 Icon28DownloadCloudOutline,
 Icon28SearchOutline,
 Icon28WaterDropOutline,
} from "@vkontakte/icons";
import {useAtomState, useAtomValue} from "@mntm/precoil";
import {currentCityAtom, currentForecastAtom} from "../store.ts";
import {api} from "../api.ts";
import {useRouteNavigator} from "@vkontakte/vk-mini-apps-router";

export interface HomeProps extends NavIdProps {

}

// Преобразует дату формата "2024-07-07" в "вс, 7 июля"
function convertDateToLocaleString(date: string) {
 const dateObj = new Date(date);
 const options = {day: 'numeric', month: 'long', weekday: 'short'};
 const formatter = new Intl.DateTimeFormat('ru-RU', options);

 return formatter.format(dateObj);
}

// Округляет числа с заданной точностью
function round(n: number, precision: number = 3) {
 const multiplier = Math.pow(10, precision);
 return Math.round(n * multiplier) / multiplier;
}

export const Home: FC<HomeProps> = ({ id }) => {
 const currentCity = useAtomValue(currentCityAtom);
 const [forecast, setForecast] = useAtomState(currentForecastAtom);
 const [isLoading, setIsLoading] = useState(!forecast);
 const router = useRouteNavigator();

 useEffect(() => {
   if (currentCity) {
     // Запрашиваем данные только тогда, когда в стейте ничего нет, либо пользователь изменил город
     if (!forecast || (round(forecast.lat) !== round(currentCity.lat) && round(forecast.lon) !== round(currentCity.lon))) {
       setIsLoading(true);

       // Получаем прогноз погоды с бекенда и кладём его в атом
       api.getForecastByCity(currentCity).then(data => {
         setForecast(data)
         setIsLoading(false);
       })
     }
   }
 }, [currentCity])

 return (
   <Panel id={id}>
     <PanelHeader
       delimiter={'spacing'}
       before={
         <IconButton onClick={() => router.push('/selectCity')}>
           <Icon28SearchOutline/>
         </IconButton>
       }
     >
       {forecast?.location || currentCity?.name || 'Город не выбран'}
     </PanelHeader>

     {
       (forecast && !isLoading) ?
         <>
           {/*Делим контент на группы для красивого отображения*/}
           <Group>
             <Placeholder
               header={(forecast.current.temp >= 0 ? '+' : '-') + `${Math.ceil(forecast.current.temp)} °C`}
               icon={<img src={`/${forecast.current.icon}.svg`} alt={''} height={128} width={128}/>}
             >
               {forecast.current.status},<br/>ощущается как {(forecast.current.feels_like >= 0 ? '+' : '-')}{Math.ceil(forecast.current.feels_like)} °C
             </Placeholder>
           </Group>

           <Group header={<Header>Метеоусловия</Header>}>
             <SimpleCell
               before={<Icon28DownloadCloudOutline/>}
               indicator={`${forecast.current.pressure} мм рт. ст.`}
             >
               Давление
             </SimpleCell>
             <SimpleCell
               before={<Icon28WaterDropOutline/>}
               indicator={`${forecast.current.humidity}%`}
             >
               Влажность
             </SimpleCell>
             <SimpleCell
               before={<Icon20WindOutline height={28} width={28}/>}
               indicator={`${forecast.current.wind_speed} м/с`}
             >
               Скорость ветра
             </SimpleCell>
           </Group>

           <Group
             header={
               <Header>
                 Прогноз на 5 дней
               </Header>
             }
           >
             {/*При рендере элементов в цикле не забываем прокидывать уникальный идентификатор - key*/}
             {forecast.forecast.map(item => (
               <SimpleCell
                 before={
                   <img src={`/${item.icon}.svg`} alt={''} height={28} width={28}/>
                 }
                 indicator={
                   <span>
                     {(item.max_temp >= 0 ? '+' : '-')}{Math.ceil(item.max_temp)} °C
                   </span>
                 }
                 key={item.date}
                 // При клике на SimpleCell будем открывать модальное окно с подробной информацией о погоде
                 onClick={() => router.push('/dailyForecast', {state: {data: item, localeDate: convertDateToLocaleString(item.date)}})}
               >
                 {convertDateToLocaleString(item.date)}
               </SimpleCell>
             ))}
           </Group>
         </>
       : !isLoading ?
         <Placeholder
           stretched
           header={'Нет данных о погоде в небытие'}
           action={
             <Button size={'m'} mode={'tertiary'} onClick={() => router.push('/selectCity')}>
               Выбрать город
             </Button>
           }
         />
       :
         <Placeholder icon={<Spinner size={'large'}/>}/>
     }
   </Panel>
 );
};

Модальные окна

Почти готово! Осталось разобраться с модальными окнами. 

1. Создадим каталог /src/modals и добавим файлы RootModal.tsx, DailyForecastModal.tsx и SelectCityModal.tsx. 

В SelectCityModal пользователь будет выбирать город. Реализуем пользовательский интерфейс, а затем напишем хук useDebounce. Будем использовать AbortController, чтобы избавиться от ненужных запросов:


    import React, {useEffect, useState} from "react";
import {
 FormItem,
 Input,
 ModalPage, ModalPageHeader,
 ModalPageProps, PanelHeaderButton, PanelHeaderClose, Placeholder, SimpleCell, Spinner, useAdaptivityConditionalRender,
 useModalRootContext, usePlatform
} from "@vkontakte/vkui";
import {useRouteNavigator} from "@vkontakte/vk-mini-apps-router";

import {api} from "../api.ts";
import {ICity} from "../types.ts";
import {useSetAtomState} from "@mntm/precoil";
import {currentCityAtom} from "../store.ts";
import {Icon24Dismiss} from "@vkontakte/icons";
import bridge from "@vkontakte/vk-bridge";

function useDebounce<T>(
 initialValue: T,
 delay: number=1000
): [T, T, React.Dispatch<T>] {
 const [value, setValue] = useState<T>(initialValue);
 const [debouncedValue, setDebouncedValue] = useState<T>(initialValue);
 // Раз в заданный промежуток проверяем, что значение не изменилось
 useEffect(() => {
   const debounce = setTimeout(() => {
     setDebouncedValue(value);
   }, delay);
   return () => {
     clearTimeout(debounce);
   };
 }, [value, delay]);

 return [debouncedValue, value, setValue];
}

export const SelectCityModal: React.FC<ModalPageProps> = ({...restProps}) => {
 const router = useRouteNavigator();
 const { updateModalHeight } = useModalRootContext();
 const platform = usePlatform();
 const { sizeX } = useAdaptivityConditionalRender();

 const [foundCities, setFoundCities] = useState<ICity[]>([]);
 const setSelectedCity = useSetAtomState(currentCityAtom);

 // Дебаунс состояние для строки поиска с таймаутом 800 мс
 const [debouncedQuery, query, setQuery] = useDebounce<string>('', 800)
 const [isLoading, setIsLoading] = useState(false);
 
 // Делаем запрос к API при каждом изменении debouncedQuery
 useEffect( () => {
   if (debouncedQuery) {
     const controller = new AbortController();
     const signal = controller.signal;

     setIsLoading(true);
     api.getCityByName(debouncedQuery, 5, signal).then(data => {
       setFoundCities(data);
       // Пересчитываем высоту модального окна после изменения данных
       updateModalHeight();
       setIsLoading(false);
     }).catch(e => {
       if (e && e.name === 'AbortError') {
         console.log('Request aborted')
       } else {
         setFoundCities([]);
         setIsLoading(false);
       }
     })
     return () => {
       // Отменяем запрос к API в том случае, если старый запрос еще не был обработан, а debouncedQuery уже изменился
       controller.abort('abort');
     }
   }
 }, [debouncedQuery])

 return (
   <ModalPage {...restProps} header={
     <ModalPageHeader
       noSeparator
       before={
         sizeX.compact &&
         platform === 'android' && (
           <PanelHeaderClose className={sizeX.compact.className} onClick={() => router.hideModal()} />
         )
       }
       after={
         sizeX.compact &&
         platform === 'ios' && (
           <PanelHeaderButton className={sizeX.compact.className} onClick={() => router.hideModal()}>
             <Icon24Dismiss />
           </PanelHeaderButton>
         )
       }
     >
       Выбор города
     </ModalPageHeader>
   }>
       <FormItem>
         <Input
           value={query}
           onChange={e => setQuery(e.target.value)}
           placeholder={'Введите название города'}
         />
       </FormItem>
       {
         foundCities.length ?
           foundCities.map(item => (
             <SimpleCell
               subtitle={item.state}
               key={`${item.name}${item.lon}${item.lat}`}
               onClick={() => {
                 setSelectedCity(item);
                 bridge.send('VKWebAppStorageSet', {'key': 'city', 'value': JSON.stringify(item)}).then(() => {});
                 router.hideModal();
               }}
             >
               {item.name}
             </SimpleCell>
           ))
         : (query && isLoading) ?
             <Placeholder icon={<Spinner/>}/>
         : (query && !isLoading) ?
             <Placeholder>
               Нет результатов
             </Placeholder>
         : null
       }

   </ModalPage>
 )
}

2. В DailyForecastModal будем отображать информацию о погоде для выбранного дня:


    import React from "react";
import {
 ModalPage, ModalPageHeader,
 ModalPageProps, PanelHeaderButton, PanelHeaderClose, SimpleCell, useAdaptivityConditionalRender,
 usePlatform
} from "@vkontakte/vkui";
import {useMetaParams, useRouteNavigator} from "@vkontakte/vk-mini-apps-router";

import {
 Icon24Dismiss,
 Icon28DownloadCloudOutline, Icon28MoonOutline,
 Icon28SunOutline,
 Icon28WaterDropOutline
} from "@vkontakte/icons";
import {IDailyForecast} from "../types.ts";

export const DailyForecastModal: React.FC<ModalPageProps> = ({...restProps}) => {
 const router = useRouteNavigator();
 // Получаем параметры, которые были переданы при переходе по этому пути
 const params = useMetaParams<{data: IDailyForecast, localeDate: string}>();
 const platform = usePlatform();
 const { sizeX } = useAdaptivityConditionalRender();

 return (
   <ModalPage {...restProps} header={
     <ModalPageHeader
       noSeparator
       before={
         sizeX.compact &&
         platform === 'android' && (
           <PanelHeaderClose className={sizeX.compact.className} onClick={() => router.hideModal()} />
         )
       }
       after={
         sizeX.compact &&
         platform === 'ios' && (
           <PanelHeaderButton className={sizeX.compact.className} onClick={() => router.hideModal()}>
             <Icon24Dismiss />
           </PanelHeaderButton>
         )
       }
     >
       Погода {params?.localeDate}
     </ModalPageHeader>
   }>
     {
       params ?
         <>
           <SimpleCell
             before={<Icon28SunOutline/>}
             indicator={<span>{(params.data.max_temp >= 0 ? '+' : '-')}{Math.ceil(params.data.max_temp)} °C</span>}
           >
             Максимальная температура
           </SimpleCell>
           <SimpleCell
             before={<Icon28MoonOutline/>}
             indicator={<span>{(params.data.min_temp >= 0 ? '+' : '-')}{Math.ceil(params.data.min_temp)} °C</span>}
           >
             Минимальная температура
           </SimpleCell>
           <SimpleCell
             before={<Icon28DownloadCloudOutline/>}
             indicator={`${params.data.pressure} мм рт. ст.`}
           >
             Давление
           </SimpleCell>
           <SimpleCell
             before={<Icon28WaterDropOutline/>}
             indicator={`${params.data.humidity}%`}
           >
             Влажность
           </SimpleCell>
         </>
         : null
     }
   </ModalPage>
 )
}

3. RootModal будет отвечать за отрисовку модальных окон:


    import React from "react";
import {ModalRoot} from "@vkontakte/vkui";
import {useActiveVkuiLocation, useRouteNavigator} from "@vkontakte/vk-mini-apps-router";
import {SelectCityModal} from "./SelectCityModal.tsx";
import {DailyForecastModal} from "./DailyForecastModal.tsx";

export const RootModal: React.FC = () => {
 const routeNavigator = useRouteNavigator();
 const { modal: activeModal } = useActiveVkuiLocation();

 return (
   <ModalRoot activeModal={activeModal} onClose={() => routeNavigator.hideModal()}>
     <SelectCityModal id={'selectCity'} dynamicContentHeight/>
     <DailyForecastModal id={'dailyForecast'}/>
   </ModalRoot>
 )
}

4. Осталось подключить ModalRoot в файле App.tsx. Смотрим на 64 строку и прокидываем в SplitLayout свойство modal:


    ...
return (
 <SplitLayout modal={<RootModal/>}>
   <SplitCol>
     <View activePanel={activePanel}>
...

Последние штрихи

Откроем файл AppConfig.tsx и в 26 строке укажем параметр layout={“card”} в AppRoot. Это действие включит отображение компонентов приложения в виде карточек.


    ...
<AdaptivityProvider {...adaptivity}>
 <AppRoot mode="full" safeAreaInsets={vkBridgeInsets} layout={'card'}>
   <RouterProvider router={router}>
...

Готово — можем вернуться в браузер и посмотреть, что у нас получилось.

Внешний вид приложения.
Внешний вид приложения.

Деплой бэкенда

Подготовка

Деплоить бэкенд будем на виртуальную машину с помощью Docker. Создадим файл Dockerfile и напишем небольшую конфигурацию, которая будет запускать наш сервис на 5973 порту:


    FROM python:3.9-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5973
CMD ["python", "main.py"]

Теперь нам нужно записать наш список зависимостей. Открываем консоль и пишем команду:


    pip freeze > requirements.txt

Перенос файлов на сервер

1. Переходим в панель управления Selectel и регистрируемся. После переходим в раздел Облачная платформа и нажимаем Создать сервер

Страница создания сервера.
Страница создания сервера.

2. В настройках указываем имя проекта, в качестве ОС выбираем Ubuntu и устанавливаем минимальную конфигурацию: 1 vCPU и 512 МБ RAM. Для работы приложения много мощностей не нужно.

Страница конфигурации сервера.
Такая конфигурация обойдется меньше 30 ₽ в день.

3. После установки конфигурации спускаемся вниз и нажимаем Создать.

4. Возвращаемся в панель управления и переходим в консоль созданного сервера. Из этого раздела копируем логин (root) и пароль для дальнейшей авторизации по SSH. 

5. Открываем терминал, подключаемся к серверу и устанавливаем Docker:


    sudo apt-get update && sudo apt install docker.io

3. Теперь клонируем репозиторий с кодом бэкенда из GitHub. Вы можете использовать готовый или предварительно создать свой:


    git clone https://github.com/Mkolba/selectel-weather-api.git

4. Переходим в директорию нашего проекта, собираем и запускаем Docker-контейнер:


    cd selectel-weather-api

    sudo docker build -t weatherapi .
sudo docker run -d -p 5973:5973 weatherapi

Так как мини-приложения ВКонтакте должны работать по протоколу HTTPS, нам нужно подключить к серверу домен и установить сертификат. А также веб-сервер для проксирования запросов.

1. Установим nginx:


    sudo apt install nginx

2. В примере будем работать с поддоменом weather.domain.ru. Откроем соответствующий файл и напишем конфигурацию:


    sudo nano /etc/nginx/sites-available/weather.domain.ru

    limit_req_zone $binary_remote_addr zone=weather:10m rate=3r/s;

upstream weather_api {
        ip_hash;
        server 127.0.0.1:5973;
}

server {
        listen 80;
        server_name weather.DOMAIN.ru;
        return 301 https://$host&request_uri;
}

server {
        listen 443;
        ssl on;
        ssl_certificate /certificate/path/DOMAIN.pem;
        ssl_certificate_key /certificate/path/DOMAIN.key;
        server_name weather.DOMAIN.ru;
        index index.html;

        access_log /var/log/nginx/weather.DOMAIN.ru.access.log;
        error_log /var/log/nginx/weather.DOMAIN.ru.error.log;

        location /api {
                if ($request_method = OPTIONS ) {
                        add_header Access-Control-Allow-Origin *;
                        add_header Access-Control-Allow-Methods GET;
                        add_header Access-Control-Allow-Headers *;
                        return 200;
                }

                limit_req zone=weather burst=2;
                proxy_set_header Host $http_host;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_redirect off;
                proxy_buffering off;
                proxy_pass http://weather_api$request_uri;
        }
}

3. Закрываем редактор с помощью сочетания CTRL + C. Редактор спросит, сохранять ли изменения, — подтверждаем нажатием Y и Enter.

4. Включим нашу конфигурацию и перезапустим nginx:


    sudo ln -s /etc/nginx/sites-available/weather.DOMAIN.ru /etc/nginx/sites-enabled/weather.DOMAIN.ru
sudo service nginx restart

5. Проверяем, что наш бекенд доступен по адресу (в примере — по https://weather.domain.ru/api). Если все работает, переходим к следующей части.

Деплой фронтенда

Подготовка

Сначала нужно указать адрес, по которому находится наш API. Откроем файл api.ts и изменим константу BASE_URL, подставив в нее URL бэкенда:


    export const BASE_URL = 'https://weather.DOMAIN.ru/api/'

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


    npm run build

После выполнения команды в нашем проекте появится папка build — в ней будет лежать собранная версия приложения.

Перенос статических файлов в S3

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

1. Авторизуемся в панели управления и переходим в раздел Объектное хранилище

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

Страница создания контейнера.
Страница создания контейнера.

3. Настраиваем контейнер. 

  • Имя: наименование контейнера, которое рекомендуется указывать латинскими буквами с нижним регистром.
  • Тип: Публичный.
  • Класс хранения: Стандартное хранение.
  • Адресация: включение Virtual-Hosted адресации является необязательным пунктом и реализуется по вашему усмотрению. Virtual-Hosted адресация позволяет использовать CORS. Если адресация для контейнера включена, ее больше нельзя отключить.

После указания параметров кликаем по кнопке Создать контейнер.

4. Загружаем файлы для отображения фронтенда. Должна получиться такая структура: 

Объекты контейнера.
Объекты контейнера.

3. Далее нужно скопировать ссылку на файл index.html — он будет точкой входа для загрузки всех остальных компонентов приложения.

4. Возвращаемся в панель управления мини-приложением и переходим в раздел Размещение. В подразделе Состояние для пользователей выбираем Включено. В остальных подразделах проставляем ссылку на файл index.html и выключаем режим разработки. Сохраняем настройки.

Настройки приложения.
Настройки приложения.

Теперь наше приложение доступно для всех! Но…

Как добавить мини-приложение в каталог

Чтобы пользователи ВКонтакте могли легко найти ваше мини-приложение, его нужно добавить в каталог. Он всегда под рукой, поэтому пользуется популярностью у широкой аудитории. Кроме того, мини-приложения тестируют участники VK Testers, а это дополнительная возможность исправить баги, проработать пользовательские сценарии и интерфейс.

Однако для того, чтобы попасть в каталог, нужно подать заявку. Модераторы VK Mini Apps рассмотрят запрос и отправят мини-приложение в бета-тестирование — минимальный срок прохождения этого этапа составляет три дня. Подробнее о процессе можно прочитать в документации

Заключение

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

Читайте также:

Инструкция
Инструкция
Инструкция