├── .gitignore ├── README.md ├── babel.config.js ├── jest.config.js ├── next-env.d.ts ├── package.json ├── public ├── favicon.png └── images │ ├── avatar.svg │ └── logo.svg ├── src ├── components │ ├── ActiveLink │ │ ├── ActiveLink.spec.tsx │ │ └── index.tsx │ ├── Async │ │ ├── Async.spec.tsx │ │ └── index.tsx │ ├── Header │ │ ├── Header.spec.tsx │ │ ├── index.tsx │ │ └── styles.module.scss │ ├── SignInButton │ │ ├── SignInButton.spec.tsx │ │ ├── index.tsx │ │ └── styles.module.scss │ └── SubscribeButton │ │ ├── SubscribeButton.spec.tsx │ │ ├── index.tsx │ │ └── styles.module.scss ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── _lib │ │ │ └── manageSubscription.ts │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ ├── subscribe.ts │ │ └── webhooks.ts │ ├── home.module.scss │ ├── index.tsx │ └── posts │ │ ├── [slug].tsx │ │ ├── index.tsx │ │ ├── post.module.scss │ │ ├── preview │ │ └── [slug].tsx │ │ └── styles.module.scss ├── services │ ├── api.ts │ ├── fauna.ts │ ├── prismic.ts │ ├── stripe-js.ts │ └── stripe.ts ├── styles │ └── global.scss └── tests │ ├── pages │ ├── Home.spec.tsx │ ├── Post.spec.tsx │ ├── PostPreview.spec.tsx │ └── Posts.spec.tsx │ └── setupTests.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Rocketseat Education 3 |

4 | 5 |

6 | Rocketseat Project 7 | License 8 |

9 | 10 | 11 | ## 💻 Projeto 12 | 13 | ignite-reactjs-app-jamstack 14 | 15 | ## 📝 Licença 16 | 17 | Esse projeto está sob a licença MIT. Veja o arquivo [LICENSE](LICENSE) para mais detalhes. 18 | 19 | --- 20 | 21 |

22 | Feito com 💜 by Rocketseat 23 |

24 | 25 | 26 | 27 | 28 |
29 |
30 | 31 |

32 | 33 | banner 34 | 35 |

36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['next/babel'] 3 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathIgnorePatterns: ["/node_modules/", "/.next/"], 3 | setupFilesAfterEnv: [ 4 | "/src/tests/setupTests.ts" 5 | ], 6 | transform: { 7 | "^.+\\.(js|jsx|ts|tsx)$": "/node_modules/babel-jest" 8 | }, 9 | testEnvironment: 'jsdom', 10 | moduleNameMapper: { 11 | "\\.(scss|css|sass)$": "identity-obj-proxy" 12 | }, 13 | collectCoverage: true, 14 | collectCoverageFrom: [ 15 | "src/**/*.tsx", 16 | "!src/**/*.spec.tsx", 17 | "!src/**/_app.tsx", 18 | "!src/**/_document.tsx", 19 | ], 20 | coverageReporters: ["lcov", "json"] 21 | }; -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ignews", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "test": "jest" 10 | }, 11 | "dependencies": { 12 | "@prismicio/client": "^4.0.0", 13 | "@stripe/stripe-js": "^1.13.1", 14 | "axios": "^0.21.1", 15 | "faunadb": "^4.1.1", 16 | "next": "10.0.9", 17 | "next-auth": "^3.13.0", 18 | "prismic-dom": "^2.2.5", 19 | "react": "17.0.1", 20 | "react-dom": "17.0.1", 21 | "react-icons": "^4.2.0", 22 | "sass": "^1.32.8", 23 | "stripe": "^8.138.0" 24 | }, 25 | "devDependencies": { 26 | "@testing-library/dom": "^7.31.0", 27 | "@testing-library/jest-dom": "^5.12.0", 28 | "@testing-library/react": "^11.2.7", 29 | "@types/next-auth": "^3.7.1", 30 | "@types/node": "^14.14.35", 31 | "@types/prismic-dom": "^2.1.1", 32 | "@types/react": "^17.0.3", 33 | "babel-jest": "^26.6.3", 34 | "identity-obj-proxy": "^3.0.0", 35 | "jest": "^26.6.3", 36 | "jest-dom": "^4.0.0", 37 | "ts-jest": "^26.5.6", 38 | "typescript": "^4.2.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rocketseat-education/ignite-reactjs-app-jamstack/efb122f91b772ab463f6319368a0ce9e065a642a/public/favicon.png -------------------------------------------------------------------------------- /public/images/avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/ActiveLink/ActiveLink.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { ActiveLink } from '.' 3 | 4 | jest.mock('next/router', () => { 5 | return { 6 | useRouter() { 7 | return { 8 | asPath: '/' 9 | } 10 | } 11 | } 12 | }) 13 | 14 | describe('ActiveLink component', () => { 15 | it('renders correctly', () => { 16 | render( 17 | 18 | Home 19 | 20 | ) 21 | 22 | expect(screen.getByText('Home')).toBeInTheDocument() 23 | }) 24 | 25 | it('adds active class if the link as currently active', () => { 26 | render( 27 | 28 | Home 29 | 30 | ) 31 | 32 | expect(screen.getByText('Home')).toHaveClass('active') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/components/ActiveLink/index.tsx: -------------------------------------------------------------------------------- 1 | import Link, { LinkProps } from "next/link"; 2 | import { useRouter } from "next/router"; 3 | import { ReactElement, cloneElement } from "react"; 4 | 5 | interface ActiveLinkProps extends LinkProps { 6 | children: ReactElement; 7 | activeClassName: string; 8 | } 9 | 10 | export function ActiveLink({ children, activeClassName, ...rest }: ActiveLinkProps) { 11 | const { asPath } = useRouter() 12 | 13 | const className = asPath === rest.href 14 | ? activeClassName 15 | : ''; 16 | 17 | return ( 18 | 19 | {cloneElement(children, { 20 | className, 21 | })} 22 | 23 | ); 24 | } -------------------------------------------------------------------------------- /src/components/Async/Async.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react' 2 | import { Async } from '.'; 3 | 4 | test('it renders correctly', async () => { 5 | render() 6 | 7 | expect(screen.getByText('Hello World')).toBeInTheDocument() 8 | 9 | await waitForElementToBeRemoved(screen.queryByText('Button')) 10 | }); -------------------------------------------------------------------------------- /src/components/Async/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | export function Async() { 4 | const [isButtonInvisible, setIsButtonInvisible] = useState(false) 5 | 6 | useEffect(() => { 7 | setTimeout(() => { 8 | setIsButtonInvisible(true) 9 | }, 1000) 10 | }, []) 11 | 12 | return ( 13 |
14 |
Hello World
15 | { !isButtonInvisible && } 16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /src/components/Header/Header.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { Header } from '.' 3 | 4 | jest.mock('next/router', () => { 5 | return { 6 | useRouter() { 7 | return { 8 | asPath: '/' 9 | } 10 | } 11 | } 12 | }) 13 | 14 | jest.mock('next-auth/client', () => { 15 | return { 16 | useSession() { 17 | return [null, false] 18 | } 19 | } 20 | }) 21 | 22 | describe('Header component', () => { 23 | it('renders correctly', () => { 24 | render( 25 |
26 | ) 27 | 28 | expect(screen.getByText('Home')).toBeInTheDocument() 29 | expect(screen.getByText('Posts')).toBeInTheDocument() 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { SignInButton } from '../SignInButton'; 2 | import { ActiveLink } from '../ActiveLink'; 3 | 4 | import styles from './styles.module.scss'; 5 | 6 | export function Header() { 7 | return ( 8 |
9 |
10 | ig.news 11 | 19 | 20 | 21 |
22 |
23 | ); 24 | } -------------------------------------------------------------------------------- /src/components/Header/styles.module.scss: -------------------------------------------------------------------------------- 1 | .headerContainer { 2 | height: 5rem; 3 | border-bottom: 1px solid var(--gray-800); 4 | } 5 | 6 | .headerContent { 7 | max-width: 1120px; 8 | height: 5rem; 9 | margin: 0 auto; 10 | padding: 0 2rem; 11 | 12 | display: flex; 13 | align-items: center; 14 | 15 | nav { 16 | margin-left: 5rem; 17 | height: 5rem; 18 | 19 | a { 20 | display: inline-block; 21 | position: relative; 22 | padding: 0 0.5rem; 23 | height: 5rem; 24 | line-height: 5rem; 25 | color: var(--gray-300); 26 | 27 | transition: color 0.2s; 28 | 29 | & + a { 30 | margin-left: 2rem; 31 | } 32 | 33 | &:hover { 34 | color: var(--white); 35 | } 36 | 37 | &.active { 38 | color: var(--white); 39 | font-weight: bold; 40 | } 41 | 42 | &.active::after { 43 | content: ''; 44 | height: 3px; 45 | border-radius: 3px 3px 0 0; 46 | width: 100%; 47 | position: absolute; 48 | bottom: 1px; 49 | left: 0; 50 | background: var(--yellow-500); 51 | } 52 | } 53 | } 54 | 55 | button { 56 | margin-left: auto; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/SignInButton/SignInButton.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { mocked } from 'ts-jest/utils' 3 | import { useSession } from 'next-auth/client' 4 | import { SignInButton } from '.' 5 | 6 | jest.mock('next-auth/client') 7 | 8 | describe('SignInButton component', () => { 9 | it('renders correctly when user is not authenticated', () => { 10 | const useSessionMocked = mocked(useSession) 11 | 12 | useSessionMocked.mockReturnValueOnce([null, false]) 13 | 14 | render() 15 | 16 | expect(screen.getByText('Sign in with Github')).toBeInTheDocument() 17 | }) 18 | 19 | it('renders correctly when user is authenticated', () => { 20 | const useSessionMocked = mocked(useSession) 21 | 22 | useSessionMocked.mockReturnValueOnce([ 23 | { user: { name: 'John Doe', email: 'john.doe@example.com' }, expires: 'fake-expires' }, 24 | false 25 | ]) 26 | 27 | render() 28 | 29 | expect(screen.getByText('John Doe')).toBeInTheDocument() 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/components/SignInButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { FaGithub } from 'react-icons/fa' 2 | import { FiX } from 'react-icons/fi' 3 | import { signIn, signOut, useSession } from 'next-auth/client' 4 | 5 | import styles from './styles.module.scss'; 6 | 7 | export function SignInButton() { 8 | const [session] = useSession() 9 | 10 | return session ? ( 11 | 20 | ) : ( 21 | 29 | ); 30 | } -------------------------------------------------------------------------------- /src/components/SignInButton/styles.module.scss: -------------------------------------------------------------------------------- 1 | .signInButton { 2 | height: 3rem; 3 | border-radius: 3rem; 4 | background: var(--gray-850); 5 | border: 0; 6 | padding: 0 1.5rem; 7 | 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | 12 | color: var(--white); 13 | font-weight: bold; 14 | 15 | svg { 16 | width: 20px; 17 | height: 20px; 18 | } 19 | 20 | svg:first-child { 21 | margin-right: 1rem; 22 | } 23 | 24 | svg.closeIcon { 25 | margin-left: 1rem; 26 | } 27 | 28 | transition: filter 0.2s; 29 | 30 | &:hover { 31 | filter: brightness(0.8); 32 | } 33 | } -------------------------------------------------------------------------------- /src/components/SubscribeButton/SubscribeButton.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/react' 2 | import { mocked } from 'ts-jest/utils' 3 | import { signIn, useSession } from 'next-auth/client' 4 | import { useRouter } from 'next/router' 5 | import { SubscribeButton } from '.' 6 | 7 | jest.mock('next-auth/client'); 8 | jest.mock('next/router'); 9 | 10 | describe('SubscribeButton component', () => { 11 | it('renders correctly', () => { 12 | const useSessionMocked = mocked(useSession) 13 | 14 | useSessionMocked.mockReturnValueOnce([null, false]) 15 | 16 | render() 17 | 18 | expect(screen.getByText('Subscribe now')).toBeInTheDocument() 19 | }) 20 | 21 | it('redirects user to sign in when not authenticated', () => { 22 | const signInMocked = mocked(signIn) 23 | const useSessionMocked = mocked(useSession) 24 | 25 | useSessionMocked.mockReturnValueOnce([null, false]) 26 | 27 | render() 28 | 29 | const subscribeButton = screen.getByText('Subscribe now'); 30 | 31 | fireEvent.click(subscribeButton) 32 | 33 | expect(signInMocked).toHaveBeenCalled() 34 | }); 35 | 36 | it('redirects tp posts when user already has a subscription', () => { 37 | const useRouterMocked = mocked(useRouter) 38 | const useSessionMocked = mocked(useSession) 39 | const pushMock = jest.fn() 40 | 41 | useSessionMocked.mockReturnValueOnce([ 42 | { 43 | user: { 44 | name: 'John Doe', 45 | email: 'john.doe@example.com' 46 | }, 47 | activeSubscription: 'fake-active-subscription', 48 | expires: 'fake-expires' 49 | }, 50 | false 51 | ]) 52 | 53 | useRouterMocked.mockReturnValueOnce({ 54 | push: pushMock 55 | } as any) 56 | 57 | render() 58 | 59 | const subscribeButton = screen.getByText('Subscribe now'); 60 | 61 | fireEvent.click(subscribeButton) 62 | 63 | expect(pushMock).toHaveBeenCalledWith('/posts') 64 | }); 65 | }) 66 | -------------------------------------------------------------------------------- /src/components/SubscribeButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSession, signIn } from 'next-auth/client'; 2 | import { useRouter } from 'next/router'; 3 | import { api } from '../../services/api'; 4 | import { getStripeJs } from '../../services/stripe-js'; 5 | import styles from './styles.module.scss'; 6 | 7 | export function SubscribeButton() { 8 | const [session] = useSession(); 9 | const router = useRouter() 10 | 11 | async function handleSubscribe() { 12 | if (!session) { 13 | signIn('github') 14 | return; 15 | } 16 | 17 | if (session.activeSubscription) { 18 | router.push('/posts'); 19 | return; 20 | } 21 | 22 | try { 23 | const response = await api.post('/subscribe') 24 | 25 | const { sessionId } = response.data; 26 | 27 | const stripe = await getStripeJs() 28 | 29 | await stripe.redirectToCheckout({ sessionId }) 30 | } catch (err) { 31 | alert(err.message); 32 | } 33 | } 34 | 35 | return ( 36 | 43 | ); 44 | } -------------------------------------------------------------------------------- /src/components/SubscribeButton/styles.module.scss: -------------------------------------------------------------------------------- 1 | .subscribeButton { 2 | width: 260px; 3 | height: 4rem; 4 | border: 0; 5 | border-radius: 2rem; 6 | background: var(--yellow-500); 7 | color: var(--gray-900); 8 | font-size: 1.25rem; 9 | font-weight: bold; 10 | 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | 15 | transition: filter 0.2s; 16 | 17 | &:hover { 18 | filter: brightness(0.8); 19 | } 20 | } -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app' 2 | import { Header } from '../components/Header'; 3 | import { Provider as NextAuthProvider } from 'next-auth/client' 4 | 5 | import '../styles/global.scss'; 6 | 7 | function MyApp({ Component, pageProps }: AppProps) { 8 | return ( 9 | 10 |
11 | 12 | 13 | ) 14 | } 15 | 16 | export default MyApp 17 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default class MyDocument extends Document { 4 | render() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /src/pages/api/_lib/manageSubscription.ts: -------------------------------------------------------------------------------- 1 | import { query as q } from 'faunadb'; 2 | 3 | import { fauna } from "../../../services/fauna"; 4 | import { stripe } from '../../../services/stripe'; 5 | 6 | export async function saveSubscription( 7 | subscriptionId: string, 8 | customerId: string, 9 | createAction = false, 10 | ) { 11 | const userRef = await fauna.query( 12 | q.Select( 13 | "ref", 14 | q.Get( 15 | q.Match( 16 | q.Index('user_by_stripe_customer_id'), 17 | customerId 18 | ) 19 | ) 20 | ) 21 | ) 22 | 23 | const subscription = await stripe.subscriptions.retrieve(subscriptionId) 24 | 25 | const subscriptionData = { 26 | id: subscription.id, 27 | userId: userRef, 28 | status: subscription.status, 29 | price_id: subscription.items.data[0].price.id, 30 | } 31 | 32 | if (createAction) { 33 | await fauna.query( 34 | q.Create( 35 | q.Collection('subscriptions'), 36 | { data: subscriptionData } 37 | ) 38 | ) 39 | } else { 40 | await fauna.query( 41 | q.Replace( 42 | q.Select( 43 | "ref", 44 | q.Get( 45 | q.Match( 46 | q.Index('subscription_by_id'), 47 | subscriptionId, 48 | ) 49 | ) 50 | ), 51 | { data: subscriptionData } 52 | ) 53 | ) 54 | } 55 | } -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { query as q } from 'faunadb' 2 | 3 | import NextAuth from 'next-auth' 4 | import Providers from 'next-auth/providers' 5 | 6 | import { fauna } from '../../../services/fauna'; 7 | 8 | export default NextAuth({ 9 | providers: [ 10 | Providers.GitHub({ 11 | clientId: process.env.GITHUB_CLIENT_ID, 12 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 13 | scope: 'read:user' 14 | }), 15 | ], 16 | callbacks: { 17 | async session(session) { 18 | try { 19 | const userActiveSubscription = await fauna.query( 20 | q.Get( 21 | q.Intersection([ 22 | q.Match( 23 | q.Index('subscription_by_user_ref'), 24 | q.Select( 25 | "ref", 26 | q.Get( 27 | q.Match( 28 | q.Index('user_by_email'), 29 | q.Casefold(session.user.email) 30 | ) 31 | ) 32 | ) 33 | ), 34 | q.Match( 35 | q.Index('subscription_by_status'), 36 | "active" 37 | ) 38 | ]) 39 | ) 40 | ) 41 | 42 | return { 43 | ...session, 44 | activeSubscription: userActiveSubscription 45 | } 46 | } catch { 47 | return { 48 | ...session, 49 | activeSubscription: null, 50 | } 51 | } 52 | }, 53 | async signIn(user, account, profile) { 54 | const { email } = user 55 | 56 | try { 57 | await fauna.query( 58 | q.If( 59 | q.Not( 60 | q.Exists( 61 | q.Match( 62 | q.Index('user_by_email'), 63 | q.Casefold(user.email) 64 | ) 65 | ) 66 | ), 67 | q.Create( 68 | q.Collection('users'), 69 | { data: { email } } 70 | ), 71 | q.Get( 72 | q.Match( 73 | q.Index('user_by_email'), 74 | q.Casefold(user.email) 75 | ) 76 | ) 77 | ) 78 | ) 79 | 80 | return true 81 | } catch { 82 | return false 83 | } 84 | }, 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /src/pages/api/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { query as q } from 'faunadb' 3 | import { getSession } from 'next-auth/client' 4 | import { fauna } from "../../services/fauna"; 5 | import { stripe } from '../../services/stripe' 6 | 7 | type User = { 8 | ref: { 9 | id: string; 10 | } 11 | data: { 12 | stripe_customer_id: string 13 | } 14 | } 15 | 16 | export default async (req: NextApiRequest, res: NextApiResponse) => { 17 | if (req.method === 'POST') { 18 | const session = await getSession({ req }) 19 | 20 | const user = await fauna.query( 21 | q.Get( 22 | q.Match( 23 | q.Index('user_by_email'), 24 | q.Casefold(session.user.email) 25 | ) 26 | ) 27 | ) 28 | 29 | let customerId = user.data.stripe_customer_id 30 | 31 | if (!customerId) { 32 | const stripeCustomer = await stripe.customers.create({ 33 | email: session.user.email, 34 | // metadata 35 | }) 36 | 37 | await fauna.query( 38 | q.Update( 39 | q.Ref(q.Collection('users'), user.ref.id), 40 | { 41 | data: { 42 | stripe_customer_id: stripeCustomer.id, 43 | } 44 | } 45 | ) 46 | ) 47 | 48 | customerId = stripeCustomer.id 49 | } 50 | 51 | const stripeCheckoutSession = await stripe.checkout.sessions.create({ 52 | customer: customerId, 53 | payment_method_types: ['card'], 54 | billing_address_collection: 'required', 55 | line_items: [ 56 | { price: 'price_1IVhtPEr8Nl1t46KAhq5JOHw', quantity: 1 } 57 | ], 58 | mode: 'subscription', 59 | allow_promotion_codes: true, 60 | success_url: process.env.STRIPE_SUCCESS_URL, 61 | cancel_url: process.env.STRIPE_CANCEL_URL 62 | }) 63 | 64 | return res.status(200).json({ sessionId: stripeCheckoutSession.id }) 65 | } else { 66 | res.setHeader('Allow', 'POST') 67 | res.status(405).end('Method not allowed') 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/pages/api/webhooks.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { Readable } from 'stream' 3 | import Stripe from "stripe"; 4 | 5 | import { stripe } from "../../services/stripe"; 6 | import { saveSubscription } from "./_lib/manageSubscription"; 7 | 8 | async function buffer(readable: Readable) { 9 | const chunks = []; 10 | 11 | for await (const chunk of readable) { 12 | chunks.push( 13 | typeof chunk === "string" ? Buffer.from(chunk) : chunk 14 | ); 15 | } 16 | 17 | return Buffer.concat(chunks); 18 | } 19 | 20 | export const config = { 21 | api: { 22 | bodyParser: false 23 | } 24 | } 25 | 26 | const relevantEvents = new Set([ 27 | 'checkout.session.completed', 28 | 'customer.subscription.updated', 29 | 'customer.subscription.deleted', 30 | ]) 31 | 32 | export default async (req: NextApiRequest, res: NextApiResponse) => { 33 | if (req.method === 'POST') { 34 | const buf = await buffer(req) 35 | const secret = req.headers['stripe-signature'] 36 | 37 | let event: Stripe.Event; 38 | 39 | try { 40 | event = stripe.webhooks.constructEvent(buf, secret, process.env.STRIPE_WEBHOOK_SECRET); 41 | } catch (err) { 42 | return res.status(400).send(`Webhook error: ${err.message}`); 43 | } 44 | 45 | const { type } = event; 46 | 47 | if (relevantEvents.has(type)) { 48 | try { 49 | switch (type) { 50 | case 'customer.subscription.updated': 51 | case 'customer.subscription.deleted': 52 | const subscription = event.data.object as Stripe.Subscription; 53 | 54 | await saveSubscription( 55 | subscription.id, 56 | subscription.customer.toString(), 57 | false 58 | ); 59 | 60 | break; 61 | case 'checkout.session.completed': 62 | const checkoutSession = event.data.object as Stripe.Checkout.Session 63 | 64 | await saveSubscription( 65 | checkoutSession.subscription.toString(), 66 | checkoutSession.customer.toString(), 67 | true 68 | ) 69 | 70 | break; 71 | default: 72 | throw new Error('Unhandled event.') 73 | } 74 | } catch (err) { 75 | return res.json({ error: 'Webhook handler failed.' }) 76 | } 77 | } 78 | 79 | res.json({ received: true }) 80 | } else { 81 | res.setHeader('Allow', 'POST') 82 | res.status(405).end('Method not allowed') 83 | } 84 | } -------------------------------------------------------------------------------- /src/pages/home.module.scss: -------------------------------------------------------------------------------- 1 | .contentContainer { 2 | max-width: 1120px; 3 | margin: 0 auto; 4 | padding: 0 2rem; 5 | height: calc(100vh - 5rem); 6 | 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | } 11 | 12 | .hero { 13 | max-width: 600px; 14 | 15 | > span { 16 | font-size: 1.5rem; 17 | font-weight: bold; 18 | } 19 | 20 | h1 { 21 | font-size: 4.5rem; 22 | line-height: 4.5rem; 23 | font-weight: 900; 24 | margin-top: 2.5rem; 25 | 26 | span { 27 | color: var(--cyan-500); 28 | } 29 | } 30 | 31 | p { 32 | font-size: 1.5rem; 33 | line-height: 2.25rem; 34 | margin-top: 1.5rem; 35 | 36 | span { 37 | color: var(--cyan-500); 38 | font-weight: bold; 39 | } 40 | } 41 | 42 | button { 43 | margin-top: 2.5rem; 44 | } 45 | } -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps } from 'next'; 2 | import Head from 'next/head'; 3 | 4 | import { SubscribeButton } from '../components/SubscribeButton'; 5 | import { stripe } from '../services/stripe'; 6 | 7 | import styles from './home.module.scss'; 8 | 9 | interface HomeProps { 10 | product: { 11 | priceId: string; 12 | amount: string; 13 | } 14 | } 15 | 16 | export default function Home({ product }: HomeProps) { 17 | return ( 18 | <> 19 | 20 | Home | ig.news 21 | 22 | 23 |
24 |
25 | 👏 Hey, welcome 26 |

News about the React world.

27 |

28 | Get access to all the publications
29 | for {product.amount} month 30 |

31 | 32 |
33 | 34 | Girl coding 35 |
36 | 37 | ) 38 | } 39 | 40 | export const getStaticProps: GetStaticProps = async () => { 41 | const price = await stripe.prices.retrieve('price_1IVhtPEr8Nl1t46KAhq5JOHw') 42 | 43 | const product = { 44 | priceId: price.id, 45 | amount: new Intl.NumberFormat('en-US', { 46 | style: 'currency', 47 | currency: 'USD', 48 | }).format(price.unit_amount / 100), 49 | }; 50 | 51 | return { 52 | props: { 53 | product, 54 | }, 55 | revalidate: 60 * 60 * 24, // 24 hours 56 | } 57 | } -------------------------------------------------------------------------------- /src/pages/posts/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from "next"; 2 | import { getSession } from "next-auth/client"; 3 | import Head from "next/head"; 4 | import { RichText } from "prismic-dom"; 5 | 6 | import { getPrismicClient } from "../../services/prismic"; 7 | 8 | import styles from './post.module.scss'; 9 | 10 | interface PostProps { 11 | post: { 12 | slug: string; 13 | title: string; 14 | content: string; 15 | updatedAt: string; 16 | } 17 | } 18 | 19 | export default function Post({ post }: PostProps) { 20 | return ( 21 | <> 22 | 23 | {post.title} | Ignews 24 | 25 | 26 |
27 |
28 |

{post.title}

29 | 30 |
34 |
35 |
36 | 37 | ); 38 | } 39 | 40 | export const getServerSideProps: GetServerSideProps = async ({ req, params }) => { 41 | const session = await getSession({ req }) 42 | const { slug } = params; 43 | 44 | if (!session?.activeSubscription) { 45 | return { 46 | redirect: { 47 | destination: '/', 48 | permanent: false, 49 | } 50 | } 51 | } 52 | 53 | const prismic = getPrismicClient(req) 54 | 55 | const response = await prismic.getByUID('publication', String(slug), {}) 56 | 57 | const post = { 58 | slug, 59 | title: RichText.asText(response.data.title), 60 | content: RichText.asHtml(response.data.content), 61 | updatedAt: new Date(response.last_publication_date).toLocaleDateString('pt-BR', { 62 | day: '2-digit', 63 | month: 'long', 64 | year: 'numeric' 65 | }) 66 | }; 67 | 68 | return { 69 | props: { 70 | post, 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /src/pages/posts/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps } from 'next'; 2 | import Head from 'next/head'; 3 | import Prismic from '@prismicio/client' 4 | import { RichText } from 'prismic-dom' 5 | import Link from 'next/link'; 6 | 7 | import { getPrismicClient } from '../../services/prismic'; 8 | 9 | import styles from './styles.module.scss'; 10 | 11 | type Post = { 12 | slug: string; 13 | title: string; 14 | excerpt: string; 15 | updatedAt: string; 16 | }; 17 | 18 | interface PostsProps { 19 | posts: Post[] 20 | } 21 | 22 | export default function Posts({ posts }: PostsProps) { 23 | return ( 24 | <> 25 | 26 | Posts | Ignews 27 | 28 | 29 |
30 |
31 | { posts.map(post => ( 32 | 33 | 34 | 35 | {post.title} 36 |

{post.excerpt}

37 |
38 | 39 | )) } 40 |
41 |
42 | 43 | ); 44 | } 45 | 46 | export const getStaticProps: GetStaticProps = async () => { 47 | const prismic = getPrismicClient() 48 | 49 | const response = await prismic.query([ 50 | Prismic.predicates.at('document.type', 'publication') 51 | ], { 52 | fetch: ['publication.title', 'publication.content'], 53 | pageSize: 100, 54 | }) 55 | 56 | const posts = response.results.map(post => { 57 | return { 58 | slug: post.uid, 59 | title: RichText.asText(post.data.title), 60 | excerpt: post.data.content.find(content => content.type === 'paragraph')?.text ?? '', 61 | updatedAt: new Date(post.last_publication_date).toLocaleDateString('pt-BR', { 62 | day: '2-digit', 63 | month: 'long', 64 | year: 'numeric' 65 | }) 66 | }; 67 | }); 68 | 69 | return { 70 | props: { 71 | posts 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /src/pages/posts/post.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1120px; 3 | margin: 0 auto; 4 | padding: 0 2rem; 5 | } 6 | 7 | .post { 8 | max-width: 720px; 9 | margin: 5rem auto 0; 10 | 11 | h1 { 12 | font-size: 3.5rem; 13 | font-weight: 900; 14 | } 15 | 16 | time { 17 | display: block; 18 | font-size: 1rem; 19 | color: var(--gray-300); 20 | margin-top: 1.5rem; 21 | } 22 | 23 | .postContent { 24 | margin-top: 2rem; 25 | line-height: 2rem; 26 | font-size: 1.125rem; 27 | color: var(--gray-100); 28 | 29 | p, ul { 30 | margin: 1.5rem 0; 31 | } 32 | 33 | ul { 34 | padding-left: 1.5rem; 35 | 36 | li { 37 | margin: 0.5rem 0; 38 | } 39 | } 40 | 41 | &.previewContent { 42 | background: linear-gradient(var(--gray-100), transparent); 43 | background-clip: text; 44 | -webkit-text-fill-color: transparent; 45 | } 46 | } 47 | } 48 | 49 | .continueReading { 50 | padding: 2rem; 51 | text-align: center; 52 | background: var(--gray-850); 53 | border-radius: 100px; 54 | font-size: 1.25rem; 55 | font-weight: bold; 56 | margin: 4rem 0 2rem; 57 | 58 | a { 59 | color: var(--yellow-500); 60 | margin-left: 0.5rem; 61 | 62 | &:hover { 63 | text-decoration: underline; 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /src/pages/posts/preview/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticPaths, GetStaticProps } from "next"; 2 | import { useSession } from "next-auth/client"; 3 | import Head from "next/head"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/router"; 6 | import { RichText } from "prismic-dom"; 7 | import { useEffect } from "react"; 8 | 9 | import { getPrismicClient } from "../../../services/prismic"; 10 | 11 | import styles from '../post.module.scss'; 12 | 13 | interface PostPreviewProps { 14 | post: { 15 | slug: string; 16 | title: string; 17 | content: string; 18 | updatedAt: string; 19 | } 20 | } 21 | 22 | export default function PostPreview({ post }: PostPreviewProps) { 23 | const [session] = useSession() 24 | const router = useRouter() 25 | 26 | useEffect(() => { 27 | if (session?.activeSubscription) { 28 | router.push(`/posts/${post.slug}`) 29 | } 30 | }, [session]) 31 | 32 | return ( 33 | <> 34 | 35 | {post.title} | Ignews 36 | 37 | 38 |
39 |
40 |

{post.title}

41 | 42 |
46 | 47 |
48 | Wanna continue reading? 49 | 50 | Subscribe now 🤗 51 | 52 |
53 |
54 |
55 | 56 | ); 57 | } 58 | 59 | export const getStaticPaths: GetStaticPaths = async () => { 60 | return { 61 | paths: [], 62 | fallback: 'blocking' 63 | } 64 | } 65 | 66 | export const getStaticProps: GetStaticProps = async ({ params }) => { 67 | const { slug } = params; 68 | 69 | const prismic = getPrismicClient() 70 | 71 | const response = await prismic.getByUID('publication', String(slug), {}) 72 | 73 | const post = { 74 | slug, 75 | title: RichText.asText(response.data.title), 76 | content: RichText.asHtml(response.data.content.splice(0, 3)), 77 | updatedAt: new Date(response.last_publication_date).toLocaleDateString('pt-BR', { 78 | day: '2-digit', 79 | month: 'long', 80 | year: 'numeric' 81 | }) 82 | }; 83 | 84 | return { 85 | props: { 86 | post, 87 | }, 88 | redirect: 60 * 30, // 30 minutes 89 | } 90 | } -------------------------------------------------------------------------------- /src/pages/posts/styles.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1120px; 3 | margin: 0 auto; 4 | padding: 0 2rem; 5 | } 6 | 7 | .posts { 8 | max-width: 720px; 9 | margin: 5rem auto 0; 10 | 11 | a { 12 | display: block; 13 | 14 | & + a { 15 | margin-top: 2rem; 16 | padding-top: 2rem; 17 | border-top: 1px solid var(--gray-700); 18 | } 19 | 20 | time { 21 | font-size: 1rem; 22 | display: flex; 23 | align-items: center; 24 | color: var(--gray-300); 25 | } 26 | 27 | strong { 28 | display: block; 29 | font-size: 1.5rem; 30 | margin-top: 1rem; 31 | line-height: 2rem; 32 | transition: color 0.2s; 33 | } 34 | 35 | p { 36 | color: var(--gray-300); 37 | margin-top: 0.5rem; 38 | line-height: 1.625rem; 39 | } 40 | 41 | &:hover strong { 42 | color: var(--yellow-500); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const api = axios.create({ 4 | baseURL: '/api' 5 | }) -------------------------------------------------------------------------------- /src/services/fauna.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'faunadb' 2 | 3 | export const fauna = new Client({ 4 | secret: process.env.FAUNADB_KEY 5 | }) -------------------------------------------------------------------------------- /src/services/prismic.ts: -------------------------------------------------------------------------------- 1 | import Prismic from '@prismicio/client' 2 | 3 | export function getPrismicClient(req?: unknown) { 4 | const prismic = Prismic.client( 5 | process.env.PRISMIC_ENDPOINT, 6 | { 7 | req, 8 | accessToken: process.env.PRISMIC_ACCESS_TOKEN 9 | } 10 | ) 11 | 12 | return prismic; 13 | } -------------------------------------------------------------------------------- /src/services/stripe-js.ts: -------------------------------------------------------------------------------- 1 | import { loadStripe } from '@stripe/stripe-js' 2 | 3 | export async function getStripeJs() { 4 | const stripeJs = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY) 5 | 6 | return stripeJs 7 | } -------------------------------------------------------------------------------- /src/services/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | import { version } from '../../package.json' 3 | 4 | export const stripe = new Stripe( 5 | process.env.STRIPE_API_KEY, 6 | { 7 | apiVersion: '2020-08-27', 8 | appInfo: { 9 | name: 'Ignews', 10 | version 11 | }, 12 | } 13 | ) 14 | -------------------------------------------------------------------------------- /src/styles/global.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | :root { 8 | --white: #FFFFFF; 9 | 10 | --gray-100: #e1e1e6; 11 | --gray-300: #a8a8b3; 12 | --gray-700: #323238; 13 | --gray-800: #29292e; 14 | --gray-850: #1f2729; 15 | --gray-900: #121214; 16 | 17 | --cyan-500: #61dafb; 18 | --yellow-500: #eba417; 19 | } 20 | 21 | @media (max-width: 1080px) { 22 | html { 23 | font-size: 93.75%; 24 | } 25 | } 26 | 27 | @media (max-width: 720px) { 28 | html { 29 | font-size: 87.5%; 30 | } 31 | } 32 | 33 | body { 34 | background: var(--gray-900); 35 | color: var(--white); 36 | } 37 | 38 | body, input, textarea, select, button { 39 | font: 400 1rem "Roboto", sans-serif; 40 | } 41 | 42 | button { 43 | cursor: pointer; 44 | } 45 | 46 | a { 47 | color: inherit; 48 | text-decoration: none; 49 | } -------------------------------------------------------------------------------- /src/tests/pages/Home.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { mocked } from 'ts-jest/utils' 3 | 4 | import { stripe } from '../../services/stripe' 5 | import Home, { getStaticProps } from '../../pages'; 6 | 7 | jest.mock('next/router') 8 | jest.mock('next-auth/client', () => { 9 | return { 10 | useSession: () => [null, false] 11 | } 12 | }) 13 | jest.mock('../../services/stripe') 14 | 15 | describe('Home page', () => { 16 | it('renders correctly', () => { 17 | render() 18 | 19 | expect(screen.getByText("for R$10,00 month")).toBeInTheDocument() 20 | }); 21 | 22 | it('loads initial data', async () => { 23 | const retriveStripePricesMocked = mocked(stripe.prices.retrieve) 24 | 25 | retriveStripePricesMocked.mockResolvedValueOnce({ 26 | id: 'fake-price-id', 27 | unit_amount: 1000, 28 | } as any) 29 | 30 | const response = await getStaticProps({}) 31 | 32 | expect(response).toEqual( 33 | expect.objectContaining({ 34 | props: { 35 | product: { 36 | priceId: 'fake-price-id', 37 | amount: '$10.00' 38 | } 39 | } 40 | }) 41 | ) 42 | }); 43 | }) -------------------------------------------------------------------------------- /src/tests/pages/Post.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { getSession } from 'next-auth/client'; 3 | import { mocked } from 'ts-jest/utils' 4 | import Post, { getServerSideProps } from '../../pages/posts/[slug]'; 5 | import { getPrismicClient } from '../../services/prismic' 6 | 7 | const post = { 8 | slug: 'my-new-post', 9 | title: 'My New Post', 10 | content: '

Post excerpt

', 11 | updatedAt: '10 de Abril' 12 | }; 13 | 14 | jest.mock('next-auth/client'); 15 | jest.mock('../../services/prismic') 16 | 17 | describe('Post page', () => { 18 | it('renders correctly', () => { 19 | render() 20 | 21 | expect(screen.getByText("My New Post")).toBeInTheDocument() 22 | expect(screen.getByText("Post excerpt")).toBeInTheDocument() 23 | }); 24 | 25 | it('redirects user if no subscription is found', async () => { 26 | const getSessionMocked = mocked(getSession) 27 | 28 | getSessionMocked.mockResolvedValueOnce(null) 29 | 30 | const response = await getServerSideProps({ 31 | params: { slug: 'my-new-post'} 32 | } as any) 33 | 34 | expect(response).toEqual( 35 | expect.objectContaining({ 36 | redirect: expect.objectContaining({ 37 | destination: '/', 38 | }) 39 | }) 40 | ) 41 | }); 42 | 43 | it('loads initial data', async () => { 44 | const getSessionMocked = mocked(getSession) 45 | const getPrismicClientMocked = mocked(getPrismicClient) 46 | 47 | getPrismicClientMocked.mockReturnValueOnce({ 48 | getByUID: jest.fn().mockResolvedValueOnce({ 49 | data: { 50 | title: [ 51 | { type: 'heading', text: 'My new post' } 52 | ], 53 | content: [ 54 | { type: 'paragraph', text: 'Post content' } 55 | ], 56 | }, 57 | last_publication_date: '04-01-2021' 58 | }) 59 | } as any) 60 | 61 | getSessionMocked.mockResolvedValueOnce({ 62 | activeSubscription: 'fake-active-subscription' 63 | } as any); 64 | 65 | const response = await getServerSideProps({ 66 | params: { slug: 'my-new-post'} 67 | } as any) 68 | 69 | expect(response).toEqual( 70 | expect.objectContaining({ 71 | props: { 72 | post: { 73 | slug: 'my-new-post', 74 | title: 'My new post', 75 | content: '

Post content

', 76 | updatedAt: '01 de abril de 2021' 77 | } 78 | } 79 | }) 80 | ) 81 | }) 82 | }) -------------------------------------------------------------------------------- /src/tests/pages/PostPreview.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { useSession } from 'next-auth/client'; 3 | import { useRouter } from 'next/router'; 4 | import { mocked } from 'ts-jest/utils' 5 | import Post, { getStaticProps } from '../../pages/posts/preview/[slug]'; 6 | import { getPrismicClient } from '../../services/prismic' 7 | 8 | const post = { 9 | slug: 'my-new-post', 10 | title: 'My New Post', 11 | content: '

Post excerpt

', 12 | updatedAt: '10 de Abril' 13 | }; 14 | 15 | jest.mock('next-auth/client'); 16 | jest.mock('next/router'); 17 | jest.mock('../../services/prismic') 18 | 19 | describe('Post preview page', () => { 20 | it('renders correctly', () => { 21 | const useSessionMocked = mocked(useSession) 22 | 23 | useSessionMocked.mockReturnValueOnce([null, false]) 24 | 25 | render() 26 | 27 | expect(screen.getByText("My New Post")).toBeInTheDocument() 28 | expect(screen.getByText("Post excerpt")).toBeInTheDocument() 29 | expect(screen.getByText("Wanna continue reading?")).toBeInTheDocument() 30 | }); 31 | 32 | it('redirects user to full post when user is subscribed', async () => { 33 | const useSessionMocked = mocked(useSession) 34 | const useRouterMocked = mocked(useRouter) 35 | const pushMock = jest.fn() 36 | 37 | useSessionMocked.mockReturnValueOnce([ 38 | { activeSubscription: 'fake-active-subscription' }, 39 | false 40 | ] as any) 41 | 42 | useRouterMocked.mockReturnValueOnce({ 43 | push: pushMock, 44 | } as any) 45 | 46 | render() 47 | 48 | expect(pushMock).toHaveBeenCalledWith('/posts/my-new-post') 49 | }); 50 | 51 | it('loads initial data', async () => { 52 | const getPrismicClientMocked = mocked(getPrismicClient) 53 | 54 | getPrismicClientMocked.mockReturnValueOnce({ 55 | getByUID: jest.fn().mockResolvedValueOnce({ 56 | data: { 57 | title: [ 58 | { type: 'heading', text: 'My new post' } 59 | ], 60 | content: [ 61 | { type: 'paragraph', text: 'Post content' } 62 | ], 63 | }, 64 | last_publication_date: '04-01-2021' 65 | }) 66 | } as any) 67 | 68 | const response = await getStaticProps({ params: { slug: 'my-new-post'} }) 69 | 70 | expect(response).toEqual( 71 | expect.objectContaining({ 72 | props: { 73 | post: { 74 | slug: 'my-new-post', 75 | title: 'My new post', 76 | content: '

Post content

', 77 | updatedAt: '01 de abril de 2021' 78 | } 79 | } 80 | }) 81 | ) 82 | }) 83 | }) -------------------------------------------------------------------------------- /src/tests/pages/Posts.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { mocked } from 'ts-jest/utils' 3 | import Posts, { getStaticProps } from '../../pages/posts'; 4 | import { getPrismicClient } from '../../services/prismic' 5 | 6 | const posts = [ 7 | { slug: 'my-new-post', title: 'My New Post', excerpt: 'Post excerpt', updatedAt: '10 de Abril' } 8 | ]; 9 | 10 | jest.mock('../../services/prismic') 11 | 12 | describe('Posts page', () => { 13 | it('renders correctly', () => { 14 | render() 15 | 16 | expect(screen.getByText("My New Post")).toBeInTheDocument() 17 | }); 18 | 19 | it('loads initial data', async () => { 20 | const getPrismicClientMocked = mocked(getPrismicClient) 21 | 22 | getPrismicClientMocked.mockReturnValueOnce({ 23 | query: jest.fn().mockResolvedValueOnce({ 24 | results: [ 25 | { 26 | uid: 'my-new-post', 27 | data: { 28 | title: [ 29 | { type: 'heading', text: 'My new post' } 30 | ], 31 | content: [ 32 | { type: 'paragraph', text: 'Post excerpt' } 33 | ], 34 | }, 35 | last_publication_date: '04-01-2021', 36 | } 37 | ] 38 | }) 39 | } as any) 40 | 41 | const response = await getStaticProps({}) 42 | 43 | expect(response).toEqual( 44 | expect.objectContaining({ 45 | props: { 46 | posts: [{ 47 | slug: 'my-new-post', 48 | title: 'My new post', 49 | excerpt: 'Post excerpt', 50 | updatedAt: '01 de abril de 2021' 51 | }] 52 | } 53 | }) 54 | ) 55 | }); 56 | }) -------------------------------------------------------------------------------- /src/tests/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------