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
( , ) .
:
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
.
, , 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% !