Как создать Fullstack-тапалку на Vue 3 с нуля

Как создать Fullstack-тапалку на Vue 3 с нуля. Практика по Telegram Mini Apps

В этой статье вы узнаете, как с нуля создать собственную Telegram-тапалку на современном стеке

Вы когда-нибудь задумывались, как реализованы мини-приложения в Telegram? Их сейчас стало много. Среди проектов — различные кликеры, игры, бизнес-приложения и другое. Сегодня Telegram создает свою экосистему, подобную App Store или Google Play, но со своими мини-приложениями. И это уже довольно востребованный «сектор рынка». 

Меня зовут Владилен Минин, я автор одноименного YouTube-канала. В этой инструкции попробую показать, как разработать полноценное Telegram-приложение. Притом не просто UI, а проект с логикой, но с простой задумкой — кликер. Плюс я уже выкладывал на своем канале видео, как сделать простой интерфейс для подобного приложения. Он-то и послужит шаблоном. Приступим! 

Важный дисклеймер. Тапалка, кликер и прочее — это всего лишь форма. Основная задача этой инструкции заключается именно в практике. Мы обсудим полноценный serverless-подход, разработку бота на Node.js и полный цикл создания FE-приложения.

Описание проекта

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

Полный технологический стек, который мы будем использовать: Vue 3, Supabase, Firebase Deploy, Pinia, Docker, виртуальные серверы Selectel, Node.js, Telegram Mini Apps, Lodash, Telegraf.

Функционал, который мы создадим: Single Page Application (SPA) для Telegram на Vue 3, реферальная система, выполнение заданий.

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

Подготовка шаблона и программирование игры

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

Клонирование репозитория

1. Клонируйте из репозитория исходный код со стилями в свою папку, затем выполните две команды в консоли: 

​​​​npm install ​​​​
​​​​npm run dev​​​​

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

Описание логики счетчика

1. Зайдем в файл stores/score.js — нам необходимо обозначить, какой есть базовый счет уровня. Пусть это будет 25. То есть для перехода на следующий уровень игроку нужно будет набрать столько баллов. 

// Базовый счет первого уровня
const baseLevelScore = 25

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

// Формула по вычислению уровней и количества очков для каждого из них
const levels = new Array(15)
  .fill(0)
  .map((_, i) => baseLevelScore * Math.pow(2, i))

Итоговый массив уровней выглядит следующим образом: 

3. Следующим этапом нужно добавить еще несколько функций, которые позволят нам выполнять «правильные вычисления». Первой мы создадим константу levelScores, которую будем вычислять в зависимости от levels.map() и так далее. Но почему именно такая зависимость? 

Дело в том, что мы создали массив levels с конкретным счетом для каждого уровня. Но всего, чтобы перейти, например, с первого на второй уровень, нам нужна сумма 25+50. Для третьего уровня — 25+50+100. Соответственно, в новой функции нам нужно посчитать, сколько всего баллов накликал игрок.

// Вычисляем сумму очков для каждого уровня
const levelScores = levels.map((_, level) => {
  let sum = 0
  for (let [index, value] of levels.entries()) {
    if (index >= level) {
      return sum + value
    }
    sum += value
  }
  return sum
})

Отлично! Теперь для достижения последнего уровня нужно накликать целых 819 175 очков. У вас тоже заболели пальцы через экран? 

// Вычисляем уровень в зависимости от текущего счета
function computeLevelByScore(score) {
  for (let [index, value] of levelScores.entries()) {
    if (score <= value) {
      return {
        level: index,
        value: levels[index],
      }
    }
  }
}

5. Теперь мы можем объединить все функции выше в одну логику и описать поведение счета (score) пользователя. При этом у нас уже есть state, где по умолчанию score = 0, если игрок впервые зашел в кликер. Поэтому просто опишем getter, через который будем получать текущий уровень, и currentScore — общее число баллов. Далее добавим поле actions, внутри которого будет изменяться сам счетчик.

store/score.js
export const useScoreStore = defineStore('score', {
  state: () => ({
    score: 0, // Базовый уровень, следующие будем получать по API
  }),
  getters: {
    level(state) {
      return computeLevelByScore(state.score)
    },
    // Этот счет нужен для отображения текущего прогресса
    currentScore(state) { 
      if (this.level.level === 0) {
        return state.score
      }
      return state.score - levelScores[this.level.level - 1]
    },
  },
  actions: {
    add(score = 1) {
      this.score += score
    },
    setScore(score) {
      this.score = score
    },
  },
})

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

Интерфейс счетчика

1. Переходим в шаблон фронтенда HomeView.vue, который был разработан в предыдущем видеоролике, и прописываем вместо статичного числа между тегами h2 динамическое значение счетчика store.store.

<template>
  <div class="game-container">
    <ScoreProgress />
    <div class="header">
      <img src="../assets/coin.png" alt="coin" />
      <h2 class="score" id="score">{{ store.score }}</h2>
    </div>
    <div class="circle">
      <img @click="increment" ref="img" id="circle" :src="imgSrc" />
    </div>
  </div>
</template>

2. Далее внутри того же HomeView.vue опишем скрипт, внутри которого будем вызывать по клику инкрементацию счетчика и изменять картинку при достижении нового уровня.

<script setup>
import { ref, computed } from 'vue'
import frog from '../assets/frog.png'
import lizzard from '../assets/lizzard.png'
import { useScoreStore } from '@/stores/score'
import ScoreProgress from '@/components/ScoreProgress.vue'

// Получаем текущий счет
const store = useScoreStore()

// Если счет больше 25, меняем картинку
const imgSrc = computed(() => (store.score > 25 ? lizzard : frog))
const img = ref(null)

function increment(event) {
  // При клике увеличиваем счет
  store.add(1)

  // Далее используем логику анимации из ролика
  // https://youtu.be/vT-XwvcK2NI
  const rect = event.target.getBoundingClientRect()

  const offfsetX = event.clientX - rect.left - rect.width / 2
  const offfsetY = event.clientY - rect.top - rect.height / 2

  const DEG = 40

  const tiltX = (offfsetY / rect.height) * DEG
  const tiltY = (offfsetX / rect.width) * -DEG

  img.value.style.setProperty('--tiltX', `${tiltX}deg`)
  img.value.style.setProperty('--tiltY', `${tiltY}deg`)

  setTimeout(() => {
    img.value.style.setProperty('--tiltX', `0deg`)
    img.value.style.setProperty('--tiltY', `0deg`)
  }, 300)

  const plusOne = document.createElement('div')
  plusOne.classList.add('plus-one')
  plusOne.textContent = '+1'
  plusOne.style.left = `${event.clientX - rect.left}px`
  plusOne.style.top = `${event.clientY - rect.top}px`

  img.value.parentElement.appendChild(plusOne)

  setTimeout(() => plusOne.remove(), 2000)
}
</script>

Готово — счетчик и картинка меняются как надо:

3. Последним этапом добавляем визуальное отображение прогресса в ScoreProgress.vue:

components/ScoreProgress.vue
<template>
  <div class="progress">
    <h4 class="progress-level">
      <span>{{ store.currentScore }}/{{ store.level.value }}</span>
      <span>{{ store.level.level + 1 }}</span>
    </h4>
    <div class="progress-container">
      <div class="progress-value" :style="{ width: progress + '%' }"></div>
    </div>
  </div>
</template>

<script setup>
import { useScoreStore } from '@/stores/score'
import { computed } from 'vue'
  
const store = useScoreStore()

const progress = computed(() => (100 * store.currentScore) / store.level.value)
</script>

Шкала прогресса наполняется:

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

Чтобы связать наш фронтенд с Telegram, его нужно захостить. Для этого можно использовать Firebase — это довольно удобный сервис, который еще и бесплатный. Переходим на платформу, создаем новый проект и открываем Hosting. Далее по инструкции устанавливаем пакеты, а после — локально:

​​​​firebase init​​​​
​​​​firebase deploy​​​​

На выходе получаем URL приложения.

Создание бота на Node.js

Бот нам понадобится для реализации реферальной программы — все как в небезызвестном Hamster Kombat. 

1. Получаем API-токен в боте @BotFather через команду ​​​​/newbot​​​​.

2. Далее устанавливаем telegraf и описываем базовую настройку бота в файле app.js:

/bot/app.js
import { Telegraf, Markup } from 'telegraf'

const token = 'YOUR TELEGRAM TOKEN'
const webAppUrl = 'APP REMOTE URL'

const bot = new Telegraf(token)

bot.command('start', (ctx) => {
  ctx.reply(
    'Привет! Нажми, чтоб запустить',
    Markup.inlineKeyboard([
      Markup.button.webApp(
        'Открыть мини-приложение',
        `${webAppUrl}?ref=${ctx.payload}` // Здесь в параметре ref передаем реферала в мини-приложение
      ),
    ])
  )
})

bot.launch()

Деплой бота на сервер

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

Будем деплоить бота в Docker — для этого добавим два файла:

Dockerfile

FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV PORT=3000
EXPOSE $PORT
CMD ["node", "app.js"]

Makefile

build:
	docker build -t tgbot .
run:
	docker run -d -p 3000:3000 --name tgbot --rm tgbot

Далее зальем весь проект в GitHub-репозиторий, чтобы можно было легко перенести проект на сервер — без использования FTP и scp. Как это сделать, уже рассказывали в Академии Selectel. Перейдем к следующему этапу в процессе деплоя.

Создание сервера

1. Переходим в раздел Облачная платформа внутри панели управления.

2. Создаем сервер. Для работы нашего приложения не нужно много мощностей, поэтому достаточно одного ядра vCPU с долей 20% и 512 МБ оперативной памяти. И обязательно добавляем публичный IP-адрес, чтобы к серверу можно подключиться через интернет.

3. Авторизуемся на сервере через консоль посредством команды ssh root@<IP>. Публичный адрес виртуальной машины можно посмотреть в разделе Порты.

Настройка системы

1. После подключения к серверу обновляем систему и устанавливаем Git: 

apt update
apt install git

2. Устанавливаем Node.js — у нас есть подробная инструкция по этому процессу.

curl -o- <https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh> | bash
source ~/.bashrc 
nvm install 20 
nvm use 20 
npm -v 
node -v

3. Устанавливаем на сервере Docker — для этого тоже можно воспользоваться инструкцией.

apt install git
git clone REPO_URL

5. Последним этапом запускаем проект:

cd PROJECT_NAME
make build
make run

Готово — бот c Telegram Mini Apps запущен.

Разработка функционала

Разработка функционала тапалки — многоуровневый процесс, который сложно полностью отразить в текстовом формате. Если у вас на каком-то из шагов возникли вопросы, обратитесь к видео на YouTube — там все показано и разбито на таймкоды.

Шаг 1. Подключение базы данных к клиенту

В качестве базы данных будем для хранения пользовательских баллов будем использовать Supabase. Это open source-аналог решения от Google.

Инициализируем проект и создаем две таблицы: User (список пользователей) и Task (список задач).

Создание таблицы User в Supabase.
Создание таблицы Task в Supabase.

На стороне  инициализируем дополнительный сервис services/supabase.js:

const SUPABASE_URL = 'https://yodsvoxtwmjearffyxhj.supabase.co'
const SUPABASE_API_KEY = 'SECRET'

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(SUPABASE_URL, SUPABASE_API_KEY)
export default supabase

Шаг 2. Подключение Telegram-библиотеки

Для того, чтобы соединить веб-приложение с функционалом Telegram Mini Apps, подключим библиотеку:

​​​​<script src="https://telegram.org/js/telegram-web-app.js"></script>​​​​

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

services/telegram.js
export function useTelegram() {
  const tg = window.Telegram.WebApp
  return { tg, user: tg.initDataUnsafe?.user }
}

Шаг 3. Создание API-запросов к базе

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

api/app.js
import supabase from '@/superbase'
import { useTelegram } from '@/telegram'
import { useScoreStore } from '../stores/score'

const { user } = useTelegram()

const MY_ID = user?.id ?? 'YOUR DEV ID' // Для разработки. В вебе нет user.id — он появляется только тогда, когда приложение запущено в рамках Telegram

// Авторизация пользователя
export async function getOrCreateUser() {
  const potentialUser = await supabase
    .from('users')
    .select()
    .eq('telegram', MY_ID)

  // Проверяем, существует ли уже текущий пользователь
  if (potentialUser.data.length !== 0) {
    return potentialUser.data[0]
  }
  
  // Если нет, то создаем нового
  const newUser = {
    telegram: MY_ID,
    friends: {},
    tasks: {},
    score: 0,
  }

  await supabase.from('users').insert(newUser)
  return newUser
}

// Добавляем обновление счета у текущего пользователя
export async function updateScore(score) {
  await supabase.from('users').update({ score }).eq('telegram', MY_ID)
}

// Завершаем задачу и начисляем бонусные баллы за ее выполнение
export async function completeTask(user, task) {
  await supabase
    .from('users')
    .update({ tasks: { ...user.tasks, [task.id]: true } })
    .eq('telegram', MY_ID)

  const score = useScoreStore()
  const newScore = score.score + task.amount
  await updateScore(newScore)
  score.setScore(newScore)
}

// Регистрируем реферала
export async function registerRef(userName, refId) {
// Получаем данные пользователя, который поделился своей реферальной ссылкой
const { data } = await supabase.from('users').select().eq('telegram', refId)

  const refUser = data[0]

  // Добавляем нас в список его рефералов, а также начисляем баллы
  await supabase
    .from('users')
    .update({
      friends: { ...refUser.friends, [MY_ID]: userName },
      score: refUser.score + 50,
    })
    .eq('telegram', refId)
}

// Получаем список всех задач (он статический)
export async function getTasks() {
  const { data } = await supabase.from('tasks').select('*')

  return data
}

Шаг 4. Создание store для приложения

Создаем еще одно хранилище данных для пользователя и задач, чтобы было удобнее с ними работать и можно было вынести логику в Pinia из самих компонентов.

stores/app.js
import { defineStore } from 'pinia'
import {
  getOrCreateUser,
  completeTask,
  registerRef,
  getTasks,
} from '@/api/app'
import { useScoreStore } from './score'
import { useTelegram } from '@/services/telegram'

const { user } = useTelegram()

export const useAppStore = defineStore('app', {
  state: () => ({
    user: {}, // настройки по умолчанию
    tasks: [],
  }),
  actions: {
    // С этого метода начинается авторизация и идентификация пользователя
    async init(ref) {
      // Получаем существующего пользователя либо создаем нового
      this.user = await getOrCreateUser()

      const score = useScoreStore()
      // Задаем данные, которые были в базе у этого пользователя
      score.setScore(this.user.score)

      // проверяем, является ли он рефералом
      if (ref && +ref !== +this.user.telegram) {
        await registerRef(user.first_name, ref)
      }
    },
    // Выполнение задачи
    async completeTask(task) {
      await completeTask(this.user, task)
    },
    // Получаем список всех задач
    async fetchTasks() {
      this.tasks = await getTasks()
    },
  },
})

Шаг 5. Запуск приложения

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

<template>
  <main class="game" v-if="loaded">
    <div class="page">
      <RouterView />
    </div>
    <TheMenu />
  </main>
</template>

<script setup>
import { onMounted, ref } from 'vue'
import { RouterView } from 'vue-router'
import TheMenu from './components/TheMenu.vue'
import { useAppStore } from './stores/app'
import { useTelegram } from './telegram'

const { tg } = useTelegram()

const app = useAppStore()
const loaded = ref(false)

// Получаем данные, которые нам прокинул бот по рефералам
const urlParams = new URLSearchParams(window.location.search)

// Передаем рефку в инициализацию и получаем данные пользователя
app.init(urlParams.get('ref')).then(() => {
  loaded.value = true
})

onMounted(() => {
  // Сигнализируем о том, что приложение готово
  tg.ready()
  // Разворачиваем на весь экран
  tg.expand()
})
</script>

Шаг 6. Сохранение счета в базе

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

Для защиты от спама добавим debounce, чтоб не заспамить базу запросами. Установим задержку в 500 мс. Если на каждый клик отправлять запрос, это не будет эффективным решением.

npm i lodash.debounce
@/store/score
import { defineStore } from 'pinia'
import debounce from 'lodash.debounce'
import { updateScore } from '@/api/app'

const debouncedSave = debounce(updateScore, 500)

// ======= 

export const useScoreStore = defineStore('score', {
  actions: {
    add(score = 1) {
      this.score += score
      debouncedSave(this.score)
    },
    setScore(score) {
      this.score = score
    },
  },
})

Шаг 7. Создание страницы друзей

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

views/FriendView.vue
<template>
  <div class="text-content">
    <h1>Your Friends</h1>

    <div class="center">
      <button class="referal" @click="copy">{{ referalText }}</button>
    </div>

    <h3 v-if="friends.length === 0">No friends yet</h3>

    <ul class="list">
      <li class="list-item" v-for="friend in friends" :key="friend.id">
        {{ friend.name }}
        <span class="list-btn done"> 50 </span>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { useTelegram } from '@/telegram'
import { useAppStore } from '@/stores/app'
import { ref, computed } from 'vue'

const { user } = useTelegram()
const app = useAppStore()

// Удобный формат для вывода в шаблон (объект -> массив)
const friends = computed(() => Object.keys(app.user.friends).map((id) => ({
  id,
  name: app.user.friends[id],
})))

const referalText = ref('Your referal')

// Копируем в буфер и меняем текст
function copy() {
  navigator.clipboard.writeText(
    'https://t.me/YOUR_BOT_NAME_IN_TELEGRAM?start=' + user?.id
  )
  referalText.value = 'Copied!'
}
</script>
Страница друзей, результат.

Шаг 8. Создание страницы задач

Добавим страницу задач, на которой будем подгружать список выполненных пользователем заданий и количество полученных очков, а также еще невыполненные задачи. 

views/TasksView.vue
<template>
  <div class="text-content">
    <h1>Your tasks</h1>
    <p v-if="app.tasks.length === 0">Loading tasks...</p>
    <ul class="list">
      <li class="list-item" v-for="task in app.tasks" :key="task.id">
        {{ task.title }}

        <span>
          <a
            @click.prevent="openTask(task)"
            target="_blank"
            class="list-btn"
            :class="{ done: app.user?.tasks?.[task.id] }"
          >
            {{ task.done ? 'Done' : task.amount }}
          </a>
        </span>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { useTelegram } from '@/telegram'
import { onMounted } from 'vue'
import { useAppStore } from '@/stores/app'

const app = useAppStore()
const { tg } = useTelegram()

onMounted(() => {
  // Если компонент готов, загружаем его с сервера
  app.fetchTasks()
})

function openTask(task) {
  // Запускаем цикл выполнения задачи
  app.completeTask(task)
  if (task.url.includes('t.me')) {
    // Открываем как внутреннюю ссылку
    tg.openTelegramLink(task.url)
  } else {
    // И как внешнюю
    tg.openLink(task.url)
  }
}
</script>
Страница задач, результат.

Заключение

Готово! У нас есть запущенный фронтенд, Telegram-бот и сама игра. Теперь вы можете использовать шаблон этого приложения для реализации собственных проектов.