Implementing subscription updates with Google Sheets, Netlify Functions, and React. Part 1



In this tutorial, we will implement ~~Real World App~~



- subscribing to updates using google tables, serverless functions and react.







The main functionality of our application will be as follows:







  • the main page displays a greeting and an offer to subscribe to updates
  • "", , :
  • 2
  • ""


:







  • , —


, — .







, Netlify



( , ) .







, , ( , , ).







.







:









, 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% !














All Articles