Реализация подписки на обновления с помощью Google Sheets, Netlify Functions и React. Часть 1

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

В этом туториале мы реализуем ~~Real World App~~ — подписку на обновления с помощью гугл таблиц, бессерверных функций и реакта.


Основной функционал нашего приложения будет следующим:


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

Дополнительный функционал:


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

В первой части туториала мы реализуем основной функционал, во второй — дополнительный.


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


Демо приложения, которое мы создадим, можно посмотреть здесь (оно вполне работоспособное, если хотите, можете подписаться на обновления).


Код приложения находится здесь.


Для реализации приложения мы будем использовать следующие технологии:


  • netlify-cli — интерфейс командной строки для запуска сервера для разработки (инициализации бессерверных функций) и "деплоя" приложения на Netlify; требуется глобальная установка: yarn global add netlify-cli или npm i -g netlify-cli; обязательно
  • google-spreadsheet — JavaScript-библиотека для работы с гугл таблицами; обязательно
  • react — на мой взгляд, это лучший JavaScript-фреймворк для фронтенда, но вы можете использовать любую другую библиотеку; наши бессерверные функции не будут зависеть от конкретного фреймворка
  • react-router-dom — React-библиотека для маршрутизации
  • semantic-ui-react — React-CSS-фреймворк (компоненты с готовыми стилями, ну, почти готовыми, мы их немного поправим)
  • react-google-recaptcha — React-компонент, позволяющий напрямую взаимодействовать с соответствующим сервисом
  • nodemailer — наиболее популярная Node.js-библиотека для работы с электронной почтой (рассылки писем)
  • dotenv — утилита для доступа к переменным среды окружения

Разумеется, на вашей машине должен быть установлен Node.js и, желательно, yarn (после того, как вы поработаете с этим пакетным менеджером, вы едва ли вернетесь к npm).


Также будьте готовы к тому, что придется создать несколько аккаунтов и, возможно, кое-что погуглить в случае, если я забуду об этом упомянуть в туториале (постараюсь этого не делать).


Надеюсь, мне удалось вас заинтриговать, и вы с нетерпением ждете, когда мы начнем писать код. Скоро, но сначала давайте создадим и настроим таблицу, а также свяжем ее с Google Cloud Platform.


Подготовка таблицы


Заходим в Google Cloud Platform (по ссылке, приведенной выше) и выполняем следующие действия:


  • создаем новый проект под названием, например, mail-list
  • ожидаем завершения создания проекта и выбираем его
  • переходим к обзору API (Go to APIs overview)
  • включаем Google Sheets API (Enable APIs and services)
  • создаем сервис-аккаунт для доступа к API (Create credentials)
  • переходим в созданный сервис-аккаунт
  • открываем вкладку Keys и добавляем ключ в формате JSON (Add key -> Create new key)
  • в скачанном файле (например, mail-list-315211-ca347b50f56a.json) нас интересуют свойства private_key и client_email; сохраните их где-нибудь, позже мы запишем их в переменные среды окружения

.


.


.


.


.


.


.


.


.


.


.


.


.


Заходим в Google Speadsheets и создаем новую таблицу (Пустой файл) с двумя графами: username и email.


.


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


.


В поисковой строке между d/ и /edit находится идентификатор таблицы, также где-нибудь его сохраните.


На этом настройка нашей таблицы завершена.


Бессерверные функции


Приложение, реализацией которого мы занимаемся, представляет собой отличный пример использования бессерверных вычислений: нам не нужен полноценный сервер, мы всего лишь хотим отправлять данные из заполненной пользователем формы в гугл таблицу, читать эти данные и рассылать уведомления подписчикам. Netlify Functions — это лишь один из вариантов использования AWS Lambda Functions для выполнения такого рода задач.


О том, что такое Netlify Functions, можно почитать здесь.


Функции, как правило, размещаются в директории functions в корне проекта. Создаем новый React-проект (mail-list — название нашего проекта):


yarn create react-app mail-list
# или
npx create ...

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


cd mail-list

yarn add google-spreadsheet dotenv
# или
npm i ...

В корне проекта создаем файл .env (touch .env) и записываем в него сохраненные данные в следующем формате:


GOOGLE_SERVICE_ACCOUNT_EMAIL="YOUR_CLIENT_EMAIL"
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- YOUR_PRIVATE_KEY -----END PRIVATE KEY-----\n"
GOOGLE_SPREADSHEET_ID="YOUR_SPREADSHEET_ID"

Создаем директорию functions, переходим в нее, создаем файл subscribe.js и открываем его в редакторе кода:


mkdir functions
cd !$
touch subscribe.js
code subscribe.js

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


// Загружаем переменные среды окружения из файла ".env"
require('dotenv').config()

const { GoogleSpreadsheet } = require('google-spreadsheet')

// Бессерверная функция (о ее сигнатуре мы поговорим позже)
// В данном случае, нас интересует только первый аргумент, принимаемый функцией - `event`
// `event` - это тоже самое, что `req` в `express`, т.е. объект запроса
exports.handler = async (event) => {
  // Создаем экземпляр класса, представляющего внутренний документ гугл таблиц
  // Конструктор класса принимает идентификатор таблицы
  const doc = new GoogleSpreadsheet(process.env.GOOGLE_SPREADSHEET_ID)

  try {
    // Выполняем авторизацию с помощью сервис-аккаунта
    await doc.useServiceAccountAuth({
      client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
      private_key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n')
    })

    // Загружаем данные документа
    await doc.loadInfo()

    // Получаем ссылку на созданную нами таблицу
    const sheet = doc.sheetsByIndex[0]

    // Получаем данные от клиента в формате JSON и преобразуем их в объект
    const data = JSON.parse(event.body)

    // Получаем строки таблицы
    const rows = await sheet.getRows()

    // Обратите внимание, что заголовки столбцов таблицы становятся одноименными свойствами строк
    // Если какая-либо из строк содержит email, указанный пользователем,
    // значит, пользователь уже оформил подписку на обновления
    if (rows.some((row) => row.email === data.email)) {
      // Формируем ответ
      const response = {
        statusCode: 400,
        body: JSON.stringify({
          error: 'Пользователь с таким email уже оформил подписку'
        }),
        // Про это поговорим позже
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Credentials': 'true'
        }
      }
      // и возвращаем его
      return response
    }

    // Добавляем данные пользователя в таблицу в виде новой строки
    await sheet.addRow(data)

    // Формируем ответ
    const response = {
      statusCode: 200,
      body: JSON.stringify({ message: 'Спасибо за подписку!' }),
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': 'true'
      }
    }
    // и возвращаем его
    return response
  } catch (err) {
    // Обрабатываем ошибку, возникшую на стороне сервера
    console.error(err)
    const response = {
      statusCode: 500,
      body: JSON.stringify({ error: 'Что-то пошло не так. Попробуйте позже' }),
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': 'true'
      }
    }
    return response
  }
}

Бессерверные функции имеют такую сигнатуру:


exports.handler = (event, context, callback) => {...}

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


Что касается этих заголовков ответа:


headers: {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Credentials': 'true'
}

То они связаны с внутренними настройками Netlify (с выполняемыми перенаправлениями при обращении к функции из клиента). Перенаправления блокируются CORS (Cross-Origin Resource Sharing — доступ к ресурсу из другого источника), потому что бессерверные функции не совсем бессерверные, под капотом они работают на основе централизованного сервера. Эти заголовки не требуются для локальной разработки, но развернуть приложение на хостинге без них не получится. В официальной документации про это ни слова. Возможно, к тому моменту, когда вы будете читать данную статью, этот недостаток будет устранен.


Следует отметить, что эти заголовки можно указать для всех ответов в файле netlify.toml.


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


Клиент


Устанавливаем зависимости для клиента:


yarn add react-router-dom semantic-ui-css semantic-ui-react react-google-recaptcha
# или
npm i ...

Код клиента находится в директории src. Удаляем из нее лишние файлы (оставляем только index.js и index.css), создаем директорию pages для страниц и hooks для пользовательских хуков. В директории pages создаем следующие файлы:


  • Home.js — домашняя/главная страница
  • Subscribe.js — страница с формой
  • Success.js — страница с сообщением об успехе операции
  • NotFound.js — резервная страница (ошибка 404)

В директории hooks создаем три файла:


  • useDeferredRoute.js — хук для отложенной маршрутизации (опционально)
  • useTimeout.js — хук-обертка для setTimeout
  • index.js — экспорт индикатора загрузки и ре-экспорт хуков

Структура директории src:


src
  hooks
    index.js
    useDeferredRoute.js
    useTimeout.js
  pages
    Home.js
    NotFound.js
    Subscribe.js
    Success.js
  index.css
  index.js

В index.css мы подключаем кастомный шрифт и вносим небольшие правки в стили semantic-ui:


@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');

* {
  font-family: 'Montserrat', sans-serif !important;
}

body {
  min-height: 100vh;
  display: grid;
  align-content: center;
  background: #8360c3;
  background: linear-gradient(135deg, #2ebf91, #8360c3);
}

h2 {
  margin-bottom: 3rem;
}

.ui.container {
  max-width: 480px !important;
  margin: 0 auto !important;
  text-align: center;
}

.ui.form {
  max-width: 300px;
  margin: 0 auto;
}

.ui.form .field > label {
  text-align: left;
  font-size: 1.2rem;
  margin-bottom: 0.8rem;
}

.ui.button {
  margin-top: 1.5rem;
  font-size: 1rem;
  letter-spacing: 1px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3) !important;
}

.email-error {
  color: #f93154;
  text-align: left;
}

В index.js мы импортируем компоненты приложения и реализуем разделение кода на уровне маршрутов с помощью lazy и Suspense:


import React, { lazy, Suspense } from 'react'
import ReactDOM from 'react-dom'
// Средства для маршрутизации
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
// Индикатор загрузки
import { Spinner } from './hooks'

// Стили `semantic-ui`
import 'semantic-ui-css/semantic.min.css'
// Кастомные стили
import './index.css'

// "Ленивые" компоненты - динамический импорт
const Home = lazy(() => import('./pages/Home'))
const Subscribe = lazy(() => import('./pages/Subscribe'))
const Success = lazy(() => import('./pages/Success'))
const NotFound = lazy(() => import('./pages/NotFound'))

ReactDOM.render(
  <Suspense fallback={<Spinner />}>
    <Router>
      <Switch>
        <Route path='/' exact component={Home} />
        <Route path='/subscribe' component={Subscribe} />
        <Route path='/success' component={Success} />
        <Route component={NotFound} />
      </Switch>
    </Router>
  </Suspense>,
  document.getElementById('root')
)

Рассмотрим, что из себя представляют пользовательские хуки.


Хук useDeferredRoute добавляет искусственную задержку при начальной загрузке приложения и переходе пользователя к другой странице. Это совершенно не обязательно, возможно, кто-то даже скажет, что это плохая практика, но мне показалось, что так приложение будет более "живым". Кроме того, не так давно при работе над одним из проектов я столкнулся с необходимостью скрытия от пользователя позиционирования элементов фона отрендеренного компонента через искусственную задержку отображения компонента (вот где бы пригодился хук useTransition, но он пока еще экспериментальный).


import { useState, useEffect } from 'react'

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

export const useDeferredRoute = (ms) => {
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const wait = async () => {
      await sleep(ms)
      setLoading(false)
    }
    wait()
  }, [ms])

  return { loading }
}

Хук useTimeout, как было отмечено, это всего лишь обертка над нативным setTimeout:


import { useEffect, useRef } from 'react'

export const useTimeout = (cb, ms) => {
  const cbRef = useRef()

  useEffect(() => {
    cbRef.current = cb
  }, [cb])

  useEffect(() => {
    function tick() {
      cbRef.current()
    }
    if (ms > 1) {
      const id = setTimeout(tick, ms)
      return () => {
        clearTimeout(id)
      }
    }
  }, [ms])
}

А вот как выглядит hooks/index.js:


// Мне не хотелось создавать директорию `components` для одного компонента
import { Loader } from 'semantic-ui-react'

export const Spinner = () => <Loader active inverted size='large' />

export { useDeferredRoute } from './useDeferredRoute'
export { useTimeout } from './useTimeout'

Теперь займемся страницами.


В Home.js нет ничего особенного. После скрытия индикатора загрузки, мы приветствуем пользователя и предлагаем ему подписаться на (кнопка "Подписаться" — это на самом деле ссылка на страницу Subscribe):


import { Link } from 'react-router-dom'
import { Container, Button } from 'semantic-ui-react'

import { Spinner, useDeferredRoute } from '../hooks'

function Home() {
  const { loading } = useDeferredRoute(1500)

  if (loading) return <Spinner />

  return (
    <Container>
      <h2>Доброго времени суток!</h2>
      <h3>
        Подпишитесь на обновления, <br /> чтобы оставаться в курсе событий!
      </h3>
      <Button color='teal' as={Link} to='/subscribe'>
        Подписаться
      </Button>
    </Container>
  )
}

export default Home

В Success.js также нет ничего особенного. После скрытия индикатора загрузки, мы благодарим пользователя за подписку и выполняем автоматическое перенаправление на главную страницу через 3 секунды с помощью нашего хука useTimeout. На случай, если автоматического перенаправления не произошло, имеется кнопка-ссылка на страницу Home:


import { Link, useHistory } from 'react-router-dom'
import { Container, Button } from 'semantic-ui-react'

import { Spinner, useDeferredRoute, useTimeout } from '../hooks'

function Success() {
  const { loading } = useDeferredRoute(500)
  const history = useHistory()

  const redirectToHomePage = () => {
    history.push('/')
  }

  useTimeout(redirectToHomePage, 3000)

  if (loading) return <Spinner />

  return (
    <Container>
      <h2>Спасибо за подписку!</h2>
      <h3>Сейчас вы будете перенаправлены на главную страницу</h3>
      <Button color='teal' as={Link} to='/'>
        На главную
      </Button>
    </Container>
  )
}

export default Success

Еще одна простая страница — NotFound — пользователь попадает на эту страницу при отсутствии совпадения с маршрутами приложения:


import { Link, useHistory } from 'react-router-dom'
import { Container, Button } from 'semantic-ui-react'

import { Spinner, useDeferredRoute, useTimeout } from '../hooks'

function NotFound() {
  const { loading } = useDeferredRoute(500)
  const history = useHistory()

  const redirectToHomePage = () => {
    history.push('/')
  }

  useTimeout(redirectToHomePage, 2000)

  if (loading) return <Spinner />

  return (
    <Container>
      <h2>Страница отсутствует</h2>
      <h3>Сейчас вы будете перенаправлены на главную страницу</h3>
      <Button color='teal' as={Link} to='/'>
        На главную
      </Button>
    </Container>
  )
}

export default NotFound

На странице Subscribe используется компонент react-google-recaptcha, которому в качестве пропа передается ключ сайта (sitekey). Данный ключ можно получить в административной консоли Google ReCAPTCHA, но для этого приложение надо сначала развернуть на Netlify. К счастью, для локальной разработки можно использовать этот тестовый ключ (это официальный ключ для тестирования): 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI. Позже мы вернемся к этому вопросу.


Еще один важный момент — это конечная точка отправки пользовательских данных. Она должна начинаться с /.netlify/, затем указывается путь к соответствующей функции: functions/subscribe/.netlify/functions/subscribe (название функции — часть пути). Следует отметить, что часть пути /.netlify/functions можно изменить в netlify.toml.


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


import { useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Container, Form, Button } from 'semantic-ui-react'
import ReCAPTCHA from 'react-google-recaptcha'

import { Spinner, useDeferredRoute } from '../hooks'

// Утилита для проверки того, что все поля заполнены
const isEmpty = (fields) => fields.some((f) => f.trim() === '')
// Простой вариант утилиты для проверки адреса электронной почты
const isEmail = (v) => /\w+@\w+\.\w+/i.test(v)

function Subscribe() {
  const [formData, setFormData] = useState({
    username: '',
    email: ''
  })
  const [error, setError] = useState(null)
  const [recaptcha, setRecaptcha] = useState(false)

  const { loading } = useDeferredRoute(1000)
  const history = useHistory()

  const onChange = ({ target: { name, value } }) => {
    setError(null)
    setFormData({
      ...formData,
      [name]: value
    })
  }

  const onSubmit = async (e) => {
    e.preventDefault()

    const email = isEmail(formData.email)

    if (!email) {
      return setError('Введен неправильный email')
    }

    try {
      const response = await fetch('/.netlify/functions/subscribe', {
        method: 'POST',
        body: JSON.stringify(formData),
        headers: {
          'Content-Type': 'application/json'
        }
      })
      if (!response.ok) {
        const json = await response.json()
        return setError(json.error)
      }
      history.push('/success')
    } catch (err) {
      console.error(err)
    }
  }

  // Учитывая, что мы используем тестовый ключ, капча всегда будет иметь истинное значение
  const disabled = isEmpty(Object.values(formData)) || !recaptcha

  const { username, email } = formData

  if (loading) return <Spinner />

  return (
    <Container>
      <h2>Подписаться на уведомления</h2>
      <Form onSubmit={onSubmit}>
        <Form.Field>
          <label>Ваше имя</label>
          <input
            placeholder='Имя'
            type='text'
            name='username'
            value={username}
            onChange={onChange}
            required
          />
        </Form.Field>
        <Form.Field>
          <label>Ваш email</label>
          <input
            placeholder='Email'
            type='email'
            name='email'
            value={email}
            onChange={onChange}
            required
          />
        </Form.Field>
        <p className='email-error'>{error}</p>
        <ReCAPTCHA
          sitekey='6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'
          onChange={() => setRecaptcha(true)}
        />
        <Button color='teal' type='submit' disabled={disabled}>
          Подписаться
        </Button>
      </Form>
    </Container>
  )
}

export default Subscribe

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


Если вы еще не установили netlify-cli, самое время это сделать:


yarn global add netlify-cli
# или
npm i -g ...

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


netlify dev

После выполнения указанной команды клиент будет запущен по адресу localhost:3000, а сервер — также на локальном хосте, но с портом 8888.


Прелесть в том, что netlify-cli умеет автоматически определять, какой фреймворк используется в проекте, и выполнять нужные команды для его запуска.


Нажимаем на кнопку "Подписаться", заполняем поля формы, проходим проверку и снова нажимаем на "Подписаться". Выполняется перенаправление на страницу с сообщением об успехе операции, затем еще одно перенаправление на главную страницу. Открываем таблицу, видим введенные нами данные.


.


.


.


Отлично, приложение работает, как ожидается.


В следующей части туториала мы развернем приложение на Netlify, закончим настройку капчи, реализуем автоматическую рассылку уведомлений и отписку от обновлений.


Благодарю за внимание и хорошего дня!




Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Источник: https://habr.com/ru/company/macloud/blog/560932/


Интересные статьи

Интересные статьи

С сегодняшнего дня мы начинаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript". Каждое значение в JavaScript при выполнении над ним ...
Доброго всем дня! В предыдущей части я представил на суд свою реализацию избитой идеи подсветки лестничного марша. Статья касалась только "железной" части проек...
В данной статье разобран код на Qt, который позволяет создать панель инструментов с возможностью менять положение виджетов на экране в зависимости от соотношения размеров...
Недавно мы на Хабр Карьере устроили конкурс ко дню эйчара и попросили эйчаров и ИТ-рекрутеров рассказать нам о самых смешных собеседованиях, которые стали легендами в их компаниях. И...
И вновь доброго времени суток! Данная статья является продолжением ранее опубликованной статьи о нашей «темной» Вселенной. В данной части мы продолжим рассмотрение различных интересных особенност...