├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc.json ├── .github └── pull_request_template.md ├── .gitignore ├── .nvmrc ├── CHALLENGE.md ├── README.md ├── babel.config.js ├── fileTransformer.js ├── jest.config.js ├── mdx.d.ts ├── package.json ├── prettier.config.js ├── public ├── index.html └── manifest.json ├── src ├── App.module.scss ├── App.tsx ├── bootstrap.tsx ├── components │ ├── ErrorMessage │ │ ├── ErrorMessage.module.scss │ │ └── ErrorMessage.tsx │ ├── Layout │ │ ├── Layout.module.scss │ │ ├── Layout.tsx │ │ └── index.js │ ├── PrivateRoute │ │ ├── PrivateRoute.tsx │ │ └── index.js │ ├── SubHeader │ │ ├── SubHeader.module.scss │ │ ├── SubHeader.tsx │ │ └── index.js │ └── index.js ├── hooks │ └── useSpotifyAuthentication.ts ├── index.tsx ├── setupTests.ts ├── types │ ├── Window.d.ts │ ├── plurall.d.ts │ └── scss.d.ts ├── utils │ ├── client.ts │ ├── index.ts │ └── token.ts └── views │ ├── Error │ ├── Error.test.tsx │ └── Error.tsx │ ├── Home │ ├── Home.jsx │ ├── Home.module.scss │ └── index.ts │ ├── LoginCallback │ ├── LoginCallback.tsx │ └── index.ts │ └── index.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.json] 13 | insert_final_newline = ignore 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PORT=4200 2 | HOST=127.0.0.1 3 | REACT_APP_NODE_ENV=local 4 | REACT_APP_ACCESS_TOKEN_URL=https://accounts.spotify.com/api/token 5 | REACT_APP_AUTHORIZATION_URL=https://accounts.spotify.com/authorize 6 | REACT_APP_CLIENT_ID=YOUR_SPOTIFY_API_CLIENT 7 | REACT_APP_API_URL=https://api.spotify.com/v1 8 | REACT_APP_CALLBACK_URL=http://127.0.0.1:4200/login/callback 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/types 2 | src/react-app-env.d.ts 3 | node_modules* 4 | dist 5 | coverage 6 | ./data 7 | .vscode 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "plugin:react/recommended", 9 | "airbnb", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier", 12 | "plugin:prettier/recommended" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaFeatures": { 17 | "jsx": true 18 | }, 19 | "ecmaVersion": 12, 20 | "sourceType": "module" 21 | }, 22 | "plugins": ["react", "@typescript-eslint", "eslint-plugin-import-helpers", "prettier"], 23 | "rules": { 24 | "camelcase": "off", 25 | "import/no-unresolved": "off", 26 | "@typescript-eslint/naming-convention": [ 27 | "error", 28 | { 29 | "selector": "interface", 30 | "format": ["PascalCase"], 31 | "custom": { 32 | "regex": "^I[A-Z]", 33 | "match": true 34 | } 35 | } 36 | ], 37 | "react/jsx-props-no-spreading": "off", 38 | "max-len": ["error", { "code": 140 }], 39 | "class-methods-use-this": "off", 40 | "import/prefer-default-export": "off", 41 | "no-shadow": "off", 42 | "no-console": "off", 43 | "no-useless-constructor": "off", 44 | "no-empty-function": "off", 45 | "lines-between-class-members": "off", 46 | "import/extensions": [ 47 | "error", 48 | "ignorePackages", 49 | { 50 | "ts": "never", 51 | "tsx": "never", 52 | "js": "never", 53 | "jsx": "never" 54 | } 55 | ], 56 | "import-helpers/order-imports": [ 57 | "warn", 58 | { 59 | "newlinesBetween": "always", 60 | "groups": [ 61 | "/^react/", 62 | "module", 63 | "/^components/", 64 | "/^utils/", 65 | "/^views/", 66 | ["parent", "sibling", "index"] 67 | ], 68 | "alphabetize": { 69 | "order": "asc", 70 | "ignoreCase": true 71 | } 72 | } 73 | ], 74 | "prettier/prettier": "error", 75 | "react/jsx-filename-extension": [ 76 | 1, 77 | { 78 | "extensions": [".jsx", ".tsx"] 79 | } 80 | ], 81 | "react/react-in-jsx-scope": "off", 82 | "react/function-component-definition": "off", 83 | "react/require-default-props": "off", 84 | "no-use-before-define": "off", 85 | "@typescript-eslint/no-use-before-define": ["error"], 86 | "@typescript-eslint/ban-ts-comment": "off" 87 | }, 88 | "settings": { 89 | "import/resolver": { 90 | "typescript": {}, 91 | "node": { 92 | "paths": ["src"] 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Após abrir o PR, mande um email com cópias para raphael.colonese@somoseducacao.com.br e william.braz@somoseducacao.com.br com assunto [TESTE Front-end] com link do PR para que nosso time avalie sua implementação. **OBS: Antes de enviar o PR, pode apagar esse paragrafo** 2 | 3 | ## Perguntinhas bem legais :) 4 | - Conte pra gente o que você espera da empresa? 5 | - O que em você, seria um diferencial do outros e porque? 6 | - Pode adicionar aqui seu Linkedin :) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env.* 3 | .vscode/settings.json 4 | /build 5 | /coverage 6 | /node_modules 7 | /tmp 8 | npm-debug.log* 9 | TODO.md 10 | yarn-debug.log* 11 | yarn-error.log* 12 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /CHALLENGE.md: -------------------------------------------------------------------------------- 1 | # Objetivo 2 | 3 | O objetivo do desafio é implementar um sistema de busca de artistas baseado na API do Spotify. 4 | 5 | O sistema deverá conter 3 páginas: 6 | 7 | - A homepage. 8 | - A página de busca (`/busca`). 9 | - A página do artista selecionado (`/artista/:id`). 10 | 11 | As chamadas para a API devem ficar restritas ao arquivo `src/utils/client.js`. Os componentes React devem apenas utilizar esse 'client'. 12 | 13 | # Descrição das páginas 14 | 15 | ## Homepage 16 | 17 | A homepage deve conter um botão na home que use o `Link` do React para levar a página de busca. 18 | 19 | ## Busca 20 | 21 | A página de busca deve conter um `input` onde o usuário irá digitar a busca. 22 | 23 | Ao digitar mais de 4 caracteres, os resultados devem aparecer abaixo, mostrando o nome e a foto do Artista. 24 | 25 | Ao clicar em um artista o usuário deve ser levado apara a página do artista. 26 | 27 | ## Página do Artista 28 | 29 | A pagina do Artista deve exibir os seguintes dados: 30 | 31 | - Nome 32 | - Popularidade 33 | - Foto 34 | - Lista de gêneros 35 | - Lista de 10 albuns, contendo: Imagem, nome do album e data de lançamento. 36 | 37 | A data de lançamento do album deve estar no formato `DD/MM/AAAA`. 38 | 39 | # Regras do Desafio 40 | 41 | 1. Resolva o desafio com o melhor que você possa fazer. 42 | 2. Quando finalizar, abra um PR do seu fork para que possamos avaliar. 43 | 3. Faça o layout ser responsivo. 44 | 4. Escreva pelo menos um teste. 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Front-end Challenge 4 | 5 | ## Objetivo 6 | 7 | Nesse desafio iremos avaliar o seu conhecimento nas tecnologias de front-end utilizadas no [Plurall](https://plurall.net) (React, JavaScript, CSS, HTML/JSX). 8 | 9 | Você deverá implementar o desafio descrito em [`CHALLENGE.md`](/CHALLENGE.md) usando esse codebase como base. 10 | 11 | Esse projeto é um `boilerplate` baseado nos projetos do [Plurall](https://plurall.net) (produto no qual você ira trabalhar). 12 | 13 | ## Critério de avaliação 14 | 15 | Abaixo estão algumas caracteristicas que achamos importantes: 16 | 17 | - Organização e legibilidade do código. 18 | - Simplicidade. 19 | - Boas praticas. 20 | - Conhecimento de Javascript. 21 | - Conhecimento de React. 22 | - Outros. 23 | 24 | ## Configurando o ambiente 25 | 26 | Você precisa ter [Node 22.14.0](https://nodejs.org/en/) (ou compatível) instalado para conseguir rodar o desafio. 27 | 28 | Faça fork do projeto em sua conta pessoal e siga os passos a seguir. 29 | 30 | ### Instale as dependências e start o projeto 31 | 32 | ```shell 33 | yarn 34 | yarn start 35 | ``` 36 | 37 | Após os passos acima, você conseguirá abrir a aplicação em http://127.0.0.1:4200/. 38 | 39 | O `client_id` default não é válido, então você receberá uma mensagem de erro. Para esse desafio (como na imagem abaixo). Queremos que você utilize a API do Spotify para autenticação, veja o passo a passo a seguir. 40 | 41 | 42 | 43 | ## Setup Spotify API 44 | 45 | Trocar a configuração do projeto é bem simples, segue abaixo o passo a passo pra você conseguir um `client id` do Spotify. 46 | 47 | - Logue no [Portal do desenvolvedor do Spotify](https://developer.spotify.com/) 48 | - Crie uma aplicação em [API do Spotify](https://developer.spotify.com/dashboard/applications). 49 | - Na tela da aplicação criada, preencha os seguintes campos abaixo. 50 | - Por fim, clique em `save`. 51 | 52 | ``` 53 | Website: http://127.0.0.1:4200/ 54 | Redirect URIs: http://127.0.0.1:4200/login/callback 55 | ``` 56 | 57 | OBS. 1: Não é necessário marcar nenhuma opção em `Which API/SDKs are you planning to use?`. mas se quiser pode marcar a opção `Web API`. 58 | 59 | OBS. 2: Para Redirec URIs direcionadas para sua máquina, apenas o endereço com ip `https://127.0.0.1:PORT` é aceito pelo spotify, se quiser, saiba mais [aqui](https://developer.spotify.com/documentation/web-api/concepts/redirect_uri). 60 | 61 | 62 | 63 | - Abra o arquivo [`.env`](./.env) na `raiz` do projeto para substituir o valor `YOUR_SPOTIFY_API_CLIENT` do `REACT_APP_CLIENT_ID` para o `client id` gerado pelo spotify. 64 | 65 | - Agora você pode parar o projeto caso esteja rodando, e rodá-lo novamente, `yarn start` e quando entrar em `http://127.0.0.1:4200` você vai ser redirecionado para logar no Spotify, você deve estar vendo uma página como essa: 66 | 67 | 68 | 69 | - Logue com suas credenciais, e você será redirecionado para a aplicação :facepunch: :smile: e já deve estar vendo uma página como essa abaixo. 70 | 71 | 72 | 73 | Agora voce já pode fazer o [desafio](/CHALLENGE.md). 74 | 75 | Boa Sorte! 76 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | /* to transfer any advansed ES to ES5 */ 4 | '@babel/preset-env', 5 | [ 6 | // to compile react to ES5 7 | '@babel/preset-react', 8 | { 9 | runtime: 'automatic', 10 | }, 11 | ], 12 | '@babel/preset-typescript', 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /fileTransformer.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | process(src, filename) { 5 | return { 6 | code: `module.exports = ${JSON.stringify(path.basename(filename))};`, 7 | } 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleNameMapper: { 4 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', 5 | '^utils(.*)$': '/src/utils$1', 6 | '^views(.*)$': '/src/views$1', 7 | '^hooks(.*)$': '/src/hooks$1', 8 | '^components(.*)$': '/src/components$1', 9 | '^types(.*)$': '/src/types$1', 10 | '\\.svg$': '/fileTransformer.js', 11 | }, 12 | roots: [''], 13 | setupFilesAfterEnv: ['/src/setupTests.ts'], 14 | testEnvironment: 'jsdom', 15 | transform: { 16 | '^.+\\.(js|jsx|ts|tsx)$': '/node_modules/babel-jest', 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /mdx.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.mdx' { 2 | const MDXComponent: () => JSX.Element 3 | export default MDXComponent 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plurall-front-end-challenge", 3 | "private": false, 4 | "version": "0.1.0", 5 | "dependencies": { 6 | "history": "^5.2.0", 7 | "plurall-footer": "^2.4.29", 8 | "plurall-header": "^5", 9 | "plurall-ui": "^0.3.0", 10 | "prop-types": "^15.8.1", 11 | "query-string": "^7.1.1", 12 | "react": "^18.3.1", 13 | "react-dom": "^18.3.1", 14 | "react-router-dom": "^6.21.1" 15 | }, 16 | "devDependencies": { 17 | "@babel/cli": "^7.27.2", 18 | "@babel/core": "^7.27.1", 19 | "@babel/preset-env": "^7.27.2", 20 | "@babel/preset-react": "^7.27.1", 21 | "@babel/preset-typescript": "^7.27.1", 22 | "@pmmmwh/react-refresh-webpack-plugin": "^0.6.0", 23 | "@testing-library/dom": "^10.4.0", 24 | "@testing-library/jest-dom": "^5.17.0", 25 | "@testing-library/react": "^16.3.0", 26 | "@types/react": "^18.0.8", 27 | "@types/react-dom": "^18.0.2", 28 | "@types/react-router-dom": "^5.3.3", 29 | "@typescript-eslint/eslint-plugin": "^5.12.0", 30 | "@typescript-eslint/parser": "^5.12.0", 31 | "babel-jest": "^27.5.1", 32 | "babel-loader": "^8.2.3", 33 | "circular-dependency-plugin": "^5.2.2", 34 | "copy-webpack-plugin": "^13.0.0", 35 | "css-loader": "^7.1.2", 36 | "dotenv": "^16.5.0", 37 | "eslint": "^8.9.0", 38 | "eslint-config-airbnb": "^19.0.4", 39 | "eslint-config-prettier": "^8.3.0", 40 | "eslint-import-resolver-typescript": "^2.5.0", 41 | "eslint-plugin-import": "^2.25.4", 42 | "eslint-plugin-import-helpers": "^1.2.1", 43 | "eslint-plugin-jsx-a11y": "^6.5.1", 44 | "eslint-plugin-prettier": "^4.0.0", 45 | "eslint-plugin-react": "^7.28.0", 46 | "eslint-plugin-react-hooks": "^4.3.0", 47 | "file-loader": "^6.2.0", 48 | "html-webpack-plugin": "^5.6.3", 49 | "identity-obj-proxy": "^3.0.0", 50 | "jest": "^27.5.1", 51 | "jest-canvas-mock": "^2.3.1", 52 | "lint-staged": "^7.0.0", 53 | "prettier": "^3.5.3", 54 | "react-refresh": "^0.17.0", 55 | "sass": "^1.89.0", 56 | "sass-loader": "^16.0.5", 57 | "style-loader": "^4.0.0", 58 | "svgo-loader": "^4.0.0", 59 | "typescript": "^5.8.3", 60 | "webpack": "^5.99.8", 61 | "webpack-cli": "^6.0.1", 62 | "webpack-dev-server": "^5.2.1" 63 | }, 64 | "lint-staged": { 65 | "*.{js,jsx,tsx,ts,json,css,md}": "prettier --write" 66 | }, 67 | "scripts": { 68 | "start": "webpack serve --mode=development", 69 | "build": "webpack --mode=production ", 70 | "test": "jest", 71 | "test-watch": "jest --watchAll", 72 | "lint": "eslint src/ --ext js,jsx,ts,tsx", 73 | "lint:fix": "eslint --fix --quiet src/ --ext js,jsx,ts,tsx", 74 | "precommit": "eslint src/ --ext js,jsx,ts,tsx --max-warnings 0 && lint-staged" 75 | }, 76 | "browserslist": { 77 | "development": [ 78 | "last 2 chrome versions", 79 | "last 2 firefox versions", 80 | "last 2 edge versions" 81 | ], 82 | "production": [ 83 | ">1%", 84 | "last 4 versions", 85 | "Firefox ESR", 86 | "not ie < 11" 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | semi: false, 4 | singleQuote: true, 5 | printWidth: 100, 6 | jsxSingleQuote: true, 7 | bracketSameLine: false, 8 | arrowParens: 'avoid', 9 | trailingComma: 'all', 10 | bracketSpacing: true, 11 | } 12 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13 | Desafio Front-end 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Plurall Front-End Challenge", 3 | "name": "Plurall Front-End Challenge", 4 | "icons": [{ 5 | "src": "https://s.gravatar.com/avatar/2e078a3b3eaba0db93d848b40cb91f6a?size=496", 6 | "sizes": "64x64 32x32 24x24 16x16", 7 | "type": "image/x-icon" 8 | }], 9 | "start_url": "./index.html", 10 | "display": "standalone", 11 | "theme_color": "#000000", 12 | "background_color": "#ffffff" 13 | } 14 | -------------------------------------------------------------------------------- /src/App.module.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-dark-gray: #333333; 3 | --color-purple: #655ba2; 4 | --color-white: #f2f4f5; 5 | --height-footer: 59px; 6 | --height-nav-bar: 63px; 7 | --padding: 20px; 8 | } 9 | 10 | * { 11 | box-sizing: border-box; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | body { 17 | background: var(--color-white); 18 | } 19 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Layout } from 'components' 3 | import { Home } from 'views' 4 | 5 | import './App.module.scss' 6 | 7 | const App = () => ( 8 | 9 | 10 | 11 | ) 12 | 13 | export default App 14 | -------------------------------------------------------------------------------- /src/bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom' 3 | import ReactDOM from 'react-dom/client' 4 | 5 | import { Error, LoginCallback } from 'views' 6 | import { PrivateRoute } from 'components' 7 | import App from './App' 8 | 9 | const callbackHistory: Array<(callback: (location: any) => void) => void> = [] 10 | window.PLURALL_CUSTOM_HISTORY = { 11 | listen: callback => { 12 | if (!callbackHistory.find(c => c === callback)) { 13 | callbackHistory.push(callback) 14 | } 15 | }, 16 | } 17 | 18 | const History = ({ children }: { children: React.ReactNode }) => { 19 | const location = useLocation() 20 | 21 | React.useEffect(() => { 22 | callbackHistory.forEach(callback => { 23 | callback(location as any) 24 | }) 25 | }, [location]) 26 | 27 | return children 28 | } 29 | 30 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) 31 | 32 | root.render( 33 | 34 | 35 | 36 | } /> 37 | } /> 38 | 42 | 43 | 44 | } 45 | /> 46 | 47 | 48 | , 49 | ) 50 | -------------------------------------------------------------------------------- /src/components/ErrorMessage/ErrorMessage.module.scss: -------------------------------------------------------------------------------- 1 | .no-results { 2 | display: flex; 3 | justify-content: center; 4 | flex-direction: column; 5 | align-items: center; 6 | margin: 2% 0; 7 | height: 60vh; 8 | 9 | img { 10 | display: flex; 11 | width: 25%; 12 | } 13 | 14 | .title-wrapper { 15 | width: 40%; 16 | text-align: center; 17 | margin-bottom: var(--spacing-xSmall); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/ErrorMessage/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from './ErrorMessage.module.scss' 2 | 3 | interface IErrorMessageProps { 4 | subTitle?: string | null 5 | title?: string | null 6 | dataTestId?: string 7 | } 8 | 9 | const ErrorMessage = ({ 10 | subTitle, 11 | title = 'Corremos e voamos, mas não encontramos o que você busca.', 12 | dataTestId, 13 | }: IErrorMessageProps) => { 14 | return ( 15 |
16 |
17 |

{title}

18 |
19 | 20 | {subTitle &&
{subTitle}
} 21 |
22 | ) 23 | } 24 | 25 | export default ErrorMessage 26 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .nav-bar { 2 | background-color: var(--color-purple); 3 | > div { 4 | height: var(--height-nav-bar); 5 | } 6 | } 7 | 8 | .content { 9 | min-height: calc(100vh - var(--height-nav-bar) - var(--height-footer)); 10 | } 11 | 12 | .footer { 13 | background-color: var(--color-dark-gray); 14 | padding: var(--padding); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | 3 | import { Footer } from 'plurall-footer' 4 | import NavBar from 'plurall-header' 5 | 6 | import { getToken, setToken } from 'utils' 7 | 8 | import * as styles from './Layout.module.scss' 9 | 10 | interface ILayout { 11 | children: ReactNode 12 | } 13 | 14 | const Layout = ({ children }: ILayout) => { 15 | const handleLogout = (path: string & Location) => { 16 | setToken('') 17 | window.location = path 18 | } 19 | 20 | const { content, footer, 'nav-bar': navBar } = styles 21 | 22 | return ( 23 | <> 24 |
25 | 35 |
36 | 37 |
{children}
38 | 39 |
40 |
41 |
42 | 43 | ) 44 | } 45 | 46 | export default Layout 47 | -------------------------------------------------------------------------------- /src/components/Layout/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Layout' 2 | -------------------------------------------------------------------------------- /src/components/PrivateRoute/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { useSpotifyAuthentication } from 'hooks/useSpotifyAuthentication' 3 | 4 | import { getToken } from 'utils' 5 | 6 | interface IPrivateRoute { 7 | children: ReactNode 8 | } 9 | 10 | const PrivateRoute = ({ children }: IPrivateRoute): ReactNode => { 11 | const { login } = useSpotifyAuthentication() 12 | 13 | const handleNotAuthenticated = () => { 14 | login() 15 | return null 16 | } 17 | 18 | return getToken() ? children : handleNotAuthenticated() 19 | } 20 | 21 | export default PrivateRoute 22 | -------------------------------------------------------------------------------- /src/components/PrivateRoute/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PrivateRoute' 2 | -------------------------------------------------------------------------------- /src/components/SubHeader/SubHeader.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | background: #fff; 3 | padding: 35px 0; 4 | } 5 | 6 | .wrapper { 7 | max-width: 1224px; 8 | padding: 0 20px; 9 | margin: 0 auto; 10 | display: flex; 11 | flex-direction: row; 12 | align-items: center; 13 | } 14 | 15 | .innerDiv { 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | 20 | .button { 21 | margin-right: 15px; 22 | } 23 | 24 | .breadcrumb { 25 | display: inline-block; 26 | text-align: left; 27 | } 28 | 29 | .heading { 30 | text-align: left; 31 | padding: 0 5px; 32 | } 33 | 34 | @media (max-width: 1224px) { 35 | .header { 36 | padding: 17px 0; 37 | } 38 | 39 | .wrapper { 40 | padding: 0 10px; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/SubHeader/SubHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { ArrowButton, Breadcrumb, Heading, Link } from 'plurall-ui' 4 | 5 | import * as styles from './SubHeader.module.scss' 6 | 7 | interface BreadcrumbItem { 8 | text: string 9 | href?: string 10 | } 11 | 12 | interface SubHeaderProps { 13 | buttonHref?: string 14 | breadcrumb: BreadcrumbItem[] 15 | heading: string 16 | } 17 | 18 | const SubHeader: React.FC = ({ buttonHref, breadcrumb, heading }) => { 19 | const { 20 | header: style_header, 21 | wrapper: style_wrapper, 22 | button: style_button, 23 | innerDiv: style_innerDiv, 24 | breadcrumb: style_breadcrumb, 25 | heading: style_heading, 26 | } = styles || {} 27 | 28 | return ( 29 |
30 |
31 | {buttonHref && ( 32 | 33 | 34 | 35 | )} 36 | 37 |
38 | 39 | {heading} 40 |
41 |
42 |
43 | ) 44 | } 45 | 46 | export default SubHeader 47 | -------------------------------------------------------------------------------- /src/components/SubHeader/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SubHeader' 2 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Layout } from './Layout' 2 | export { default as SubHeader } from './SubHeader' 3 | export { default as PrivateRoute } from './PrivateRoute' 4 | export { default as ErrorMessage } from './ErrorMessage/ErrorMessage' 5 | -------------------------------------------------------------------------------- /src/hooks/useSpotifyAuthentication.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { setToken } from 'utils/token' 3 | 4 | export const useSpotifyAuthentication = () => { 5 | const [error, setError] = useState('') 6 | /* 7 | * Gera um código de verificação e um desafio de código para autenticação OAuth 2.0 8 | * usando o PKCE (Proof Key for Code Exchange). 9 | * Mais detalhes: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow 10 | */ 11 | const generateCodeVerifier = (length = 128): string => { 12 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 13 | const values = crypto.getRandomValues(new Uint8Array(length)) 14 | return values.reduce((acc, x) => acc + possible[x % possible.length], '') 15 | } 16 | 17 | const sha256 = async (plain?: string) => { 18 | const encoder = new TextEncoder() 19 | const data = encoder.encode(plain) 20 | return window.crypto.subtle.digest('SHA-256', data) 21 | } 22 | 23 | const base64encode = (input: ArrayBuffer) => { 24 | return btoa( 25 | Array.from(new Uint8Array(input)) 26 | .map(byte => String.fromCharCode(byte)) 27 | .join(''), 28 | ) 29 | .replace(/=/g, '') 30 | .replace(/\+/g, '-') 31 | .replace(/\//g, '_') 32 | } 33 | 34 | /* 35 | * Gera um código de desafio a partir do código de verificação usando SHA-256 36 | * e codifica o resultado em Base64 URL Safe. 37 | * O código de desafio é usado para verificar a autenticidade do código de verificação 38 | * durante o processo de autenticação. 39 | * Mais detalhes: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow 40 | */ 41 | const generateCodeChallenge = async (verifier: string): Promise => { 42 | const digest = await sha256(verifier) 43 | return base64encode(digest) 44 | } 45 | 46 | /* 47 | * A função login é chamada quando o usuário deseja iniciar o processo de autenticação. 48 | * Ela gera um código de verificação e um desafio de código, armazena o código de verificação 49 | * no localStorage e redireciona o usuário para a página de autorização do Spotify. 50 | */ 51 | const login = async () => { 52 | const verifier = generateCodeVerifier() 53 | const challenge = await generateCodeChallenge(verifier) 54 | 55 | localStorage.setItem('code_verifier', verifier) 56 | 57 | const params = new URLSearchParams({ 58 | response_type: 'code', 59 | client_id: process.env.REACT_APP_CLIENT_ID || '', 60 | scope: 'user-read-private', 61 | code_challenge_method: 'S256', 62 | code_challenge: challenge, 63 | redirect_uri: process.env.REACT_APP_CALLBACK_URL || '', 64 | }) 65 | 66 | window.location.href = `${process.env.REACT_APP_AUTHORIZATION_URL}?${params.toString()}` 67 | } 68 | 69 | // Get code parameter from URL 70 | const getCodeFromUrl = (): string => { 71 | const urlParams = new URLSearchParams(window.location.search) 72 | return urlParams.get('code') || '' 73 | } 74 | 75 | /** 76 | * Deve ser usada na rota de callback da API. 77 | * Esta função é o fim do processo de autenticação, resgatando da URL o argumento 78 | * code_verifier e usando ele para criar o token baseado no método PKCE (Proof Key for Code Exchange). 79 | * 80 | * Mais detalhes: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow 81 | */ 82 | const getToken = async () => { 83 | const code = getCodeFromUrl() 84 | const codeVerifier = localStorage.getItem('code_verifier') || '' 85 | 86 | const url = process.env.REACT_APP_ACCESS_TOKEN_URL || '' 87 | const payload = { 88 | method: 'POST', 89 | headers: { 90 | 'Content-Type': 'application/x-www-form-urlencoded', 91 | }, 92 | body: new URLSearchParams({ 93 | client_id: process.env.REACT_APP_CLIENT_ID || '', 94 | grant_type: 'authorization_code', 95 | code, 96 | redirect_uri: process.env.REACT_APP_CALLBACK_URL || '', 97 | code_verifier: codeVerifier, 98 | }), 99 | } 100 | 101 | const body = await fetch(url, payload) 102 | const response = await body.json() 103 | 104 | if (response.error) { 105 | setError(response.error_description) 106 | return 107 | } 108 | 109 | setToken(response.access_token) 110 | } 111 | 112 | return { login, getToken, error } 113 | } 114 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import('./bootstrap') 3 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import '@testing-library/jest-dom' 3 | import 'jest-canvas-mock' 4 | import { configure } from '@testing-library/react' 5 | 6 | configure({ testIdAttribute: 'data-test-id' }) 7 | 8 | const localStorageMock = { 9 | getItem: jest.fn(), 10 | setItem: jest.fn(), 11 | removeItem: jest.fn(), 12 | clear: jest.fn(), 13 | } 14 | 15 | Object.defineProperty(global, 'localStorage', { 16 | value: localStorageMock, 17 | }) 18 | -------------------------------------------------------------------------------- /src/types/Window.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | interface Window { 3 | PLURALL_CUSTOM_HISTORY: { 4 | listen: (callback: (location: any) => void) => void 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/types/plurall.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'plurall-ui' 2 | 3 | declare module 'plurall-footer' 4 | 5 | declare module 'plurall-header' 6 | -------------------------------------------------------------------------------- /src/types/scss.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' 2 | -------------------------------------------------------------------------------- /src/utils/client.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | import { clearToken, getToken } from './token' 3 | 4 | class SomosClient { 5 | // eslint-disable-next-line 6 | constructor() {} 7 | 8 | // eslint-disable-next-line 9 | onError = (error: unknown) => {} 10 | 11 | // eslint-disable-next-line 12 | async getArtists() { 13 | // Obs: para chamadas na api, você já tem o token salvo no cookie, `authenticated_token` - use ele para mandar no header das chamadas - da uma olhada no `src/utils` 14 | // retornar a lista de artistas - https://developer.spotify.com/console/get-several-artists/ 15 | } 16 | } 17 | 18 | export default SomosClient 19 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SomosClient } from './client' 2 | export { getToken, setToken, clearToken } from './token' 3 | -------------------------------------------------------------------------------- /src/utils/token.ts: -------------------------------------------------------------------------------- 1 | const domain = (): string => { 2 | const { 3 | location: { hostname }, 4 | } = window 5 | return hostname 6 | } 7 | 8 | const getToken = (): string | undefined => { 9 | const r = document.cookie.match('\\bauthenticated_token=([^;]*)\\b') 10 | return r ? r[1] : undefined 11 | } 12 | 13 | const setToken = (token: string | undefined): void => { 14 | document.cookie = `authenticated_token=${token || ''};path=/;domain=${domain()}` 15 | } 16 | 17 | const clearToken = (): void => { 18 | const cookies: string[] = document.cookie.split(';') 19 | for (let i = 0; i < cookies.length; i += 1) { 20 | const cookie: string = cookies[i] 21 | const eqPos: number = cookie.indexOf('=') 22 | const name: string = eqPos > -1 ? cookie.substr(0, eqPos) : cookie 23 | document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=${domain()}` 24 | } 25 | } 26 | 27 | export { getToken, setToken, clearToken } 28 | -------------------------------------------------------------------------------- /src/views/Error/Error.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { ReactElement, ReactNode } from 'react' 3 | import { Route, Routes, MemoryRouter } from 'react-router-dom' 4 | 5 | import { render } from '@testing-library/react' 6 | 7 | import Error from './Error' 8 | 9 | jest.mock( 10 | '../../components/Layout/Layout.tsx', 11 | () => 12 | ({ children }: { children: ReactNode }): ReactElement =>
{children}
, 13 | ) 14 | 15 | describe('', () => { 16 | test('deve renderizar o componente de erro corretamente', () => { 17 | const { getByTestId } = render( 18 | 25 | 26 | } /> 27 | 28 | , 29 | ) 30 | 31 | const errorComponent = getByTestId('error-message') 32 | 33 | expect(errorComponent).toBeInTheDocument() 34 | }) 35 | 36 | test('deve renderizar o componente com o título e subtítulo padrão', () => { 37 | const { getByTestId } = render( 38 | 45 | 46 | } /> 47 | 48 | , 49 | ) 50 | const title = getByTestId('error-message-title') 51 | const subTitle = getByTestId('error-message-subtitle') 52 | 53 | expect(title.textContent).toBe('custom-title') 54 | expect(subTitle.textContent).toBe('custom-subtitle') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/views/Error/Error.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useLocation } from 'react-router-dom' 3 | 4 | import queryString from 'query-string' 5 | 6 | import { ErrorMessage, Layout } from 'components' 7 | 8 | const Error = () => { 9 | const [title, setTitle] = useState( 10 | 'Ops! Ocorreu um erro.', 11 | ) 12 | const [subTitle, setSubTitle] = useState( 13 | 'Caso o erro persista, entre em contato com o suporte.', 14 | ) 15 | 16 | const location = useLocation() 17 | 18 | useEffect(() => { 19 | const { status, message, title: queryTitle } = queryString.parse(location.search) 20 | 21 | if (queryTitle) { 22 | setTitle(queryTitle) 23 | } 24 | 25 | if (message) { 26 | setSubTitle(message) 27 | } 28 | }, [location]) 29 | 30 | return ( 31 | 32 | 37 | 38 | ) 39 | } 40 | 41 | export default Error 42 | -------------------------------------------------------------------------------- /src/views/Home/Home.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { SubHeader } from 'components' 4 | import { SomosClient } from 'utils' 5 | 6 | import * as styles from './Home.module.scss' 7 | 8 | class Home extends React.Component { 9 | state = {} 10 | 11 | client = new SomosClient() 12 | 13 | render() { 14 | return ( 15 | <> 16 | 17 |
18 |

Home da aplicação

19 |
20 | 21 | ) 22 | } 23 | } 24 | 25 | export default Home 26 | -------------------------------------------------------------------------------- /src/views/Home/Home.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | max-width: 1224px; 3 | padding: 30px 20px; 4 | margin: 0 auto; 5 | font: 16px/14px 'Nunito', sans-serif; 6 | } 7 | -------------------------------------------------------------------------------- /src/views/Home/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Home' 2 | -------------------------------------------------------------------------------- /src/views/LoginCallback/LoginCallback.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { useLocation, useNavigate } from 'react-router-dom' 3 | 4 | import queryString from 'query-string' 5 | 6 | import { useSpotifyAuthentication } from 'hooks/useSpotifyAuthentication' 7 | 8 | interface ISearchQuery { 9 | redirectTo?: string 10 | } 11 | 12 | const LoginCallback = () => { 13 | const [redirect, setRedirect] = useState(false) 14 | 15 | const location = useLocation() 16 | const navigate = useNavigate() 17 | const { getToken, error } = useSpotifyAuthentication() 18 | 19 | useEffect(() => { 20 | getToken().then(() => { 21 | setRedirect(true) 22 | }) 23 | }, [location]) 24 | 25 | if (redirect) { 26 | const search: ISearchQuery = queryString.parse(location.search) 27 | navigate(search.redirectTo || '/') 28 | return 29 | } 30 | 31 | if (error) { 32 | return
{error}
33 | } 34 | 35 | return ( 36 |
37 | Você tem que estar logado para acessar esta página 38 |
39 | ) 40 | } 41 | 42 | export default LoginCallback 43 | -------------------------------------------------------------------------------- /src/views/LoginCallback/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LoginCallback' 2 | -------------------------------------------------------------------------------- /src/views/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Home } from './Home' 2 | export { default as Error } from './Error/Error' 3 | export { default as LoginCallback } from './LoginCallback' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "baseUrl": "src", 5 | "declaration": true, 6 | "declarationDir": "build", 7 | "module": "esnext", 8 | "target": "es5", 9 | "lib": ["es6", "dom", "es2016", "es2017"], 10 | "sourceMap": false, 11 | "jsx": "react-jsx", 12 | "moduleResolution": "node", 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "allowJs": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "paths": { "react": ["./node_modules/@types/react"] } 24 | }, 25 | "include": ["src/**/*"], 26 | "exclude": ["node_modules", "build"], 27 | "files": ["mdx.d.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin') 4 | const CopyWebpackPlugin = require('copy-webpack-plugin') 5 | const HtmlWebpackPlugin = require('html-webpack-plugin') 6 | const CircularDependencyPlugin = require('circular-dependency-plugin') 7 | const path = require('path') 8 | const sass = require('sass') 9 | const webpack = require('webpack') 10 | 11 | const deps = require('./package.json').dependencies 12 | 13 | // Carrega as variáveis do arquivo .env 14 | require('dotenv').config({ path: './.env' }) 15 | 16 | module.exports = (env, argv) => { 17 | const isDevelopment = argv.mode === 'development' 18 | const processEnv = process.env 19 | 20 | const config = { 21 | mode: isDevelopment ? 'development' : 'production', 22 | devtool: isDevelopment ? 'eval-source-map' : 'source-map', 23 | entry: path.resolve(__dirname, 'src', 'index.tsx'), 24 | optimization: { 25 | runtimeChunk: 'single', 26 | }, 27 | devServer: { 28 | port: process.env.PORT, 29 | host: process.env.HOST, 30 | allowedHosts: process.env.HOST, 31 | static: { 32 | directory: path.resolve(__dirname, 'public'), 33 | }, 34 | hot: true, 35 | historyApiFallback: true, 36 | open: true, 37 | }, 38 | output: { 39 | path: path.resolve(__dirname, 'build'), 40 | filename: '[name].bundle.[contenthash].js', 41 | publicPath: isDevelopment 42 | ? `http://${process.env.HOST}:${process.env.PORT}/` 43 | : `${process.env.CI_ENVIRONMENT_URL}/`, 44 | clean: true, 45 | }, 46 | resolve: { 47 | // fallback: { querystring: require.resolve('querystring-es3') }, 48 | alias: { 49 | src: path.resolve(__dirname, 'src/'), 50 | // react: path.resolve('./node_modules/react'), 51 | components: path.resolve(__dirname, 'src/components'), 52 | utils: path.resolve(__dirname, 'src/utils'), 53 | views: path.resolve(__dirname, 'src/views'), 54 | hooks: path.resolve(__dirname, 'src/hooks'), 55 | }, 56 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 57 | }, 58 | plugins: [ 59 | new CopyWebpackPlugin({ 60 | patterns: [path.resolve(__dirname, 'public', 'manifest.json')], 61 | }), 62 | isDevelopment && new ReactRefreshWebpackPlugin(), 63 | new HtmlWebpackPlugin({ 64 | template: path.resolve(__dirname, 'public', 'index.html'), 65 | }), 66 | new webpack.DefinePlugin({ 67 | 'process.env': JSON.stringify(processEnv), 68 | }), 69 | new CircularDependencyPlugin({ 70 | exclude: /node_modules/, 71 | failOnError: true, 72 | allowAsyncCycles: true, 73 | cwd: process.cwd(), 74 | }), 75 | ].filter(Boolean), 76 | module: { 77 | rules: [ 78 | { 79 | test: /\.(js|jsx|ts|tsx)$/, 80 | exclude: /node_modules/, 81 | use: { 82 | loader: 'babel-loader', 83 | options: { 84 | plugins: [isDevelopment && require.resolve('react-refresh/babel')].filter(Boolean), 85 | presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], 86 | }, 87 | }, 88 | }, 89 | { 90 | test: /\.scss$/, 91 | exclude: /node_modules/, 92 | use: [ 93 | 'style-loader', 94 | 'css-loader', 95 | { 96 | loader: 'sass-loader', 97 | options: { 98 | implementation: sass, 99 | sourceMap: isDevelopment, 100 | }, 101 | }, 102 | ], 103 | }, 104 | { 105 | test: /\.svg$/, 106 | use: [{ loader: 'file-loader' }, { loader: 'svgo-loader' }], 107 | }, 108 | ], 109 | }, 110 | } 111 | 112 | return config 113 | } 114 | --------------------------------------------------------------------------------