Русский
Русский
English
Статистика
Реклама

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

В этом туториале мы реализуем ~~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-listyarn 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 functionscd !$touch subscribe.jscode 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% скидку на первый месяц аренды сервера любой конфигурации!


Источник: habr.com
К списку статей
Опубликовано: 04.06.2021 10:04:57
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании маклауд

Разработка веб-сайтов

Reactjs

Serverless

Vds

Vps

Быстрый vps

Дешевый vds

Google spreadsheets

Подписка

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru