├── .babelrc
├── .eslintrc
├── .flowconfig
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── index.html
├── package.json
├── src
├── components
│ ├── App.js
│ ├── Container.js
│ ├── Header.js
│ ├── Item.js
│ ├── List.js
│ ├── Message.js
│ └── Ribbon.js
├── emitter.js
├── index.js
├── style
│ ├── App.css
│ ├── Header.css
│ ├── Item.css
│ ├── List.css
│ ├── Message.css
│ └── Ribbon.css
├── type.js
└── utils.js
├── test
├── .eslintrc
└── unit
│ ├── __snapshots__
│ └── utils.test.js.snap
│ ├── components
│ ├── App.test.js
│ ├── Container.test.js
│ ├── Header.test.js
│ ├── Item.test.js
│ ├── List.test.js
│ ├── Message.test.js
│ ├── Ribbon.test.js
│ └── __snapshots__
│ │ ├── App.test.js.snap
│ │ ├── Container.test.js.snap
│ │ ├── Header.test.js.snap
│ │ ├── Item.test.js.snap
│ │ ├── List.test.js.snap
│ │ ├── Message.test.js.snap
│ │ └── Ribbon.test.js.snap
│ ├── jest-setup.js
│ └── utils.test.js
├── webpack
├── base.js
├── webpack.config.babel.js
└── webpack.config.dev.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["env", "react", "stage-0"],
3 | "plugins": ["react-hot-loader/babel"]
4 | }
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | env:
2 | browser: true
3 | es6: true
4 |
5 | parserOptions:
6 | ecmaFeatures:
7 | jsx: true
8 |
9 | parser: "babel-eslint"
10 |
11 | plugins:
12 | - react
13 |
14 | extends:
15 | - airbnb
16 |
17 | rules:
18 | react/jsx-wrap-multilines: 0
19 | object-curly-newline: 0
20 | react/no-unused-state: 0
21 | react/sort-comp: 0
22 | react/forbid-prop-types: 0
23 | react/jsx-filename-extension: 0
24 | react/require-render-return: 0
25 | import/no-unresolved: 0
26 | no-underscore-dangle: 0
27 | no-unused-vars: 0
28 | comma-dangle: 0
29 | consistent-return: 0
30 | arrow-body-style: 0
31 | arrow-parens: 0
32 | no-unused-expressions: [2, { allowTernary: true, allowShortCircuit: true }]
33 |
--------------------------------------------------------------------------------
/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 | .*/dist/.*
3 | .*/webpack/.*
4 | .*/node_modules/.*
5 |
6 | [include]
7 |
8 | [libs]
9 |
10 | [options]
11 | esproposal.class_static_fields=enable
12 | esproposal.class_instance_fields=enable
13 | suppress_comment= \\(.\\|\n\\)*\\$FlowFixMe
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | dist
4 | .DS_Store
5 | npm-debug.log
6 | flow-typed
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "8"
5 |
6 | branches:
7 | only:
8 | - master
9 |
10 | sudo: false
11 |
12 | before_install:
13 | - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.2.1
14 | - export PATH=$HOME/.yarn/bin:$PATH
15 |
16 | cache:
17 | yarn: true
18 |
19 | script:
20 | yarn && yarn test
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright © 2017 Leo Hsieh [ http://leoj.js.org ]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-iTunes-search
2 |
3 | [](https://travis-ci.org/LeoAJ/react-iTunes-search)
4 | [](https://github.com/LeoAJ/react-iTunes-search/blob/master/LICENSE)
5 | [](http://makeapullrequest.com)
6 | [](https://nodejs.org)
7 |
8 | :musical_note: :mag: Simple web app for iTunes search with React (v16)
9 |
10 | >If you like this one and you also like Redux, there is a Redux implementation version [here](https://github.com/LeoAJ/redux-iTunes-search). Please check it out.
11 |
12 | ## Preview
13 |
14 | 
15 |
16 | ## Live Demo
17 |
18 | http://leoj.js.org/react-iTunes-search/
19 |
20 | ## Detail
21 |
22 | To know more detail, please read my [post](http://leoj.js.org/personal/React-iTunes-Search/).
23 |
24 | ## Installation
25 |
26 | 1. `git clone git@github.com:LeoAJ/react-iTunes-search.git`
27 | 2. `cd react-iTunes-search`
28 | 3. `yarn`
29 |
30 | ## Dev
31 |
32 | Run webpack dev server with [react-hot-loader 3](https://github.com/gaearon/react-hot-loader)
33 |
34 | ```
35 | yarn start
36 | ```
37 |
38 | and go to `localhost:3000`
39 |
40 | ## Deploy
41 |
42 | Use webpack minify plugin
43 |
44 | ```
45 | yarn deploy
46 | ```
47 |
48 | ## Lint
49 |
50 | run eslint, using [eslint-config-airbnb](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb) rules.
51 |
52 | ```
53 | yarn lint
54 | ```
55 |
56 | ## Flow
57 |
58 | ```
59 | flow-typed install
60 | yarn flow
61 | ```
62 |
63 | ## Unit test
64 |
65 | Use `Jest`
66 |
67 | ```
68 | yarn jest
69 | ```
70 |
71 | ### Test
72 |
73 | run both `lint`, `flow` and `jest`
74 |
75 | ```
76 | yarn test
77 | ```
78 |
79 | ## Built with
80 |
81 | * React (v16)
82 | * Babel 6
83 | * ES6/ES7 (async/await)
84 | * Webpack
85 | * React-hot-loader 3
86 | * PostCSS
87 | * Eslint
88 | * Materialize
89 | * iTunes search API
90 | * Flow
91 | * Jest
92 | * Enzyme
93 |
94 | ## License
95 |
96 | MIT
97 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | iTunes Search by React
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-iTunes-search",
3 | "version": "2.2.2",
4 | "description": "simple web app for iTunes Search implemented by React",
5 | "keywords": [
6 | "react",
7 | "iTunes",
8 | "search",
9 | "apple",
10 | "babel",
11 | "ES6"
12 | ],
13 | "repository": {
14 | "type": "git",
15 | "url": "git@github.com:LeoAJ/react-facebook-friends.git"
16 | },
17 | "author": "Leo Hsieh (http://leoj.js.org)",
18 | "license": "MIT",
19 | "scripts": {
20 | "coverage": "jest --coverage",
21 | "jest:watch": "jest --watch",
22 | "test": "yarn lint && yarn flow && yarn jest",
23 | "jest:update": "jest --u test/unit",
24 | "jest": "jest test/unit",
25 | "flow": "flow",
26 | "clean": "rimraf dist/",
27 | "build": "webpack --config ./webpack/webpack.config.babel.js --progress --colors --define process.env.NODE_ENV='\"production\"'",
28 | "dev": "webpack-dev-server --config ./webpack/webpack.config.dev.js",
29 | "deploy": "yarn clean && yarn build",
30 | "lint": "eslint src/** webpack/** test/**",
31 | "start": "yarn dev"
32 | },
33 | "dependencies": {
34 | "autoprefixer": "^7.1.6",
35 | "babel-polyfill": "^6.26.0",
36 | "classnames": "^2.2.3",
37 | "fecha": "^2.2.0",
38 | "react": "^16.0.0",
39 | "react-dom": "^16.0.0",
40 | "react-hot-loader": "^3.0.0-beta.6"
41 | },
42 | "devDependencies": {
43 | "babel-core": "^6.7.2",
44 | "babel-eslint": "^8.0.1",
45 | "babel-jest": "^21.2.0",
46 | "babel-loader": "^7.0.0",
47 | "babel-preset-env": "^1.6.0",
48 | "babel-preset-react": "^6.5.0",
49 | "babel-preset-stage-0": "^6.5.0",
50 | "css-loader": "^0.28.1",
51 | "enzyme": "^3.1.0",
52 | "enzyme-adapter-react-16": "^1.0.2",
53 | "enzyme-to-json": "^3.1.4",
54 | "eslint": "^4.8.0",
55 | "eslint-config-airbnb": "^16.0.0",
56 | "eslint-plugin-import": "^2.7.0",
57 | "eslint-plugin-jsx-a11y": "^6.0.2",
58 | "eslint-plugin-react": "^7.4.0",
59 | "file-loader": "^1.1.4",
60 | "flow-bin": "^0.59.0",
61 | "identity-obj-proxy": "^3.0.0",
62 | "imports-loader": "^0.7.0",
63 | "jest": "^21.2.1",
64 | "postcss-loader": "^2.0.3",
65 | "prettier": "^1.3.1",
66 | "raf": "^3.4.0",
67 | "rimraf": "^2.5.0",
68 | "style-loader": "^0.19.0",
69 | "url-loader": "^0.6.2",
70 | "webpack": "^3.6.0",
71 | "webpack-dev-server": "^2.4.2"
72 | },
73 | "jest": {
74 | "setupFiles": [
75 | "raf/polyfill",
76 | "./test/unit/jest-setup.js"
77 | ],
78 | "collectCoverageFrom": [
79 | "src/**.js",
80 | "!src/index.js",
81 | "!src/type.js"
82 | ],
83 | "snapshotSerializers": [
84 | "enzyme-to-json/serializer"
85 | ],
86 | "moduleNameMapper": {
87 | "\\.(css)$": "identity-obj-proxy"
88 | },
89 | "testPathIgnorePatterns": [
90 | "/node_modules/"
91 | ]
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Header from './Header';
3 | import Container from './Container';
4 | import Ribbon from './Ribbon';
5 | import '../style/App.css';
6 |
7 | const App = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default App;
18 |
--------------------------------------------------------------------------------
/src/components/Container.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import React from 'react';
3 | import List from './List';
4 | import Message from './Message';
5 | import emitter from '../emitter';
6 | import { getApiUrl } from '../utils';
7 | import type { HeaderState, ContainerState } from '../type';
8 |
9 | class Container extends React.PureComponent<{}, ContainerState> {
10 | state: ContainerState = {
11 | status: 'init',
12 | data: {}
13 | };
14 |
15 | async getSearchResult(headerState: HeaderState) {
16 | try {
17 | this.setState({ status: 'loading' });
18 | const resp = await fetch(getApiUrl(headerState));
19 | const json = await resp.json();
20 | this.setState({
21 | data: { ...json },
22 | status: json.resultCount ? '' : 'noContent'
23 | });
24 | } catch (e) {
25 | this.setState({ status: 'error' });
26 | }
27 | }
28 |
29 | componentDidMount() {
30 | emitter.on('search', this.getSearchResult.bind(this));
31 | }
32 |
33 | componentWillUnmount() {
34 | emitter.removeListener('search');
35 | }
36 |
37 | render() {
38 | const { status, data } = this.state;
39 | return (
40 |
41 | {status.length ? :
}
42 |
43 | );
44 | }
45 | }
46 |
47 | export default Container;
48 |
--------------------------------------------------------------------------------
/src/components/Header.js:
--------------------------------------------------------------------------------
1 | /* global $ */
2 | // @flow
3 | /* eslint-disable jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events */
4 | import React from 'react';
5 | import emitter from '../emitter';
6 | import type { HeaderState, SearchOption } from '../type';
7 | import '../style/Header.css';
8 |
9 | const options: Array = [
10 | 'All',
11 | 'Audiobook',
12 | 'eBook',
13 | 'Movie',
14 | 'Music',
15 | 'Music Video',
16 | 'Podcast',
17 | 'TV Show',
18 | 'Software'
19 | ];
20 |
21 | class Header extends React.PureComponent<{}, HeaderState> {
22 | emitSearch: () => void;
23 | _onKeyUp: (e: Object) => void;
24 | _onClick: (e: Object) => void;
25 | _update: (e: Object) => Function;
26 | ticking: boolean;
27 | rAf: any;
28 |
29 | state: HeaderState = {
30 | media: 'All',
31 | query: ''
32 | };
33 |
34 | constructor(props: Object) {
35 | super(props);
36 | this.ticking = false;
37 | this.rAf = null;
38 | this.emitSearch = () => emitter.emit('search', this.state);
39 | this._onClick = e => this.setState(
40 | { media: e.target.textContent },
41 | () => (this.state.query.length ? this.emitSearch() : null)
42 | );
43 | this._update = ({ keyCode, target: { value: query } }) => _ => {
44 | this.setState({ query }, () => keyCode === 13 && this.emitSearch());
45 | this.ticking = false;
46 | };
47 | this._onKeyUp = e => {
48 | if (!this.ticking) {
49 | this.rAf = window.requestAnimationFrame(this._update(e));
50 | this.ticking = true;
51 | }
52 | };
53 | }
54 |
55 | renderSearchOption = () => options.map(opt =>
56 |
60 |
65 | {opt}
66 |
67 | );
68 |
69 | componentWillUnmount() {
70 | window.cancelAnimationFrame(this.rAf);
71 | }
72 |
73 | render() {
74 | return (
75 |
76 |
101 |
102 | );
103 | }
104 | }
105 |
106 | export default Header;
107 |
--------------------------------------------------------------------------------
/src/components/Item.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import React, { PropTypes } from 'react';
3 | import { getKind, kindColorMap } from '../utils';
4 | import type { SearchResult } from '../type';
5 | import '../style/Item.css';
6 |
7 | const Item = (props: SearchResult) => (
8 |
9 |
10 |
11 |

16 |
17 |
18 |
{props.trackName || props.collectionName}more_vert
19 |
20 |
21 | more
22 |
23 | {
24 | getKind(props.kind).length ?
25 |
26 | {getKind(props.kind)}
27 |
: null
28 | }
29 |
30 |
31 |
32 |
{props.trackName || props.collectionName}close
33 |
{props.longDescription || props.description || 'No description.'}
34 |
35 |
36 |
37 | );
38 |
39 | export default Item;
40 |
--------------------------------------------------------------------------------
/src/components/List.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import React, { PropTypes } from 'react';
3 | import Item from './Item';
4 | import type { SearchResult } from '../type';
5 | import '../style/List.css';
6 |
7 | const List = ({
8 | results,
9 | resultCount
10 | }: {
11 | results: Array,
12 | resultCount: number
13 | }) => (
14 |
15 | {resultCount > 0 ? results.map((item, i) => ) : null}
16 |
17 | );
18 |
19 | export default List;
20 |
--------------------------------------------------------------------------------
/src/components/Message.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import React, { PropTypes } from 'react';
3 | import '../style/Message.css';
4 |
5 | const msgMap: Object = {
6 | init: {
7 | icon: 'music_note',
8 | msg: 'Welcome back!'
9 | },
10 | loading: {
11 | msg: 'Loading...'
12 | },
13 | noContent: {
14 | icon: 'info',
15 | msg: 'No match'
16 | },
17 | error: {
18 | icon: 'error',
19 | msg: 'Error!'
20 | }
21 | };
22 |
23 | const spinner = _ => (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 |
40 | const Message = ({ status }: { status: string }) => (
41 |
42 | {status === 'loading' ? spinner() : {msgMap[status].icon}}
43 | {msgMap[status].msg}
44 |
45 | );
46 |
47 | export default Message;
48 |
--------------------------------------------------------------------------------
/src/components/Ribbon.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import '../style/Ribbon.css';
3 |
4 | const Ribbon = () => (
5 |
12 |
31 |
32 | );
33 |
34 | export default Ribbon;
35 |
--------------------------------------------------------------------------------
/src/emitter.js:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'events';
2 |
3 | export default new EventEmitter();
4 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import 'babel-polyfill';
2 | import { render } from 'react-dom';
3 | import { AppContainer } from 'react-hot-loader';
4 | import React from 'react';
5 | import App from './components/App';
6 |
7 | const renderApp = Comp => render(
8 |
9 |
10 | ,
11 | document.querySelector('#itunes-search')
12 | );
13 |
14 | renderApp(App);
15 |
16 | if (module.hot) {
17 | module.hot.accept('./components/App', _ => renderApp(App));
18 | }
19 |
--------------------------------------------------------------------------------
/src/style/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | background: #eeeeee;
3 | }
4 |
--------------------------------------------------------------------------------
/src/style/Header.css:
--------------------------------------------------------------------------------
1 | .dropdown-wrapper {
2 | text-align: center;
3 | position: relative;
4 | width: 100px;
5 | left: 60px;
6 | display: block;
7 | padding: 0 10px;
8 | }
9 |
10 | .dropdown-wrapper:hover {
11 | background: #009688;
12 | }
13 |
14 | .nav-wrapper {
15 | display: flex;
16 | justify-content: center;
17 | }
18 |
19 | .header-search-wrapper {
20 | position: relative;
21 | width: 30vw;
22 | }
23 |
24 | .header-search-wrapper i {
25 | position: absolute;
26 | left: 12px;
27 | }
28 |
29 | .header-search-wrapper input.search-input {
30 | background: rgba(255, 255, 255, .3);
31 | border-radius: 3px;
32 | padding-left: 50px;
33 | }
34 |
35 | .btn-wrapper {
36 | margin: auto 10px;
37 | }
38 |
39 | ul li.select {
40 | font-weight: 600;
41 | background: #e0e0e0;
42 | }
43 |
--------------------------------------------------------------------------------
/src/style/Item.css:
--------------------------------------------------------------------------------
1 | .card-wrapper {
2 | display: inline-block;
3 | width: 300px;
4 | }
5 |
6 | .kind {
7 | padding: 2px 4px;
8 | font-weight: 300;
9 | font-size: 0.8em;
10 | border-radius: 2px;
11 | }
12 |
--------------------------------------------------------------------------------
/src/style/List.css:
--------------------------------------------------------------------------------
1 | .list-wrapper {
2 | padding: 10px 45px;
3 | /* column width */
4 | column-width: 300px;
5 | /* space between columns */
6 | column-gap: 2px;
7 | }
8 |
--------------------------------------------------------------------------------
/src/style/Message.css:
--------------------------------------------------------------------------------
1 | #itunes-search .toast {
2 | justify-content: baseline;
3 | width: 200px;
4 | margin: 0 auto;
5 | }
6 |
7 | .info {
8 | background: rgb(50, 50, 50);
9 | color: white;
10 | }
11 |
12 | .text {
13 | margin-left: 10px;
14 | }
15 |
16 | /* from: http://tobiasahlin.com/spinkit/ */
17 | .sk-circle {
18 | width: 30px;
19 | height: 30px;
20 | position: relative;
21 | }
22 | .sk-circle .sk-child {
23 | width: 100%;
24 | height: 100%;
25 | position: absolute;
26 | left: 0;
27 | top: 0;
28 | }
29 | .sk-circle .sk-child:before {
30 | content: '';
31 | display: block;
32 | margin: 0 auto;
33 | width: 15%;
34 | height: 15%;
35 | background-color: white;
36 | border-radius: 100%;
37 | -webkit-animation: sk-circleBounceDelay 1.2s infinite ease-in-out both;
38 | animation: sk-circleBounceDelay 1.2s infinite ease-in-out both;
39 | }
40 | .sk-circle .sk-circle2 {
41 | -webkit-transform: rotate(30deg);
42 | -ms-transform: rotate(30deg);
43 | transform: rotate(30deg); }
44 | .sk-circle .sk-circle3 {
45 | -webkit-transform: rotate(60deg);
46 | -ms-transform: rotate(60deg);
47 | transform: rotate(60deg); }
48 | .sk-circle .sk-circle4 {
49 | -webkit-transform: rotate(90deg);
50 | -ms-transform: rotate(90deg);
51 | transform: rotate(90deg); }
52 | .sk-circle .sk-circle5 {
53 | -webkit-transform: rotate(120deg);
54 | -ms-transform: rotate(120deg);
55 | transform: rotate(120deg); }
56 | .sk-circle .sk-circle6 {
57 | -webkit-transform: rotate(150deg);
58 | -ms-transform: rotate(150deg);
59 | transform: rotate(150deg); }
60 | .sk-circle .sk-circle7 {
61 | -webkit-transform: rotate(180deg);
62 | -ms-transform: rotate(180deg);
63 | transform: rotate(180deg); }
64 | .sk-circle .sk-circle8 {
65 | -webkit-transform: rotate(210deg);
66 | -ms-transform: rotate(210deg);
67 | transform: rotate(210deg); }
68 | .sk-circle .sk-circle9 {
69 | -webkit-transform: rotate(240deg);
70 | -ms-transform: rotate(240deg);
71 | transform: rotate(240deg); }
72 | .sk-circle .sk-circle10 {
73 | -webkit-transform: rotate(270deg);
74 | -ms-transform: rotate(270deg);
75 | transform: rotate(270deg); }
76 | .sk-circle .sk-circle11 {
77 | -webkit-transform: rotate(300deg);
78 | -ms-transform: rotate(300deg);
79 | transform: rotate(300deg); }
80 | .sk-circle .sk-circle12 {
81 | -webkit-transform: rotate(330deg);
82 | -ms-transform: rotate(330deg);
83 | transform: rotate(330deg); }
84 | .sk-circle .sk-circle2:before {
85 | -webkit-animation-delay: -1.1s;
86 | animation-delay: -1.1s; }
87 | .sk-circle .sk-circle3:before {
88 | -webkit-animation-delay: -1s;
89 | animation-delay: -1s; }
90 | .sk-circle .sk-circle4:before {
91 | -webkit-animation-delay: -0.9s;
92 | animation-delay: -0.9s; }
93 | .sk-circle .sk-circle5:before {
94 | -webkit-animation-delay: -0.8s;
95 | animation-delay: -0.8s; }
96 | .sk-circle .sk-circle6:before {
97 | -webkit-animation-delay: -0.7s;
98 | animation-delay: -0.7s; }
99 | .sk-circle .sk-circle7:before {
100 | -webkit-animation-delay: -0.6s;
101 | animation-delay: -0.6s; }
102 | .sk-circle .sk-circle8:before {
103 | -webkit-animation-delay: -0.5s;
104 | animation-delay: -0.5s; }
105 | .sk-circle .sk-circle9:before {
106 | -webkit-animation-delay: -0.4s;
107 | animation-delay: -0.4s; }
108 | .sk-circle .sk-circle10:before {
109 | -webkit-animation-delay: -0.3s;
110 | animation-delay: -0.3s; }
111 | .sk-circle .sk-circle11:before {
112 | -webkit-animation-delay: -0.2s;
113 | animation-delay: -0.2s; }
114 | .sk-circle .sk-circle12:before {
115 | -webkit-animation-delay: -0.1s;
116 | animation-delay: -0.1s; }
117 |
118 | @-webkit-keyframes sk-circleBounceDelay {
119 | 0%, 80%, 100% {
120 | -webkit-transform: scale(0);
121 | transform: scale(0);
122 | } 40% {
123 | -webkit-transform: scale(1);
124 | transform: scale(1);
125 | }
126 | }
127 |
128 | @keyframes sk-circleBounceDelay {
129 | 0%, 80%, 100% {
130 | -webkit-transform: scale(0);
131 | transform: scale(0);
132 | } 40% {
133 | -webkit-transform: scale(1);
134 | transform: scale(1);
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/style/Ribbon.css:
--------------------------------------------------------------------------------
1 | .github-corner {
2 | position: fixed;
3 | top: 0;
4 | right: 0;
5 | z-index: 1000;
6 | }
7 |
8 | .github-corner:hover .octo-arm {
9 | animation: octocat-wave 560ms ease-in-out;
10 | }
11 |
12 | .svg {
13 | z-index: 1000;
14 | fill: #FD6C6C;
15 | color: #fff;
16 | position: absolute;
17 | top: 0;
18 | border: 0;
19 | right: 0;
20 | }
21 |
22 | .path {
23 | transform-origin: 130px 106px;
24 | }
25 |
26 | @keyframes octocat-wave {
27 | 0%,
28 | 100% {
29 | transform: rotate(0);
30 | }
31 | 20%,
32 | 60% {
33 | transform: rotate(-25deg);
34 | }
35 | 40%,
36 | 80% {
37 | transform: rotate(10deg);
38 | }
39 | }
40 | @media (max-width: 500px) {
41 | .github-corner:hover .octo-arm {
42 | animation: none;
43 | }
44 | .github-corner .octo-arm {
45 | animation: octocat-wave 560ms ease-in-out;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/type.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | export type ContainerState = {
3 | status: 'init' | 'loading' | 'noContent' | 'error' | '',
4 | data?: Object
5 | };
6 |
7 | export type SearchOption = 'All' | 'Audiobook' | 'eBook' | 'Movie' | 'Music' | 'Music Video' | 'Podcast' | 'TV Show' | 'Short Film' | 'Software';
8 |
9 | export type HeaderState = {
10 | media: SearchOption,
11 | query: string
12 | };
13 |
14 | export type SearchResult = {
15 | trackId: number,
16 | trackPrice?: number,
17 | collectionPrice?: number,
18 | price?: number,
19 | trackViewUrl?: string,
20 | collectionViewUrl?: string,
21 | artworkUrl100: string,
22 | trackName?: string,
23 | collectionName?: string,
24 | kind: string,
25 | longDescription?: string,
26 | description?: string
27 | };
28 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import type { HeaderState } from './type';
3 |
4 | export const kindColorMap: Object = {
5 | song: 'teal',
6 | 'feature-movie': 'pink',
7 | ebook: 'blue',
8 | 'music-video': 'orange',
9 | podcast: 'green',
10 | 'tv-episode': 'red',
11 | software: 'indigo'
12 | };
13 |
14 | export const capitalize = (str: string): string => (`${str[0].toUpperCase()}${str.slice(1).toLowerCase()}`);
15 |
16 | export function getMedia(str: string): string {
17 | if (str.indexOf(' ') === -1) {
18 | return str.toLowerCase();
19 | }
20 | const sg = str.split(' ');
21 | return `${sg[0].toLowerCase()}${capitalize(sg[1])}`;
22 | }
23 |
24 | export const getApiUrl = ({
25 | media,
26 | query
27 | }: HeaderState) => `https://itunes.apple.com/search?media=${getMedia(media || 'all')}&term=${query.split(' ').join('+')}`;
28 |
29 | export function getKind(str: string): string {
30 | if (typeof str !== 'string') {
31 | return '';
32 | }
33 |
34 | if (str === 'tv') {
35 | return 'TV';
36 | } else if (str === 'feature') {
37 | return '';
38 | }
39 |
40 | if (str.indexOf('-') === -1) {
41 | return capitalize(str);
42 | }
43 | const sg = str.split('-');
44 | return `${getKind(sg[0])} ${capitalize(sg[1])}`.trim();
45 | }
46 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends" : "../.eslintrc",
3 | "env" : {
4 | "jest" : true
5 | },
6 | "rules": {
7 | "max-len": 0
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/test/unit/__snapshots__/utils.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`utils.js capitalize should be able to capitalize words 1`] = `"Abc"`;
4 |
5 | exports[`utils.js capitalize should be able to capitalize words 2`] = `"Abc"`;
6 |
7 | exports[`utils.js capitalize should be able to capitalize words 3`] = `"Abc"`;
8 |
9 | exports[`utils.js getApiUrl should return correct API endpoint 1`] = `"https://itunes.apple.com/search?media=music&term=bsb"`;
10 |
11 | exports[`utils.js getApiUrl should return correct API endpoint without media type 1`] = `"https://itunes.apple.com/search?media=all&term=bsb"`;
12 |
13 | exports[`utils.js getKind should return correct format for UI 1`] = `"Movie"`;
14 |
15 | exports[`utils.js getKind should return correct format for UI 2`] = `"TV"`;
16 |
17 | exports[`utils.js getKind should return correct format for UI 3`] = `"Song"`;
18 |
19 | exports[`utils.js getMedia should return proper term for API usage 1`] = `"tvShow"`;
20 |
--------------------------------------------------------------------------------
/test/unit/components/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import App from '../../../src/components/App';
4 |
5 | describe('', () => {
6 | test('App component should renders correctly', () => {
7 | const component = shallow();
8 | expect(component).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/test/unit/components/Container.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import Container from '../../../src/components/Container';
4 |
5 | const mockData = {
6 | resultCount: 1,
7 | results: [
8 | {
9 | trackId: '1',
10 | trackPrice: 0.99,
11 | artworkUrl100: '',
12 | trackName: '',
13 | description: ''
14 | }
15 | ]
16 | };
17 |
18 | const mockResponse = {
19 | ok: true,
20 | json: _ => mockData
21 | };
22 |
23 | global.fetch = jest.fn().mockImplementation(_ => new Promise((resolve, reject) => resolve(mockResponse)));
24 |
25 | describe('', () => {
26 | let component;
27 | beforeEach(() => {
28 | component = shallow();
29 | });
30 | test('init state', () => {
31 | expect(component).toMatchSnapshot();
32 | });
33 | test('loading state', () => {
34 | component.setState({ status: 'loading' });
35 | expect(component).toMatchSnapshot();
36 | });
37 | test('show data', () => {
38 | const data = {
39 | resultCount: 2,
40 | results: [
41 | {
42 | trackName: 'test1'
43 | },
44 | {
45 | trackName: 'test2'
46 | }
47 | ]
48 | };
49 | component.setState({
50 | status: '',
51 | data
52 | });
53 | expect(component).toMatchSnapshot();
54 | });
55 | test('no search results state', () => {
56 | component.setState({ status: 'noContent' });
57 | expect(component).toMatchSnapshot();
58 | });
59 | test('error state', () => {
60 | component.setState({ status: 'error' });
61 | expect(component).toMatchSnapshot();
62 | });
63 |
64 | test('getSearchResult should fetch data', async () => {
65 | const state = {
66 | media: 'music',
67 | query: 'bsb'
68 | };
69 | const instance = component.instance();
70 | await instance.getSearchResult(state);
71 | expect(component.state()).toMatchSnapshot();
72 | });
73 |
74 | test('should set error state if there is a fetch error', async () => {
75 | const instance = component.instance();
76 | await instance.getSearchResult();
77 | expect(component.state()).toMatchSnapshot();
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/test/unit/components/Header.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import Header from '../../../src/components/Header';
4 |
5 | describe('', () => {
6 | let component;
7 | let instance;
8 | let mockEvent;
9 | beforeEach(() => {
10 | mockEvent = {
11 | keyCode: 13,
12 | target: {
13 | value: 'test'
14 | }
15 | };
16 | component = shallow();
17 | instance = component.instance();
18 | });
19 | test('Header component should renders correctly', () => {
20 | expect(component).toMatchSnapshot();
21 | });
22 | test('_onKeyUp should start requestAnimationFrame', () => {
23 | instance._onKeyUp(mockEvent);
24 | expect(instance.ticking).toBeTruthy();
25 | expect(instance.rAf).toBeDefined();
26 | });
27 | test('_update should set new state', () => {
28 | instance._update(mockEvent)();
29 | expect(component.state().query).toBe('test');
30 | expect(instance.ticking).toBeFalsy();
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/test/unit/components/Item.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import Item from '../../../src/components/Item';
4 |
5 | describe(' ', () => {
6 | test('Item component should renders correctly', () => {
7 | const props = {
8 | artworkUrl100: '/100x100/',
9 | trackName: '',
10 | trackViewUrl: '',
11 | longDescription: ''
12 | };
13 | const component = shallow( );
14 | expect(component).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/test/unit/components/List.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import List from '../../../src/components/List';
4 |
5 | describe('
', () => {
6 | test('List component should renders correctly', () => {
7 | const props = {
8 | resultCount: 2,
9 | results: [
10 | {
11 | trackId: '1',
12 | trackPrice: 0.99,
13 | artworkUrl100: '',
14 | trackName: '',
15 | description: ''
16 | },
17 | {
18 | trackId: '2',
19 | trackPrice: 1.99,
20 | artworkUrl100: '',
21 | trackName: '',
22 | description: ''
23 | }
24 | ]
25 | };
26 | const component = shallow(
);
27 | expect(component).toMatchSnapshot();
28 | });
29 |
30 | test('no results', () => {
31 | const props = {
32 | resultCount: 0,
33 | results: []
34 | };
35 | const component = shallow(
);
36 | expect(component).toMatchSnapshot();
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/test/unit/components/Message.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import Message from '../../../src/components/Message';
4 |
5 | describe('', () => {
6 | test('Message component should renders correctly', () => {
7 | const props = {
8 | status: 'loading'
9 | };
10 | const component = shallow();
11 | expect(component).toMatchSnapshot();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/test/unit/components/Ribbon.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import Ribbon from '../../../src/components/Ribbon';
4 |
5 | describe('', () => {
6 | test('Ribbon component should renders correctly', () => {
7 | const component = shallow();
8 | expect(component).toMatchSnapshot();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/test/unit/components/__snapshots__/App.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` App component should renders correctly 1`] = `
4 |
5 |
6 |
7 |
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/test/unit/components/__snapshots__/Container.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` error state 1`] = `
4 |
5 |
8 |
9 | `;
10 |
11 | exports[` getSearchResult should fetch data 1`] = `
12 | Object {
13 | "data": Object {
14 | "resultCount": 1,
15 | "results": Array [
16 | Object {
17 | "artworkUrl100": "",
18 | "description": "",
19 | "trackId": "1",
20 | "trackName": "",
21 | "trackPrice": 0.99,
22 | },
23 | ],
24 | },
25 | "status": "",
26 | }
27 | `;
28 |
29 | exports[` init state 1`] = `
30 |
31 |
34 |
35 | `;
36 |
37 | exports[` loading state 1`] = `
38 |
39 |
42 |
43 | `;
44 |
45 | exports[` no search results state 1`] = `
46 |
47 |
50 |
51 | `;
52 |
53 | exports[` should set error state if there is a fetch error 1`] = `
54 | Object {
55 | "data": Object {},
56 | "status": "error",
57 | }
58 | `;
59 |
60 | exports[` show data 1`] = `
61 |
62 |
75 |
76 | `;
77 |
--------------------------------------------------------------------------------
/test/unit/components/__snapshots__/Header.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` Header component should renders correctly 1`] = `
4 |
7 |
152 |
153 | `;
154 |
--------------------------------------------------------------------------------
/test/unit/components/__snapshots__/Item.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` Item component should renders correctly 1`] = `
4 |
7 |
10 |
13 |

18 |
19 |
39 |
42 |
45 |
48 | close
49 |
50 |
51 |
52 | No description.
53 |
54 |
55 |
56 |
57 | `;
58 |
--------------------------------------------------------------------------------
/test/unit/components/__snapshots__/List.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`
List component should renders correctly 1`] = `
4 |
7 |
15 |
23 |
24 | `;
25 |
26 | exports[`
no results 1`] = `
27 |
30 | `;
31 |
--------------------------------------------------------------------------------
/test/unit/components/__snapshots__/Message.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` Message component should renders correctly 1`] = `
4 |
7 |
10 |
13 |
16 |
19 |
22 |
25 |
28 |
31 |
34 |
37 |
40 |
43 |
46 |
47 |
50 | Loading...
51 |
52 |
53 | `;
54 |
--------------------------------------------------------------------------------
/test/unit/components/__snapshots__/Ribbon.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` Ribbon component should renders correctly 1`] = `
4 |
11 |
32 |
33 | `;
34 |
--------------------------------------------------------------------------------
/test/unit/jest-setup.js:
--------------------------------------------------------------------------------
1 | import Enzyme, { shallow, render, mount } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | Enzyme.configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/test/unit/utils.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | capitalize,
3 | getApiUrl,
4 | getMedia,
5 | getKind
6 | } from '../../src/utils';
7 |
8 | describe('utils.js', () => {
9 | test('capitalize should be able to capitalize words', () => {
10 | const result = 'Abc';
11 | expect(capitalize('abc')).toMatchSnapshot();
12 | expect(capitalize('aBc')).toMatchSnapshot();
13 | expect(capitalize('ABC')).toMatchSnapshot();
14 | });
15 |
16 | test('getMedia should return proper term for API usage', () => {
17 | expect(getMedia('TV Show')).toMatchSnapshot();
18 | });
19 |
20 | test('getApiUrl should return correct API endpoint', () => {
21 | expect(getApiUrl({
22 | media: 'music',
23 | query: 'bsb'
24 | })).toMatchSnapshot();
25 | });
26 |
27 | test('getApiUrl should return correct API endpoint without media type', () => {
28 | expect(getApiUrl({
29 | query: 'bsb'
30 | })).toMatchSnapshot();
31 | });
32 |
33 | test('getKind should return correct format for UI', () => {
34 | expect(getKind('feature-movie')).toMatchSnapshot();
35 | expect(getKind('tv')).toMatchSnapshot();
36 | expect(getKind('song')).toMatchSnapshot();
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/webpack/base.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require('autoprefixer');
2 |
3 | module.exports = {
4 | module: {
5 | rules: [{
6 | test: /\.css$/,
7 | exclude: /node_modules/,
8 | use: [
9 | 'style-loader',
10 | 'css-loader',
11 | {
12 | loader: 'postcss-loader',
13 | options: {
14 | plugins: _ => [autoprefixer({ browsers: ['last 2 versions'] })]
15 | }
16 | }
17 | ]
18 | }, {
19 | test: /\.(woff|woff2|ttf|svg)$/,
20 | exclude: /node_modules/,
21 | use: [
22 | 'url-loader?limit=100000'
23 | ],
24 | }, {
25 | test: /\.(eot|png)$/,
26 | exclude: /node_modules/,
27 | use: [
28 | 'file-loader'
29 | ]
30 | }]
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/webpack/webpack.config.babel.js:
--------------------------------------------------------------------------------
1 | // import path from 'path';
2 | // import webpack from 'webpack';
3 | // import webpackConfig from './base';
4 |
5 | const path = require('path');
6 | const webpack = require('webpack');
7 | const webpackConfig = require('./base');
8 |
9 | webpackConfig.module.rules.push({
10 | test: /\.js$/,
11 | exclude: /node_modules/,
12 | use: ['babel-loader']
13 | });
14 |
15 | module.exports = Object.assign({}, webpackConfig, {
16 | devtool: 'source-map',
17 | entry: './src',
18 | output: {
19 | path: path.resolve(__dirname, '../', 'dist/'),
20 | filename: 'bundle.js'
21 | },
22 | plugins: [
23 | new webpack.optimize.UglifyJsPlugin({
24 | output: {
25 | comments: false
26 | },
27 | compress: {
28 | warnings: false
29 | }
30 | })
31 | ]
32 | });
33 |
--------------------------------------------------------------------------------
/webpack/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const webpackConfig = require('./base');
4 |
5 | webpackConfig.module.rules.push({
6 | test: /\.js$/,
7 | exclude: /node_modules/,
8 | use: [
9 | 'react-hot-loader/webpack',
10 | 'babel-loader'
11 | ]
12 | });
13 |
14 | module.exports = Object.assign({}, webpackConfig, {
15 | devtool: 'cheap-module-eval-source-map',
16 | entry: [
17 | 'babel-polyfill',
18 | 'react-hot-loader/patch',
19 | 'webpack-dev-server/client?http://localhost:3000',
20 | 'webpack/hot/only-dev-server',
21 | './src'
22 | ],
23 | output: {
24 | publicPath: '/dist/',
25 | path: path.resolve(__dirname, '../', 'dist/'),
26 | filename: 'bundle.js'
27 | },
28 | plugins: [
29 | new webpack.HotModuleReplacementPlugin(),
30 | new webpack.NamedModulesPlugin()
31 | ],
32 | devServer: {
33 | port: 3000,
34 | hot: true,
35 | compress: false,
36 | historyApiFallback: true,
37 | stats: {
38 | colors: true,
39 | timings: true,
40 | version: true,
41 | warnings: true
42 | }
43 | }
44 | });
45 |
--------------------------------------------------------------------------------