diff --git a/media-store/app-src/.babelrc b/media-store/app-src/.babelrc
new file mode 100644
index 00000000..d3472902
--- /dev/null
+++ b/media-store/app-src/.babelrc
@@ -0,0 +1,5 @@
+{
+ "presets": ["@babel/preset-react", "@babel/preset-env"],
+ "plugins": ["@babel/plugin-transform-runtime", "babel-plugin-syntax-dynamic-import"]
+}
+
\ No newline at end of file
diff --git a/media-store/app-src/.eslintrc.json b/media-store/app-src/.eslintrc.json
new file mode 100644
index 00000000..537def27
--- /dev/null
+++ b/media-store/app-src/.eslintrc.json
@@ -0,0 +1,41 @@
+{
+ "env": {
+ "browser": true,
+ "es2020": true
+ },
+ "extends": ["plugin:react/recommended", "airbnb", "prettier"],
+ "parserOptions": {
+ "ecmaFeatures": {
+ "jsx": true
+ },
+ "ecmaVersion": 11,
+ "sourceType": "module"
+ },
+ "plugins": ["react", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error", { "parser": "flow", "endOfLine": "auto" }],
+ "linebreak-style": [0, "error", "windows"],
+ "import/prefer-default-export": "off",
+ "no-shadow": "off",
+ "react/forbid-prop-types": "off",
+ "no-alert": "off",
+ "jsx-a11y/label-has-associated-control": [
+ "error",
+ {
+ "required": {
+ "some": ["nesting", "id"]
+ }
+ }
+ ],
+ "jsx-a11y/label-has-for": [
+ "error",
+ {
+ "required": {
+ "some": ["nesting", "id"]
+ }
+ }
+ ],
+ "react/jsx-props-no-spreading": "off", // props spreading,
+ "no-console": "off"
+ }
+}
diff --git a/media-store/app-src/.gitignore b/media-store/app-src/.gitignore
new file mode 100644
index 00000000..4d29575d
--- /dev/null
+++ b/media-store/app-src/.gitignore
@@ -0,0 +1,23 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/media-store/app-src/.prettierrc b/media-store/app-src/.prettierrc
new file mode 100644
index 00000000..5ac85e27
--- /dev/null
+++ b/media-store/app-src/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "printWidth": 100,
+ "singleQuote": true
+}
diff --git a/media-store/app-src/.vscode/launch.json b/media-store/app-src/.vscode/launch.json
new file mode 100644
index 00000000..d4e0b5d5
--- /dev/null
+++ b/media-store/app-src/.vscode/launch.json
@@ -0,0 +1,13 @@
+
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Chrome",
+ "type": "chrome",
+ "request": "launch",
+ "url": "http://localhost:3000",
+ "webRoot": "${workspaceRoot}/src"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/media-store/app-src/README.md b/media-store/app-src/README.md
new file mode 100644
index 00000000..269c0546
--- /dev/null
+++ b/media-store/app-src/README.md
@@ -0,0 +1 @@
+"# Media store UI"
diff --git a/media-store/app-src/package.json b/media-store/app-src/package.json
new file mode 100644
index 00000000..28696e9c
--- /dev/null
+++ b/media-store/app-src/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "mediastore",
+ "version": "0.1.0",
+ "private": false,
+ "scripts": {
+ "start": "./node_modules/.bin/webpack-dev-server --config ./webpack/webpack-dev-server.js",
+ "watch": "./node_modules/.bin/webpack -w --config ./webpack/webpack.dev.js",
+ "build": "./node_modules/.bin/webpack --config ./webpack/webpack.prod.js",
+ "lint": "./node_modules/.bin/eslint"
+ },
+ "dependencies": {
+ "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/react": "^9.3.2",
+ "@testing-library/user-event": "^7.1.2",
+ "@umijs/hooks": "^1.9.3",
+ "antd": "^4.8.2",
+ "axios": "^0.20.0",
+ "clean-webpack-plugin": "^3.0.0",
+ "copy-webpack-plugin": "^6.3.2",
+ "css-minimizer-webpack-plugin": "^1.1.5",
+ "events": "^3.2.0",
+ "html-webpack-plugin": "^4.5.0",
+ "lodash": "^4.17.20",
+ "mini-css-extract-plugin": "^1.3.1",
+ "moment": "^2.29.1",
+ "prop-types": "^15.7.2",
+ "react": "^16.14.0",
+ "react-dev-utils": "^11.0.1",
+ "react-dom": "^16.14.0",
+ "react-router-dom": "^5.2.0",
+ "terser-webpack-plugin": "^5.0.3",
+ "webpack": "5.8.0",
+ "webpack-dev-server": "^3.11.0",
+ "webpack-merge": "^5.4.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.9",
+ "@babel/plugin-transform-runtime": "^7.12.1",
+ "@babel/polyfill": "^7.12.1",
+ "@babel/preset-env": "^7.12.7",
+ "@babel/preset-react": "^7.12.7",
+ "@babel/runtime": "^7.12.5",
+ "babel-loader": "^8.2.2",
+ "babel-plugin-syntax-dynamic-import": "^6.18.0",
+ "cowsay": "^1.4.0",
+ "css-loader": "^5.0.1",
+ "eslint": "^7.14.0",
+ "eslint-config-airbnb": "^18.2.1",
+ "eslint-config-prettier": "^6.15.0",
+ "eslint-plugin-import": "^2.22.1",
+ "eslint-plugin-jsx-a11y": "^6.4.1",
+ "eslint-plugin-prettier": "^3.1.4",
+ "eslint-plugin-react": "^7.21.5",
+ "eslint-plugin-react-hooks": "^4.2.0",
+ "prettier": "^2.2.1",
+ "style-loader": "^2.0.0",
+ "url-loader": "^4.1.1",
+ "webpack-cli": "^3.3.12"
+ },
+ "eslintConfig": {
+ "extends": "react-app"
+ }
+}
diff --git a/media-store/app-src/public/index.html b/media-store/app-src/public/index.html
new file mode 100644
index 00000000..e2722955
--- /dev/null
+++ b/media-store/app-src/public/index.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/media-store/app-src/public/logo192.png b/media-store/app-src/public/logo192.png
new file mode 100644
index 00000000..fc44b0a3
Binary files /dev/null and b/media-store/app-src/public/logo192.png differ
diff --git a/media-store/app-src/public/logo512.png b/media-store/app-src/public/logo512.png
new file mode 100644
index 00000000..a4e47a65
Binary files /dev/null and b/media-store/app-src/public/logo512.png differ
diff --git a/media-store/app-src/public/manifest.json b/media-store/app-src/public/manifest.json
new file mode 100644
index 00000000..45979ace
--- /dev/null
+++ b/media-store/app-src/public/manifest.json
@@ -0,0 +1,31 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff",
+ "sap.app": {
+ "id": "mediastore",
+ "applicationVersion": {
+ "version": "1.0.0"
+ }
+ }
+}
diff --git a/media-store/app-src/public/robots.txt b/media-store/app-src/public/robots.txt
new file mode 100644
index 00000000..e9e57dc4
--- /dev/null
+++ b/media-store/app-src/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/media-store/app-src/public/xs-app.json b/media-store/app-src/public/xs-app.json
new file mode 100644
index 00000000..930c40ee
--- /dev/null
+++ b/media-store/app-src/public/xs-app.json
@@ -0,0 +1,10 @@
+{
+ "welcomeFile": "/index.html",
+ "routes": [
+ {
+ "source": "^(.*)",
+ "target": "$1",
+ "service": "html5-apps-repo-rt"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/media-store/app-src/src/App.css b/media-store/app-src/src/App.css
new file mode 100644
index 00000000..1ec8f2fe
--- /dev/null
+++ b/media-store/app-src/src/App.css
@@ -0,0 +1,57 @@
+@import "~antd/dist/antd.css";
+
+html {
+ overflow: hidden;
+}
+#root {
+ height: 100%;
+}
+section.ant-layout {
+ height: 100vh;
+ overflow: auto;
+}
+
+/* Layout
+*/
+.site-layout .site-layout-background {
+ background: #fff;
+}
+
+.App {
+ text-align: center;
+}
+
+.App-logo {
+ height: 40vmin;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .App-logo {
+ animation: App-logo-spin infinite 20s linear;
+ }
+}
+
+.App-header {
+ background-color: #282c34;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(10px + 2vmin);
+ color: white;
+}
+
+.App-link {
+ color: #61dafb;
+}
+
+@keyframes App-logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/media-store/app-src/src/App.jsx b/media-store/app-src/src/App.jsx
new file mode 100644
index 00000000..d1657522
--- /dev/null
+++ b/media-store/app-src/src/App.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import 'antd/dist/antd.css';
+import './App.css';
+import { Layout } from 'antd';
+import { MyRouter } from './components/Router';
+import { AppStateContextProvider } from './contexts/AppStateContext';
+
+const App = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/media-store/app-src/src/api/axiosInstance.js b/media-store/app-src/src/api/axiosInstance.js
new file mode 100644
index 00000000..45fc04c4
--- /dev/null
+++ b/media-store/app-src/src/api/axiosInstance.js
@@ -0,0 +1,116 @@
+import axios from 'axios';
+import { getUserFromLS, getLocaleFromLS } from '../util/localStorageService';
+import { emitter } from '../util/EventEmitter';
+
+/**
+ * This is axios instance
+ */
+const axiosInstance = axios.create({
+ baseURL: process.env.SERVICE_URL,
+ timeout: 2000,
+});
+
+/**
+ * Changing user axios default params,
+ * which are used in api call functions (calls.js)
+ * @param {*} currentUser current user from react state and local storage
+ */
+function changeUserDefaults(currentUser) {
+ if (currentUser) {
+ axiosInstance.defaults.headers.common.Authorization = `Basic ${currentUser.accessToken}`;
+ axiosInstance.defaults.userID = currentUser.ID;
+ if (currentUser.roles.includes('customer')) {
+ axiosInstance.defaults.userEntity = `Customers/${currentUser.ID}`;
+ axiosInstance.defaults.tracksEntity = 'MarkedTracks';
+ } else {
+ axiosInstance.defaults.userEntity = `Employees/${currentUser.ID}`;
+ axiosInstance.defaults.tracksEntity = 'Tracks';
+ }
+ } else {
+ axiosInstance.defaults.tracksEntity = 'Tracks';
+ }
+}
+/**
+ * This func changing axios instance default params
+ * @param {*} locale current locale from react state and local storage
+ */
+function changeLocaleDefaults(locale) {
+ if (locale) {
+ axiosInstance.defaults.headers.common['Accept-language'] = locale;
+ }
+}
+
+/**
+ * Initializing initial data
+ */
+const user = getUserFromLS();
+const locale = getLocaleFromLS();
+changeUserDefaults(user);
+changeLocaleDefaults(locale);
+
+/**
+ * Error interceptor for refresh tokens mechanism
+ */
+let isRefreshing = false;
+let subscribers = [];
+const refreshTokens = (refreshToken) => {
+ return axiosInstance.post(
+ 'users/refreshTokens',
+ { refreshToken },
+ {
+ headers: { 'content-type': 'application/json' },
+ }
+ );
+};
+axiosInstance.interceptors.response.use(null, (error) => {
+ const originalRequest = error.config;
+ const user = getUserFromLS();
+
+ if (error.response && error.response.status === 401 && !!user) {
+ if (originalRequest.url === 'users/login') {
+ return Promise.reject(error);
+ }
+
+ // if users/refreshTokens request failed
+ if (isRefreshing && originalRequest.url === 'users/refreshTokens') {
+ subscribers.forEach((request) => request.reject(error));
+ subscribers = [];
+ isRefreshing = false;
+ return Promise.reject(error);
+ }
+
+ // if got a 401 error we sending users/refreshTokens request
+ if (!isRefreshing) {
+ isRefreshing = true;
+
+ refreshTokens(user.refreshToken)
+ .then((response) => {
+ emitter.emit('UPDATE_USER', response.data);
+ subscribers.forEach((request) => request.resolve(response.data.accessToken));
+ subscribers = [];
+ isRefreshing = false;
+ })
+ .catch(() => {
+ emitter.emit('UPDATE_USER', undefined);
+ });
+ }
+
+ // holding requests which should be sended after users/refreshTokens complete
+ // otherwise if users/refreshTokens failed an error will be thrown
+ return new Promise((resolve, reject) => {
+ subscribers.push({
+ resolve: (newAccessToken) => {
+ originalRequest.headers.Authorization = `Basic ${newAccessToken}`;
+ resolve(axiosInstance(originalRequest));
+ },
+ reject: (err) => {
+ reject(err);
+ },
+ });
+ });
+ }
+
+ return Promise.reject(error);
+});
+
+export { axiosInstance, changeLocaleDefaults, changeUserDefaults };
diff --git a/media-store/app-src/src/api/calls.js b/media-store/app-src/src/api/calls.js
new file mode 100644
index 00000000..f22c18bb
--- /dev/null
+++ b/media-store/app-src/src/api/calls.js
@@ -0,0 +1,167 @@
+import { isEmpty } from 'lodash';
+import { axiosInstance } from './axiosInstance';
+
+const BROWSE_TRACKS_SERVICE = 'browse-tracks';
+const INVOICES_SERVICE = 'browse-invoices';
+const USER_SERVICE = 'users';
+const MANAGE_STORE = 'manage-store';
+
+const constructGenresQuery = (genreIds) => {
+ return !isEmpty(genreIds)
+ ? ` and ${genreIds.map((value) => `genre_ID eq ${value}`).join(' or ')}`
+ : '';
+};
+
+const fetchTacks = ({ $top = 20, $skip = 0, genreIds = [], substr = '' } = {}) => {
+ const serializeTracksUrl = () => {
+ return `$expand=genre,album($expand=artist)&$top=${$top}&$skip=${$skip}&$filter=${`contains(name,'${substr}')${constructGenresQuery(
+ genreIds
+ )}`}`;
+ };
+
+ return axiosInstance.get(`${BROWSE_TRACKS_SERVICE}/${axiosInstance.defaults.tracksEntity}`, {
+ params: {},
+ paramsSerializer: () => serializeTracksUrl(),
+ });
+};
+
+const countTracks = ({ genreIds = [], substr = '' } = {}) => {
+ const { tracksEntity } = axiosInstance.defaults;
+
+ return axiosInstance.get(
+ `${BROWSE_TRACKS_SERVICE}/${tracksEntity}/$count?$filter=${`contains(name,'${substr}')${constructGenresQuery(
+ genreIds
+ )}`}`
+ );
+};
+
+const fetchGenres = () => {
+ return axiosInstance.get(`${BROWSE_TRACKS_SERVICE}/Genres`);
+};
+
+const invoice = (tracks) => {
+ return axiosInstance.post(
+ `${INVOICES_SERVICE}/invoice`,
+ {
+ tracks: tracks.map(({ unitPrice, ID }) => ({
+ unitPrice: `${unitPrice}`,
+ ID,
+ })),
+ },
+ {
+ headers: { 'content-type': 'application/json;IEEE754Compatible=true' },
+ }
+ );
+};
+
+const fetchPerson = () => {
+ return axiosInstance.get(`${USER_SERVICE}/${axiosInstance.defaults.userEntity}`);
+};
+
+const confirmPerson = (person) => {
+ return axiosInstance.put(
+ `${USER_SERVICE}/${axiosInstance.defaults.userEntity}`,
+ {
+ ...person,
+ },
+ {
+ headers: { 'content-type': 'application/json' },
+ }
+ );
+};
+
+const fetchInvoices = () => {
+ return axiosInstance.get(
+ `${INVOICES_SERVICE}/Invoices?$expand=invoiceItems($expand=track($expand=album($expand=artist)))`
+ );
+};
+
+const cancelInvoice = (ID) => {
+ return axiosInstance.post(
+ `${INVOICES_SERVICE}/cancelInvoice`,
+ {
+ ID,
+ },
+ {
+ headers: { 'content-type': 'application/json' },
+ }
+ );
+};
+
+const fetchAlbumsByName = (substr = '', top) => {
+ return axiosInstance.get(
+ `${BROWSE_TRACKS_SERVICE}/Albums?$filter=${`contains(title,'${substr}')&$top=${top}`}`
+ );
+};
+
+const addTrack = (data) => {
+ return axiosInstance.post(`${MANAGE_STORE}/Tracks`, data, {
+ headers: { 'content-type': 'application/json;IEEE754Compatible=true' },
+ });
+};
+
+const addArtist = (data) => {
+ return axiosInstance.post(`${MANAGE_STORE}/Artists`, data, {
+ headers: { 'content-type': 'application/json' },
+ });
+};
+
+const addAlbum = (data) => {
+ return axiosInstance.post(`${MANAGE_STORE}/Albums`, data, {
+ headers: { 'content-type': 'application/json' },
+ });
+};
+
+const fetchArtistsByName = (substr = '', top) => {
+ return axiosInstance.get(
+ `${MANAGE_STORE}/Artists?$filter=${`contains(name,'${substr}')&$top=${top}`}`
+ );
+};
+
+const login = (data) => {
+ return axiosInstance.post(`${USER_SERVICE}/login`, data, {
+ headers: { 'content-type': 'application/json' },
+ });
+};
+
+const updateTrack = (track) => {
+ return axiosInstance.put(
+ `${MANAGE_STORE}/Tracks/${track.ID}`,
+ {
+ ...track,
+ },
+ {
+ headers: { 'content-type': 'application/json;IEEE754Compatible=true' },
+ }
+ );
+};
+
+const getTrack = (ID) => {
+ return axiosInstance.get(
+ `${BROWSE_TRACKS_SERVICE}/${axiosInstance.defaults.tracksEntity}/${ID}?$expand=genre,album($expand=artist)`
+ );
+};
+
+const deleteTrack = (ID) => {
+ return axiosInstance.delete(`${MANAGE_STORE}/Tracks(${ID})`);
+};
+
+export {
+ fetchTacks,
+ countTracks,
+ fetchGenres,
+ invoice,
+ fetchPerson,
+ confirmPerson,
+ fetchInvoices,
+ cancelInvoice,
+ fetchAlbumsByName,
+ addTrack,
+ addArtist,
+ addAlbum,
+ fetchArtistsByName,
+ login,
+ updateTrack,
+ getTrack,
+ deleteTrack,
+};
diff --git a/media-store/app-src/src/components/ErrorPage.jsx b/media-store/app-src/src/components/ErrorPage.jsx
new file mode 100644
index 00000000..e39c7f52
--- /dev/null
+++ b/media-store/app-src/src/components/ErrorPage.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { useHistory } from 'react-router-dom';
+import { isEmpty } from 'lodash';
+import { Result, Button } from 'antd';
+import { useAppState } from '../hooks/useAppState';
+
+const ErrorPage = () => {
+ const { error, setError } = useAppState();
+ const history = useHistory();
+
+ const onGoHome = () => {
+ setError({});
+ history.push('/');
+ };
+
+ const goLoginPage = () => {
+ setError({});
+ history.push('/login');
+ };
+
+ const goHomeButton = (
+
+ );
+ const goLoginButton = (
+
+ );
+
+ const errorResultProps = isEmpty(error)
+ ? {
+ status: 404,
+ title: 'Not found',
+ subTitle: 'Sorry, the page you visited does not exist.',
+ extra: goHomeButton,
+ }
+ : {
+ status: [404, 403, 500].includes(error.status) ? error.status : 'error',
+ title: error.statusText,
+ subTitle: error.message,
+ extra: error.status === 401 ? [goHomeButton, goLoginButton] : goHomeButton,
+ };
+
+ return ;
+};
+
+export default ErrorPage;
diff --git a/media-store/app-src/src/components/Header.css b/media-store/app-src/src/components/Header.css
new file mode 100644
index 00000000..3d78184d
--- /dev/null
+++ b/media-store/app-src/src/components/Header.css
@@ -0,0 +1,3 @@
+.ant-menu-item .anticon {
+ margin: 0;
+}
diff --git a/media-store/app-src/src/components/Header.jsx b/media-store/app-src/src/components/Header.jsx
new file mode 100644
index 00000000..9a509407
--- /dev/null
+++ b/media-store/app-src/src/components/Header.jsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import { Menu, Badge, Spin } from 'antd';
+import { isEmpty } from 'lodash';
+import {
+ CreditCardOutlined,
+ LogoutOutlined,
+ LoginOutlined,
+ LoadingOutlined,
+} from '@ant-design/icons';
+import { useHistory, useLocation } from 'react-router-dom';
+import { useAppState } from '../hooks/useAppState';
+import { setLocaleToLS } from '../util/localStorageService';
+import { changeLocaleDefaults } from '../api/axiosInstance';
+import { emitter } from '../util/EventEmitter';
+import './Header.css';
+import { requireEmployee, requireCustomer } from '../util/constants';
+
+const { SubMenu } = Menu;
+
+const keys = ['/', '/person', '/login', '/manage', '/invoice', '/invoices'];
+const AVAILABLE_LOCALES = ['en', 'fr', 'de'];
+const RELOAD_LOCATION_NUMBER = 0;
+
+const Header = () => {
+ const history = useHistory();
+ const location = useLocation();
+ const { user, invoicedItems, setInvoicedItems, locale, setLocale, loading } = useAppState();
+ const currentKey = [keys.find((key) => key === location.pathname)];
+ const haveInvoicedItems = !isEmpty(invoicedItems);
+ const invoicedItemsLength = invoicedItems.length;
+
+ const onChangeLocale = (value) => {
+ setLocaleToLS(value);
+ changeLocaleDefaults(value);
+ setLocale(value);
+ history.go(RELOAD_LOCATION_NUMBER);
+ };
+ const localeElements = AVAILABLE_LOCALES.filter((localeName) => localeName !== locale).map(
+ (curLocale, index) => (
+ onChangeLocale(curLocale)}>
+ {curLocale}
+
+ )
+ );
+
+ const onUserLogout = () => {
+ emitter.emit('UPDATE_USER', undefined);
+ history.go(0);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default Header;
diff --git a/media-store/app-src/src/components/InvoicePage.jsx b/media-store/app-src/src/components/InvoicePage.jsx
new file mode 100644
index 00000000..645a24a0
--- /dev/null
+++ b/media-store/app-src/src/components/InvoicePage.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { Table, Button, message } from 'antd';
+import { useHistory } from 'react-router-dom';
+import { useAppState } from '../hooks/useAppState';
+import { invoice } from '../api/calls';
+import { useErrors } from '../hooks/useErrors';
+import { MESSAGE_TIMEOUT } from '../util/constants';
+
+const columns = [
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ },
+ {
+ title: 'Artist',
+ dataIndex: 'artist',
+ },
+ {
+ title: 'Album',
+ dataIndex: 'albumTitle',
+ },
+ {
+ title: 'Price',
+ dataIndex: 'unitPrice',
+ },
+];
+
+const InvoicePage = () => {
+ const history = useHistory();
+ const { handleError } = useErrors();
+ const { user, invoicedItems, setInvoicedItems, setLoading } = useAppState();
+
+ const data = invoicedItems.map(({ ID, ...otherProps }) => ({
+ key: `invoiceItem${ID}`,
+ ...otherProps,
+ }));
+
+ const onBuy = () => {
+ setLoading(true);
+ invoice(
+ invoicedItems.map(({ ID, unitPrice }) => ({
+ ID,
+ unitPrice,
+ }))
+ )
+ .then(() => {
+ setInvoicedItems([]);
+ message.success('Invoice successfully completed', MESSAGE_TIMEOUT);
+ history.push('/invoices');
+ })
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ };
+ const onCancel = () => {
+ setInvoicedItems([]);
+ history.push('/');
+ };
+ const goLogin = () => {
+ history.push('/login');
+ };
+
+ return (
+
+
(
+
+ {user ? (
+ <>
+
+
+ >
+ ) : (
+
+
+ to buy selected
+
+ )}
+
+ )}
+ />
+
+ );
+};
+
+export default InvoicePage;
diff --git a/media-store/app-src/src/components/Login.jsx b/media-store/app-src/src/components/Login.jsx
new file mode 100644
index 00000000..91ca97e8
--- /dev/null
+++ b/media-store/app-src/src/components/Login.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Form, Input, Button, Checkbox, message } from 'antd';
+import { useHistory } from 'react-router-dom';
+import { login } from '../api/calls';
+import { useAppState } from '../hooks/useAppState';
+import { useErrors } from '../hooks/useErrors';
+import { MESSAGE_TIMEOUT } from '../util/constants';
+import { emitter } from '../util/EventEmitter';
+
+const layout = {
+ labelCol: {
+ span: 8,
+ },
+ wrapperCol: {
+ span: 8,
+ },
+};
+const tailLayout = {
+ wrapperCol: {
+ offset: 8,
+ span: 8,
+ },
+};
+
+const Login = () => {
+ const [form] = Form.useForm();
+ const history = useHistory();
+ const { setLoading, setInvoicedItems } = useAppState();
+ const { handleError } = useErrors();
+
+ const onFinish = (values) => {
+ setLoading(true);
+ login({ email: values.email, password: values.password })
+ .then(({ data: user }) => {
+ emitter.emit('UPDATE_USER', user);
+ if (user.roles.includes('employee')) {
+ setInvoicedItems([]);
+ }
+ history.push('/');
+ })
+ .catch((error) => {
+ console.log(error);
+ if (error.response && error.response.status === 401) {
+ form.resetFields();
+ message.error('Invalid credentials!', MESSAGE_TIMEOUT);
+ } else {
+ handleError(error);
+ }
+ })
+ .finally(() => setLoading(false));
+ };
+
+ const onFinishFailed = (errorInfo) => {
+ console.log('Validation Failed:', errorInfo);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Remember me
+
+
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/media-store/app-src/src/components/ManageStore.jsx b/media-store/app-src/src/components/ManageStore.jsx
new file mode 100644
index 00000000..71167a7c
--- /dev/null
+++ b/media-store/app-src/src/components/ManageStore.jsx
@@ -0,0 +1,115 @@
+import React, { useState, useMemo, useEffect } from 'react';
+import { Form, Radio, Button, message } from 'antd';
+import { TrackForm } from './manage-store/TrackForm';
+import { AddArtistForm } from './manage-store/AddArtistForm';
+import { AddAlbumForm } from './manage-store/AddAlbumForm';
+import { useErrors } from '../hooks/useErrors';
+import { useAppState } from '../hooks/useAppState';
+import { addTrack, addArtist, addAlbum } from '../api/calls';
+import { MESSAGE_TIMEOUT } from '../util/constants';
+
+const FORM_TYPES = {
+ track: 'track',
+ artist: 'artist',
+ album: 'album',
+ playlist: '',
+};
+
+const chooseForm = (type) => {
+ return (
+ (type === 'track' && ) ||
+ (type === 'artist' && ) ||
+ (type === 'album' && )
+ );
+};
+
+const ManageStore = () => {
+ const [form] = Form.useForm();
+ const { handleError } = useErrors();
+ const { setLoading } = useAppState();
+ const [formType, setFormType] = useState('track');
+
+ useEffect(() => {
+ form.resetFields();
+ }, [formType]);
+
+ const formElement = useMemo(() => {
+ return chooseForm(formType);
+ }, [formType]);
+
+ const onChangeForm = (event) => {
+ setFormType(event.target.value);
+ };
+
+ const sendCreateRequest = ({ type, ...data }) => {
+ setLoading(true);
+
+ let promise;
+ switch (type) {
+ case FORM_TYPES.track:
+ promise = addTrack({
+ name: data.name,
+ composer: data.composer,
+ album: { ID: data.albumID },
+ genre: { ID: data.genreID },
+ unitPrice: data.unitPrice.toString(),
+ });
+ break;
+ case FORM_TYPES.artist:
+ promise = addArtist(data);
+ break;
+ case FORM_TYPES.album:
+ promise = addAlbum({ title: data.name, artist: { ID: data.artistID } });
+ break;
+ default:
+ }
+
+ promise
+ .then(() => {
+ message.success('Entity successfully created', MESSAGE_TIMEOUT);
+ form.resetFields();
+ })
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ };
+
+ return (
+
+
+ Track
+ Album
+ Artist
+
+
+ {formElement}
+
+
+
+
+ );
+};
+
+export default ManageStore;
diff --git a/media-store/app-src/src/components/MyInvoicesPage.jsx b/media-store/app-src/src/components/MyInvoicesPage.jsx
new file mode 100644
index 00000000..bb490282
--- /dev/null
+++ b/media-store/app-src/src/components/MyInvoicesPage.jsx
@@ -0,0 +1,170 @@
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { Button, message, Tag, Collapse, Table, Spin } from 'antd';
+import moment from 'moment';
+import { useErrors } from '../hooks/useErrors';
+import { useAppState } from '../hooks/useAppState';
+import { cancelInvoice, fetchInvoices } from '../api/calls';
+import { MESSAGE_TIMEOUT } from '../util/constants';
+
+const { Panel } = Collapse;
+const INVOICE_STATUS = {
+ 2: {
+ tagTitle: 'Shipped',
+ color: 'green',
+ },
+ 1: {
+ tagTitle: 'Submitted',
+ color: 'processing',
+ canCancel: true,
+ },
+ '-1': {
+ tagTitle: 'Cancelled',
+ color: 'default',
+ },
+};
+const CANCELLED_STATUS = -1;
+const DATE_TIME_FORMAT_PATTERN = 'LLLL';
+const UTC_DATE_TIME_FORMAT = 'YYYY-MM-DDThh:mm:ss';
+const INVOICE_ITEMS_COLUMNS = [
+ {
+ title: 'Track name',
+ dataIndex: 'name',
+ },
+ {
+ title: 'Artist',
+ dataIndex: 'artistName',
+ },
+ {
+ title: 'Album',
+ dataIndex: 'albumTitle',
+ },
+ {
+ title: 'Price',
+ dataIndex: 'unitPrice',
+ },
+];
+const LEVERAGE_DURATION = 1; // in hours
+const STATUSES = { submitted: 1, shipped: 2, canceled: -1 };
+
+const isLeverageTimeExpired = (utcNowTimestamp, invoiceDate) => {
+ const duration = moment.duration(moment(utcNowTimestamp).diff(moment(invoiceDate).valueOf()));
+ return duration.asHours() > LEVERAGE_DURATION;
+};
+
+const chooseStatus = (utcNowTimestamp, invoiceDate, statusFromDb) => {
+ if (isLeverageTimeExpired(utcNowTimestamp, invoiceDate) && statusFromDb !== STATUSES.canceled) {
+ return INVOICE_STATUS[STATUSES.shipped];
+ }
+ return INVOICE_STATUS[statusFromDb];
+};
+
+const ExtraHeader = ({ ID, invoiceDate, status: initialStatus }) => {
+ const { loading, setLoading } = useAppState();
+ const { handleError } = useErrors();
+ const [loadingHeaderId, setLoadingHeaderId] = useState();
+ const [status, setStatus] = useState(initialStatus);
+
+ const statusConfig = useMemo(() => {
+ const utcNowTimestamp = moment(moment().utc().format(UTC_DATE_TIME_FORMAT)).valueOf();
+ return chooseStatus(utcNowTimestamp, invoiceDate, status);
+ }, [status]);
+
+ const onCancelInvoice = (event, ID) => {
+ event.stopPropagation();
+ setLoading(true);
+ setLoadingHeaderId(ID);
+ cancelInvoice(ID)
+ .then(() => {
+ message.success('Invoice successfully cancelled', MESSAGE_TIMEOUT);
+ setLoadingHeaderId(undefined);
+ setStatus(CANCELLED_STATUS);
+ })
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ };
+
+ return (
+
+ {statusConfig.tagTitle}
+ {statusConfig.canCancel && (
+
+ )}
+
+ );
+};
+ExtraHeader.propTypes = {
+ ID: PropTypes.number.isRequired,
+ status: PropTypes.number.isRequired,
+ invoiceDate: PropTypes.string.isRequired,
+};
+
+const MyInvoicesPage = () => {
+ const { handleError } = useErrors();
+ const { setLoading } = useAppState();
+ const [invoices, setInvoices] = useState([]);
+
+ useEffect(() => {
+ setLoading(true);
+ fetchInvoices()
+ .then(({ data: { value } }) => setInvoices(value))
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ }, []);
+
+ const genExtra = useCallback(
+ (ID, status, invoiceDate) => ,
+ []
+ );
+ const invoiceElements = useMemo(() => {
+ return invoices.map(({ ID, status, invoiceDate, total, invoiceItems }) => {
+ const invoiceItemsData = invoiceItems.map(
+ ({
+ ID,
+ track: {
+ name,
+ unitPrice,
+ album: {
+ title: albumTitle,
+ artist: { name: artistName },
+ },
+ },
+ }) => ({
+ key: ID,
+ ID,
+ name,
+ unitPrice,
+ albumTitle,
+ artistName,
+ })
+ );
+
+ return (
+
+
+
{`Total price: ${total}`}}
+ />
+
+
+ );
+ });
+ }, [invoices]);
+
+ return (
+ {invoiceElements && {invoiceElements}}
+ );
+};
+
+export default MyInvoicesPage;
diff --git a/media-store/app-src/src/components/PersonPage.jsx b/media-store/app-src/src/components/PersonPage.jsx
new file mode 100644
index 00000000..d312a233
--- /dev/null
+++ b/media-store/app-src/src/components/PersonPage.jsx
@@ -0,0 +1,108 @@
+import React, { useState } from 'react';
+import { Form, Button, message, Input } from 'antd';
+import { omit, map } from 'lodash';
+import { fetchPerson, confirmPerson } from '../api/calls';
+import { useErrors } from '../hooks/useErrors';
+import { useAppState } from '../hooks/useAppState';
+import { MESSAGE_TIMEOUT } from '../util/constants';
+import { useAbortableEffect } from '../hooks/useAbortableEffect';
+
+const PERSON_PROP = {
+ address: 'Address ',
+ city: 'City ',
+ country: 'Country ',
+ fax: 'Fax: ',
+ firstName: 'First name: ',
+ lastName: 'Last name: ',
+ phone: 'Phone: ',
+ postalCode: 'Postal code: ',
+ state: 'State',
+ email: 'email',
+ company: 'Company: ',
+};
+
+const PersonPage = () => {
+ const { setLoading } = useAppState();
+ const { handleError } = useErrors();
+ const [form] = Form.useForm();
+ const [person, setPerson] = useState({
+ lastName: '',
+ firstName: '',
+ city: '',
+ state: '',
+ address: '',
+ country: '',
+ phone: '',
+ postalCode: '',
+ fax: '',
+ email: '',
+ company: '',
+ });
+
+ useAbortableEffect((status) => {
+ setLoading(true);
+
+ fetchPerson()
+ .then(({ data }) => {
+ const personData = omit(data, '@odata.context', 'ID');
+ if (!status.aborted) {
+ setPerson(personData);
+ }
+ })
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ }, []);
+
+ const onConfirmChanges = (newPerson) => {
+ setLoading(true);
+ confirmPerson(newPerson)
+ .then(() => {
+ message.success('Person successfully updated', MESSAGE_TIMEOUT);
+ })
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ };
+
+ const personProperties = map(Object.keys(person), (currentKey) => (
+
+
+
+
+
+ ));
+
+ return (
+ <>
+ {person.lastName !== '' && (
+
+
+
+
+ )}
+ >
+ );
+};
+
+export default PersonPage;
diff --git a/media-store/app-src/src/components/Router.jsx b/media-store/app-src/src/components/Router.jsx
new file mode 100644
index 00000000..0877250a
--- /dev/null
+++ b/media-store/app-src/src/components/Router.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
+import { isEmpty } from 'lodash';
+import TracksContainer from './TracksPage';
+import Header from './Header';
+import PersonPage from './PersonPage';
+import ErrorPage from './ErrorPage';
+import InvoicePage from './InvoicePage';
+import ManageStore from './ManageStore';
+import MyInvoicesPage from './MyInvoicesPage';
+import Login from './Login';
+import { withRestrictions } from '../hocs/withRestrictions';
+import { requireEmployee } from '../util/constants';
+
+// const TracksContainer = React.lazy(() => import('./TracksPage'));
+// const Header = React.lazy(() => import('./Header'));
+// const PersonPage = React.lazy(() => import('./PersonPage'));
+// const ErrorPage = React.lazy(() => import('./ErrorPage'));
+// const InvoicePage = React.lazy(() => import('./InvoicePage'));
+// const ManageStore = React.lazy(() => import('./ManageStore'));
+// const MyInvoicesPage = React.lazy(() => import('./MyInvoicesPage'));
+// const Login = React.lazy(() => import('./Login'));
+
+const RestrictedLogin = withRestrictions(Login, ({ user }) => !user);
+const RestrictedInvoicePage = withRestrictions(
+ InvoicePage,
+ ({ user, invoicedItems }) => !requireEmployee(user) && !isEmpty(invoicedItems)
+);
+const RestrictedPersonPage = withRestrictions(PersonPage, ({ user }) => !!user);
+const RestrictedManageStore = withRestrictions(ManageStore, ({ user }) => requireEmployee(user));
+
+const MyRouter = () => {
+ return (
+
+
+
+ Loading...
}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export { MyRouter };
diff --git a/media-store/app-src/src/components/TracksPage.css b/media-store/app-src/src/components/TracksPage.css
new file mode 100644
index 00000000..c3855497
--- /dev/null
+++ b/media-store/app-src/src/components/TracksPage.css
@@ -0,0 +1,4 @@
+.ant-select > div.ant-select-selector {
+ padding: 5px;
+ min-width: 300px;
+}
diff --git a/media-store/app-src/src/components/TracksPage.jsx b/media-store/app-src/src/components/TracksPage.jsx
new file mode 100644
index 00000000..b3c5b22a
--- /dev/null
+++ b/media-store/app-src/src/components/TracksPage.jsx
@@ -0,0 +1,215 @@
+import React, { useState } from 'react';
+import { debounce } from 'lodash';
+import { Input, Col, Row, Select, Pagination } from 'antd';
+import { Track } from './tracks/Track';
+import { ManagedTrack } from './tracks/ManagedTrack';
+import { useAppState } from '../hooks/useAppState';
+import { useErrors } from '../hooks/useErrors';
+import { fetchTacks, countTracks, fetchGenres } from '../api/calls';
+import { useAbortableEffect } from '../hooks/useAbortableEffect';
+import { requireEmployee } from '../util/constants';
+import './TracksPage.css';
+
+const { Search } = Input;
+const { Option } = Select;
+
+const DEBOUNCE_TIMER = 500;
+const DEBOUNCE_OPTIONS = {
+ leading: true,
+ trailing: false,
+};
+
+const renderGenres = (genres) =>
+ genres.map(({ ID, name }) => (
+
+ ));
+
+const TracksContainer = () => {
+ const { setLoading, user } = useAppState();
+ const { handleError } = useErrors();
+ const [state, setState] = useState({
+ tracks: [],
+ genres: [],
+ pagination: {
+ currentPage: 1,
+ totalItems: 0,
+ pageSize: 20,
+ },
+ searchOptions: {
+ substr: '',
+ genreIds: [],
+ },
+ });
+
+ useAbortableEffect((status) => {
+ setLoading(true);
+
+ const countTracksReq = countTracks();
+ const getTracksRequest = fetchTacks();
+ const getGenresReq = fetchGenres();
+
+ Promise.all([countTracksReq, getTracksRequest, getGenresReq])
+ .then(
+ ([
+ { data: totalItems },
+ {
+ data: { value: tracks },
+ },
+ {
+ data: { value: genres },
+ },
+ ]) => {
+ if (!status.aborted) {
+ setState({
+ ...state,
+ tracks,
+ genres,
+ pagination: { ...state.pagination, totalItems },
+ });
+ }
+ }
+ )
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ }, []);
+
+ const onSearch = debounce(
+ () => {
+ setLoading(true);
+ const options = {
+ $top: state.pagination.pageSize,
+ substr: state.searchOptions.substr,
+ genreIds: state.searchOptions.genreIds,
+ };
+
+ Promise.all([
+ fetchTacks(options),
+ countTracks({
+ substr: options.substr,
+ genreIds: options.genreIds,
+ }),
+ ])
+ .then(([{ data: { value: tracks } }, { data: totalItems }]) =>
+ setState({
+ ...state,
+ tracks,
+ pagination: { ...state.pagination, totalItems },
+ })
+ )
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ },
+ DEBOUNCE_TIMER,
+ DEBOUNCE_OPTIONS
+ );
+ const onSelectChange = (genres) => {
+ setState({
+ ...state,
+ searchOptions: {
+ ...state.searchOptions,
+ genreIds: genres.map((value) => parseInt(value, 10)),
+ },
+ });
+ };
+ const onSearchChange = (event) => {
+ setState({
+ ...state,
+ searchOptions: { ...state.searchOptions, substr: event.target.value },
+ });
+ };
+ const onChangePage = (pageNumber) => {
+ document.querySelector('section.ant-layout').scrollTo({ top: 0, left: 0, behavior: 'smooth' });
+ setLoading(true);
+
+ const options = {
+ $top: state.pagination.pageSize,
+ substr: state.searchOptions.substr,
+ genreIds: state.searchOptions.genreIds,
+ $skip: (pageNumber - 1) * state.pagination.pageSize,
+ };
+ fetchTacks(options)
+ .then((response) =>
+ setState({
+ ...state,
+ tracks: response.data.value,
+ pagination: { ...state.pagination, currentPage: pageNumber },
+ })
+ )
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ };
+ const deleteTrack = (ID) => {
+ setState({
+ ...state,
+ tracks: state.tracks.filter(({ ID: curID }) => curID !== ID),
+ });
+ };
+ const renderTracks = (tracks) => {
+ const isEmployee = requireEmployee(user);
+ const TrackComponent = isEmployee ? ManagedTrack : Track;
+ return tracks.map((track) => {
+ const isAlreadyOrdered = !isEmployee && track.alreadyOrdered;
+ const onDeleteTrack = isEmployee && ((ID) => deleteTrack(ID));
+ return (
+
+
+
+ );
+ });
+ };
+
+ const trackElements = renderTracks(state.tracks);
+ const genreElements = renderGenres(state.genres);
+
+ return (
+ <>
+
+
+
+
+
+ {trackElements}
+
+
+ >
+ );
+};
+
+export default TracksContainer;
diff --git a/media-store/app-src/src/components/manage-store/AddAlbumForm.jsx b/media-store/app-src/src/components/manage-store/AddAlbumForm.jsx
new file mode 100644
index 00000000..0a4978ae
--- /dev/null
+++ b/media-store/app-src/src/components/manage-store/AddAlbumForm.jsx
@@ -0,0 +1,62 @@
+import React, { useEffect } from 'react';
+import { Form, Input, Select } from 'antd';
+import { useSearch } from '@umijs/hooks';
+import { useErrors } from '../../hooks/useErrors';
+import { fetchArtistsByName } from '../../api/calls';
+
+const REQUIRED = [
+ {
+ required: true,
+ message: 'This filed is required!',
+ },
+];
+const ARTISTS_LIMIT = 10;
+
+const getArtists = function (value) {
+ return fetchArtistsByName(value, ARTISTS_LIMIT)
+ .then((response) => response.data.value)
+ .catch(this.handleError);
+};
+
+const AddAlbumForm = () => {
+ const { handleError } = useErrors();
+ const {
+ data: artists,
+ loading: isArtistsLoading,
+ onChange: onChangeArtistInput,
+ cancel: onArtistCancel,
+ } = useSearch(getArtists.bind({ handleError }));
+
+ useEffect(() => {
+ onChangeArtistInput();
+ }, []);
+
+ return (
+ <>
+ Add album
+
+
+
+
+
+
+ >
+ );
+};
+
+export { AddAlbumForm };
diff --git a/media-store/app-src/src/components/manage-store/AddArtistForm.jsx b/media-store/app-src/src/components/manage-store/AddArtistForm.jsx
new file mode 100644
index 00000000..3cc7567f
--- /dev/null
+++ b/media-store/app-src/src/components/manage-store/AddArtistForm.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { Form, Input } from 'antd';
+
+const REQUIRED = [
+ {
+ required: true,
+ message: 'This filed is required!',
+ },
+];
+
+const AddArtistForm = () => {
+ return (
+ <>
+ Add artist
+
+
+
+ >
+ );
+};
+
+export { AddArtistForm };
diff --git a/media-store/app-src/src/components/manage-store/TrackForm.jsx b/media-store/app-src/src/components/manage-store/TrackForm.jsx
new file mode 100644
index 00000000..317f3494
--- /dev/null
+++ b/media-store/app-src/src/components/manage-store/TrackForm.jsx
@@ -0,0 +1,93 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { Form, Input, Select, InputNumber } from 'antd';
+import { head } from 'lodash';
+import { useSearch } from '@umijs/hooks';
+import { useAppState } from '../../hooks/useAppState';
+import { fetchAlbumsByName, fetchGenres } from '../../api/calls';
+import { useErrors } from '../../hooks/useErrors';
+
+const ALBUMS_LIMIT = 10;
+const REQUIRED = [
+ {
+ required: true,
+ message: 'This filed is required!',
+ },
+];
+
+const getAlbums = function (value) {
+ return fetchAlbumsByName(value, ALBUMS_LIMIT)
+ .then((response) => response.data.value)
+ .catch(this.handleError);
+};
+
+const TrackForm = ({ initialAlbumTitle }) => {
+ const { handleError } = useErrors();
+ const {
+ data: albums,
+ loading: isAlbumsLoading,
+ onChange: onChangeAlbumInput,
+ cancel: onAlbumCancel,
+ } = useSearch(getAlbums.bind({ handleError }));
+ const { setLoading } = useAppState();
+ const [genres, setGenres] = useState([]);
+
+ useEffect(() => {
+ setLoading(true);
+ Promise.all([fetchGenres(), onChangeAlbumInput(initialAlbumTitle)])
+ .then((responses) => setGenres(head(responses).data.value))
+ .catch(handleError)
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ value.replace(/\$\s?|(,*)/g, '')}
+ />
+
+
+ );
+};
+
+TrackForm.propTypes = {
+ initialAlbumTitle: PropTypes.string,
+};
+
+export { TrackForm };
diff --git a/media-store/app-src/src/components/tracks/DeleteAction.jsx b/media-store/app-src/src/components/tracks/DeleteAction.jsx
new file mode 100644
index 00000000..948fc7ab
--- /dev/null
+++ b/media-store/app-src/src/components/tracks/DeleteAction.jsx
@@ -0,0 +1,44 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Modal, message } from 'antd';
+import { DeleteOutlined } from '@ant-design/icons';
+import { deleteTrack } from '../../api/calls';
+import { useErrors } from '../../hooks/useErrors';
+import { MESSAGE_TIMEOUT } from '../../util/constants';
+
+const DeleteAction = ({ ID, onDeleteTrack }) => {
+ const [modalVisible, setModalVisible] = useState(false);
+ const { handleError } = useErrors();
+
+ const onOk = () => {
+ setModalVisible(false);
+ deleteTrack(ID)
+ .then(() => {
+ onDeleteTrack();
+ setModalVisible(false);
+ message.success('Track successfully deleted!', MESSAGE_TIMEOUT);
+ })
+ .catch(handleError);
+ };
+
+ const onCancel = () => setModalVisible(false);
+ const onOpenModal = () => {
+ setModalVisible(true);
+ };
+
+ return (
+ <>
+ Delete
+
+ Are You really want to delete this track?
+
+ >
+ );
+};
+
+DeleteAction.propTypes = {
+ ID: PropTypes.number.isRequired,
+ onDeleteTrack: PropTypes.func.isRequired,
+};
+
+export { DeleteAction };
diff --git a/media-store/app-src/src/components/tracks/EditAction.jsx b/media-store/app-src/src/components/tracks/EditAction.jsx
new file mode 100644
index 00000000..6b156076
--- /dev/null
+++ b/media-store/app-src/src/components/tracks/EditAction.jsx
@@ -0,0 +1,113 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, Modal, Form, message } from 'antd';
+import { EditOutlined, LoadingOutlined } from '@ant-design/icons';
+import { useErrors } from '../../hooks/useErrors';
+import { TrackForm } from '../manage-store/TrackForm';
+import { updateTrack, getTrack } from '../../api/calls';
+import { MESSAGE_TIMEOUT } from '../../util/constants';
+
+const EditAction = ({ ID, name, composer, genre, unitPrice, album, afterTrackUpdate }) => {
+ const [visible, setVisible] = React.useState(false);
+ const [confirmLoading, setConfirmLoading] = React.useState(false);
+ const [updateLoading, setUpdateLoading] = React.useState(false);
+ const [form] = Form.useForm();
+ const { handleError } = useErrors();
+
+ const onShowModal = () => {
+ setVisible(true);
+ };
+
+ const onFinish = (value) => {
+ setConfirmLoading(true);
+ updateTrack({
+ ID,
+ name: value.name,
+ composer: value.composer,
+ album: { ID: value.albumID },
+ genre: { ID: value.genreID },
+ unitPrice: value.unitPrice.toString(),
+ })
+ .then(() => {
+ message.success('Track successfully updated!', MESSAGE_TIMEOUT);
+ setConfirmLoading(false);
+ setVisible(false);
+ afterCloseModal();
+ })
+ .catch(handleError);
+ };
+
+ const handleOk = () => {
+ form.submit();
+ };
+
+ const handleCancel = () => {
+ setVisible(false);
+ };
+
+ const afterCloseModal = () => {
+ setUpdateLoading(true);
+ getTrack(ID)
+ .then((response) => {
+ afterTrackUpdate(response.data);
+ setUpdateLoading(false);
+ })
+ .catch(handleError);
+ };
+
+ return (
+ <>
+ {updateLoading ? : }
+
+ Cancel
+ ,
+ ,
+ ]}
+ >
+
+
+ >
+ );
+};
+
+EditAction.propTypes = {
+ ID: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ composer: PropTypes.string.isRequired,
+ genre: PropTypes.object.isRequired,
+ unitPrice: PropTypes.number.isRequired,
+ album: PropTypes.object.isRequired,
+ afterTrackUpdate: PropTypes.func.isRequired,
+};
+
+export { EditAction };
diff --git a/media-store/app-src/src/components/tracks/ManagedTrack.css b/media-store/app-src/src/components/tracks/ManagedTrack.css
new file mode 100644
index 00000000..e2107b16
--- /dev/null
+++ b/media-store/app-src/src/components/tracks/ManagedTrack.css
@@ -0,0 +1,7 @@
+span > span.anticon.anticon-delete:hover {
+ color: #ff4d4f;
+}
+
+.card-element {
+ transition: opacity 0.5s ease-in-out;
+}
diff --git a/media-store/app-src/src/components/tracks/ManagedTrack.jsx b/media-store/app-src/src/components/tracks/ManagedTrack.jsx
new file mode 100644
index 00000000..8beaa37c
--- /dev/null
+++ b/media-store/app-src/src/components/tracks/ManagedTrack.jsx
@@ -0,0 +1,42 @@
+import React, { useState, useRef } from "react";
+import { Card } from "antd";
+import { EditAction } from "./EditAction";
+import { DeleteAction } from "./DeleteAction";
+import { TrackCardBody } from "./TrackCardBody";
+import "./ManagedTrack.css";
+
+const ManagedTrack = ({ initialTrack, onDeleteTrack }) => {
+ const trackElement = useRef();
+ const [track, setTrack] = useState(initialTrack);
+
+ return (
+
+ {
+ trackElement.current.style.opacity = 0;
+ setTimeout(() => onDeleteTrack(track.ID), 500);
+ }}
+ />,
+ setTrack(value)}
+ />,
+ ]}
+ title={track.name}
+ bordered={false}
+ >
+
+
+
+ );
+};
+
+export { ManagedTrack };
diff --git a/media-store/app-src/src/components/tracks/Track.jsx b/media-store/app-src/src/components/tracks/Track.jsx
new file mode 100644
index 00000000..0ab57308
--- /dev/null
+++ b/media-store/app-src/src/components/tracks/Track.jsx
@@ -0,0 +1,60 @@
+import React, { useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { Card, Button } from 'antd';
+import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
+import { useAppState } from '../../hooks/useAppState';
+import { TrackCardBody } from './TrackCardBody';
+
+const Track = ({ initialTrack, isAlreadyOrdered }) => {
+ const trackElement = useRef();
+ const { setInvoicedItems, invoicedItems } = useAppState();
+ const [isJustInvoiced, setIsJustInvoiced] = useState(
+ invoicedItems.find((curTrack) => curTrack.ID === initialTrack.ID)
+ );
+
+ const onChangedStatus = () => {
+ const newIsJustInvoiced = !isJustInvoiced;
+ if (newIsJustInvoiced) {
+ setInvoicedItems([
+ ...invoicedItems,
+ {
+ ID: initialTrack.ID,
+ name: initialTrack.name,
+ artist: initialTrack.album.artist.name,
+ albumTitle: initialTrack.album.title,
+ unitPrice: initialTrack.unitPrice,
+ },
+ ]);
+ } else {
+ setInvoicedItems(invoicedItems.filter(({ ID: curID }) => curID !== initialTrack.ID));
+ }
+ setIsJustInvoiced(newIsJustInvoiced);
+ };
+
+ return (
+
+
+ {!isAlreadyOrdered && (
+
+ )}
+ >,
+ ]}
+ title={initialTrack.name}
+ bordered={false}
+ >
+
+
+
+ );
+};
+
+Track.propTypes = {
+ initialTrack: PropTypes.object,
+ isAlreadyOrdered: PropTypes.bool,
+};
+
+export { Track };
diff --git a/media-store/app-src/src/components/tracks/TrackCardBody.jsx b/media-store/app-src/src/components/tracks/TrackCardBody.jsx
new file mode 100644
index 00000000..7587c99a
--- /dev/null
+++ b/media-store/app-src/src/components/tracks/TrackCardBody.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const TrackCardBody = ({ track }) => {
+ return (
+ <>
+
+ Artist:
+ {track.album.artist.name}
+
+
+ Album:
+ {track.album.title}
+
+
+ Genre:
+ {track.genre.name}
+
+
+ {track.composer && (
+
+ Compositor:
+ {track.composer}
+
+ )}
+
+
+
+ Price:
+ {track.unitPrice}
+
+
+ >
+ );
+};
+
+TrackCardBody.propTypes = {
+ track: PropTypes.object.isRequired,
+};
+
+export { TrackCardBody };
diff --git a/media-store/app-src/src/contexts/AppStateContext.jsx b/media-store/app-src/src/contexts/AppStateContext.jsx
new file mode 100644
index 00000000..5b5dd180
--- /dev/null
+++ b/media-store/app-src/src/contexts/AppStateContext.jsx
@@ -0,0 +1,66 @@
+import React, { useMemo, createContext, useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { getUserFromLS, getLocaleFromLS, setUserToLS } from '../util/localStorageService';
+import { changeUserDefaults } from '../api/axiosInstance';
+import { emitter } from '../util/EventEmitter';
+
+const globalContext = {
+ error: {},
+ loading: true,
+ user: {
+ ID: undefined,
+ roles: [],
+ email: undefined,
+ accessToken: undefined,
+ refreshToken: undefined,
+ },
+ locale: undefined,
+ invoicedItems: [],
+ notifications: [],
+};
+const AppStateContext = createContext(globalContext);
+
+const AppStateContextProvider = ({ children }) => {
+ const [invoicedItems, setInvoicedItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState({});
+ const [user, setUser] = useState(getUserFromLS());
+ const [locale, setLocale] = useState(getLocaleFromLS());
+
+ useEffect(() => {
+ const updateUser = (newUser) => {
+ console.log('USER_UPDATE WAS TRIGGERED');
+ changeUserDefaults(newUser);
+ setUserToLS(newUser);
+ setUser(newUser);
+ };
+ emitter.on('UPDATE_USER', updateUser);
+ return () => {
+ emitter.removeListener('UPDATE_USER', updateUser);
+ };
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ error,
+ loading,
+ invoicedItems,
+ user,
+ locale,
+ setLoading,
+ setError,
+ setInvoicedItems,
+ setUser,
+ setLocale,
+ }),
+ [locale, user, loading, error, invoicedItems]
+ );
+
+ return {children};
+};
+
+AppStateContextProvider.propTypes = {
+ children: PropTypes.element.isRequired,
+};
+
+export { AppStateContextProvider, AppStateContext };
diff --git a/media-store/app-src/src/hocs/withRestrictions.jsx b/media-store/app-src/src/hocs/withRestrictions.jsx
new file mode 100644
index 00000000..26eedba3
--- /dev/null
+++ b/media-store/app-src/src/hocs/withRestrictions.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { Redirect } from 'react-router-dom';
+import { useAppState } from '../hooks/useAppState';
+
+const withRestrictions = (Component, isUserMeetRestrictions) => {
+ return (props) => {
+ const { user, invoicedItems } = useAppState();
+ return isUserMeetRestrictions({ user, invoicedItems }) ? (
+
+ ) : (
+
+ );
+ };
+};
+
+export { withRestrictions };
diff --git a/media-store/app-src/src/hooks/useAbortableEffect.js b/media-store/app-src/src/hooks/useAbortableEffect.js
new file mode 100644
index 00000000..648d923d
--- /dev/null
+++ b/media-store/app-src/src/hooks/useAbortableEffect.js
@@ -0,0 +1,22 @@
+import { useEffect } from 'react';
+
+function useAbortableEffect(effect, dependencies) {
+ const status = {}; // mutable status object
+ useEffect(() => {
+ status.aborted = false;
+ // pass the mutable object to the effect callback
+ // store the returned value for cleanup
+ const cleanUpFn = effect(status);
+ return () => {
+ // mutate the object to signal the consumer
+ // this effect is cleaning up
+ status.aborted = true;
+ if (typeof cleanUpFn === 'function') {
+ // run the cleanup function
+ cleanUpFn();
+ }
+ };
+ }, [...dependencies]);
+}
+
+export { useAbortableEffect };
diff --git a/media-store/app-src/src/hooks/useAppState.js b/media-store/app-src/src/hooks/useAppState.js
new file mode 100644
index 00000000..831ae73d
--- /dev/null
+++ b/media-store/app-src/src/hooks/useAppState.js
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { AppStateContext } from '../contexts/AppStateContext';
+
+const useAppState = () => useContext(AppStateContext);
+
+export { useAppState };
diff --git a/media-store/app-src/src/hooks/useErrors.js b/media-store/app-src/src/hooks/useErrors.js
new file mode 100644
index 00000000..4ccdd694
--- /dev/null
+++ b/media-store/app-src/src/hooks/useErrors.js
@@ -0,0 +1,34 @@
+import { useHistory } from 'react-router-dom';
+import { useAppState } from './useAppState';
+
+const useErrors = () => {
+ const history = useHistory();
+ const { setError } = useAppState();
+
+ const handleError = (error) => {
+ console.error('Error', error);
+
+ if (error.response) {
+ const { status, statusText, data } = error.response;
+ setError({
+ status,
+ statusText,
+ message: data.error ? data.error.message : data,
+ });
+ } else {
+ setError({
+ status: '',
+ statusText: 'Error',
+ message: 'Something went wrong. Seems like request is too long',
+ });
+ }
+
+ history.push('/error');
+ };
+
+ return {
+ handleError,
+ };
+};
+
+export { useErrors };
diff --git a/media-store/app-src/src/index.jsx b/media-store/app-src/src/index.jsx
new file mode 100644
index 00000000..8110e69e
--- /dev/null
+++ b/media-store/app-src/src/index.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import App from './App';
+// import * as serviceWorker from './serviceWorker';
+
+ReactDOM.render(, document.getElementById('root'));
+
+// If you want your app to work offline and load faster, you can change
+// unregister() to register() below. Note this comes with some pitfalls.
+// Learn more about service workers: https://bit.ly/CRA-PWA
+// serviceWorker.unregister();
diff --git a/media-store/app-src/src/logo.svg b/media-store/app-src/src/logo.svg
new file mode 100644
index 00000000..6b60c104
--- /dev/null
+++ b/media-store/app-src/src/logo.svg
@@ -0,0 +1,7 @@
+
diff --git a/media-store/app-src/src/serviceWorker.js b/media-store/app-src/src/serviceWorker.js
new file mode 100644
index 00000000..e69de29b
diff --git a/media-store/app-src/src/setupTests.js b/media-store/app-src/src/setupTests.js
new file mode 100644
index 00000000..74b1a275
--- /dev/null
+++ b/media-store/app-src/src/setupTests.js
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom/extend-expect';
diff --git a/media-store/app-src/src/util/EventEmitter.js b/media-store/app-src/src/util/EventEmitter.js
new file mode 100644
index 00000000..b28a45af
--- /dev/null
+++ b/media-store/app-src/src/util/EventEmitter.js
@@ -0,0 +1,5 @@
+import EventEmitter from 'events';
+
+const emitter = new EventEmitter();
+
+export { emitter };
diff --git a/media-store/app-src/src/util/constants.js b/media-store/app-src/src/util/constants.js
new file mode 100644
index 00000000..4b9b4460
--- /dev/null
+++ b/media-store/app-src/src/util/constants.js
@@ -0,0 +1,7 @@
+export const AVAILABLE_LOCALES = ['en', 'fr', 'de'];
+
+export const MESSAGE_TIMEOUT = 2;
+
+export const requireEmployee = (user) => !!user && user.roles.includes('employee');
+
+export const requireCustomer = (user) => !!user && user.roles.includes('customer');
diff --git a/media-store/app-src/src/util/localStorageService.js b/media-store/app-src/src/util/localStorageService.js
new file mode 100644
index 00000000..853b4817
--- /dev/null
+++ b/media-store/app-src/src/util/localStorageService.js
@@ -0,0 +1,36 @@
+import { isValidUser } from './validateUser';
+import { AVAILABLE_LOCALES } from './constants';
+
+const setUserToLS = (user) => {
+ if (user) {
+ localStorage.setItem('user', JSON.stringify(user));
+ } else {
+ localStorage.removeItem('user');
+ }
+};
+
+const getUserFromLS = () => {
+ let userFromLS;
+ try {
+ userFromLS = JSON.parse(localStorage.getItem('user'));
+ if (isValidUser(userFromLS)) {
+ return userFromLS;
+ }
+ } catch (e) {
+ console.error('User from local storage are not valid');
+ }
+ return undefined;
+};
+
+const getLocaleFromLS = () => {
+ const localeFromLS = localStorage.getItem('locale');
+ return localeFromLS && localeFromLS !== 'undefined' && AVAILABLE_LOCALES.includes(localeFromLS)
+ ? localeFromLS
+ : 'en';
+};
+
+const setLocaleToLS = (locale) => {
+ localStorage.setItem('locale', locale);
+};
+
+export { setLocaleToLS, getLocaleFromLS, getUserFromLS, setUserToLS };
diff --git a/media-store/app-src/src/util/validateUser.js b/media-store/app-src/src/util/validateUser.js
new file mode 100644
index 00000000..b2d52b29
--- /dev/null
+++ b/media-store/app-src/src/util/validateUser.js
@@ -0,0 +1,18 @@
+import { isArray, isEmpty, isString, isNumber } from 'lodash';
+
+const CUSTOMER_ROLE = 'customer';
+const EMPLOYEE_ROLE = 'employee';
+
+const isValidUser = (user) => {
+ return (
+ !isEmpty(user) &&
+ isNumber(user.ID) &&
+ isArray(user.roles) &&
+ !!user.roles.some((role) => role === CUSTOMER_ROLE || role === EMPLOYEE_ROLE) &&
+ isString(user.email) &&
+ isString(user.accessToken) &&
+ isString(user.refreshToken)
+ );
+};
+
+export { isValidUser };
diff --git a/media-store/app-src/webpack/common-plugins.js b/media-store/app-src/webpack/common-plugins.js
new file mode 100644
index 00000000..9ae39a04
--- /dev/null
+++ b/media-store/app-src/webpack/common-plugins.js
@@ -0,0 +1,33 @@
+const path = require('path');
+const webpack = require('webpack');
+const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const CopyPlugin = require('copy-webpack-plugin');
+const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
+
+module.exports = {
+ plugins: [
+ new CleanWebpackPlugin(),
+ new HtmlWebpackPlugin({
+ template: path.join(__dirname, '../public/index.html'),
+ filename: path.join(__dirname, '../../app/index.html'),
+ publicPath: '/static/', // for js bundles path
+ }),
+ new InterpolateHtmlPlugin(HtmlWebpackPlugin, {
+ PUBLIC_URL: '',
+ }),
+ new CopyPlugin({
+ patterns: [
+ {
+ from: path.join(__dirname, '../public'),
+ to: path.join(__dirname, '../../app'),
+ globOptions: {
+ dot: true,
+ ignore: ['**/index.html'],
+ },
+ },
+ ],
+ }),
+ new webpack.ProgressPlugin(),
+ ],
+};
diff --git a/media-store/app-src/webpack/common-rules.js b/media-store/app-src/webpack/common-rules.js
new file mode 100644
index 00000000..1afcc25e
--- /dev/null
+++ b/media-store/app-src/webpack/common-rules.js
@@ -0,0 +1,18 @@
+module.exports = {
+ rules: [
+ {
+ test: /\.(js|jsx)$/,
+ exclude: /(node_modules)/,
+ use: {
+ loader: 'babel-loader',
+ options: {
+ presets: ['@babel/preset-env', '@babel/preset-react'],
+ },
+ },
+ },
+ {
+ test: /\.(png|jpg)$/,
+ use: [{ loader: 'url-loader' }],
+ },
+ ],
+};
diff --git a/media-store/app-src/webpack/webpack-dev-server.js b/media-store/app-src/webpack/webpack-dev-server.js
new file mode 100644
index 00000000..8d5b8030
--- /dev/null
+++ b/media-store/app-src/webpack/webpack-dev-server.js
@@ -0,0 +1,62 @@
+const path = require('path');
+const webpack = require('webpack');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
+const { rules } = require('./common-rules');
+
+module.exports = {
+ mode: 'development',
+ devtool: 'inline-source-map',
+ entry: {
+ index: './src/index.jsx',
+ react: ['react', 'react-dom'],
+ lodash: ['lodash'],
+ moment: ['moment'],
+ events: ['events'],
+ axios: ['axios'],
+ antd: ['antd'],
+ },
+ devServer: {
+ contentBase: './dist',
+ compress: true, // compress files to gzip to increase download speed
+ port: 3000,
+ disableHostCheck: false, // by default true, it is not recomended,
+ // because it makes app vulnerable to DNS rebinding attacks
+ headers: {
+ 'X-Custom-header': 'custom', // this requires apps with authentication
+ // useful config obj
+ },
+ open: true, // open the browser after server had been started
+ hot: true, // hot module replacement
+ historyApiFallback: true, // needs for react-router-dom
+ },
+ plugins: [
+ new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
+ new HtmlWebpackPlugin({
+ template: path.join(__dirname, '../public/index.html'),
+ }),
+ new InterpolateHtmlPlugin(HtmlWebpackPlugin, {
+ PUBLIC_URL: '',
+ }),
+ new webpack.ProgressPlugin(),
+ new webpack.DefinePlugin({
+ 'process.env.SERVICE_URL': JSON.stringify('http://localhost:4004/'),
+ }),
+ new webpack.HotModuleReplacementPlugin(), // for hot module replacement option of devServer
+ ],
+ output: {
+ filename: '[name].[fullhash].js',
+ path: path.resolve(__dirname, 'dist'),
+ },
+ module: {
+ rules: [
+ ...rules,
+ {
+ test: /\.css$/,
+ use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
+ },
+ ],
+ },
+ resolve: { extensions: ['*', '.js', '.jsx'] },
+};
diff --git a/media-store/app-src/webpack/webpack.common.js b/media-store/app-src/webpack/webpack.common.js
new file mode 100644
index 00000000..83b11f1f
--- /dev/null
+++ b/media-store/app-src/webpack/webpack.common.js
@@ -0,0 +1,25 @@
+const path = require('path');
+
+module.exports = {
+ entry: {
+ app: './src/index.jsx', // Bundle with our code
+ react: ['react', 'react-dom'],
+ lodash: ['lodash'],
+ moment: ['moment'],
+ events: ['events'],
+ axios: ['axios'],
+ antd: ['antd'],
+ },
+ output: {
+ // [name] - name of the entry (bundle),
+ // [checksum] or [hash] - to cache different bundles
+ // from update when developing (doing changes in the files)
+ filename: '[name].[fullhash].js',
+ // in this folder path bundles will be placed
+ path: path.resolve(__dirname, '../../app/static'),
+ // where you uploaded your bundled files. (Relative to server root)
+ // needs for react-router-dom
+ publicPath: '/static/',
+ },
+ resolve: { extensions: ['*', '.js', '.jsx'] },
+};
diff --git a/media-store/app-src/webpack/webpack.dev.js b/media-store/app-src/webpack/webpack.dev.js
new file mode 100644
index 00000000..ef02a698
--- /dev/null
+++ b/media-store/app-src/webpack/webpack.dev.js
@@ -0,0 +1,25 @@
+const webpack = require('webpack');
+const { merge } = require('webpack-merge');
+const common = require('./webpack.common.js');
+const { rules } = require('./common-rules');
+const { plugins } = require('./common-plugins');
+
+module.exports = merge(common, {
+ mode: 'development',
+ devtool: 'inline-source-map',
+ plugins: [
+ ...plugins,
+ new webpack.DefinePlugin({
+ 'process.env.SERVICE_URL': JSON.stringify('http://localhost:4004/'),
+ }),
+ ],
+ module: {
+ rules: [
+ ...rules,
+ {
+ test: /\.css$/,
+ use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
+ },
+ ],
+ },
+});
diff --git a/media-store/app-src/webpack/webpack.prod.js b/media-store/app-src/webpack/webpack.prod.js
new file mode 100644
index 00000000..bb047d76
--- /dev/null
+++ b/media-store/app-src/webpack/webpack.prod.js
@@ -0,0 +1,40 @@
+const webpack = require('webpack');
+const { merge } = require('webpack-merge');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
+const TerserPlugin = require('terser-webpack-plugin');
+const common = require('./webpack.common.js');
+const { rules } = require('./common-rules');
+const { plugins } = require('./common-plugins');
+
+module.exports = merge(common, {
+ mode: 'production',
+ devtool: 'source-map',
+ plugins: [
+ ...plugins,
+ new webpack.DefinePlugin({
+ 'process.env.SERVICE_URL': JSON.stringify('api/'),
+ }),
+ new MiniCssExtractPlugin({
+ filename: '[name].css',
+ chunkFilename: '[id].css',
+ }),
+ ],
+ optimization: {
+ splitChunks: {
+ // To split up js code to different bundles.
+ chunks: 'all', // Now bundle with our code will be cleaned up
+ }, // from vendors imports (2mb ~> 100kb)
+ minimize: true,
+ minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], // to minimize file size
+ },
+ module: {
+ rules: [
+ ...rules,
+ {
+ test: /\.css$/,
+ use: [MiniCssExtractPlugin.loader, 'css-loader'],
+ },
+ ],
+ },
+});
diff --git a/media-store/app/favicon.ico b/media-store/app/favicon.ico
new file mode 100644
index 00000000..bcd5dfd6
Binary files /dev/null and b/media-store/app/favicon.ico differ
diff --git a/media-store/app/index.html b/media-store/app/index.html
new file mode 100644
index 00000000..28e725c5
--- /dev/null
+++ b/media-store/app/index.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/media-store/app/logo192.png b/media-store/app/logo192.png
new file mode 100644
index 00000000..fc44b0a3
Binary files /dev/null and b/media-store/app/logo192.png differ
diff --git a/media-store/app/logo512.png b/media-store/app/logo512.png
new file mode 100644
index 00000000..a4e47a65
Binary files /dev/null and b/media-store/app/logo512.png differ
diff --git a/media-store/app/manifest.json b/media-store/app/manifest.json
new file mode 100644
index 00000000..45979ace
--- /dev/null
+++ b/media-store/app/manifest.json
@@ -0,0 +1,31 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff",
+ "sap.app": {
+ "id": "mediastore",
+ "applicationVersion": {
+ "version": "1.0.0"
+ }
+ }
+}
diff --git a/media-store/app/robots.txt b/media-store/app/robots.txt
new file mode 100644
index 00000000..e9e57dc4
--- /dev/null
+++ b/media-store/app/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/media-store/app/xs-app.json b/media-store/app/xs-app.json
new file mode 100644
index 00000000..930c40ee
--- /dev/null
+++ b/media-store/app/xs-app.json
@@ -0,0 +1,10 @@
+{
+ "welcomeFile": "/index.html",
+ "routes": [
+ {
+ "source": "^(.*)",
+ "target": "$1",
+ "service": "html5-apps-repo-rt"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/media-store/deployers/approuter/package.json b/media-store/deployers/approuter/package.json
new file mode 100644
index 00000000..d9a4cd89
--- /dev/null
+++ b/media-store/deployers/approuter/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "media-store-approuter",
+ "description": "Approuter",
+ "version": "1.0.0",
+ "dependencies": {
+ "@sap/approuter": "^6.8.2"
+ },
+ "scripts": {
+ "start": "node node_modules/@sap/approuter/approuter.js"
+ }
+}
diff --git a/media-store/deployers/approuter/xs-app.json b/media-store/deployers/approuter/xs-app.json
new file mode 100644
index 00000000..4270e0cb
--- /dev/null
+++ b/media-store/deployers/approuter/xs-app.json
@@ -0,0 +1,17 @@
+{
+ "welcomeFile": "/index.html",
+ "authenticationMethod": "none",
+ "routes": [
+ {
+ "source": "/api/(.*)",
+ "target": "$1",
+ "destination": "srv-binding",
+ "authenticationType": "none"
+ },
+ {
+ "source": "^(.*)",
+ "target": "mediastore/$1",
+ "service": "html5-apps-repo-rt"
+ }
+ ]
+}
diff --git a/media-store/deployers/html5Deployer/package.json b/media-store/deployers/html5Deployer/package.json
new file mode 100644
index 00000000..09f597b2
--- /dev/null
+++ b/media-store/deployers/html5Deployer/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "media-store-html5deployer",
+ "engines": {
+ "node": ">=6.0.0"
+ },
+ "dependencies": {
+ "@sap/html5-app-deployer": "^2.0.0"
+ },
+ "scripts": {
+ "start": "node node_modules/@sap/html5-app-deployer/index.js"
+ }
+}
diff --git a/media-store/srv/browse-invoices-service.js b/media-store/srv/browse-invoices-service.js
index bf19d66a..a7e490cf 100644
--- a/media-store/srv/browse-invoices-service.js
+++ b/media-store/srv/browse-invoices-service.js
@@ -49,7 +49,6 @@ module.exports = async function () {
({ track_ID: curID }) => newInvoicedTracks.includes(curID)
);
if (isNewInvoiceHasInvoicedTracks) {
- await transaction.rollback();
req.reject(400, "Invoice contains already owned values");
}
diff --git a/media-store/srv/browse-tracks-service.js b/media-store/srv/browse-tracks-service.js
index 34173e1b..794e7b87 100644
--- a/media-store/srv/browse-tracks-service.js
+++ b/media-store/srv/browse-tracks-service.js
@@ -1,5 +1,7 @@
const cds = require("@sap/cds");
+const SHIPPED_STATUS = 1;
+
module.exports = async function () {
const db = await cds.connect.to("db"); // connect to database service
@@ -13,6 +15,7 @@ module.exports = async function () {
"invoice_ID in",
SELECT("ID").from(Invoices).where({
customer_ID: req.user.attr.ID,
+ status: SHIPPED_STATUS,
})
)
);
diff --git a/media-store/srv/manage-store-service.js b/media-store/srv/manage-store-service.js
index 43f5f77b..d32b48d4 100644
--- a/media-store/srv/manage-store-service.js
+++ b/media-store/srv/manage-store-service.js
@@ -4,8 +4,7 @@ module.exports = async function () {
const db = await cds.connect.to("db"); // connect to database service
this.on("CREATE", "*", async (req) => {
- const selectLastQuery = SELECT.one(req.entity)
- .orderBy({ ID: "desc" });
+ const selectLastQuery = SELECT.one(req.entity).orderBy({ ID: "desc" });
const transaction = await db.tx(req);
diff --git a/media-store/util/helpers.js b/media-store/util/helpers.js
new file mode 100644
index 00000000..2587f977
--- /dev/null
+++ b/media-store/util/helpers.js
@@ -0,0 +1,28 @@
+const getDurationInMilliseconds = (start) => {
+ const NS_PER_SEC = 1e9; // convert to nanoseconds
+ const NS_TO_MS = 1e6; // convert to milliseconds
+ const diff = process.hrtime(start);
+ return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS;
+};
+
+const getFormattedDateTime = () => {
+ let currentDateTime = new Date();
+ let formattedDateTime =
+ currentDateTime.getFullYear() +
+ "-" +
+ (currentDateTime.getMonth() + 1) +
+ "-" +
+ currentDateTime.getDate() +
+ " " +
+ currentDateTime.getHours() +
+ ":" +
+ currentDateTime.getMinutes() +
+ ":" +
+ currentDateTime.getSeconds();
+ return formattedDateTime;
+};
+
+module.exports = {
+ getFormattedDateTime,
+ getDurationInMilliseconds,
+};