diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b5534f6a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 2 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{jade,pug,md}] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d587c1ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,287 @@ +# Created by https://www.gitignore.io/api/osx,sass,linux,grunt,windows,webstorm,sublimetext,visualstudiocode,node +# Edit at https://www.gitignore.io/?templates=osx,sass,linux,grunt,windows,webstorm,sublimetext,visualstudiocode,node + +### grunt ### +# Grunt usually compiles files inside this directory +build/ +arquivos/ + +# Grunt usually preprocesses files such as coffeescript, compass... inside the .tmp directory +.tmp/ + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# react / gatsby +public/ + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +### OSX ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Sass ### +.sass-cache/ +*.css.map +*.sass.map +*.scss.map + +### SublimeText ### +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +*.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + +### VisualStudioCode ### +.vscode/* + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +### WebStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/ + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### WebStorm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/**/sonarlint/ + +# SonarQube Plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.gitignore.io/api/osx,sass,linux,grunt,windows,webstorm,sublimetext,visualstudiocode,node diff --git a/README.md b/README.md index 3ab4cfb1..59ddbfff 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,67 @@ -# Bluefoot DEV: Teste prático para Frontend Developer - - -## Instruções - -Crie um `fork` deste projeto, e desenvolva em cima do seu fork. Use o *README.md* principal do seu repositório para nos contar como foi sua experiência em realizar o teste, esperamos que você nos conte: as decisões que você tomou, como você organizou seu código, as funcionalidades e instruções de como rodar seu projeto e até as dificuldades e desafios que você teve. - - -## Briefing - -Você deve desenvolver uma aplicação e interface de busca de produtos para um grande ecommerce de uma multinacional no Brasil. - -A interface deve ser bastante semelhante a busca do [Submarino](https://www.submarino.com.br), um input text onde os resultados vão aparecendo conforme o usuário digita seu termo. Em parte do resultado, você deve exibir os resultados de uma das API's (autocomplete), e em outra parte você deve efetuar outro request por Full Text em outro endpoint trazendo os 3 primeiros produtos do response. - - -`Endpoint da API de autocomplete:` -http://agenciabluefoot.vtexcommercestable.com.br/buscaautocomplete/?productNameContains={{termo}} - - -`Endpoint da API de search full text:` -http://agenciabluefoot.vtexcommercestable.com.br/api/catalog_system/pub/products/search/{{termo}}?map=ft - -Documentação completa da API de busca: [https://documenter.getpostman.com/view/845/search-103/Hs43] - - -Para cada um dos 3 produtos, devemos exibir dados como titulo, thumbnail, preço e outros dados que você considerar importante para a taxa de conversão (esperamos suas considerações no Readme.md lembra?) - -Quando clicarmos no produto, você pode optar por dois caminhos: - -- Levar o usuário para o link do produto -- Exibir os dados completos do produto em outra interface elaborada por você - -Com o submit do formulário, deve ser exibida uma lista com todos os produtos encontrados no response, exibindo dados como título, thumbnail, preço, link e outros dados importantes para a taxa de conversão da loja. - - -### O que nós esperamos do seu teste - -* Ver na solução a utilização de um framework / biblioteca da sua escolha e que você nos conte sobre essa escolha. Aqui na Bluefoot nós utilizamos o React. -* Queremos ver a utilização de dependency managers (npm, webpack etc) -* Automação de tasks com gulp, grunt ou outra ferramenta de sua escolha -* HTML5 escrito da maneira mais semântica possível -* CSS3 com um pre processador de sua escolha, aqui na Bluefoot utilizamos SASS SCSS mas gostamos do PostCSS também. -* Layout responsivo - -### O que nós ficaríamos felizes de ver em seu teste - -* Testes unitários -* Alguma metodologia para definição e organização do seu código CSS - -### O que nos impressionaria - -* Testes de aceitação -* [BEM naming convention](http://getbem.com/naming/) -* Aplicação de animações em css quando possível -* Ver o código rodando live - -### O que nós não gostaríamos - -* Descobrir que não foi você quem fez seu teste -* Ver commits gigantes, sem mensagens ou com -m sem pé nem cabeça - - -## O que avaliaremos de seu teste - -* Histórico de commits do git -* As instruções de como rodar o projeto -* Organização, semântica, estrutura, legibilidade, manutenibilidade, escalabilidade do seu código e suas tomadas de decisões -* Alcance dos objetivos propostos -* Componentização e extensibilidade dos componentes Javascript +# Como rodar o projeto + + ## Clonar o projeto + + ``` +$ git clone git@github.com:charliston/frontend-developer.git +$ frontend-developer + ``` + +## Instalar as dependências com: +`$ yarn install` +ou +`$ npm install` + +## Rodar o projeto: +`$ yarn start` +ou +`$ npm start` + +## Node Proxy +Adicionado um Node Proxy para rotear as requisições feitas para API da loja que não permite acesso direto devido a política de CORS. Ao rodar o projeto, a aplicação Node também é executada. + +# Considerações + +**Projeto feito em 2018, atualizado em 2021** + +## Meu background +Sou programador front-end a 4 anos mais ou menos, sendo minha última experiência de trabalho com AngularJS. Iniciei os +estudos com React por conta e algumas vídeo aulas. Tive um problema pra iniciar projetos por conta pois as informações +que tem sobre React ou estão desatualizadas ou não funcionam. + +## Estrutura do projeto +Visei a melhor escalabilidade do projeto por modularizar ele. Não sei se a estrutura de pastas está nos "padrões React", +mas é as que eu me sinti confortável com o resultado. +Esse é meu primeiro projeto inteiramente feito sem seguir nenhuma vídeo-aula ou tutorial (*yey!*). Isso me deixou muito +confiante das minhas capacidades com React + +## Testes +Como disse anteriormente, não achei nada relacionado a Jest com Redux e API que fosse de fácil aprendizado. Mas como +quero sempre evoluir profissionalmente, após a entrega desse projeto, irei entrar mais profudamente nesse assunto, pois +sei que os testes unitários são importantes. + +## Layout +Visando a maior velocidade de produção, eu copiei a estrutura de layout de alguns exemplos que existem na Internet. +Visto que não temos um Layout padrão pra seguir, meio que imitando essa prática com isso (*IMO*). + +## Dificuldades +Eu tive um pouco de dificuldade em entender a API num primeiro momento, principalmente por estar externo ao desenvolvimento +da mesma. Então optei por caminhos que eu já tinha trabalhado antes com desenvolvimento de e-commerce. + +## Tecnologias +- Utilizei o grid do **Bootstrap 4.1**, por ser o mais atualizado, utilizar tecnologias novas de grid, maturidade do projeto e +por eu gostar mais; +- **Fontawesome** 5, por ter os ícones bem mais trabalhados e visualmente mais agradáveis; +- **SCSS** por dar mais velocidade ao desenvolvimento de estilos; +- **create-react-app** por dar um início de projeto mais fácil pra quem não tem tanta intimidade com React; +- **Redux** para gerenciamento de estados; +- **React Loadable** pois fazendo um outro teste, um dos requisitos era esse. Achei tão interessante essa idéia que logo quis +utilizar também. Inclusive esse era uma das coisas que eu não gostava no AngularJS: ter que carregar toda a aplicação de +uma vez. Com o React Loadable, consegui que a aplicação fosse fatiada e carregada sob demanda, ganhando muito tempo de +carregamento; +- **Slick Carousel** pra fazer uma página inicial um pouco menos feia. (Sei que a parte de destaques não ficou linda); +- **[stackedit.io](stackedit.io)** pois fica mais fácil editar arquivos MD com ele :o) + +Espero que meu projeto esteja dentro do que a empresa busca. Eu gostei muito de fazer esse teste, como eu disse, foi o +primeiro projeto que tive a confiança de fazer do zero sozinho (sem ajuda de tutoriais) usando o que aprendi nessa +jornada solo de horas de vídeos, documentações, frustrações (quase desisti de React) e, finalmente, alegria. diff --git a/app.js b/app.js new file mode 100644 index 00000000..ec417a12 --- /dev/null +++ b/app.js @@ -0,0 +1,50 @@ +const express = require('express') +const morgan = require('morgan') +const { createProxyMiddleware } = require('http-proxy-middleware') + +// Create Express Server +const app = express() + +// Configuration +const PORT = 3001 +const HOST = 'localhost' +const API_SERVICE_URL = 'http://agenciabluefoot.vtexcommercestable.com.br' + +// Logging +app.use(morgan('dev')) + +// Info GET endpoint +app.get('/info', (req, res, next) => { + res.send('This is a proxy service which proxies to JSONPlaceholder API.') +}) + +// Authorization +app.use('', (req, res, next) => { + if (req.headers.authorization) { + next() + } else { + res.sendStatus(403) + } +}) + +// Proxy endpoints +app.use('/api', createProxyMiddleware({ + target: `${API_SERVICE_URL}/api`, + changeOrigin: true, + pathRewrite: { + [`^/api`]: '', + }, +})) + +app.use('/buscaautocomplete', createProxyMiddleware({ + target: `${API_SERVICE_URL}/buscaautocomplete`, + changeOrigin: true, + pathRewrite: { + [`^/buscaautocomplete`]: '', + }, +})) + +// Start Proxy +app.listen(PORT, HOST, () => { + console.log(`Starting Proxy at ${HOST}:${PORT}`) +}) diff --git a/front/.gitignore b/front/.gitignore new file mode 100644 index 00000000..5d647565 --- /dev/null +++ b/front/.gitignore @@ -0,0 +1,56 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +*.keystore + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +*/fastlane/report.xml +*/fastlane/Preview.html +*/fastlane/screenshots + +# Bundle artifact +*.jsbundle diff --git a/_config.yml b/front/_config.yml similarity index 100% rename from _config.yml rename to front/_config.yml diff --git a/front/package.json b/front/package.json new file mode 100644 index 00000000..8d857b68 --- /dev/null +++ b/front/package.json @@ -0,0 +1,31 @@ +{ + "name": "frontend-developer", + "version": "0.1.0", + "private": true, + "dependencies": { + "node-sass-chokidar": "^1.2.2", + "npm-run-all": "^4.1.2", + "react": "^16.3.0", + "react-dom": "^16.3.0", + "react-html-parser": "^2.0.2", + "react-loadable": "^5.3.1", + "react-redux": "^5.0.7", + "react-router-dom": "^4.2.2", + "react-scripts": "1.1.1", + "react-slick": "^0.23.1", + "redux": "^4.0.0", + "redux-thunk": "^2.2.0", + "slick-carousel": "^1.8.1" + }, + "proxy": "http://localhost:3001", + "scripts": { + "build-css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --output-style compressed", + "watch-css": "npm run build-css -- --watch --recursive", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject", + "start-js": "react-scripts start", + "start": "npm-run-all -p watch-css start-js", + "build-js": "react-scripts build", + "build": "npm-run-all build-css build-js" + } +} diff --git a/front/public/favicon.ico b/front/public/favicon.ico new file mode 100644 index 00000000..a11777cc Binary files /dev/null and b/front/public/favicon.ico differ diff --git a/front/public/index.html b/front/public/index.html new file mode 100644 index 00000000..52c25ac6 --- /dev/null +++ b/front/public/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + Charliston.dev + + + +
+ + + diff --git a/front/public/manifest.json b/front/public/manifest.json new file mode 100644 index 00000000..e087138f --- /dev/null +++ b/front/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Frontend dev", + "name": "Frontend developer", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#126ccd", + "background_color": "#ffffff" +} diff --git a/front/src/actions/actionTypes.js b/front/src/actions/actionTypes.js new file mode 100644 index 00000000..c957e756 --- /dev/null +++ b/front/src/actions/actionTypes.js @@ -0,0 +1,6 @@ +export const LOAD_FEATURED_PRODUCTS_SUCCESS = 'LOAD_FEATURED_PRODUCTS_SUCCESS' +export const LOAD_PRODUCTBY_ID_SUCCESS = 'LOAD_PRODUCTBY_ID_SUCCESS' +export const LOAD_AUTOCOMPLETE_SEARCH_SUCCESS = 'LOAD_AUTOCOMPLETE_SEARCH_SUCCESS' +export const LOAD_FULL_TEXT_PRODUCT_SEARCH_SUCCESS = 'LOAD_FULL_TEXT_PRODUCT_SEARCH_SUCCESS' +export const LOAD_PRODUCT_SEARCH_SUCCESS = 'LOAD_PRODUCT_SEARCH_SUCCESS' +export const CLEAR_PRODUCT = 'CLEAR_PRODUCT' diff --git a/front/src/actions/productActions.js b/front/src/actions/productActions.js new file mode 100644 index 00000000..7d042ebb --- /dev/null +++ b/front/src/actions/productActions.js @@ -0,0 +1,50 @@ +import * as types from './actionTypes' +import productApi from '../api/ProductApi' + +export function loadFeaturedProductsSuccess (products) { + return { type: types.LOAD_FEATURED_PRODUCTS_SUCCESS, products } +} + +export function loadProductByIdSuccess (product) { + return { type: types.LOAD_PRODUCTBY_ID_SUCCESS, product } +} + +export function clearProduct () { + return { type: types.CLEAR_PRODUCT } +} + +export function loadProductSearchSuccess (products) { + return { type: types.LOAD_PRODUCT_SEARCH_SUCCESS, products } +} + +export function loadFeaturedProducts () { + // make async call to api, handle promise, dispatch action when promise is resolved + return function (dispatch) { + return productApi.getFeaturedProducts().then(products => { + dispatch(loadFeaturedProductsSuccess(products)) + }).catch(error => { + throw(error) + }) + } +} + +export function loadProductById (productId) { + return function (dispatch) { + return productApi.getProductById(productId).then(product => { + dispatch(loadProductByIdSuccess(product)) + }).catch(error => { + throw(error) + }) + } +} + +export function loadProductSearch (query, ini = 0, limit = 11) { + return function (dispatch) { + return productApi.fullTextSearch(query, ini, limit).then(products => { + dispatch(loadProductSearchSuccess(products)) + }).catch(error => { + throw(error) + }) + } +} + diff --git a/front/src/actions/searchActions.js b/front/src/actions/searchActions.js new file mode 100644 index 00000000..454ce791 --- /dev/null +++ b/front/src/actions/searchActions.js @@ -0,0 +1,32 @@ +import * as types from './actionTypes' +import searchApi from '../api/SearchApi' +import productApi from '../api/ProductApi' + +export function loadAutocompleteSearchSuccess (results) { + return { type: types.LOAD_AUTOCOMPLETE_SEARCH_SUCCESS, results } +} + +export function loadFullTextProductSearchSuccess (products) { + return { type: types.LOAD_FULL_TEXT_PRODUCT_SEARCH_SUCCESS, products } +} + +export function loadAutocompleteSearch (query) { + return function (dispatch) { + return searchApi.getAutocompleteSearch(query).then(results => { + dispatch(loadAutocompleteSearchSuccess(results)) + }).catch(error => { + throw(error) + }) + } +} + +export function loadFullTextProductSearch (query) { + return function (dispatch) { + return productApi.fullTextSearch(query).then(products => { + dispatch(loadFullTextProductSearchSuccess(products)) + }).catch(error => { + throw(error) + }) + } +} + diff --git a/front/src/api/ProductApi.js b/front/src/api/ProductApi.js new file mode 100644 index 00000000..36ff6629 --- /dev/null +++ b/front/src/api/ProductApi.js @@ -0,0 +1,44 @@ +class ProductApi { + static API_URL = '/api' + + static getFeaturedProducts () { + return fetch(`${this.API_URL}/catalog_system/pub/products/search/?O=OrderByBestDiscountDESC&_from=0&_to=5`, + { + headers: { + 'Authorization': 'L0r3m1pSUm', + } + }).then(response => { + return response.json() + }).catch(error => { + return error + }) + } + + static fullTextSearch (query, ini = 0, limit = 2) { + return fetch(`${this.API_URL}/catalog_system/pub/products/search/${query}?map=ft&_from=${ini}&_to=${limit}`, + { + headers: { + 'Authorization': 'L0r3m1pSUm', + } + }).then(response => { + return response.json() + }).catch(error => { + return error + }) + } + + static getProductById (productId) { + return fetch(`${this.API_URL}/catalog_system/pub/products/search/?fq=productId:${productId}&_from=0&_to=1`, + { + headers: { + 'Authorization': 'L0r3m1pSUm', + } + }).then(response => { + return response.json() + }).catch(error => { + return error + }) + } +} + +export default ProductApi diff --git a/front/src/api/SearchApi.js b/front/src/api/SearchApi.js new file mode 100644 index 00000000..967ece16 --- /dev/null +++ b/front/src/api/SearchApi.js @@ -0,0 +1,18 @@ +class SearchApi { + static AUTOCOMPLETE_URL = '/buscaautocomplete' + + static getAutocompleteSearch (query) { + return fetch(`${this.AUTOCOMPLETE_URL}/?productNameContains=${query}`, + { + headers: { + 'Authorization': 'L0r3m1pSUm', + } + }).then(response => { + return response.json() + }).catch(error => { + return error + }) + } +} + +export default SearchApi diff --git a/front/src/components/App.js b/front/src/components/App.js new file mode 100644 index 00000000..8f34c0f0 --- /dev/null +++ b/front/src/components/App.js @@ -0,0 +1,26 @@ +import React, { Component } from 'react' +import { Switch, Route } from 'react-router-dom' +import * as Routes from './routes' +import Loading from './Shared/Loading' +import Loadable from 'react-loadable' + +export const Navbar = Loadable({ loader: () => import('./Header/Navbar'), loading: Loading, }) + +class App extends Component { + render () { + return ( +
+
+ + + + + + + +
+ ) + } +} + +export default App diff --git a/front/src/components/App.test.js b/front/src/components/App.test.js new file mode 100644 index 00000000..4bf19359 --- /dev/null +++ b/front/src/components/App.test.js @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import App from './App' + +it('renders without crashing', () => { + const div = document.createElement('div') + ReactDOM.render(, div) + ReactDOM.unmountComponentAtNode(div) +}) diff --git a/front/src/components/Header/Navbar.js b/front/src/components/Header/Navbar.js new file mode 100644 index 00000000..e6a58c47 --- /dev/null +++ b/front/src/components/Header/Navbar.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import '../../styles/navbar.css' +import Loading from '../Shared/Loading' +import Loadable from 'react-loadable' + +export const Search = Loadable({ loader: () => import('./Search'), loading: Loading, }) + +export class Navbar extends Component { + render () { + return ( +
+
+
+
+ Awesome Logo +
+
+ +
+
+
+ +
+
+
+
+
+ ) + } +} + +export default Navbar diff --git a/front/src/components/Header/Search.js b/front/src/components/Header/Search.js new file mode 100644 index 00000000..f569eb8b --- /dev/null +++ b/front/src/components/Header/Search.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import * as searchActions from '../../actions/searchActions' +import Loading from '../Shared/Loading' +import Loadable from 'react-loadable' +import '../../styles/search.css' + +export const SearchAutocomplete = Loadable({ loader: () => import('./SearchAutocomplete'), loading: Loading, }) +export const SearchSuggestion = Loadable({ loader: () => import('./SearchSuggestion'), loading: Loading, }) + +export class Search extends Component { + constructor (props, context) { + super(props, context) + this.state = { + query: '', + } + } + + static contextTypes = { + router: PropTypes.object + } + + setBodyActive = () => { + const search = this.props.search + document.getElementById('header-overlay').classList.add('active') + document.getElementById('search').classList.add('active') + if (search.results.length > 0 && search.products.length > 0) { + document.getElementById('box').classList.add('active') + } + } + unsetBodyActive = () => { + document.getElementById('header-overlay').classList.remove('active') + document.getElementById('search').classList.remove('active') + document.getElementById('box').classList.remove('active') + } + + handleChange = (event) => { + // like "TV" + if (event.target.value.length > 1) { + this.props.sendQuery(event.target.value) + this.setState({ query: event.target.value }) + } + } + + onSubmit = (event) => { + event.preventDefault() + document.getElementById('search-input').blur() + this.context.router.history.push(`/products/busca/${this.state.query}`) + } + + render () { + const search = this.props.search + return ( + + ) + } +} + +function mapStateToProps (state) { + return { + search: { + results: state.search.results.itemsReturned, + products: state.search.products + }, + } +} + +const mapDispatchToProps = dispatch => ({ + sendQuery: query => { + dispatch(searchActions.loadAutocompleteSearch(query)) + dispatch(searchActions.loadFullTextProductSearch(query)) + }, +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Search) diff --git a/front/src/components/Header/SearchAutocomplete.js b/front/src/components/Header/SearchAutocomplete.js new file mode 100644 index 00000000..3863b66c --- /dev/null +++ b/front/src/components/Header/SearchAutocomplete.js @@ -0,0 +1,95 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' +import '../../styles/navbar.css' + +export function SearchAutocomplete (props) { + const escapeRegExp = (str = '') => ( + str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1') + ) + + const Highlight = ({ search = '', children = '' }) => { + const patt = new RegExp(`(${escapeRegExp(search)})`, 'i') + const parts = String(children).split(patt) + + if (search) { + return parts.map((part, index) => ( + patt.test(part) ? {part} : part + )) + } else { + return children + } + } + + const maxValues = { + suggestions: 3, + seeMore: 2, + } + + const results = { + suggestions: [], + seeMore: [], + } + + const initProps = () => { + props.results.map(result => { + if (result.items.length === 0 && results.seeMore.length < maxValues.seeMore) { + results.seeMore.push(result) + } + + if (result.items.length !== 0 && results.suggestions.length < maxValues.suggestions) { + result.items.map(subItem => { + return results.suggestions.push(subItem) + }) + } + return true + }) + } + initProps() + + return ( +
+
Você quis dizer:
+
    + { + results.suggestions.map(item => { + return ( +
  • + + {item.nameComplete} + +
  • + ) + }) + } +
+
Veja mais…
+
    + { + results.seeMore.map((item, i) => { + // key by index isnt good, but since we dont have the categoryId… + return ( +
  • + + {item.name} + +
  • + ) + }) + } +
+
+ ) +} + +SearchAutocomplete.defaultProps = { + results: [], + query: '', +} + +SearchAutocomplete.propTypes = { + results: PropTypes.array, + query: PropTypes.string, +} + +export default SearchAutocomplete diff --git a/front/src/components/Header/SearchSuggestion.js b/front/src/components/Header/SearchSuggestion.js new file mode 100644 index 00000000..03c9fcea --- /dev/null +++ b/front/src/components/Header/SearchSuggestion.js @@ -0,0 +1,42 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' +import * as helpers from '../../utils/helpers' +import '../../styles/navbar.css' + +export function SearchSuggestion (props) { + return ( +
+
Produtos sugeridos:
+
    + { + props.products.map(product => { + let image = helpers.getFirstImage(product) + let price = helpers.getBestPrice(product) + return ( +
  • + + {product.productName} + {product.productName} + {price} + +
  • + ) + }) + } +
+
+ ) +} + +SearchSuggestion.defaultProps = { + products: [], +} + +SearchSuggestion.propTypes = { + products: PropTypes.array, +} + +export default SearchSuggestion diff --git a/front/src/components/Home/Featured.js b/front/src/components/Home/Featured.js new file mode 100644 index 00000000..0159c49d --- /dev/null +++ b/front/src/components/Home/Featured.js @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' +import Slider from 'react-slick' +import * as helpers from '../../utils/helpers' +import '../../styles/featured.css' + +// not the best way, but since there inst a featured endpoint +export function Featured (props) { + const settings = { + dots: false, + arrows: true, + infinite: true, + autoplay: true, + autoplaySpeed: 2500, + slidesToShow: 1, + slidesToScroll: 1, + } + + return ( +
+
+
+
+ + { + props.products.map(product => { + const image = helpers.getFirstImage(product) + const price = helpers.getBestPrice(product) + return ( +
+ +
+

{product.productName}

+ A partir de {price} +
+ +
+ ) + }) + } +
+
+
+
+
+ ) +} + +Featured.defaultProps = { + products: [], +} + +Featured.propTypes = { + products: PropTypes.array, +} + +export default Featured diff --git a/front/src/components/Home/index.js b/front/src/components/Home/index.js new file mode 100644 index 00000000..706ceb90 --- /dev/null +++ b/front/src/components/Home/index.js @@ -0,0 +1,25 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import Loadable from 'react-loadable' +import Loading from '../Shared/Loading' + +export const Featured = Loadable({ loader: () => import('./Featured'), loading: Loading, }) + +class App extends Component { + render () { + const products = this.props.products + return ( +
+ +
+ ) + } +} + +function mapStateToProps (state, ownProps) { + return { + products: state.products + } +} + +export default connect(mapStateToProps)(App) diff --git a/front/src/components/Products/ProductDetail.js b/front/src/components/Products/ProductDetail.js new file mode 100644 index 00000000..6e8416a1 --- /dev/null +++ b/front/src/components/Products/ProductDetail.js @@ -0,0 +1,154 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { loadProductById, clearProduct } from '../../actions/productActions'; +import { Link } from 'react-router-dom'; +import Slider from "react-slick"; +import ReactHtmlParser from 'react-html-parser'; +import '../../styles/productDetail.css'; + +export class ProductDetail extends Component { + componentWillReceiveProps(nextProps) { + if (this.props.match.params.productId !== nextProps.match.params.productId) { + this.props.loadProductById(nextProps.match.params.productId); + } + } + + componentWillUnmount() { + this.props.clearProduct(); + } + + componentDidMount() { + this.props.loadProductById(this.props.match.params.productId); + } + + formatCurrency(value) { + return value.toLocaleString('pt-br',{style: 'currency', currency: 'BRL'}); + } + + render() { + const product = this.props.product; + + if(!product.items) { + return ( +
+
+
+ Carregando… +
+
+
+ ) + } + + const settings = { + dots: false, + arrows: true, + infinite: true, + autoplay: false, + slidesToShow: 1, + slidesToScroll: 1, + }; + + const item = product.items[0]; + const seller = item.sellers[0]; + + return ( +
+
+
+ + { + item.images.map(image => { + return {`${product.productName} + }) + } + +
+
+

+ {product.brand} {product.productName} +

+ em {product.categories[0].replace(/\//g, ' ')} +

Vendido por {seller.sellerName}

+ + {seller.commertialOffer.Price !== 0 && +
+ +
+ + {(Number(seller.commertialOffer.ListPrice) === Number(seller.commertialOffer.Price)) ? '' : `De: ${this.formatCurrency(seller.commertialOffer.ListPrice)}`} + +
+

Por: {this.formatCurrency(seller.commertialOffer.Price)}

+
+ } + +
+
+ { seller.commertialOffer.Price !== 0 && +
+
+ Quantidade +
+
+
+ +
+
+
+ } + +
+ + { seller.commertialOffer.Price === 0 ? ( +
+ +
+ Adicionar na lista de desejos +
+
+ ) : ( +
+ Oferta recomendada +