├── .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 |
3 |
4 |
5 |
6 |
7 |
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 |
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 |
96 |
--------------------------------------------------------------------------------
/public/images/logo.svg:
--------------------------------------------------------------------------------
1 |
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 |

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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------