├── README.md ├── package.json ├── public ├── backpack.png ├── favicon.ico ├── favicon3.ico ├── index.html ├── manifest.json └── preview.png ├── src ├── Components │ ├── Buttons │ │ ├── AddButton.css │ │ ├── AddButton.js │ │ ├── FilterButton.css │ │ ├── FilterButton.js │ │ ├── GetButton.css │ │ ├── GetButton.js │ │ ├── MoreButton.js │ │ └── test.js │ ├── ContentBox │ │ ├── ContentBox.css │ │ ├── ContentBox.js │ │ └── test.js │ ├── Filters │ │ ├── Filters.css │ │ ├── Filters.js │ │ ├── Search │ │ │ ├── Search.css │ │ │ ├── Search.js │ │ │ └── test.js │ │ └── test.js │ ├── Header │ │ ├── Header.css │ │ ├── Header.js │ │ └── test.js │ ├── Home │ │ ├── Home.css │ │ ├── Home.js │ │ ├── __snapshots__ │ │ │ └── test.js.snap │ │ └── test.js │ ├── Product │ │ ├── Info │ │ │ ├── Info.css │ │ │ ├── Info.js │ │ │ ├── Tag │ │ │ │ ├── Tag.css │ │ │ │ ├── Tag.js │ │ │ │ └── test.js │ │ │ ├── __snapshots__ │ │ │ │ └── test.js.snap │ │ │ └── test.js │ │ ├── PreviewImage │ │ │ ├── PreviewImage.css │ │ │ ├── PreviewImage.js │ │ │ └── test.js │ │ ├── Product.css │ │ ├── Product.js │ │ ├── Voting │ │ │ ├── Voting.css │ │ │ ├── Voting.js │ │ │ └── test.js │ │ ├── __snapshots__ │ │ │ └── test.js.snap │ │ └── test.js │ ├── Routes │ │ └── Routes.js │ ├── Signup │ │ ├── Signup.css │ │ ├── Signup.js │ │ ├── __snapshots__ │ │ │ └── test.js.snap │ │ └── test.js │ ├── Submit │ │ ├── Form │ │ │ ├── Form.css │ │ │ ├── Form.js │ │ │ └── test.js │ │ ├── Submit.css │ │ ├── Submit.js │ │ └── test.js │ └── types.js ├── index.css ├── index.js ├── logo.svg ├── registerServiceWorker.js └── setupTests.js └── yarn.lock /README.md: -------------------------------------------------------------------------------- 1 | # 🏫 Studddent 2 | A curated list of online student discounts for developers and designers. Upvote/Downvote and add your own links 🎒. 3 | 4 | ## Todo: 5 | #### Pending Page (33%) 6 | - ~~Page for viewing pending posts (https://studddent.com/pending)~~ 7 | - Integrate passport 8 | - Allow admin to accept/reject/modify submissions from pending page 9 | 10 | #### Tags (50%) 11 | - ~~Tags (Design, Develop, Utility, Bundle)~~ 12 | - ~~Filtering~~ 13 | - Add tags on submission 14 | - Remove Utility tag, add Education and Other tags 15 | 16 | #### Other (60%) 17 | - ~~npm scripts for dev vs. production~~ 18 | - ~~Collect email on submission, send thanks if submission accepted~~ 19 | - ~~experiment w/ affiliate links~~ Decided not to use affiliate 20 | - Improve post ordering 21 | - Finish promote page / figure out pricing 22 | 23 | 24 | ![Preview](public/preview.png) 25 | 26 | [Link](https://studddent.com/) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "studddentv2", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.18.0", 7 | "enzyme": "^3.3.0", 8 | "enzyme-adapter-react-16": "^1.1.1", 9 | "fetch-mock": "^6.4.3", 10 | "react": "^16.4.0", 11 | "react-addons-css-transition-group": "^15.6.2", 12 | "react-addons-test-utils": "^15.6.2", 13 | "react-dom": "^16.4.0", 14 | "react-router": "^4.2.0", 15 | "react-router-dom": "^4.2.2", 16 | "react-scripts": "1.1.4", 17 | "react-test-renderer": "^16.4.0" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "jest": { 26 | "collectCoverageFrom": [ 27 | "src/Components/**/*.js" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/backpack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calumptrck/Studddent/06bb6912decfbc8cf23a3cefdad928fef2aac251/public/backpack.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calumptrck/Studddent/06bb6912decfbc8cf23a3cefdad928fef2aac251/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon3.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calumptrck/Studddent/06bb6912decfbc8cf23a3cefdad928fef2aac251/public/favicon3.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Studddent — Best Online Student Discounts 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calumptrck/Studddent/06bb6912decfbc8cf23a3cefdad928fef2aac251/public/preview.png -------------------------------------------------------------------------------- /src/Components/Buttons/AddButton.css: -------------------------------------------------------------------------------- 1 | a { 2 | display: inline-block; 3 | } 4 | 5 | .addDiscount { 6 | background-image: linear-gradient(-90deg,rgb(71, 187, 255) 0,rgb(39, 151, 244) 100%); 7 | color: #fff; 8 | font-weight: 400; 9 | margin-top: 20px; 10 | margin-bottom: 50px; 11 | border-radius: 1em; 12 | font-size: 14px; 13 | letter-spacing: 0.6px; 14 | cursor: pointer; 15 | transition: all .24s cubic-bezier(0,.5,.51,1); 16 | } 17 | 18 | .addDiscount:hover { 19 | border-radius: .9em; 20 | } 21 | 22 | .grow2 { 23 | transition: all .24s cubic-bezier(0,.5,.51,1); } 24 | .grow2:hover { transform: scale(1.04); } 25 | 26 | .shadow2 { 27 | box-shadow: 0 12px 25px rgba(0,0,0,0.04); 28 | -moz-box-shadow: 0 12px 25px rgba(0,0,0,0.04); 29 | -webkit-box-shadow: 0 12px 25px rgba(0,0,0,0.04); 30 | } 31 | 32 | .shadow2:hover { 33 | box-shadow: 0 14px 25px rgba(0,0,0,0.08); 34 | -moz-box-shadow: 0 14px 25px rgba(0,0,0,0.08); 35 | -webkit-box-shadow: 0 14px 25px rgba(0,0,0,0.08); 36 | } -------------------------------------------------------------------------------- /src/Components/Buttons/AddButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './AddButton.css' 3 | 4 | const AddButton = ({children, path}) => 5 | 6 | 7 | 8 | 9 | export default AddButton -------------------------------------------------------------------------------- /src/Components/Buttons/FilterButton.css: -------------------------------------------------------------------------------- 1 | 2 | .filterButton { 3 | float: left; 4 | padding-right: 30px; 5 | font-size: 15px; 6 | font-weight: bold; 7 | color: rgb(135, 135, 135); 8 | letter-spacing: 0.6px; 9 | cursor: pointer; 10 | transition: all .26s cubic-bezier(0,.5,.51,1); 11 | background: none; 12 | border: none; 13 | border-radius: 1.5em; 14 | padding: 10px 18px 10px 18px; 15 | letter-spacing: 0.9px; 16 | margin-left: 10px; 17 | } 18 | 19 | .active { 20 | background-image: linear-gradient(-180deg,#FF6A57 0,#FF2F68 100%); 21 | color: #fff; 22 | box-shadow: 0 15px 25px rgba(0,0,0,0.06); 23 | } 24 | 25 | .filterButton:hover { 26 | background-image: linear-gradient(-180deg,#FF6A57 0,#FF2F68 100%); 27 | color: #fff; 28 | box-shadow: 0 15px 25px rgba(0,0,0,0.06); 29 | transform: scale(1.06); 30 | } 31 | 32 | button:focus {outline:0;} 33 | 34 | @media screen and (max-width: 992px) { 35 | .filters { 36 | margin-left: 15px; 37 | } 38 | 39 | .filterButton { 40 | margin-left: 0px; 41 | margin-right: 15px; 42 | } 43 | 44 | .filters > :last-child {margin-right: 0 !important; } 45 | } 46 | 47 | @media screen and (max-width: 537px) { 48 | .filters { 49 | margin-left: 0px; 50 | 51 | } 52 | .filterButton { 53 | margin-right: 0px; 54 | font-size: 12px; 55 | padding: 10px 14px 10px 14px; 56 | } 57 | } -------------------------------------------------------------------------------- /src/Components/Buttons/FilterButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './FilterButton.css' 3 | 4 | const FilterButton = ({children, id, className, onClick}) => 5 | 6 | 7 | export default FilterButton -------------------------------------------------------------------------------- /src/Components/Buttons/GetButton.css: -------------------------------------------------------------------------------- 1 | a { 2 | display: inline-block; 3 | } 4 | 5 | .getButton { 6 | background-color: #fff; 7 | border: solid 2px rgb(39, 151, 244); 8 | font-family: Roboto, sans-serif; 9 | color: rgb(39, 151, 244); 10 | font-weight: 600; 11 | margin-top: 20px; 12 | border-radius: .8em; 13 | padding: 7px 35px 7px 35px; 14 | font-size: 17px; 15 | letter-spacing: 0.9px; 16 | cursor: pointer; 17 | transition: all .24s cubic-bezier(0,.5,.51,1) padding 3s; 18 | display: inline-block; 19 | backface-visibility: hidden; 20 | } 21 | 22 | .getButton:hover { 23 | border: none; 24 | background-image: linear-gradient(-90deg,rgb(71, 187, 255) 0,rgb(39, 151, 244) 100%); 25 | color: #fff; 26 | padding: 9px 24px 9px 24px; 27 | } 28 | 29 | .grow2 { 30 | transition: all .24s cubic-bezier(0,.5,.51,1); } 31 | .grow2:hover { transform: scale(1.08); } 32 | 33 | .shadow2 { 34 | box-shadow: 0 12px 25px rgba(0,0,0,0.04); 35 | -moz-box-shadow: 0 12px 25px rgba(0,0,0,0.04); 36 | -webkit-box-shadow: 0 12px 25px rgba(0,0,0,0.04); 37 | } 38 | 39 | .shadow2:hover { 40 | box-shadow: 0 14px 25px rgba(0,0,0,0.08); 41 | -moz-box-shadow: 0 14px 25px rgba(0,0,0,0.08); 42 | -webkit-box-shadow: 0 14px 25px rgba(0,0,0,0.08); 43 | } 44 | 45 | @media screen and (max-width: 535px) { 46 | .getButton { 47 | padding: 7px 20px 7px 20px; 48 | } 49 | } -------------------------------------------------------------------------------- /src/Components/Buttons/GetButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './GetButton.css' 3 | 4 | const GetButton = ({url, children}) => 5 | 6 | 7 | 8 | 9 | export default GetButton -------------------------------------------------------------------------------- /src/Components/Buttons/MoreButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './GetButton.css' 3 | 4 | const MoreButton = ({children, showMore, type}) => 5 | 6 | 7 | export default MoreButton -------------------------------------------------------------------------------- /src/Components/Buttons/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import AddButton from './AddButton'; 5 | import FilterButton from './FilterButton'; 6 | import GetButton from './GetButton'; 7 | import MoreButton from './MoreButton'; 8 | 9 | const filterClick = jest.fn(); 10 | 11 | 12 | describe('Buttons Render', () => { 13 | it('MoreButton renders without crashing', () => { 14 | const div = document.createElement('div'); 15 | ReactDOM.render(MoreButton, div); 16 | ReactDOM.unmountComponentAtNode(div); 17 | }); 18 | 19 | it('AddButton renders without crashing', () => { 20 | const div = document.createElement('div'); 21 | ReactDOM.render(AddButton, div); 22 | ReactDOM.unmountComponentAtNode(div); 23 | }); 24 | 25 | it('FilterButton renders without crashing', () => { 26 | const div = document.createElement('div'); 27 | ReactDOM.render(FilterButton, div); 28 | ReactDOM.unmountComponentAtNode(div); 29 | }); 30 | 31 | it('GetButton renders without crashing', () => { 32 | const div = document.createElement('div'); 33 | ReactDOM.render(GetButton, div); 34 | ReactDOM.unmountComponentAtNode(div); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/Components/ContentBox/ContentBox.css: -------------------------------------------------------------------------------- 1 | .contentBox { 2 | margin-top: 50px; 3 | width: 100%; 4 | display: block; 5 | border-radius: 1em; 6 | } -------------------------------------------------------------------------------- /src/Components/ContentBox/ContentBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './ContentBox.css' 3 | 4 | const ContentBox = ({children}) => 5 |
6 |
7 | {children} 8 |
9 |
10 | 11 | 12 | export default ContentBox; -------------------------------------------------------------------------------- /src/Components/ContentBox/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import ContentBox from './ContentBox'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | 11 | -------------------------------------------------------------------------------- /src/Components/Filters/Filters.css: -------------------------------------------------------------------------------- 1 | .filtersContainer { 2 | margin-top: 40px; 3 | display: block; 4 | height: 120px; 5 | } 6 | 7 | .filters { 8 | margin-top: 10px; 9 | float: right; 10 | } 11 | 12 | 13 | input { 14 | float: left; 15 | } 16 | 17 | @media screen and (max-width: 992px) { 18 | .filters { 19 | float: none; 20 | margin-left: 5px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Components/Filters/Filters.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Filters.css' 3 | 4 | import Search from './Search/Search'; 5 | import FilterButton from '../Buttons/FilterButton'; 6 | 7 | const Filters = ({buttons, buttonClick, searchUpdate}) => 8 |
9 | 10 |
11 | {buttons.map((button) => 12 | buttonClick(button.id)} 16 | >{button.label.toUpperCase()} 17 | 18 | )} 19 |
20 |
21 | 22 | 23 | export default Filters; -------------------------------------------------------------------------------- /src/Components/Filters/Search/Search.css: -------------------------------------------------------------------------------- 1 | input { 2 | height: 55px; 3 | width: 40%; 4 | padding: 1em; 5 | padding-left: 1.5em; 6 | margin-bottom: 10px; 7 | background-color: #F6F6F8; 8 | color: #450b0b; 9 | font-family: Roboto, Arial, sans-serif; 10 | font-size: 16px; 11 | border: 0; 12 | outline: none; 13 | border-radius: 2em; 14 | transition: all 150ms; 15 | border: solid 3px rgb(237, 237, 237); 16 | display: block; 17 | } 18 | 19 | input:focus { 20 | border: solid 3px #FF6A57; 21 | } 22 | 23 | @media screen and (max-width: 992px) { 24 | input { 25 | width: 100%; 26 | margin-bottom: 30px; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Components/Filters/Search/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Search.css' 3 | 4 | const Search = ({searchUpdate}) => 5 | 6 | 7 | 8 | export default Search; 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Components/Filters/Search/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Search from './Search'; 4 | 5 | // To Test - Number of callbacks after entering a word 6 | 7 | const searcUpdate = jest.fn(); 8 | 9 | it('renders without crashing', () => { 10 | const div = document.createElement('div'); 11 | ReactDOM.render(, div); 12 | ReactDOM.unmountComponentAtNode(div); 13 | }); 14 | 15 | -------------------------------------------------------------------------------- /src/Components/Filters/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Filters from './Filters'; 4 | import renderer from 'react-test-renderer'; 5 | import FilterButton from '../Buttons/FilterButton'; 6 | import Enzyme from 'enzyme' 7 | import { shallow, mount, render } from 'enzyme' 8 | import Adapter from 'enzyme-adapter-react-16' 9 | 10 | Enzyme.configure({ adapter: new Adapter() }) 11 | 12 | 13 | const buttons = [ 14 | { 15 | id: 0, 16 | label: "ALL", 17 | active: true, 18 | }, 19 | { 20 | id: 1, 21 | label: "DESIGN", 22 | active: false, 23 | }, 24 | { 25 | id: 2, 26 | label: "DEVELOPMENT", 27 | active: false, 28 | }, 29 | { 30 | id: 3, 31 | label: "UTILITY", 32 | active: false, 33 | } 34 | ] 35 | 36 | const onButtonClick = jest.fn() 37 | 38 | const buttonClick = (id) => { 39 | for (let i = 0; i < buttons.length; i++) { 40 | if (i === id) { 41 | buttons[i].active = true; 42 | } else { 43 | buttons[i].active = false; 44 | } 45 | } 46 | } 47 | 48 | describe('Filters', () => { 49 | it('renders without crashing', () => { 50 | const div = document.createElement('div'); 51 | ReactDOM.render(, div); 52 | ReactDOM.unmountComponentAtNode(div); 53 | }); 54 | 55 | test('Has one active button', () => { 56 | const expected = [{ 57 | id: 0, 58 | label: "ALL", 59 | active: false, 60 | }, 61 | { 62 | id: 1, 63 | label: "DESIGN", 64 | active: true, 65 | }] 66 | const wrapper = shallow() 67 | wrapper.find('.default').first().simulate('click') 68 | expect(buttons).toEqual(expect.arrayContaining(expected)); 69 | }); 70 | }); -------------------------------------------------------------------------------- /src/Components/Header/Header.css: -------------------------------------------------------------------------------- 1 | .header { 2 | width: 100%; 3 | background-color: #fff; 4 | padding: 70px 0px 50px 0px; 5 | box-shadow: 0 18px 40px 0 hsla(0, 0%, 40%, 0.02); 6 | display: block; 7 | min-height: 170px; 8 | } 9 | 10 | .a:hover, a:visited, a:link, a:active { 11 | text-decoration: none; 12 | } 13 | 14 | p { 15 | margin-top: 1px; 16 | } 17 | 18 | .title { 19 | font-family: Work Sans; 20 | font-weight: 600; 21 | font-size: 36px; 22 | } 23 | 24 | a .title { 25 | color: #292B2C; 26 | } 27 | 28 | .desc { 29 | font-family: Work Sans; 30 | font-weight: 400; 31 | font-size: 19px; 32 | line-height: 22px; 33 | } -------------------------------------------------------------------------------- /src/Components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Header.css' 3 | 4 | 5 | const Header = ({buttonText, children}) => 6 |
7 |
8 | {children} 9 |
10 |
11 | 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /src/Components/Header/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Header from './Header'; 4 | 5 | const searcUpdate = jest.fn(); 6 | 7 | it('renders without crashing', () => { 8 | const div = document.createElement('div'); 9 | ReactDOM.render(
, div); 10 | ReactDOM.unmountComponentAtNode(div); 11 | }); 12 | 13 | -------------------------------------------------------------------------------- /src/Components/Home/Home.css: -------------------------------------------------------------------------------- 1 | .viewMore { 2 | height: 80px; 3 | text-align: center; 4 | } 5 | 6 | .addTask-appear { 7 | opacity: 0.01; 8 | padding-bottom: 1px; 9 | margin-bottom: 15px; 10 | } 11 | 12 | .addTask-appear.addTask-appear-active { 13 | opacity: 1; 14 | padding-bottom: 18px; 15 | margin-bottom: 14px; 16 | transition: 700ms; 17 | } 18 | 19 | .addTask-enter { 20 | opacity: 0.01; 21 | width: 100%; 22 | height: 120px; 23 | background-color: #fff; 24 | border-radius: .75em; 25 | box-shadow: 0 2px 30px 0 hsla(0, 0%, 40%, 0.04); 26 | display: block; 27 | margin-bottom: 40px; 28 | } 29 | 30 | .addTask-enter.addTask-enter-active { 31 | opacity: 1; 32 | width: 100%; 33 | height: 150px; 34 | background-color: #fff; 35 | border-radius: .75em; 36 | box-shadow: 0 8px 30px 0 hsla(0, 0%, 40%, 0.08); 37 | display: block; 38 | margin-bottom: 40px; 39 | } -------------------------------------------------------------------------------- /src/Components/Home/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import axios from 'axios'; 3 | 4 | import Header from '../Header/Header'; 5 | import Signup from '../Signup/Signup'; 6 | import ContentBox from '../ContentBox/ContentBox'; 7 | import Filters from '../Filters/Filters'; 8 | import Product from '../Product/Product'; 9 | import MoreButton from '../Buttons/MoreButton'; 10 | import AddButton from '../Buttons/AddButton'; 11 | import './Home.css'; 12 | 13 | const PATH_BASE = 'https://studddent.com/api'; 14 | 15 | const isSearched = searchTerm => item => 16 | item.name.toLowerCase().includes(searchTerm.toLowerCase()); 17 | 18 | const vote = (id, type) => { 19 | axios.post(`${PATH_BASE}/votes/${type}`, { 20 | postid: id, 21 | postK: process.env.REACT_APP_PH, 22 | }).catch((error) => console.log(error)); 23 | } 24 | 25 | class Home extends Component { 26 | _isMounted = false; // Independent from state 27 | constructor() { 28 | super(); 29 | this.state = { 30 | count: 7, 31 | searchTerm: '', 32 | buttons: [ 33 | { 34 | id: 0, 35 | label: "All", 36 | active: false, 37 | }, 38 | { 39 | id: 1, 40 | label: "Design", 41 | active: false, 42 | }, 43 | { 44 | id: 2, 45 | label: "Development", 46 | active: false, 47 | }, 48 | { 49 | id: 3, 50 | label: "Utility", 51 | active: false, 52 | }, 53 | ], 54 | products: null, 55 | filteredProducts: null, 56 | votes: JSON.parse(localStorage.getItem('votes')) || { up: [], down: [] }, 57 | error: null, 58 | } 59 | this.filterButtonClick = this.filterButtonClick.bind(this); 60 | this.upVote = this.upVote.bind(this); 61 | this.downVote = this.downVote.bind(this); 62 | this.fetchTasks = this.fetchTasks.bind(this); 63 | this.showMore = this.showMore.bind(this); 64 | this.onSearchChange = this.onSearchChange.bind(this); 65 | } 66 | 67 | filterButtonClick(id) { 68 | let { products, filteredProducts } = this.state; 69 | const buttons = this.state.buttons 70 | for (let i = 0; i < buttons.length; i++) { 71 | if (i === id) { 72 | document.title = (id > 0) 73 | ? this.state.buttons[id].label+" Student Discounts" 74 | : "Studddent - Best Online Student Discounts."; 75 | buttons[i].active = true; 76 | filteredProducts = products.filter((product) => 77 | i === 0 || product.tags.indexOf(buttons[i].label.toLowerCase()) >= 0); 78 | this.setState({ 79 | filteredProducts, 80 | count: 7, 81 | }) 82 | } else { 83 | buttons[i].active = false; 84 | } 85 | } 86 | this.setState({ 87 | buttons, 88 | }) 89 | } 90 | 91 | upVote(id) { 92 | let { up, down } = this.state.votes; 93 | let products = this.state.products; 94 | let product = products.find(product => product._id === id) 95 | if (up.indexOf(id) < 0) { 96 | if (down.indexOf(id) >= 0) { 97 | down.splice(down.indexOf(id), 1); 98 | product.votes.down--; 99 | product.votes.up++; 100 | vote(id, 5); 101 | } else { 102 | product.votes.up++; 103 | vote(id, 3); 104 | } 105 | 106 | this.setState({ 107 | products: [...products, product], 108 | votes: { 109 | up: [...up, id], 110 | down, 111 | } 112 | }, () => localStorage.setItem('votes', JSON.stringify(this.state.votes))) 113 | } else { 114 | up.splice(up.indexOf(id), 1); 115 | product.votes.up--; 116 | vote(id, 2); 117 | this.setState({ 118 | products: [...products, product], 119 | votes: { 120 | up, 121 | down, 122 | } 123 | }, () => localStorage.setItem('votes', JSON.stringify(this.state.votes))) 124 | } 125 | } 126 | 127 | downVote(id) { 128 | let { up, down } = this.state.votes; 129 | let products = this.state.products; 130 | let product = products.find(product => product._id === id) 131 | if (down.indexOf(id) < 0) { 132 | if (up.indexOf(id) >= 0) { 133 | up.splice(up.indexOf(id), 1); 134 | product.votes.up--; 135 | product.votes.down++; 136 | vote(id, 0); 137 | } else { 138 | product.votes.down++; 139 | vote(id, 1); 140 | } 141 | this.setState({ 142 | products: [...products, product], 143 | votes: { 144 | up, 145 | down: [...down, id] 146 | } 147 | }, () => localStorage.setItem('votes', JSON.stringify(this.state.votes))) 148 | } else { 149 | down.splice(down.indexOf(id), 1); 150 | product.votes.down--; 151 | vote(id, 4); 152 | this.setState({ 153 | products: [...products, product], 154 | votes: { 155 | up, 156 | down, 157 | } 158 | }, () => localStorage.setItem('votes', JSON.stringify(this.state.votes))) 159 | } 160 | } 161 | 162 | showMore() { 163 | this.setState((prevState) => ({ 164 | count: prevState.count + 7, 165 | }) 166 | ) 167 | } 168 | 169 | fetchTasks() { 170 | const {pageId} = this.props; 171 | axios(`${PATH_BASE}/links`) 172 | .then(result => { 173 | this._isMounted && this.setState({ products: result.data, filteredProducts: result.data }) 174 | pageId >= 0 && this.filterButtonClick(pageId) 175 | }) 176 | .catch(error => this._isMounted && this.setState({ error: error })); 177 | } 178 | 179 | onSearchChange(event) { 180 | this.setState({ searchTerm: event.target.value }); 181 | } 182 | 183 | componentDidMount() { 184 | this._isMounted = true; 185 | this.fetchTasks(); 186 | } 187 | 188 | componentWillUnmount() { 189 | this._isMounted = false; 190 | } 191 | 192 | render() { 193 | const { buttons, products, votes, count, filteredProducts } = this.state; 194 | return ( 195 |
196 |
197 |

Studddent.

198 |

Best online student discounts. Resources for learning design & development.

199 | Submit a Discount 200 |
201 | 202 | 203 | {filteredProducts && 204 | filteredProducts.filter((post) => 205 | post.accepted === true) 206 | .filter(isSearched(this.state.searchTerm)) 207 | .slice(0, count).map((product) => 208 | 214 | )} 215 |
216 | {products && 217 | filteredProducts.filter(isSearched(this.state.searchTerm)).length > count && 218 |
219 | VIEW MORE 220 |
} 221 |
222 | 223 | {products && } 224 |
225 |
226 | ); 227 | } 228 | } 229 | 230 | export default Home; -------------------------------------------------------------------------------- /src/Components/Home/__snapshots__/test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Home has a valid snapshot 1`] = ` 4 |
5 |
8 |
11 | 14 |

17 | Studddent. 18 |

19 |
20 |

23 | Best online student discounts. Resources for learning design & development. 24 |

25 | 28 | 34 | 35 |
36 |
37 |
40 | 49 |
52 | 60 | 68 | 76 | 84 |
85 |
86 |
89 |
92 |
95 |
96 |
97 |
98 | `; 99 | -------------------------------------------------------------------------------- /src/Components/Home/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Home from './Home'; 4 | import renderer from 'react-test-renderer'; 5 | import Enzyme from 'enzyme' 6 | import { shallow, mount, render } from 'enzyme' 7 | import Adapter from 'enzyme-adapter-react-16' 8 | 9 | Enzyme.configure({ adapter: new Adapter() }) 10 | 11 | // Render & Snapshot 12 | 13 | describe('Home', () => { 14 | it('renders without crashing', () => { 15 | const div = document.createElement('div'); 16 | ReactDOM.render(, div); 17 | ReactDOM.unmountComponentAtNode(div); 18 | }); 19 | 20 | test('has a valid snapshot', () => { 21 | const component = renderer.create( 22 | ); 23 | let tree = component.toJSON(); 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | 27 | test('Search Updates State', () => { 28 | const wrap = mount( 29 | 30 | ) 31 | 32 | wrap.find('input').simulate('change', { 33 | target: { value: 'Microsoft' } 34 | }) 35 | 36 | wrap.update() 37 | expect(wrap.state('searchTerm')).toEqual('Microsoft'); 38 | }); 39 | 40 | }); 41 | 42 | // Backend Integration 43 | 44 | const DELAY_MS = 1000 45 | 46 | const sleep = (ms) => { 47 | return new Promise(resolve => setTimeout(resolve, ms)); 48 | } 49 | 50 | const fetchTasks = async (url) => { 51 | try { 52 | const response = await fetch(url) 53 | const responseJson = await response.json() 54 | return responseJson 55 | } 56 | catch (e) { 57 | console.log(`fetchResponseJson failed:`, e) 58 | } 59 | } 60 | 61 | describe(`API Updates State`, () => { 62 | test(`on Home componentDidMount`, async () => { 63 | const wrapper = shallow() 64 | await wrapper.instance().componentDidMount() 65 | await sleep(DELAY_MS+100) 66 | expect(wrapper.state('products')).not.toContain(null) 67 | expect(wrapper.state('error')).toEqual(null) 68 | 69 | // Force update to sync component with state 70 | wrapper.update() 71 | 72 | }) 73 | 74 | test(`on vote`, async () => { 75 | const wrapper = mount() 76 | await wrapper.instance().componentDidMount() 77 | await sleep(DELAY_MS+100) 78 | const expectedCount1 = wrapper.state('products')[0].votes.down; 79 | const expectedCount2 = expectedCount1+1; 80 | await wrapper.instance().upVote('5a9c3bfc1f50583c497f0ee9') 81 | await sleep(250); 82 | await wrapper.instance().downVote('5a9c3bfc1f50583c497f0ee9') 83 | await sleep(250); 84 | expect(wrapper.state('products')[0].votes.down).toEqual(expectedCount2) 85 | await wrapper.instance().upVote('5a9c3bfc1f50583c497f0ee9') 86 | await sleep(250); 87 | await wrapper.instance().upVote('5a9c3bfc1f50583c497f0ee9') 88 | await sleep(250); 89 | expect(wrapper.state('products')[0].votes.down).toEqual(expectedCount1) 90 | 91 | }) 92 | 93 | }) -------------------------------------------------------------------------------- /src/Components/Product/Info/Info.css: -------------------------------------------------------------------------------- 1 | .info { 2 | float: left; 3 | background-color: rgb(208, 208, 208); 4 | background-color: #fff; 5 | border-right: solid 2px #eee; 6 | border-left: solid 2px #eee; 7 | height: 100%; 8 | width: 60%; 9 | padding: 20px; 10 | } 11 | 12 | h1 { 13 | font-size: 23px; 14 | font-family: Roboto, sans-serif; 15 | font-weight: 500; 16 | } 17 | 18 | .promo { 19 | font-family: Roboto, sans-serif; 20 | margin-top: -3px; 21 | font-size: 15px; 22 | color: rgb(0, 124, 196); 23 | font-weight: 500; 24 | } 25 | 26 | .infoText { 27 | height: 80%; 28 | } 29 | 30 | @media screen and (max-width: 768px) { 31 | h1 { 32 | font-size: 21px; 33 | } 34 | 35 | .info { 36 | width: 55%; 37 | } 38 | } 39 | 40 | @media screen and (max-width: 535px) { 41 | .info { 42 | border-left: none; 43 | border-top: solid 2px #eee; 44 | border-bottom-left-radius: 0.75em; 45 | width: 70%; 46 | height: 150px; 47 | } 48 | 49 | h1 { 50 | font-size: 18px; 51 | } 52 | } -------------------------------------------------------------------------------- /src/Components/Product/Info/Info.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Info.css' 3 | 4 | import Tag from './Tag/Tag'; 5 | 6 | const Info = ({ title, promo, tags }) => 7 |
8 |
9 |

{title}

10 |
{promo}
11 |
12 |
13 | {tags.map((tag) => 14 | 15 | )} 16 |
17 |
18 | 19 | export default Info -------------------------------------------------------------------------------- /src/Components/Product/Info/Tag/Tag.css: -------------------------------------------------------------------------------- 1 | 2 | .tags { 3 | display: block; 4 | } 5 | 6 | .tag { 7 | color: rgb(138, 138, 138); 8 | margin-right: 12px; 9 | padding: 6px 14px 6px 14px; 10 | border: solid 2px #eee; 11 | border-radius: .75em; 12 | display: inline; 13 | font-size: 13px; 14 | font-weight: 600; 15 | letter-spacing: 0.8px; 16 | user-select: none; 17 | } 18 | 19 | @media screen and (max-width: 535px) { 20 | .tags { 21 | margin-left: -4px; 22 | } 23 | 24 | .tag { 25 | font-size: 11px; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Components/Product/Info/Tag/Tag.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Tag.css' 3 | 4 | const Tag = ({ label }) => 5 |
{label}
6 | 7 | export default Tag -------------------------------------------------------------------------------- /src/Components/Product/Info/Tag/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Tag from './Tag'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | 11 | -------------------------------------------------------------------------------- /src/Components/Product/Info/__snapshots__/test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Home has a valid snapshot 1`] = ` 4 |
7 |
10 |

11 |
14 |
15 |
18 |
21 | DESIGN 22 |
23 |
26 | DEVELOPMENT 27 |
28 |
31 | UTILITY 32 |
33 |
34 |

35 | `; 36 | -------------------------------------------------------------------------------- /src/Components/Product/Info/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Info from './Info'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | const tags = ['design', 'development', 'utility'] 7 | 8 | describe('Home', () => { 9 | it('renders without crashing', () => { 10 | const div = document.createElement('div'); 11 | ReactDOM.render(, div); 12 | ReactDOM.unmountComponentAtNode(div); 13 | }); 14 | 15 | test('has a valid snapshot', () => { 16 | const component = renderer.create( 17 | ); 18 | let tree = component.toJSON(); 19 | expect(tree).toMatchSnapshot(); 20 | }); 21 | }); -------------------------------------------------------------------------------- /src/Components/Product/PreviewImage/PreviewImage.css: -------------------------------------------------------------------------------- 1 | 2 | .previewImage { 3 | float: left; 4 | height: 100%; 5 | width: 20%; 6 | background-color: white; 7 | border-top-left-radius: 0.75em; 8 | border-bottom-left-radius: 0.75em; 9 | padding: 2px; 10 | white-space: nowrap; 11 | text-align: center; margin: 0 0; 12 | } 13 | 14 | img { 15 | vertical-align: middle; 16 | width: auto; 17 | max-width: 80%; 18 | max-height: 50px; 19 | 20 | } 21 | 22 | .helper { /* Vertical Center Thumbnails */ 23 | display: inline-block; 24 | height: 100%; 25 | vertical-align: middle; 26 | } 27 | 28 | @media screen and (max-width: 535px) { 29 | .previewImage { 30 | float: none; 31 | height: 100px; 32 | width: 100%; 33 | border-bottom-left-radius: 0em; 34 | } 35 | } -------------------------------------------------------------------------------- /src/Components/Product/PreviewImage/PreviewImage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './PreviewImage.css' 3 | 4 | const PreviewImage = ({ src }) => 5 |
6 | 7 | preview 8 |
9 | 10 | export default PreviewImage -------------------------------------------------------------------------------- /src/Components/Product/PreviewImage/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import PreviewImage from './PreviewImage'; 4 | 5 | 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render(, div); 9 | ReactDOM.unmountComponentAtNode(div); 10 | }); 11 | 12 | -------------------------------------------------------------------------------- /src/Components/Product/Product.css: -------------------------------------------------------------------------------- 1 | .productBox { 2 | width: 100%; 3 | height: 150px; 4 | background-color: #fff; 5 | border-radius: .75em; 6 | box-shadow: 0 8px 30px 0 hsla(0, 0%, 40%, 0.08); 7 | display: block; 8 | margin-bottom: 40px; 9 | transition: all .24s cubic-bezier(0,.5,.51,1); 10 | } 11 | 12 | .productBox:hover { 13 | transform: scale(1.00); 14 | } 15 | 16 | 17 | @media screen and (max-width: 768px) { 18 | .productBox { 19 | height: 160px; 20 | } 21 | } 22 | 23 | @media screen and (max-width: 535px) { 24 | .productBox { 25 | height: 250px; 26 | margin-bottom: 50px; 27 | } 28 | 29 | .tags { 30 | margin-left: -4px; 31 | } 32 | 33 | .tag { 34 | font-size: 11px; 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/Components/Product/Product.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Product.css' 3 | 4 | import PreviewImage from './PreviewImage/PreviewImage'; 5 | import Info from './Info/Info'; 6 | import Voting from './Voting/Voting'; 7 | 8 | const Product = ({product, upVote, downVote, votes}) => { 9 | let upIndex = votes.up.indexOf(product._id); 10 | let downIndex = votes.down.indexOf(product._id); 11 | return ( 12 |
13 | 14 | 15 | = 0 ? "up" : downIndex >= 0 ? "down" : "neutral"} /> 16 |
17 | ); 18 | } 19 | 20 | 21 | export default Product; -------------------------------------------------------------------------------- /src/Components/Product/Voting/Voting.css: -------------------------------------------------------------------------------- 1 | .voting { 2 | height: 100%; 3 | width: 20%; 4 | float: left; 5 | background-color: #fff; 6 | border-top-right-radius: 0.75em; 7 | border-bottom-right-radius: 0.75em; 8 | text-align: center; 9 | } 10 | 11 | .score { 12 | font-family: Roboto, sans-serif; 13 | font-size: 18px; 14 | font-weight: 600; 15 | margin-top: 12px; 16 | width: 100%; 17 | margin-top: 30px; 18 | margin-left: 30px; 19 | 20 | } 21 | 22 | .score a:visited, a:link, a:active { 23 | text-decoration: none; 24 | color: #ababab; 25 | } 26 | 27 | .score p { 28 | float: left; 29 | padding: 0px 15px 0px 15px; 30 | color: #717171; 31 | } 32 | 33 | .upThumb, .downThumb { 34 | cursor: pointer; 35 | } 36 | 37 | .score .upThumb, .downThumb { 38 | float: left; 39 | color: rgb(187, 187, 187); 40 | font-size: 24px; 41 | } 42 | 43 | 44 | .visit { 45 | height: 60px; 46 | } 47 | 48 | .upThumb .up { 49 | color: #0099F0; 50 | } 51 | 52 | .downThumb .down { 53 | color: #FF2F68; 54 | } 55 | 56 | @media screen and (max-width: 991px) { 57 | .score { 58 | margin-left: 20px; 59 | } 60 | } 61 | 62 | @media screen and (max-width: 768px) { 63 | .voting { 64 | width: 25%; 65 | } 66 | .score { 67 | margin-left: 14px; 68 | } 69 | } 70 | 71 | @media screen and (max-width: 535px) { 72 | .voting { 73 | border-top-right-radius: 0em; 74 | border-top: solid 2px #eee; 75 | width: 30%; 76 | height: 150px; 77 | } 78 | 79 | .score { 80 | margin-left: 7%; 81 | } 82 | 83 | .score p { 84 | padding: 0px 9% 0px 9%; 85 | } 86 | } -------------------------------------------------------------------------------- /src/Components/Product/Voting/Voting.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Voting.css' 3 | 4 | import GetButton from '../../Buttons/GetButton' 5 | 6 | const Voting = ({ product, upVote, downVote, voteClass }) => 7 |
8 |
9 | VIEW 10 |
11 |
12 |
upVote(product._id)} href="#">
13 |

{product.votes.up - product.votes.down}

14 |
downVote(product._id)}>
15 |
16 |
17 | 18 | export default Voting -------------------------------------------------------------------------------- /src/Components/Product/Voting/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Voting from './Voting'; 4 | 5 | const product = { 6 | "_id": "5a9c3bfc1f50583c497f0ee9", 7 | "name": "Student Developer Pack", 8 | "email": "calum@calum.co", 9 | "url": "https://education.github.com/pack", 10 | "feature": "Free While You're a Student", 11 | "__v": 0, 12 | "image": "https://dwa5x7aod66zk.cloudfront.net/assets/sdp-backpack-a64038716bf134f45e809ff86b9611fb97e41bbd2ccfa3181da73cf164d3c200.png", 13 | "votes": { 14 | "down": 22, 15 | "up": 391 16 | }, 17 | "tags": [ 18 | "development", 19 | "bundle" 20 | ], 21 | "features": [ 22 | "GitHub Developer Account", 23 | "$50 DigitalOcean Credit", 24 | "Free Namecheap Domain" 25 | ], 26 | "accepted": true 27 | } 28 | 29 | const upVote = jest.fn() 30 | const downVote = jest.fn() 31 | const vote = jest.fn() 32 | 33 | it('renders without crashing', () => { 34 | const div = document.createElement('div'); 35 | ReactDOM.render(, div); 36 | ReactDOM.unmountComponentAtNode(div); 37 | }); 38 | 39 | -------------------------------------------------------------------------------- /src/Components/Product/__snapshots__/test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Product has a valid snapshot 1`] = ` 4 |
7 |
10 | 13 | preview 17 |
18 |
21 |
24 |

25 | Student Developer Pack 26 |

27 |
30 | Free While You're a Student 31 |
32 |
33 |
36 |
39 | DEVELOPMENT 40 |
41 |
44 | BUNDLE 45 |
46 |
47 |
48 |
51 | 65 |
68 |
73 |