7 | }
8 |
9 | export namespace LoadCharacterById {
10 | export interface Model {
11 | id: string
12 | cover: string
13 | name: string
14 | description: string
15 | comicsCount: number
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/presentation/components/header/components/menu-link/styles.ts:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components'
2 |
3 | export const Container = styled.a<{ active?: boolean }>`
4 | display: flex;
5 | align-items: center;
6 | cursor: pointer;
7 | opacity: 0.6;
8 |
9 | ${props => props.active && css`
10 | opacity: 1;
11 | `}
12 |
13 | :hover {
14 | opacity: 1;
15 | }
16 |
17 | > svg {
18 | margin-right: var(--space-xs);
19 | }
20 | `
21 |
--------------------------------------------------------------------------------
/src/presentation/utils/withSSGErrorHandler.tsx:
--------------------------------------------------------------------------------
1 | import { GetStaticProps, GetStaticPropsContext, GetStaticPropsResult } from 'next'
2 |
3 | export function withSSGErrorHandler (fn: GetStaticProps
) {
4 | return async (ctx: GetStaticPropsContext): Promise> => {
5 | try {
6 | return await fn(ctx)
7 | } catch (err) {
8 | console.error(err)
9 | return {
10 | notFound: true
11 | }
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/presentation/layouts/app-layouts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Footer } from '../../components/footer'
2 | import { Header } from '../../components/header'
3 | import { PageWrapper } from './styles'
4 |
5 | interface AppLayoutProps {
6 | children: React.ReactNode
7 | }
8 |
9 | export function AppLayout ({ children }: AppLayoutProps): React.ReactElement {
10 | return (
11 | <>
12 |
13 |
14 | {children}
15 |
16 |
17 | >
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/domain/usecases/load-comic-by-id.ts:
--------------------------------------------------------------------------------
1 | export interface LoadComicByIdDTO {
2 | id: string
3 | }
4 |
5 | export interface LoadComicById {
6 | execute: (params?: LoadComicByIdDTO) => Promise
7 | }
8 |
9 | export namespace LoadComicById {
10 | export interface Model {
11 | id: number
12 | title: string
13 | cover: string
14 | publishedAt: string
15 | writer: string
16 | penciler: string
17 | coverArtist: string
18 | description: string
19 | comics: this[]
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/domain/usecases/load-characters.ts:
--------------------------------------------------------------------------------
1 | export interface LoadCharactersListDTO {
2 | titleStartsWith?: number
3 | limit?: number
4 | offset?: number
5 | orderBy?: string
6 | }
7 |
8 | export interface LoadCharactersList {
9 | execute: (params?: LoadCharactersListDTO) => Promise
10 | }
11 |
12 | export namespace LoadCharactersList {
13 | export interface Model {
14 | id: string
15 | cover: string
16 | name: string
17 | description: string
18 | comicsCount: number
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "standard-with-typescript",
3 | "env": {
4 | "node": true,
5 | "es6": true,
6 | "jest": true
7 | },
8 | "parserOptions": {
9 | "project": "./tsconfig.json"
10 | },
11 | "rules": {
12 | "@typescript-eslint/consistent-type-assertions": "off",
13 | "@typescript-eslint/strict-boolean-expressions": "off",
14 | "@typescript-eslint/restrict-template-expressions": "off",
15 | "@typescript-eslint/restrict-plus-operands": "off",
16 | "@typescript-eslint/no-namespace": "off"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/presentation/components/footer/index.tsx:
--------------------------------------------------------------------------------
1 | import { FiGithub } from 'react-icons/fi'
2 | import { Wrapper } from '../wrapper'
3 | import { Container } from './styles'
4 |
5 | export function Footer (): React.ReactElement {
6 | return (
7 |
8 |
9 | By @felipebarcelospro
10 |
11 |
12 |
13 | Open on Github
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/.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 |
36 | # tests
37 | coverage
38 |
39 | # env
40 | .env
41 |
--------------------------------------------------------------------------------
/src/domain/usecases/load-comics.ts:
--------------------------------------------------------------------------------
1 | export interface LoadComicsListDTO {
2 | titleStartsWith?: number
3 | limit?: number
4 | offset?: number
5 | orderBy?: string
6 | dateRange?: string
7 | characters?: string
8 | }
9 |
10 | export interface LoadComicsList {
11 | execute: (params?: LoadComicsListDTO) => Promise
12 | }
13 |
14 | export namespace LoadComicsList {
15 | export interface Model {
16 | id: number
17 | title: string
18 | cover: string
19 | publishedAt: string
20 | writer: string
21 | penciler: string
22 | coverArtist: string
23 | description: string
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/presentation/components/share/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | align-items: center;
6 |
7 | @media (max-width: 800px) {
8 | flex-direction: column;
9 |
10 | > button {
11 | width: 100%;
12 | margin: 0!important;
13 | margin-bottom: var(--space-sm)!important;
14 | }
15 | }
16 |
17 | h1 {
18 | font-size: var(--size-xs);
19 | text-transform: uppercase;
20 | opacity: 0.4;
21 | margin-right: var(--space-xs);
22 | }
23 |
24 | > button:not(:last-child) {
25 | margin-right: var(--space-sm);
26 | }
27 | `
28 |
--------------------------------------------------------------------------------
/src/data/protocols/http/http-client.ts:
--------------------------------------------------------------------------------
1 | export interface HttpRequest {
2 | url: string
3 | method: HttpMethod
4 | body?: any
5 | headers?: any
6 | params?: any
7 | }
8 |
9 | export interface HttpClient {
10 | request: (data: HttpRequest) => Promise>
11 | }
12 |
13 | export type HttpMethod = 'post' | 'get' | 'put' | 'delete'
14 |
15 | export enum HttpStatusCode {
16 | ok = 200,
17 | noContent = 204,
18 | badRequest = 400,
19 | unauthorized = 401,
20 | forbidden = 403,
21 | notFound = 404,
22 | serverError = 500
23 | }
24 |
25 | export interface HttpResponse {
26 | statusCode: HttpStatusCode
27 | body?: T
28 | }
29 |
--------------------------------------------------------------------------------
/src/presentation/components/character-list/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.section`
4 | padding: var(--space-xl) 0;
5 |
6 | > h1 {
7 | font-size: var(--size-xs);
8 | text-transform: uppercase;
9 | opacity: 0.4;
10 | margin-bottom: var(--space-sm);
11 | }
12 | `
13 |
14 | export const Grid = styled.section`
15 | display: grid;
16 | grid-template-columns: repeat(5, 1fr);
17 | grid-gap: var(--space-sm);
18 |
19 | @media (max-width: 1200px) {
20 | grid-template-columns: repeat(4, 1fr);
21 | }
22 |
23 | @media (max-width: 800px) {
24 | padding-bottom: var(--space-md);
25 | }
26 | `
27 |
--------------------------------------------------------------------------------
/src/presentation/components/comic-list/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.section`
4 | padding: var(--space-xl) 0;
5 |
6 | > h1 {
7 | font-size: var(--size-xs);
8 | text-transform: uppercase;
9 | opacity: 0.4;
10 | margin-bottom: var(--space-sm);
11 | }
12 | `
13 |
14 | export const Grid = styled.section`
15 | display: grid;
16 | grid-template-columns: repeat(5, 1fr);
17 | grid-gap: var(--space-sm);
18 |
19 | @media (max-width: 1200px) {
20 | grid-template-columns: repeat(4, 1fr);
21 | }
22 |
23 | @media (max-width: 800px) {
24 | padding-bottom: var(--space-md);
25 | }
26 | `
27 |
--------------------------------------------------------------------------------
/src/presentation/components/header/components/avatar/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { Container } from './styles'
4 |
5 | interface Name {
6 | firstName: string
7 | lastName: string
8 | }
9 |
10 | interface AvatarProps {
11 | name: Name
12 | }
13 |
14 | export function Avatar ({ name }: AvatarProps): React.ReactElement {
15 | const [initials, setInitials] = React.useState('')
16 |
17 | React.useEffect(() => {
18 | const { firstName, lastName } = name
19 | const initials = `${firstName[0]}${lastName[0]}`
20 | setInitials(initials)
21 | }, [name])
22 |
23 | return (
24 |
25 | {initials}
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/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 | "incremental": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve"
21 | },
22 | "include": [
23 | "next-env.d.ts",
24 | "**/*.ts",
25 | "**/*.tsx"
26 | , "pages/_document.js" ],
27 | "exclude": [
28 | "node_modules"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/src/presentation/components/header/components/menu-link/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import NextLink from 'next/link'
3 |
4 | import { Container } from './styles'
5 | import { useRouter } from 'next/router'
6 |
7 | interface MenuLinkProps {
8 | children: React.ReactNode
9 | icon: React.ReactNode
10 | path: string
11 | exact?: boolean
12 | }
13 |
14 | export function MenuLink ({ icon, children, path, exact }: MenuLinkProps): React.ReactElement {
15 | const router = useRouter()
16 |
17 | return (
18 |
19 |
20 | {icon}
21 | {children}
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/presentation/components/banner/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import NextImage from 'next/image'
3 | import NextLink from 'next/link'
4 |
5 | import { Container, Overlay } from './styles'
6 |
7 | interface BannerProps {
8 | src: string
9 | title: string
10 | createdAt: string
11 | href: string
12 | }
13 |
14 | export function Banner ({ src, title, createdAt, href }: BannerProps): React.ReactElement {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | {title}
22 | Released at {createdAt}
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/presentation/components/header/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.header`
4 | background: var(--color-bg-accent);
5 | border-bottom: 1px solid var(--color-border);
6 |
7 | > div {
8 | display: flex;
9 | justify-content: space-between;
10 | align-items: center;
11 |
12 | min-height: 90px;
13 |
14 | .header-logo {
15 | opacity: 1;
16 | transition: opacity 0.3s ease-in-out;
17 | cursor: pointer;
18 |
19 | :hover {
20 | opacity: 0.6;
21 | }
22 | }
23 | }
24 | `
25 |
26 | export const Menu = styled.nav`
27 | display: flex;
28 | align-items: center;
29 |
30 | > a:not(:last-child) {
31 | margin-right: var(--space-md);
32 | }
33 | `
34 |
--------------------------------------------------------------------------------
/src/presentation/components/comic-list/components/comic-list-item/index.tsx:
--------------------------------------------------------------------------------
1 | import NextImage from 'next/image'
2 | import NextLink from 'next/link'
3 |
4 | import { ComicModel } from '../../../../../domain/models/comic'
5 | import { Container } from './styles'
6 |
7 | interface ComicListItemProps {
8 | data: ComicModel
9 | }
10 |
11 | export function ComicListItem ({ data }: ComicListItemProps): React.ReactElement {
12 | const { id, cover, title, writer } = data
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
{title}
21 | By {writer}
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/presentation/components/header/index.tsx:
--------------------------------------------------------------------------------
1 | import NextImage from 'next/image'
2 | import NextLink from 'next/link'
3 |
4 | import { Wrapper } from '../wrapper'
5 | import { MenuLink } from './components/menu-link'
6 | import { Container, Menu } from './styles'
7 | import { FiHome } from 'react-icons/fi'
8 |
9 | export function Header (): React.ReactElement {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/presentation/components/search-input/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | align-items: center;
6 | height: var(--size-lg);
7 | border: 2px solid var(--color-border);
8 | border-radius: var(--radius-base);
9 | background: #222534;
10 | padding: 0 var(--space-xs);
11 |
12 | svg {
13 | opacity: 0.4;
14 | margin-right: var(--space-xs);
15 | }
16 |
17 | input {
18 | width: 100%;
19 | height: 100%;
20 | background: transparent;
21 | font-size: var(--font-size-lg);
22 | color: white;
23 | border: 0;
24 |
25 | :focus {
26 | outline: 0;
27 | box-shadow: none;
28 | }
29 |
30 | ::placeholder {
31 | color: white;
32 | opacity: 0.4;
33 | }
34 | }
35 | `
36 |
--------------------------------------------------------------------------------
/src/presentation/components/character-list/components/character-list-item/index.tsx:
--------------------------------------------------------------------------------
1 | import NextLink from 'next/link'
2 | import { CharacterModel } from '../../../../../domain/models/character'
3 |
4 | import { Container } from './styles'
5 |
6 | interface CharacterListItemProps {
7 | data: CharacterModel
8 | }
9 |
10 | export function CharacterListItem ({ data }: CharacterListItemProps): React.ReactElement {
11 | const { id, cover, name, comicsCount } = data
12 |
13 | return (
14 |
15 |
16 |
17 |

18 |
19 |
20 |
{name}
21 | {comicsCount} comics
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | 
3 |
4 | A hub of all Marvel comics featuring your favorite heroes created with NextJS.
5 | [marvel-hub.vercel.app](https://marvel-hub.vercel.app)
6 |
7 | 
8 |
9 | ## Stack
10 |
11 | **Front-end:** NextJs, Jest, Styled Components
12 |
13 |
14 | ## Run in Locally
15 |
16 | Install *Marvel Hub* with yarn
17 |
18 | ```bash
19 | git clone https://github.com/felipebarcelospro/marvel-hub.git
20 | cd marvel-hub
21 | yarn && yarn dev
22 | ```
23 |
24 | ## Run Tests
25 |
26 | To run the tests, run the following command
27 |
28 | ```bash
29 | yarn test
30 | ```
31 |
32 |
33 | ## Support
34 |
35 | For support, send an email to felipebarcelospro@gmail.com.
36 |
37 | ## Licença
38 |
39 | [MIT](https://choosealicense.com/licenses/mit/)
40 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Marvel Hub",
3 | "icons": [
4 | {
5 | "src": "\/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image\/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "\/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image\/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "\/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image\/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "\/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image\/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "\/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image\/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "\/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image\/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/src/presentation/components/comic-list/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { ComicModel } from '../../../domain/models/comic'
4 | import { ComicListItem } from './components/comic-list-item'
5 | import { Container, Grid } from './styles'
6 |
7 | interface ComicListProps {
8 | title: string
9 | data: ComicModel[]
10 | }
11 |
12 | export function ComicList ({ title, data }: ComicListProps): React.ReactElement {
13 | if (!data || data.length === 0) return <>>
14 |
15 | return (
16 |
17 | {title}
18 |
19 | {data.length > 0 && (
20 |
21 | {data.map((data, index) => (
22 |
23 | ))}
24 |
25 | )}
26 |
27 | {data.length === 0 && (
28 | No comics found
29 | )}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/presentation/components/character-list/index.tsx:
--------------------------------------------------------------------------------
1 | import { CharacterModel } from '../../../domain/models/character'
2 | import { CharacterListItem } from './components/character-list-item'
3 | import { Container, Grid } from './styles'
4 |
5 | interface CharacterListProps {
6 | title: string
7 | data: CharacterModel[]
8 | }
9 |
10 | export function CharacterList ({ title, data }: CharacterListProps): React.ReactElement {
11 | if (!data || data.length === 0) return <>>
12 |
13 | return (
14 |
15 | {title}
16 |
17 | {data.length > 0 && (
18 |
19 | {data.map((character, index) => (
20 |
21 | ))}
22 |
23 | )}
24 |
25 | {data.length === 0 && (
26 | No comics found
27 | )}
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/main/factories/http/mock-http-client.ts:
--------------------------------------------------------------------------------
1 | import faker from 'faker'
2 |
3 | import { HttpRequest, HttpClient, HttpResponse, HttpStatusCode } from '../../../data/protocols/http/http-client'
4 |
5 | export const mockHttpRequest = (): HttpRequest => ({
6 | url: faker.internet.url(),
7 | method: faker.random.arrayElement(['get', 'post', 'put', 'delete']),
8 | body: faker.random.objectElement(),
9 | headers: faker.random.objectElement()
10 | })
11 |
12 | export class HttpClientSpy implements HttpClient {
13 | url?: string
14 | method?: string
15 | body?: any
16 | headers?: any
17 | params?: any
18 | response: HttpResponse = {
19 | statusCode: HttpStatusCode.ok
20 | }
21 |
22 | async request (data: HttpRequest): Promise> {
23 | this.url = data.url
24 | this.method = data.method
25 | this.body = data.body
26 | this.headers = data.headers
27 | this.params = data.params
28 |
29 | return this.response
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/presentation/components/banner/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.section`
4 | margin-top: var(--space-xl);
5 | margin-bottom: var(--space-xl);
6 |
7 | position: relative;
8 | height: 24rem;
9 | background: var(--color-bg-accent);
10 | border: 3px solid var(--color-border);
11 | border-radius: var(--radius-base);
12 | overflow: hidden;
13 | box-shadow: var(--shadow);
14 | transition: all 0.3s ease-in-out;
15 | cursor: pointer;
16 |
17 | @media (max-width: 800px) {
18 | height: 20rem;
19 | }
20 |
21 | @media (min-width: 800px) {
22 | :hover {
23 | border-color: var(--color-white);
24 | box-shadow: rgb(0 0 0 / 80%) 0px 40px 58px -16px, rgb(0 0 0 / 72%) 0px 30px 22px -10px;
25 | }
26 | }
27 |
28 | > span {
29 | position: absolute;
30 | }
31 | `
32 |
33 | export const Overlay = styled.div`
34 | position: absolute;
35 | padding-left: var(--space-xl);
36 | padding-bottom: var(--space-lg);
37 | bottom: 0;
38 |
39 | h1, h2 {
40 | font-size: var(--size-xs);
41 | }
42 |
43 | h1 {
44 | line-height: 120%;
45 | }
46 |
47 | h2 {
48 | opacity: 0.6;
49 | font-weight: 400;
50 | }
51 | `
52 |
--------------------------------------------------------------------------------
/src/presentation/components/comic-list/components/comic-list-item/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.article`
4 | background: linear-gradient(rgb(48, 50, 62), rgb(30, 31, 42));
5 | border: 3px solid #3a3c48;
6 | border-radius: var(--radius-base);
7 | box-shadow: var(--shadow);
8 | cursor: pointer;
9 | transition: all 0.3s ease-in-out;
10 | overflow: hidden;
11 |
12 | @media (max-width: 800px) {
13 | min-width: 22rem;
14 | }
15 |
16 | @media (min-width: 800px) {
17 | :hover {
18 | border-color: var(--color-white);
19 | box-shadow: rgb(0 0 0 / 80%) 0px 40px 58px -16px, rgb(0 0 0 / 72%) 0px 30px 22px -10px;
20 | transform: scale(1.03);
21 | }
22 | }
23 |
24 | img {
25 | width: 100%;
26 | }
27 |
28 | .info {
29 | border-top: 1px solid #3a3c48;
30 | padding: 1rem;
31 | margin-top: -6px;
32 |
33 | h1, h2 {
34 | font-size: var(--font-size-md);
35 | line-height: 120%;
36 | }
37 |
38 | h1 {
39 | margin-bottom: var(--space-xs);
40 | }
41 |
42 | h2 {
43 | opacity: 0.6;
44 |
45 | font-weight: 400;
46 | font-size: var(--font-size-sm);
47 | }
48 | }
49 | `
50 |
--------------------------------------------------------------------------------
/src/presentation/pages/error/index.tsx:
--------------------------------------------------------------------------------
1 | import NextImage from 'next/image'
2 | import NextLink from 'next/link'
3 |
4 | import NextHead from 'next/head'
5 |
6 | import { Wrapper } from '../../components/wrapper'
7 | import { AppLayout } from '../../layouts/app-layouts'
8 | import { Container } from './styles'
9 |
10 | export function ErrorPage (): React.ReactElement {
11 | return (
12 | <>
13 |
14 | MarvelHub - Error 404
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
404...
25 |
26 |
STATION ANSWERS:
27 |
I think you've reached the edge of the universe. The page you requested was not found.
28 |
29 |
30 | Back to the front page
31 |
32 |
33 |
34 |
35 |
36 | >
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/presentation/components/share/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { FiFacebook, FiTwitter } from 'react-icons/fi'
3 | import { Container } from './styles'
4 |
5 | export function Share (): React.ReactElement {
6 | const [actualPath, setActualPath] = useState('')
7 |
8 | const shareOnFacebook = (): void => {
9 | const url = `https://www.facebook.com/sharer/sharer.php?u=${actualPath}`
10 | const encodedUrl = encodeURIComponent(window.location.href)
11 | window.open(`${url}${encodedUrl}`, '_blank')
12 | }
13 |
14 | const shareOnTwitter = (): void => {
15 | const url = `https://twitter.com/intent/tweet?url=${actualPath}`
16 | const encodedUrl = encodeURIComponent(window.location.href)
17 | window.open(`${url}${encodedUrl}`, '_blank')
18 | }
19 |
20 | useEffect(() => {
21 | setActualPath(window.location.href)
22 | }, [])
23 |
24 | return (
25 |
26 |
30 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-characters.ts:
--------------------------------------------------------------------------------
1 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
2 | import { LoadCharactersList, LoadCharactersListDTO } from '../../domain/usecases/load-characters'
3 | import { HttpClient, HttpStatusCode } from '../protocols/http/http-client'
4 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
5 |
6 | export class RemoteLoadCharactersList implements LoadCharactersList {
7 | constructor (
8 | private readonly httpClient: HttpClient
9 | ) {}
10 |
11 | async execute (params?: LoadCharactersListDTO): Promise {
12 | const httpResponse = await this.httpClient.request({
13 | url: '/characters',
14 | method: 'get',
15 | params: params
16 | })
17 |
18 | if (httpResponse.statusCode !== HttpStatusCode.ok) {
19 | throw new UnexpectedError(httpResponse.statusCode)
20 | }
21 |
22 | return httpResponse.body.data.results.map((character: any) => {
23 | return {
24 | id: character.id,
25 | cover: `${character.thumbnail.path}.${character.thumbnail.extension}`,
26 | name: character.name,
27 | description: character.description,
28 | comicsCount: character.comics.available
29 | }
30 | })
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/pages/comics/[id].tsx:
--------------------------------------------------------------------------------
1 | import { GetStaticPaths, GetStaticProps } from 'next'
2 | import { SingleComicPage } from '../../src/presentation/pages/comics'
3 | import { makeRemoteLoadComicById } from '../../src/main/factories/usecases/remote-load-comic-by-id'
4 | import { makeRemoteLoadComicsList } from '../../src/main/factories/usecases/remote-load-comics'
5 | import { withSSGErrorHandler } from '../../src/presentation/utils/withSSGErrorHandler'
6 |
7 | export default function SingleComic (props): React.ReactElement {
8 | return
9 | }
10 |
11 | export const getStaticPaths: GetStaticPaths = async () => {
12 | return {
13 | paths: [],
14 | fallback: 'blocking'
15 | }
16 | }
17 |
18 | export const getStaticProps: GetStaticProps = withSSGErrorHandler(async ctx => {
19 | const comicId = String(ctx.params.id)
20 |
21 | const remoteLoadComicById = makeRemoteLoadComicById()
22 | const comic = await remoteLoadComicById.execute({
23 | id: comicId
24 | })
25 |
26 | const remoteLoadComics = makeRemoteLoadComicsList()
27 | const readMore = await remoteLoadComics.execute({
28 | limit: 5,
29 | offset: Math.floor(Math.random() * 1000)
30 | })
31 |
32 | return {
33 | props: {
34 | comic,
35 | readMore: readMore
36 | },
37 | revalidate: 60 * 60 * 24 * 30
38 | }
39 | })
40 |
--------------------------------------------------------------------------------
/src/infra/http/marvel-http-client.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosResponse } from 'axios'
2 | import md5 from 'md5'
3 |
4 | import { HttpClient, HttpRequest, HttpResponse } from '../../data/protocols/http/http-client'
5 | import { MarvelHttpResponse } from '../../data/protocols/http/marvel-http-response'
6 |
7 | export class MarvelHttpClient implements HttpClient {
8 | async request (data: HttpRequest): Promise> {
9 | const publicKey = process.env.NEXT_PUBLIC_MARVELAPI_PUBLIC_TOKEN
10 | const privateKey = process.env.NEXT_PUBLIC_MARVELAPI_PRIVATE_TOKEN
11 |
12 | const timestamp = new Date().getTime()
13 | const hash = md5(timestamp + privateKey + publicKey)
14 |
15 | let axiosResponse: AxiosResponse
16 |
17 | try {
18 | axiosResponse = await axios.request({
19 | url: `https://gateway.marvel.com:443/v1/public/${data.url}`,
20 | method: data.method,
21 | data: data.body,
22 | headers: data.headers,
23 | params: {
24 | ts: timestamp,
25 | apikey: publicKey,
26 | hash,
27 | ...data.params
28 | }
29 | })
30 | } catch (error) {
31 | axiosResponse = error.response
32 | }
33 |
34 | return {
35 | statusCode: axiosResponse.status,
36 | body: axiosResponse.data
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pages/characters/[id].tsx:
--------------------------------------------------------------------------------
1 | import { GetStaticPaths, GetStaticProps } from 'next'
2 | import { SingleCharacterPage } from '../../src/presentation/pages/character'
3 | import { makeRemoteLoadCharacterById } from '../../src/main/factories/usecases/remote-load-character-by-id'
4 | import { makeRemoteLoadComicsList } from '../../src/main/factories/usecases/remote-load-comics'
5 | import { withSSGErrorHandler } from '../../src/presentation/utils/withSSGErrorHandler'
6 |
7 | export default function SingleCharacter (props): React.ReactElement {
8 | return
9 | }
10 |
11 | export const getStaticPaths: GetStaticPaths = async () => {
12 | return {
13 | paths: [],
14 | fallback: 'blocking'
15 | }
16 | }
17 |
18 | export const getStaticProps: GetStaticProps = withSSGErrorHandler(async ctx => {
19 | const characterId = String(ctx.params.id)
20 |
21 | const remoteLoadCharacter = makeRemoteLoadCharacterById()
22 | const character = await remoteLoadCharacter.execute({
23 | id: characterId
24 | })
25 |
26 | const remoteLoadComics = makeRemoteLoadComicsList()
27 | const characterComics = await remoteLoadComics.execute({
28 | characters: characterId,
29 | offset: Math.floor(Math.random() * 100)
30 | })
31 |
32 | return {
33 | props: {
34 | character,
35 | characterComics: characterComics
36 | },
37 | revalidate: 60 * 60 * 24 * 30
38 | }
39 | })
40 |
--------------------------------------------------------------------------------
/src/presentation/components/character-list/components/character-list-item/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.article`
4 | display: flex;
5 | align-items: center;
6 | background: linear-gradient(rgb(48, 50, 62), rgb(30, 31, 42));
7 | border: 3px solid #3a3c48;
8 | border-radius: var(--radius-base);
9 | padding: 0.6rem;
10 | box-shadow: var(--shadow);
11 | cursor: pointer;
12 | transition: all 0.3s ease-in-out;
13 |
14 | @media (max-width: 800px) {
15 | min-width: 22rem;
16 | }
17 |
18 | @media (min-width: 800px) {
19 | :hover {
20 | border-color: var(--color-white);
21 | box-shadow: rgb(0 0 0 / 80%) 0px 40px 58px -16px, rgb(0 0 0 / 72%) 0px 30px 22px -10px;
22 | transform: scale(1.03);
23 | }
24 | }
25 |
26 | > span {
27 | opacity: 0.6;
28 | font-weight: bold;
29 | }
30 |
31 | .avatar {
32 | height: 4rem;
33 | width: 4rem;
34 | border-radius: var(--radius-base);
35 | overflow: hidden;
36 |
37 | margin-right: var(--space-sm);
38 |
39 | img {
40 | height: 100%;
41 | width: 100%;
42 | object-fit: cover;
43 | }
44 | }
45 |
46 | .info {
47 | h1, h2 {
48 | font-size: var(--font-size-md);
49 | line-height: 120%;
50 | }
51 |
52 | h2 {
53 | opacity: 0.6;
54 |
55 | font-weight: 400;
56 | font-size: var(--font-size-sm);
57 | }
58 | }
59 | `
60 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/presentation/pages/comics/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.div``
4 |
5 | export const Banner = styled.section`
6 | padding: var(--space-xl) 0;
7 | background: var(--color-bg-accent);
8 | border-bottom: 1px solid var(--color-border);
9 |
10 | > div {
11 | display: grid;
12 | grid-template-columns: 1fr 4fr;
13 | grid-gap: var(--space-lg);
14 |
15 | @media (max-width: 800px) {
16 | grid-template-columns: 1fr;
17 | }
18 |
19 | .thumbnail-container {
20 | .thumbnail {
21 | overflow: hidden;
22 | box-shadow: var(--shadow);
23 | border-radius: var(--radius-base);
24 | height: 30rem;
25 | }
26 | }
27 |
28 | .info-container {
29 | display: flex;
30 | flex-direction: column;
31 | justify-content: center;
32 |
33 | > h1 {
34 | font-size: 1.8rem;
35 | margin-bottom: var(--space-lg);
36 | line-height: 140%;
37 | }
38 |
39 | > hr {
40 | opacity: 0.05;
41 | }
42 |
43 | .item {
44 | margin: var(--space-sm) 0;
45 |
46 | h1, h2 {
47 | font-size: var(--font-size-md);
48 | }
49 |
50 | h1 {
51 | margin-bottom: var(--space-xs);
52 | }
53 |
54 | h2, p {
55 | opacity: 0.6;
56 |
57 | font-weight: 400;
58 | font-size: var(--font-size-md);
59 | }
60 | }
61 | }
62 | }
63 | `
64 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-character-by-id.ts:
--------------------------------------------------------------------------------
1 | import { EntityNotFound } from '../../domain/errors/entity-not-found'
2 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
3 | import { LoadCharacterById, LoadCharacterByIdDTO } from '../../domain/usecases/load-characters-by-id'
4 | import { HttpClient, HttpStatusCode } from '../protocols/http/http-client'
5 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
6 |
7 | export class RemoteLoadCharacterById implements LoadCharacterById {
8 | constructor (
9 | private readonly httpClient: HttpClient>
10 | ) {}
11 |
12 | async execute (params: LoadCharacterByIdDTO): Promise {
13 | const httpResponse = await this.httpClient.request({
14 | url: `/characters/${params.id}`,
15 | method: 'get'
16 | })
17 |
18 | if (httpResponse.statusCode !== HttpStatusCode.ok) {
19 | throw new UnexpectedError(httpResponse.statusCode)
20 | }
21 |
22 | const characterData: any = httpResponse.body.data.results[0]
23 |
24 | if (!characterData) {
25 | throw new EntityNotFound('Character')
26 | }
27 |
28 | return {
29 | id: characterData.id,
30 | cover: `${characterData.thumbnail.path}.${characterData.thumbnail.extension}`,
31 | name: characterData.name,
32 | description: characterData.description,
33 | comicsCount: characterData.comics.available
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/presentation/pages/character/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.div``
4 |
5 | export const Banner = styled.section`
6 | padding: var(--space-xl) 0;
7 | background: var(--color-bg-accent);
8 | border-bottom: 1px solid var(--color-border);
9 |
10 | > div {
11 | display: grid;
12 | grid-template-columns: 1fr 4fr;
13 | grid-gap: var(--space-lg);
14 |
15 | @media (max-width: 800px) {
16 | grid-template-columns: 1fr;
17 | }
18 |
19 | .thumbnail-container {
20 | .thumbnail {
21 | overflow: hidden;
22 | box-shadow: var(--shadow);
23 | border-radius: var(--radius-base);
24 | height: 18rem;
25 | }
26 | }
27 |
28 | .info-container {
29 | display: flex;
30 | flex-direction: column;
31 | justify-content: center;
32 |
33 | > h1 {
34 | font-size: 1.8rem;
35 | margin-bottom: var(--space-lg);
36 | line-height: 140%;
37 | }
38 |
39 | > hr {
40 | opacity: 0.05;
41 | }
42 |
43 | .item {
44 | margin: var(--space-sm) 0;
45 |
46 | h1, h2 {
47 | font-size: var(--font-size-md);
48 | line-height: 120%;
49 | }
50 |
51 | h1 {
52 | margin-bottom: var(--space-xs);
53 | }
54 |
55 | h2, p {
56 | opacity: 0.6;
57 |
58 | font-weight: 400;
59 | font-size: var(--font-size-md);
60 | }
61 | }
62 | }
63 | }
64 | `
65 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-comics.ts:
--------------------------------------------------------------------------------
1 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
2 | import { LoadComicsList, LoadComicsListDTO } from '../../domain/usecases/load-comics'
3 | import { HttpClient, HttpStatusCode } from '../protocols/http/http-client'
4 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
5 |
6 | export class RemoteLoadComicsList implements LoadComicsList {
7 | constructor (
8 | private readonly httpClient: HttpClient
9 | ) {}
10 |
11 | async execute (params?: LoadComicsListDTO): Promise {
12 | const httpResponse = await this.httpClient.request({
13 | url: '/comics',
14 | method: 'get',
15 | params: params
16 | })
17 |
18 | if (httpResponse.statusCode !== HttpStatusCode.ok) {
19 | throw new UnexpectedError(httpResponse.statusCode)
20 | }
21 |
22 | return httpResponse.body.data.results.map((comic: any) => {
23 | return {
24 | id: comic.id,
25 | title: comic.title,
26 | cover: comic.thumbnail.path + '.' + comic.thumbnail.extension,
27 | publishedAt: comic.dates[0].date,
28 | writer: comic.creators.items.find(creator => creator.role === 'writer')?.name ?? 'Unknown',
29 | penciler: comic.creators.items.find(creator => creator.role === 'penciler')?.name ?? 'Unknown',
30 | coverArtist: comic.creators.items.find(creator => creator.role === 'penciller (cover)')?.name ?? 'Unknown',
31 | description: comic.description
32 | }
33 | })
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/presentation/pages/main/index.tsx:
--------------------------------------------------------------------------------
1 | import NextHead from 'next/head'
2 |
3 | import { Banner } from '../../components/banner'
4 | import { CharacterList } from '../../components/character-list'
5 | import { ComicList } from '../../components/comic-list'
6 | import { Wrapper } from '../../components/wrapper'
7 | import { AppLayout } from '../../layouts/app-layouts'
8 | import { ComicModel } from '../../../domain/models/comic'
9 | import { CharacterModel } from '../../../domain/models/character'
10 |
11 | interface MainPageProps {
12 | characters: CharacterModel[]
13 | comics: ComicModel[]
14 | spiderManComics: ComicModel[]
15 | ironManComics: ComicModel[]
16 | featuredComics: ComicModel[]
17 | }
18 |
19 | export function MainPage ({ characters, comics, spiderManComics, ironManComics, featuredComics }: MainPageProps): React.ReactElement {
20 | return (
21 | <>
22 |
23 | Marvel Hub
24 |
25 |
26 |
27 |
28 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | >
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { GetStaticProps } from 'next'
2 | import { MainPage } from '../src/presentation/pages/main'
3 | import { makeRemoteLoadComicsList } from '../src/main/factories/usecases/remote-load-comics'
4 | import { makeRemoteLoadCharactersList } from '../src/main/factories/usecases/remote-load-characters'
5 |
6 | export default function Main (props): React.ReactElement {
7 | return
8 | }
9 |
10 | export const getStaticProps: GetStaticProps = async ctx => {
11 | const remoteLoadComics = makeRemoteLoadComicsList()
12 | const remoteLoadCharacters = makeRemoteLoadCharactersList()
13 |
14 | const characters = await remoteLoadCharacters.execute({
15 | limit: 10,
16 | offset: Math.floor(Math.random() * 100)
17 | })
18 |
19 | const comics = await remoteLoadComics.execute({
20 | limit: 5,
21 | offset: Math.floor(Math.random() * 100)
22 | })
23 |
24 | const featuredComics = await remoteLoadComics.execute({
25 | limit: 5,
26 | dateRange: '2021-01-01,2021-12-31',
27 | offset: Math.floor(Math.random() * 100)
28 | })
29 |
30 | const spiderManComics = await remoteLoadComics.execute({
31 | characters: '1014858',
32 | limit: 5,
33 | offset: Math.floor(Math.random() * 100)
34 | })
35 |
36 | const ironManComics = await remoteLoadComics.execute({
37 | characters: '1009368',
38 | limit: 5,
39 | offset: Math.floor(Math.random() * 100)
40 | })
41 |
42 | return {
43 | props: {
44 | characters: characters,
45 | comics: comics,
46 | featuredComics: featuredComics,
47 | spiderManComics: spiderManComics,
48 | ironManComics: ironManComics
49 | },
50 | revalidate: 60 * 60 * 24 * 30
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "marvel-hub",
3 | "private": true,
4 | "scripts": {
5 | "dev": "next dev",
6 | "build": "next build",
7 | "start": "next start",
8 | "lint": "eslint .",
9 | "lint:fix": "npm run lint -- --fix",
10 | "test": "jest --passWithNoTests --no-cache --runInBand",
11 | "test:watch": "npm test -- --watch",
12 | "test:staged": "npm test -- --findRelatedTests"
13 | },
14 | "dependencies": {
15 | "axios": "^0.24.0",
16 | "md5": "^2.3.0",
17 | "next": "12.0.7",
18 | "react": "17.0.2",
19 | "react-dom": "17.0.2",
20 | "react-icons": "^4.3.1",
21 | "styled-components": "^5.3.3"
22 | },
23 | "devDependencies": {
24 | "@types/faker": "^5.1.2",
25 | "@types/jest": "^27.4.0",
26 | "@types/md5": "^2.3.1",
27 | "@types/node": "^17.0.8",
28 | "@types/react": "^17.0.38",
29 | "@types/styled-components": "^5.1.19",
30 | "@typescript-eslint/eslint-plugin": "^5.9.0",
31 | "babel-plugin-styled-components": "^2.0.2",
32 | "eslint": "^8.6.0",
33 | "eslint-config-next": "12.0.7",
34 | "eslint-config-standard-with-typescript": "^21.0.1",
35 | "eslint-plugin-import": "^2.25.4",
36 | "eslint-plugin-node": "^11.1.0",
37 | "eslint-plugin-promise": "^6.0.0",
38 | "eslint-plugin-standard": "^5.0.0",
39 | "faker": "^5.1.0",
40 | "git-commit-msg-linter": "3.2.8",
41 | "husky": "^7.0.2",
42 | "jest": "^27.4.7",
43 | "lint-staged": "^11.1.2",
44 | "ts-jest": "^27.1.2",
45 | "typescript": "^4.5.4"
46 | },
47 | "version": "1.0.0",
48 | "main": "index.js",
49 | "repository": "https://github.com/StartecJobsDev/reactjs-test.git",
50 | "author": "Felipe Barcelos ",
51 | "license": "MIT"
52 | }
53 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-comic-by-id.ts:
--------------------------------------------------------------------------------
1 | import { EntityNotFound } from '../../domain/errors/entity-not-found'
2 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
3 | import { LoadComicById, LoadComicByIdDTO } from '../../domain/usecases/load-comic-by-id'
4 | import { HttpClient, HttpStatusCode } from '../protocols/http/http-client'
5 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
6 |
7 | export class RemoteLoadComicById implements LoadComicById {
8 | constructor (
9 | private readonly httpClient: HttpClient>
10 | ) {}
11 |
12 | async execute (params: LoadComicByIdDTO): Promise {
13 | const httpResponse = await this.httpClient.request({
14 | url: `/comics/${params.id}`,
15 | method: 'get'
16 | })
17 |
18 | if (httpResponse.statusCode !== HttpStatusCode.ok) {
19 | throw new UnexpectedError(httpResponse.statusCode)
20 | }
21 |
22 | const comicData: any = httpResponse.body.data.results[0]
23 |
24 | if (!comicData) {
25 | throw new EntityNotFound('Comic')
26 | }
27 |
28 | return {
29 | id: comicData.id,
30 | title: comicData.title,
31 | cover: comicData.thumbnail.path + '.' + comicData.thumbnail.extension,
32 | publishedAt: comicData.dates[0].date,
33 | writer: comicData.creators.items.find(creator => creator.role === 'writer')?.name ?? 'Unknown',
34 | penciler: comicData.creators.items.find(creator => creator.role === 'penciler')?.name ?? 'Unknown',
35 | coverArtist: comicData.creators.items.find(creator => creator.role === 'penciller (cover)')?.name ?? 'Unknown',
36 | description: comicData.description,
37 | comics: []
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/presentation/pages/error/styles.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | align-items: center;
6 |
7 | height: calc(100vh - 180px);
8 |
9 | > div {
10 | display: grid;
11 | grid-template-columns: 1fr 1fr;
12 | grid-gap: var(--space-xl);
13 | align-items: center;
14 | width: 900px;
15 |
16 | .info-container {
17 | > h1 {
18 | font-size: 4rem;
19 | color: var(--color-primary-400);
20 | margin-bottom: var(--space-xl);
21 | }
22 |
23 | > span {
24 | display: block;
25 | font-size: 1rem;
26 | margin-bottom: var(--space-xs);
27 | font-weight: bold;
28 | color: var(--color-primary-400);
29 | }
30 |
31 | > p {
32 | font-size: 1em;
33 | opacity: 0.6;
34 | margin-bottom: var(--space-lg);
35 | }
36 |
37 | > a {
38 | display: flex;
39 | align-items: center;
40 | justify-content: center;
41 |
42 | border: 0;
43 | border-radius: var(--radius-base);
44 |
45 | height: var(--size-md);
46 | padding: var(--space-md);
47 |
48 | transition: all ease-in 0.4s;
49 |
50 | font-weight: 500;
51 |
52 | background: rgba(255, 255, 255, 0.05);
53 | border: 1px solid rgba(255, 255, 255, 0.1);
54 | color: var(--color-white);
55 | width: fit-content;
56 |
57 | text-transform: uppercase;
58 | text-decoration: none;
59 | font-weight: 800;
60 | transition: all ease-in 0.3s;
61 |
62 | :hover {
63 | background: rgba(255, 255, 255, 0.1);
64 | border: 1px solid rgba(255, 255, 255, 0.2);
65 | }
66 | }
67 | }
68 | }
69 | `
70 |
--------------------------------------------------------------------------------
/src/presentation/pages/character/index.tsx:
--------------------------------------------------------------------------------
1 | import NextImage from 'next/image'
2 | import NextHead from 'next/head'
3 |
4 | import { ComicList } from '../../components/comic-list'
5 | import { Share } from '../../components/share'
6 | import { Wrapper } from '../../components/wrapper'
7 | import { AppLayout } from '../../layouts/app-layouts'
8 | import { Banner, Container } from './styles'
9 | import { CharacterModel } from '../../../domain/models/character'
10 | import { ComicModel } from '../../../domain/models/comic'
11 |
12 | interface SingleCharacterProps {
13 | character: CharacterModel
14 | characterComics: ComicModel[]
15 | }
16 |
17 | export function SingleCharacterPage ({ character, characterComics }: SingleCharacterProps): React.ReactElement {
18 | const { name, description, cover } = character
19 |
20 | return (
21 | <>
22 |
23 | Marvel Hub - {name}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
35 |
36 |
{name}
37 |
38 | {description && (
39 | <>
40 |
41 |
42 |
43 |
Description:
44 |
{description}
45 |
46 |
47 |
48 | >
49 | )}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | >
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { DocumentContext, DocumentInitialProps, Head, Html, Main, NextScript } from 'next/document'
2 |
3 | import { ServerStyleSheet } from 'styled-components'
4 |
5 | interface MyDocumentProps {
6 | styleTags: string
7 | }
8 |
9 | export default class MyDocument extends Document {
10 | static async getInitialProps (
11 | ctx: DocumentContext
12 | ): Promise {
13 | const sheet = new ServerStyleSheet()
14 | const originalRenderPage = ctx.renderPage
15 |
16 | try {
17 | ctx.renderPage = () =>
18 | originalRenderPage({
19 | enhanceApp: (App) => (props) =>
20 | sheet.collectStyles()
21 | })
22 |
23 | const initialProps = await Document.getInitialProps(ctx)
24 | return {
25 | ...initialProps,
26 | styles: (
27 | <>
28 | {initialProps.styles}
29 | {sheet.getStyleElement()}
30 | >
31 | )
32 | }
33 | } finally {
34 | sheet.seal()
35 | }
36 | }
37 |
38 | render (): React.ReactElement {
39 | return (
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | {this.props.styleTags}
61 |
62 |
63 |
64 |
65 |
66 |
67 | )
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/presentation/pages/comics/index.tsx:
--------------------------------------------------------------------------------
1 | import NextImage from 'next/image'
2 | import NextHead from 'next/head'
3 |
4 | import { ComicList } from '../../components/comic-list'
5 | import { Share } from '../../components/share'
6 | import { Wrapper } from '../../components/wrapper'
7 | import { AppLayout } from '../../layouts/app-layouts'
8 | import { Container, Banner } from './styles'
9 | import { ComicModel } from '../../../domain/models/comic'
10 |
11 | interface SingleComicPagecomic {
12 | comic: ComicModel
13 | readMore: ComicModel[]
14 | }
15 |
16 | export function SingleComicPage ({ comic, readMore }: SingleComicPagecomic): React.ReactElement {
17 | return (
18 | <>
19 |
20 | Marvel Hub - {comic.title}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
32 |
33 |
{comic.title}
34 |
35 |
36 |
Published:
37 | {comic.publishedAt}
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
Writer:
46 | {comic.writer}
47 |
48 |
49 |
50 |
51 |
52 |
Penciler:
53 | {comic.penciler}
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
Cover Artist:
62 | {comic.coverArtist}
63 |
64 |
65 | {comic.description && (
66 | <>
67 |
68 |
69 |
70 |
Description:
71 |
{comic.description}
72 |
73 | >
74 | )}
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | >
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-character-by-id.spec.ts:
--------------------------------------------------------------------------------
1 | import { EntityNotFound } from '../../domain/errors/entity-not-found'
2 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
3 | import { HttpClientSpy } from '../../main/factories/http/mock-http-client'
4 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
5 | import { RemoteLoadCharacterById } from './remote-load-character-by-id'
6 |
7 | interface Sut {
8 | sut: RemoteLoadCharacterById
9 | httpClient: HttpClientSpy
10 | }
11 |
12 | const makeFakeCharacter = (): unknown => ({
13 | id: '1',
14 | name: 'any_name',
15 | description: 'any_description',
16 | thumbnail: {
17 | path: 'any_cover',
18 | extension: 'jpg'
19 | },
20 | comics: {
21 | available: 10
22 | }
23 | })
24 |
25 | const makeSut = (): Sut => {
26 | const httpClient = new HttpClientSpy()
27 | const sut = new RemoteLoadCharacterById(httpClient)
28 |
29 | return { sut, httpClient }
30 | }
31 |
32 | describe('RemoteLoadCharacterById', () => {
33 | it('should by called', () => {
34 | const { sut } = makeSut()
35 |
36 | expect(sut).toBeTruthy()
37 | })
38 |
39 | it('should call httpClient with correct URL', async () => {
40 | const { sut, httpClient } = makeSut()
41 |
42 | httpClient.response.body = {
43 | code: 200,
44 | status: 'ok',
45 | data: {
46 | results: [
47 | makeFakeCharacter()
48 | ]
49 | }
50 | }
51 |
52 | await sut.execute({ id: '1' })
53 |
54 | expect(httpClient.url).toBe('/characters/1')
55 | })
56 |
57 | it('should call httpClient with correct method', async () => {
58 | const { sut, httpClient } = makeSut()
59 |
60 | httpClient.response.body = {
61 | code: 200,
62 | status: 'ok',
63 | data: {
64 | results: [
65 | makeFakeCharacter()
66 | ]
67 | }
68 | }
69 |
70 | await sut.execute({ id: '1' })
71 |
72 | expect(httpClient.method).toBe('get')
73 | })
74 |
75 | it('should return a character on success', async () => {
76 | const { sut, httpClient } = makeSut()
77 |
78 | httpClient.response.body = {
79 | code: 200,
80 | status: 'ok',
81 | data: {
82 | results: [
83 | makeFakeCharacter()
84 | ]
85 | }
86 | }
87 |
88 | const character = await sut.execute({ id: '1' })
89 |
90 | expect(character).toEqual({
91 | id: '1',
92 | name: 'any_name',
93 | description: 'any_description',
94 | cover: 'any_cover.jpg',
95 | comicsCount: 10
96 | })
97 | })
98 |
99 | it('should throw if httpClient returns an error', async () => {
100 | const { sut, httpClient } = makeSut()
101 |
102 | httpClient.response.statusCode = 500
103 |
104 | const promise = sut.execute({ id: '1' })
105 |
106 | await expect(promise).rejects.toEqual(new UnexpectedError(httpClient.response.statusCode))
107 | })
108 |
109 | it('should throw if comic is not found', async () => {
110 | const { sut, httpClient } = makeSut()
111 |
112 | httpClient.response.body = {
113 | code: 200,
114 | status: 'OK',
115 | data: {
116 | results: []
117 | }
118 | }
119 |
120 | const promise = sut.execute({ id: '2' })
121 |
122 | await expect(promise).rejects.toEqual(new EntityNotFound('Character'))
123 | })
124 | })
125 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-characters.spec.ts:
--------------------------------------------------------------------------------
1 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
2 | import { HttpClientSpy } from '../../main/factories/http/mock-http-client'
3 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
4 | import { RemoteLoadCharactersList } from './remote-load-characters'
5 |
6 | interface Sut {
7 | sut: RemoteLoadCharactersList
8 | httpClient: HttpClientSpy
9 | }
10 |
11 | const makeFakeCharacter = (): unknown => ({
12 | id: '1',
13 | name: 'any_name',
14 | description: 'any_description',
15 | thumbnail: {
16 | path: 'any_cover',
17 | extension: 'jpg'
18 | },
19 | comics: {
20 | available: 10
21 | }
22 | })
23 |
24 | const makeSut = (): Sut => {
25 | const httpClient = new HttpClientSpy()
26 | const sut = new RemoteLoadCharactersList(httpClient)
27 |
28 | return { sut, httpClient }
29 | }
30 |
31 | describe('RemoteLoadCharactersList', () => {
32 | it('should by called', () => {
33 | const { sut } = makeSut()
34 |
35 | expect(sut).toBeTruthy()
36 | })
37 |
38 | it('should call httpClient with correct URL', async () => {
39 | const { sut, httpClient } = makeSut()
40 |
41 | httpClient.response.body = {
42 | code: 200,
43 | status: 'ok',
44 | data: {
45 | results: [
46 | makeFakeCharacter()
47 | ]
48 | }
49 | }
50 |
51 | await sut.execute()
52 |
53 | expect(httpClient.url).toBe('/characters')
54 | })
55 |
56 | it('should call httpClient with correct method', async () => {
57 | const { sut, httpClient } = makeSut()
58 |
59 | httpClient.response.body = {
60 | code: 200,
61 | status: 'ok',
62 | data: {
63 | results: [
64 | makeFakeCharacter()
65 | ]
66 | }
67 | }
68 |
69 | await sut.execute()
70 |
71 | expect(httpClient.method).toBe('get')
72 | })
73 |
74 | it('should call httpClient with correct params', async () => {
75 | const { sut, httpClient } = makeSut()
76 |
77 | httpClient.response.body = {
78 | code: 200,
79 | status: 'ok',
80 | data: {
81 | results: [
82 | makeFakeCharacter()
83 | ]
84 | }
85 | }
86 |
87 | const params = {
88 | limit: 20,
89 | offset: 0
90 | }
91 |
92 | await sut.execute(params)
93 |
94 | expect(httpClient.params).toEqual(params)
95 | })
96 |
97 | it('should return a list of characters on success', async () => {
98 | const { sut, httpClient } = makeSut()
99 |
100 | httpClient.response.body = {
101 | code: 200,
102 | status: 'ok',
103 | data: {
104 | results: [
105 | makeFakeCharacter()
106 | ]
107 | }
108 | }
109 |
110 | const characters = await sut.execute()
111 |
112 | expect(characters).toEqual([
113 | {
114 | id: '1',
115 | name: 'any_name',
116 | description: 'any_description',
117 | cover: 'any_cover.jpg',
118 | comicsCount: 10
119 | }
120 | ])
121 | })
122 |
123 | it('should throw if httpClient returns an error', async () => {
124 | const { sut, httpClient } = makeSut()
125 |
126 | httpClient.response.statusCode = 500
127 |
128 | const promise = sut.execute()
129 |
130 | await expect(promise).rejects.toEqual(new UnexpectedError(httpClient.response.statusCode))
131 | })
132 | })
133 |
134 | export {}
135 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-comics.spec.ts:
--------------------------------------------------------------------------------
1 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
2 | import { HttpClientSpy } from '../../main/factories/http/mock-http-client'
3 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
4 | import { RemoteLoadComicsList } from './remote-load-comics'
5 |
6 | interface Sut {
7 | sut: RemoteLoadComicsList
8 | httpClient: HttpClientSpy
9 | }
10 |
11 | const makeFakeComic = (): unknown => ({
12 | id: 1,
13 | title: 'any_title',
14 | description: 'any_description',
15 | thumbnail: {
16 | path: 'any_cover',
17 | extension: 'jpg'
18 | },
19 | dates: [
20 | {
21 | type: 'onsaleDate',
22 | date: 'any_publishedAt'
23 | }
24 | ],
25 | creators: {
26 | items: [
27 | {
28 | role: 'writer',
29 | name: 'any_writer'
30 | },
31 | {
32 | role: 'penciler',
33 | name: 'any_penciler'
34 | },
35 | {
36 | role: 'penciller (cover)',
37 | name: 'any_coverArtist'
38 | }
39 | ]
40 | }
41 | })
42 |
43 | const makeSut = (): Sut => {
44 | const httpClient = new HttpClientSpy()
45 | const sut = new RemoteLoadComicsList(httpClient)
46 |
47 | return { sut, httpClient }
48 | }
49 |
50 | describe('RemoteLoadComics', () => {
51 | it('should by called', () => {
52 | const { sut } = makeSut()
53 |
54 | expect(sut).toBeTruthy()
55 | })
56 |
57 | it('should call httpClient with correct URL', async () => {
58 | const { sut, httpClient } = makeSut()
59 |
60 | httpClient.response.body = {
61 | code: 200,
62 | status: 'OK',
63 | data: {
64 | results: []
65 | }
66 | }
67 |
68 | await sut.execute()
69 |
70 | expect(httpClient.url).toBe('/comics')
71 | })
72 |
73 | it('should call httpClient with correct method', async () => {
74 | const { sut, httpClient } = makeSut()
75 |
76 | httpClient.response.body = {
77 | code: 200,
78 | status: 'OK',
79 | data: {
80 | results: []
81 | }
82 | }
83 |
84 | await sut.execute()
85 |
86 | expect(httpClient.method).toBe('get')
87 | })
88 |
89 | it('should call httpClient with correct params', async () => {
90 | const { sut, httpClient } = makeSut()
91 |
92 | const params = {
93 | offset: 1,
94 | limit: 2
95 | }
96 |
97 | httpClient.response.body = {
98 | code: 200,
99 | status: 'OK',
100 | data: {
101 | results: []
102 | }
103 | }
104 |
105 | await sut.execute(params)
106 |
107 | expect(httpClient.params).toEqual(params)
108 | })
109 |
110 | it('should throw a unexpected error if httpClient returns a statusCode diferent of 200', async () => {
111 | const { sut, httpClient } = makeSut()
112 |
113 | httpClient.response.statusCode = 500
114 |
115 | const promise = sut.execute()
116 |
117 | await expect(promise).rejects.toEqual(new UnexpectedError(httpClient.response.statusCode))
118 | })
119 |
120 | it('should return a comic', async () => {
121 | const { sut, httpClient } = makeSut()
122 |
123 | httpClient.response.body = {
124 | code: 200,
125 | status: 'OK',
126 | data: {
127 | results: [
128 | makeFakeComic()
129 | ]
130 | }
131 | }
132 |
133 | const comics = await sut.execute()
134 | const firstComic = comics[0]
135 |
136 | expect(firstComic.id).toBe(1)
137 | expect(firstComic.title).toBe('any_title')
138 | expect(firstComic.description).toBe('any_description')
139 | expect(firstComic.cover).toBe('any_cover.jpg')
140 | expect(firstComic.publishedAt).toBe('any_publishedAt')
141 | expect(firstComic.writer).toBe('any_writer')
142 | expect(firstComic.penciler).toBe('any_penciler')
143 | expect(firstComic.coverArtist).toBe('any_coverArtist')
144 | })
145 | })
146 |
147 | export {}
148 |
--------------------------------------------------------------------------------
/src/data/usecases/remote-load-comic-by-id.spec.ts:
--------------------------------------------------------------------------------
1 | import { EntityNotFound } from '../../domain/errors/entity-not-found'
2 | import { UnexpectedError } from '../../domain/errors/unexpected-error'
3 | import { HttpClientSpy } from '../../main/factories/http/mock-http-client'
4 | import { MarvelHttpResponse } from '../protocols/http/marvel-http-response'
5 | import { RemoteLoadComicById } from './remote-load-comic-by-id'
6 |
7 | interface Sut {
8 | sut: RemoteLoadComicById
9 | httpClient: HttpClientSpy
10 | }
11 |
12 | const makeFakeComic = (): unknown => ({
13 | id: '1',
14 | title: 'any_title',
15 | description: 'any_description',
16 | thumbnail: {
17 | path: 'any_cover',
18 | extension: 'jpg'
19 | },
20 | dates: [
21 | {
22 | type: 'onsaleDate',
23 | date: 'any_publishedAt'
24 | }
25 | ],
26 | creators: {
27 | items: [
28 | {
29 | role: 'writer',
30 | name: 'any_writer'
31 | },
32 | {
33 | role: 'penciler',
34 | name: 'any_penciler'
35 | },
36 | {
37 | role: 'penciller (cover)',
38 | name: 'any_coverArtist'
39 | }
40 | ]
41 | }
42 | })
43 |
44 | const makeSut = (): Sut => {
45 | const httpClient = new HttpClientSpy()
46 | const sut = new RemoteLoadComicById(httpClient)
47 |
48 | return { sut, httpClient }
49 | }
50 |
51 | describe('RemoteLoadComicById', () => {
52 | it('should by called', () => {
53 | const { sut } = makeSut()
54 |
55 | expect(sut).toBeTruthy()
56 | })
57 |
58 | it('should call httpClient with correct URL', async () => {
59 | const { sut, httpClient } = makeSut()
60 |
61 | httpClient.response.body = {
62 | code: 200,
63 | status: 'OK',
64 | data: {
65 | results: [
66 | makeFakeComic()
67 | ]
68 | }
69 | }
70 |
71 | await sut.execute({ id: '1' })
72 |
73 | expect(httpClient.url).toBe('/comics/1')
74 | })
75 |
76 | it('should return a comic on success', async () => {
77 | const { sut, httpClient } = makeSut()
78 |
79 | httpClient.response.body = {
80 | code: 200,
81 | status: 'OK',
82 | data: {
83 | results: [
84 | makeFakeComic()
85 | ]
86 | }
87 | }
88 |
89 | const comic = await sut.execute({ id: '1' })
90 |
91 | expect(comic.id).toBe('1')
92 | expect(comic.title).toBe('any_title')
93 | expect(comic.description).toBe('any_description')
94 | expect(comic.cover).toBe('any_cover.jpg')
95 | expect(comic.publishedAt).toBe('any_publishedAt')
96 | expect(comic.writer).toBe('any_writer')
97 | expect(comic.penciler).toBe('any_penciler')
98 | expect(comic.coverArtist).toBe('any_coverArtist')
99 | })
100 |
101 | it('should throw if httpClient returns an error', async () => {
102 | const { sut, httpClient } = makeSut()
103 |
104 | httpClient.response.statusCode = 500
105 |
106 | const promise = sut.execute({ id: '1' })
107 |
108 | await expect(promise).rejects.toEqual(new UnexpectedError(httpClient.response.statusCode))
109 | })
110 |
111 | it('should throw if comic is not found', async () => {
112 | const { sut, httpClient } = makeSut()
113 |
114 | httpClient.response.body = {
115 | code: 200,
116 | status: 'OK',
117 | data: {
118 | results: []
119 | }
120 | }
121 |
122 | const promise = sut.execute({ id: '2' })
123 |
124 | await expect(promise).rejects.toEqual(new EntityNotFound('Comic'))
125 | })
126 |
127 | it('should call httpClient with correct method', async () => {
128 | const { sut, httpClient } = makeSut()
129 |
130 | httpClient.response.body = {
131 | code: 200,
132 | status: 'OK',
133 | data: {
134 | results: [
135 | makeFakeComic()
136 | ]
137 | }
138 | }
139 |
140 | await sut.execute({
141 | id: '1'
142 | })
143 |
144 | expect(httpClient.method).toBe('get')
145 | })
146 | })
147 |
148 | export {}
149 |
--------------------------------------------------------------------------------
/src/presentation/styles/global.ts:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components'
2 |
3 | const GlobalStyle = createGlobalStyle`
4 | :root {
5 | /* COLORS */
6 | --color-bg: #171A27;
7 | --color-bg-accent: #121520;
8 |
9 | --color-text: #fff;
10 | --color-text-secondary: rgba(255, 255, 255, 0.6);
11 |
12 | --color-border: rgba(255, 255, 255, 0.1);
13 |
14 | --color-primary-500: #ED1D24;
15 | --color-primary-400: #ED1F69;
16 |
17 | --color-black: #000000;
18 | --color-white: #ffffff;
19 |
20 | --color-gray-100: #f6f6f9;
21 | --color-gray-200: #f1f1f3;
22 | --color-gray-300: #e2e8f0;
23 | --color-gray-400: #a0aec0;
24 | --color-gray-500: #2d3748;
25 |
26 | --color-red-100: #fff5f5;
27 | --color-red-200: #feb2b2;
28 | --color-red-300: #f56565;
29 | --color-red-400: #9b2c2c;
30 | --color-red-500: #63171b;
31 |
32 | --color-orange-100: #fffaf0;
33 | --color-orange-200: #fbd38d;
34 | --color-orange-300: #ed8936;
35 | --color-orange-400: #9c4221;
36 | --color-orange-500: #652b19;
37 |
38 | --color-green-100: #f0fff4;
39 | --color-green-200: #9ae6b4;
40 | --color-green-300: #48bb78;
41 | --color-green-400: #276749;
42 | --color-green-500: #1c4532;
43 |
44 | --color-blue-100: #ebf8ff;
45 | --color-blue-200: #90cdf4;
46 | --color-blue-300: #4299e1;
47 | --color-blue-400: #2c5282;
48 | --color-blue-500: #1a365d;
49 |
50 | /* FONT-SIZE */
51 | --font-size-xs: 0.75rem;
52 | --font-size-sm: 0.875rem;
53 | --font-size-md: 1rem;
54 | --font-size-lg: 1.125rem;
55 | --font-size-xl: 1.25rem;
56 | --font-size-2xl: 1.5rem;
57 | --font-size-3xl: 1.875rem;
58 |
59 | /* LINE-HEIGHT */
60 | --line-height-xs: 1;
61 | --line-height-md: 1.5;
62 | --line-height-lg: 2;
63 |
64 | /* BREAKPOINTS */
65 | --breakpoints-sm: 30em;
66 | --breakpoints-md: 48em;
67 | --breakpoints-lg: 62em;
68 | --breakpoints-xl: 80em;
69 |
70 | /* SPACE */
71 | --space-xs: 0.5rem;
72 | --space-sm: 1rem;
73 | --space-md: 1.5rem;
74 | --space-lg: 2rem;
75 | --space-xl: 2.5rem;
76 |
77 | /* SIZES */
78 | --size-sm: 2rem;
79 | --size-md: 3rem;
80 | --size-lg: 4rem;
81 |
82 | /* BORDER-RADIUS */
83 | --radius-base: 0.8rem;
84 | --radius-full: 50%;
85 |
86 | /* Z-INDEX */
87 | --zindex-hide: -1;
88 | --zindex-dropdown: 1000;
89 | --zindex-sticky: 1100;
90 | --zindex-banner: 1200;
91 | --zindex-overlay: 1300;
92 | --zindex-modal: 1400;
93 | --zindex-popover: 1500;
94 | --zindex-toast: 1700;
95 | --zindex-tooltip: 1800;
96 |
97 | /* SIZES */
98 | --shadow: 0 .6rem .6rem 0 rgba(0, 0, 0, 0.25);
99 |
100 | /* FONT-FAMILY */
101 | --font-family: 'Inter', sans-serif;
102 | }
103 |
104 | *,
105 | *::before,
106 | *::after {
107 | margin: 0;
108 | padding: 0;
109 | box-sizing: border-box;
110 | }
111 |
112 | :root {
113 | scroll-behavior: smooth;
114 | }
115 |
116 | @media (max-width: 1080px) {
117 | html {
118 | font-size: 93.75%;
119 | }
120 | }
121 |
122 | @media (max-width: 800px) {
123 | html {
124 | font-size: 87.5%;
125 | }
126 | }
127 |
128 | body {
129 | margin: 0;
130 |
131 | line-height: 140%;
132 | font-weight: 400;
133 |
134 | background: var(--color-bg);
135 |
136 | color: var(--color-white);
137 | font-family: sans-serif;
138 | }
139 |
140 | /* GRID_SYSTEM */
141 | .row {
142 | display: flex;
143 | flex-wrap: wrap;
144 |
145 | & > * {
146 | margin: 0 var(--space-sm);
147 |
148 | &:first-child {
149 | margin-left: 0;
150 | margin-right: var(--space-sm);
151 | }
152 |
153 | &:last-child {
154 | margin-left: var(--space-sm);
155 | margin-right: 0;
156 | }
157 |
158 | @media only screen and (max-width: 468px) {
159 | padding: 0;
160 | width: 100% !important;
161 | }
162 | }
163 |
164 | .col-sm {
165 | width: calc(25% - var(--space-sm)) !important;
166 | }
167 |
168 | .col-md {
169 | width: calc(50% - var(--space-sm)) !important;
170 | }
171 |
172 | .col-lg {
173 | width: calc(75% - var(--space-sm)) !important;
174 | }
175 |
176 | .large-xl {
177 | width: calc(100% - var(--space-sm)) !important;
178 | }
179 | }
180 |
181 | /* CAROUSEL */
182 | .carousel-wrapper {
183 | @media (max-width: 800px) {
184 | display: flex!important;
185 | scroll-snap-type: x mandatory;
186 | overflow-x: auto;
187 |
188 | > div, > img {
189 | scroll-snap-align: start
190 | }
191 | }
192 | }
193 |
194 | /* BUTTONS */
195 | .btn {
196 | display: flex;
197 | align-items: center;
198 | justify-content: center;
199 |
200 | border: 0;
201 | border-radius: var(--radius-base);
202 | font-size: var(--font-size-md);
203 |
204 | height: var(--size-md);
205 | padding: var(--space-md);
206 |
207 | transition: background-color ease-in 0.2s;
208 | cursor: pointer;
209 | text-decoration: none;
210 |
211 | font-weight: 500;
212 |
213 | &.btn-primary {
214 | background: var(--color-primary-500);
215 | color: var(--color-white);
216 |
217 | box-shadow: var(--shadow);
218 |
219 | &:hover {
220 | background: var(--color-primary-400);
221 | }
222 | }
223 |
224 | &.btn-outline {
225 | background: rgba(255, 255, 255, 0.05);
226 | border: 1px solid rgba(255, 255, 255, 0.1);
227 | color: var(--color-white);
228 |
229 | &:hover {
230 | background: rgba(255, 255, 255, 0.1);
231 | border: 1px solid rgba(255, 255, 255, 0.2);
232 | }
233 | }
234 |
235 | &.btn-sm {
236 | height: var(--size-sm) !important;
237 | padding: var(--space-sm) !important;
238 | }
239 |
240 | &.btn-md {
241 | height: var(--size-md) !important;
242 | padding: var(--space-md) !important;
243 | }
244 |
245 | &.btn-lg {
246 | height: var(--size-lg) !important;
247 | padding: var(--space-lg) !important;
248 | }
249 |
250 | &.only-icon {
251 | svg {
252 | margin-right: 0;
253 | }
254 | }
255 |
256 | &:disabled {
257 | opacity: 0.6;
258 | cursor: not-allowed;
259 | }
260 |
261 | svg {
262 | margin-right: var(--space-sm);
263 | }
264 | }
265 | `
266 |
267 | export { GlobalStyle }
268 |
--------------------------------------------------------------------------------
/public/error.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/banner-spiderman.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------