├── .gitignore ├── jsconfig.json ├── src ├── public │ ├── favicon.ico │ ├── icons │ │ ├── icon.png │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ └── icon-512x512.png │ ├── images │ │ ├── hero.jpg │ │ └── placeholder.png │ └── manifest.json ├── scripts │ ├── global │ │ ├── api-endpoint.js │ │ └── config.js │ ├── utils │ │ ├── sw-register.js │ │ ├── drawer-initiator.js │ │ ├── websocket-initiator.js │ │ ├── post-review.js │ │ ├── dark-mode.js │ │ ├── notif-helper.js │ │ └── like-button-presenter.js │ ├── routes │ │ ├── routes.js │ │ └── url-parser.js │ ├── views │ │ ├── templates │ │ │ ├── spinner-html.js │ │ │ ├── button-html.js │ │ │ └── template-html.js │ │ ├── pages │ │ │ ├── favorite.js │ │ │ ├── home.js │ │ │ └── detail.js │ │ └── App.js │ ├── components │ │ ├── footer-ku.js │ │ ├── hero.js │ │ └── app-bar.js │ ├── data │ │ ├── restaurant-source.js │ │ └── restaurant-idb.js │ ├── index.js │ └── sw.js ├── styles │ ├── like.css │ ├── form.css │ ├── spinner.css │ ├── responsive.css │ └── main.css └── templates │ └── index.html ├── steps_file.js ├── webpack.dev.js ├── steps.d.ts ├── specs ├── favRestaurantIdbSpec.js ├── helpers │ └── testFactories.js ├── favRestaurantArraySpec.js ├── unlikeRestaurantSpec.js ├── contract │ └── favRestaurantContract.js └── likeRestaurantSpec.js ├── .eslintrc.json ├── codecept.conf.js ├── sharp.js ├── webpack.prod.js ├── webpack.common.js ├── package.json ├── karma.conf.js ├── e2e └── Favorite_Restaurant.spec.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /src/public/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon.png -------------------------------------------------------------------------------- /src/public/images/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/images/hero.jpg -------------------------------------------------------------------------------- /src/public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/public/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/public/images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firmanjabar/nongkis/HEAD/src/public/images/placeholder.png -------------------------------------------------------------------------------- /src/scripts/global/api-endpoint.js: -------------------------------------------------------------------------------- 1 | import CONFIG from './config'; 2 | 3 | const API_ENDPOINT = { 4 | LIST: `${CONFIG.BASE_URL}list`, 5 | DETAIL: (id) => `${CONFIG.BASE_URL}detail/${id}`, 6 | POST_REVIEW: `${CONFIG.BASE_URL}review`, 7 | }; 8 | 9 | export default API_ENDPOINT; 10 | -------------------------------------------------------------------------------- /src/scripts/utils/sw-register.js: -------------------------------------------------------------------------------- 1 | import { Workbox } from 'workbox-window'; 2 | 3 | const swRegister = async () => { 4 | if ('serviceWorker' in navigator) { 5 | const workbox = new Workbox('../sw.js'); 6 | workbox.register(); 7 | } 8 | }; 9 | 10 | export default swRegister; 11 | -------------------------------------------------------------------------------- /steps_file.js: -------------------------------------------------------------------------------- 1 | // in this file you can append custom step methods to 'I' object 2 | 3 | module.exports = function() { 4 | return actor({ 5 | 6 | // Define custom steps here, use 'this' to access default methods of I. 7 | // It is recommended to place a general 'login' function here. 8 | 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/scripts/routes/routes.js: -------------------------------------------------------------------------------- 1 | import Home from '../views/pages/home'; 2 | import Favorite from '../views/pages/favorite'; 3 | import Detail from '../views/pages/detail'; 4 | 5 | const routes = { 6 | '/': Home, 7 | '/home': Home, 8 | '/favorite': Favorite, 9 | '/detail/:id': Detail, 10 | }; 11 | 12 | export default routes; 13 | -------------------------------------------------------------------------------- /src/scripts/views/templates/spinner-html.js: -------------------------------------------------------------------------------- 1 | const Spinner = () => ` 2 |
3 | 4 | 5 | 6 |
7 | `; 8 | 9 | export default Spinner; 10 | -------------------------------------------------------------------------------- /src/styles/like.css: -------------------------------------------------------------------------------- 1 | .like { 2 | font-size: 18px; 3 | position: fixed; 4 | bottom: 16px; 5 | right: 16px; 6 | background-color: #db0000; 7 | color: white; 8 | border: 0; 9 | border-radius: 50%; 10 | width: 55px; 11 | height: 55px; 12 | cursor: pointer; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | } 17 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const { merge } = require('webpack-merge'); 3 | const path = require('path'); 4 | const common = require('./webpack.common'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | devServer: { 9 | contentBase: path.resolve(__dirname, 'dist'), 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /steps.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type steps_file = typeof import('./steps_file.js'); 3 | 4 | declare namespace CodeceptJS { 5 | interface SupportObject { I: CodeceptJS.I } 6 | interface CallbackOrder { [0]: CodeceptJS.I } 7 | interface Methods extends CodeceptJS.Puppeteer {} 8 | interface I extends ReturnType {} 9 | namespace Translation { 10 | interface Actions {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/scripts/global/config.js: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | KEY: '12345', 3 | BASE_URL: 'https://restaurant-api.dicoding.dev/', 4 | BASE_IMAGE_URL: 'https://restaurant-api.dicoding.dev/images/medium/', 5 | BASE_IMAGE_URL_SM: 'https://restaurant-api.dicoding.dev/images/small/', 6 | DATABASE_NAME: 'restaurant-list-database', 7 | DATABASE_VERSION: 1, 8 | OBJECT_STORE_NAME: 'restaurants', 9 | WEB_SOCKET_SERVER: 'wss://javascript.info/article/websocket/chat/ws', 10 | }; 11 | 12 | export default CONFIG; 13 | -------------------------------------------------------------------------------- /src/scripts/components/footer-ku.js: -------------------------------------------------------------------------------- 1 | class FooterKu extends HTMLElement { 2 | connectedCallback() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | this.innerHTML = ` 8 |
9 |
    10 |
  • Copyright © 2020 - Nongki's
  • 11 |
  • handcrafted with by firmanjabar
  • 12 |
13 |
14 | `; 15 | } 16 | } 17 | 18 | customElements.define('footer-ku', FooterKu); 19 | -------------------------------------------------------------------------------- /src/scripts/views/templates/button-html.js: -------------------------------------------------------------------------------- 1 | const createLikeRestButtonTemp = () => ` 2 | 5 | `; 6 | 7 | const createUnlikeRestButtonTemp = () => ` 8 | 11 | `; 12 | 13 | export { createLikeRestButtonTemp, createUnlikeRestButtonTemp }; 14 | -------------------------------------------------------------------------------- /specs/favRestaurantIdbSpec.js: -------------------------------------------------------------------------------- 1 | import { itActsAsFavoriteRestaurantModel } from './contract/favRestaurantContract'; 2 | import FavRestaurantIdb from '../src/scripts/data/restaurant-idb'; 3 | 4 | describe('Favorite Movie Idb Contract Test Implementation', () => { 5 | afterEach(async () => { 6 | (await FavRestaurantIdb.getAllRestaurants()).forEach(async (restaurant) => { 7 | await FavRestaurantIdb.deleteRestaurant(restaurant.id); 8 | }); 9 | }); 10 | 11 | itActsAsFavoriteRestaurantModel(FavRestaurantIdb); 12 | }); 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true, 5 | "node": true 6 | }, 7 | "extends": ["airbnb-base"], 8 | "parserOptions": { 9 | "ecmaVersion": 11, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "linebreak-style": "off", 14 | "no-underscore-dangle": "off", 15 | "operator-linebreak": "off", 16 | "implicit-arrow-linebreak": "off", 17 | "no-console": "off", 18 | "no-undef": "off", 19 | "consistent-return": "off", 20 | "no-prototype-builtins": "off" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /specs/helpers/testFactories.js: -------------------------------------------------------------------------------- 1 | import LikeButtonInitiator from '../../src/scripts/utils/like-button-presenter'; 2 | import FavRestaurantIdb from '../../src/scripts/data/restaurant-idb'; 3 | 4 | const createLikeButtonPresenterWithRestaurant = async (restaurant) => { 5 | await LikeButtonInitiator.init({ 6 | likeButtonContainer: document.querySelector('#likeButtonContainer'), 7 | favoriteRestaurant: FavRestaurantIdb, 8 | data: { 9 | restaurant, 10 | }, 11 | }); 12 | }; 13 | 14 | // eslint-disable-next-line import/prefer-default-export 15 | export { createLikeButtonPresenterWithRestaurant }; 16 | -------------------------------------------------------------------------------- /src/scripts/utils/drawer-initiator.js: -------------------------------------------------------------------------------- 1 | const DrawerInitiator = { 2 | init({ button, drawer, content }) { 3 | button.addEventListener('click', (event) => { 4 | this._toggleDrawer(event, drawer); 5 | }); 6 | 7 | content.addEventListener('click', (event) => { 8 | this._closeDrawer(event, drawer); 9 | }); 10 | }, 11 | 12 | _toggleDrawer(event, drawer) { 13 | event.stopPropagation(); 14 | drawer.classList.toggle('nav-list-block'); 15 | }, 16 | 17 | _closeDrawer(event, drawer) { 18 | event.stopPropagation(); 19 | drawer.classList.remove('nav-list-block'); 20 | }, 21 | }; 22 | 23 | export default DrawerInitiator; 24 | -------------------------------------------------------------------------------- /src/scripts/components/hero.js: -------------------------------------------------------------------------------- 1 | class Hero extends HTMLElement { 2 | connectedCallback() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | this.innerHTML = ` 8 |
9 |

10 | Good Food is A 11 | Good Mood. 12 |

13 |

14 | Food is the ingredient that bind us together! And nothing brings people together like a 15 | Good Food! 16 |

17 | Let's Nongkrong! 18 |
19 | `; 20 | } 21 | } 22 | 23 | customElements.define('hero-custom', Hero); 24 | -------------------------------------------------------------------------------- /src/scripts/utils/websocket-initiator.js: -------------------------------------------------------------------------------- 1 | import NotifHelper from './notif-helper'; 2 | 3 | const WebSocketInitiator = { 4 | init(url) { 5 | const webSocket = new WebSocket(url); 6 | webSocket.onmessage = this._onMessageHandler; 7 | }, 8 | 9 | _onMessageHandler(message) { 10 | console.log(message.data); 11 | NotifHelper.sendNotification({ 12 | title: 'Notif from WebSocket', 13 | options: { 14 | body: message.data, 15 | icon: 'icons/icon-192x192.png', 16 | image: 'https://miro.medium.com/max/1200/1*mk1-6aYaf_Bes1E3Imhc0A.jpeg', 17 | vibrate: [200, 100, 200], 18 | }, 19 | }); 20 | }, 21 | }; 22 | 23 | export default WebSocketInitiator; 24 | -------------------------------------------------------------------------------- /src/styles/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | margin: 2em 0; 3 | padding: 1.5em; 4 | border: 2px solid #6e6e6e; 5 | border-radius: 0.5em; 6 | } 7 | 8 | .form-control { 9 | display: block; 10 | width: 100%; 11 | min-height: 1em; 12 | padding: 0.3em; 13 | font-family: 'Comfortaa', cursive; 14 | font-size: 1em; 15 | font-weight: normal; 16 | color: rgb(0, 0, 0); 17 | background-color: #fff; 18 | background-clip: padding-box; 19 | border: 2px solid #6e6e6e; 20 | appearance: none; 21 | border-radius: 0.5em; 22 | } 23 | 24 | .form-label { 25 | padding-bottom: 8px; 26 | font-weight: bold; 27 | font-style: normal; 28 | font-size: small; 29 | color: var(--secondary-color); 30 | } 31 | 32 | .mb-3 { 33 | margin-bottom: 1.3em; 34 | margin-top: 0.5em; 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/spinner.css: -------------------------------------------------------------------------------- 1 | .spinner-wrapper { 2 | width: 100%; 3 | height: 100vh; 4 | background-color: var(--primary-color); 5 | margin: 0; 6 | padding: 0; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | } 11 | 12 | @keyframes spinner-grow { 13 | 0% { 14 | transform: scale(0); 15 | } 16 | 50% { 17 | opacity: 1; 18 | transform: none; 19 | } 20 | } 21 | .spinner-grow { 22 | display: inline-block; 23 | width: 2rem; 24 | height: 2rem; 25 | vertical-align: text-bottom; 26 | border-radius: 50%; 27 | opacity: 0; 28 | animation: spinner-grow 0.75s linear infinite; 29 | } 30 | 31 | .merah { 32 | background-color: gold; 33 | } 34 | 35 | .kuning { 36 | background-color: #db2a2a; 37 | } 38 | 39 | .biru { 40 | background-color: var(--secondary-color); 41 | } 42 | -------------------------------------------------------------------------------- /codecept.conf.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const { setHeadlessWhen } = require('@codeceptjs/configure'); 3 | 4 | // turn on headless mode when running with HEADLESS=true environment variable 5 | // export HEADLESS=true && npx codeceptjs run 6 | setHeadlessWhen(process.env.HEADLESS); 7 | 8 | exports.config = { 9 | tests: 'e2e/**/*.spec.js', 10 | output: 'e2e/outputs', 11 | helpers: { 12 | Puppeteer: { 13 | url: 'http://localhost:8080', 14 | show: true, 15 | windowSize: '1200x900', 16 | }, 17 | }, 18 | include: { 19 | I: './steps_file.js', 20 | }, 21 | bootstrap: null, 22 | mocha: {}, 23 | name: 'restaurant-apps', 24 | plugins: { 25 | retryFailedStep: { 26 | enabled: true, 27 | }, 28 | screenshotOnFail: { 29 | enabled: true, 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/scripts/data/restaurant-source.js: -------------------------------------------------------------------------------- 1 | import API_ENDPOINT from '../global/api-endpoint'; 2 | import CONFIG from '../global/config'; 3 | 4 | class RestaurantSource { 5 | static async listRestaurant() { 6 | const response = await fetch(API_ENDPOINT.LIST); 7 | return response.json(); 8 | } 9 | 10 | static async detailRestaurant(id) { 11 | const response = await fetch(API_ENDPOINT.DETAIL(id)); 12 | // console.log(id, response); 13 | return response.json(); 14 | } 15 | 16 | static async postRestaurant(data) { 17 | const rawResponse = await fetch(API_ENDPOINT.POST_REVIEW, { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | 'X-Auth-Token': CONFIG.KEY, 22 | }, 23 | body: JSON.stringify(data), 24 | }); 25 | return rawResponse; 26 | } 27 | } 28 | 29 | export default RestaurantSource; 30 | -------------------------------------------------------------------------------- /src/scripts/routes/url-parser.js: -------------------------------------------------------------------------------- 1 | const UrlParser = { 2 | parseActiveUrlWithCombiner() { 3 | const url = window.location.hash.slice(1).toLowerCase(); 4 | const splitedUrl = this._urlSplitter(url); 5 | return this._urlCombiner(splitedUrl); 6 | }, 7 | 8 | parseActiveUrlWithoutCombiner() { 9 | const url = window.location.hash.slice(1).toLowerCase(); 10 | return this._urlSplitter(url); 11 | }, 12 | 13 | _urlSplitter(url) { 14 | const urlsSplits = url.split('/'); 15 | return { 16 | resource: urlsSplits[1] || null, 17 | id: urlsSplits[2] || null, 18 | verb: urlsSplits[3] || null, 19 | }; 20 | }, 21 | 22 | _urlCombiner(splitedUrl) { 23 | return ( 24 | (splitedUrl.resource ? `/${splitedUrl.resource}` : '/') + 25 | (splitedUrl.id ? '/:id' : '') + 26 | (splitedUrl.verb ? `/${splitedUrl.verb}` : '') 27 | ); 28 | }, 29 | }; 30 | 31 | export default UrlParser; 32 | -------------------------------------------------------------------------------- /src/scripts/utils/post-review.js: -------------------------------------------------------------------------------- 1 | import RestaurantSource from '../data/restaurant-source'; 2 | 3 | const PostReview = (url, name, review) => { 4 | const dataInput = { 5 | id: url.id, 6 | name, 7 | review, 8 | }; 9 | RestaurantSource.postRestaurant(dataInput); 10 | 11 | const reviewContainer = document.querySelector('.detail-review'); 12 | const options = { year: 'numeric', month: 'long', day: 'numeric' }; 13 | const date = new Date().toLocaleDateString('id-ID', options); 14 | const newReview = ` 15 |
16 |
17 |

 ${name}

18 |

${date}

19 |
20 |
21 | ${review} 22 |
23 |
24 | `; 25 | reviewContainer.innerHTML += newReview; 26 | }; 27 | 28 | export default PostReview; 29 | -------------------------------------------------------------------------------- /src/scripts/views/pages/favorite.js: -------------------------------------------------------------------------------- 1 | import FavRestaurantIdb from '../../data/restaurant-idb'; 2 | import { restaurantItemTemplate } from '../templates/template-html'; 3 | 4 | const Favorite = { 5 | async render() { 6 | return ` 7 |
8 |

Your Favorite Cafe / Restaurant

9 |
10 |
11 | 12 | `; 13 | }, 14 | 15 | async afterRender() { 16 | const data = await FavRestaurantIdb.getAllRestaurants(); 17 | const listContainer = document.querySelector('#list-rest'); 18 | if (data.length === 0) { 19 | listContainer.innerHTML = ` 20 | You don't have any Favorite Cafe or Restaurant 21 | `; 22 | } 23 | const totalRest = data.length; 24 | data.forEach((restaurant, index) => { 25 | listContainer.innerHTML += restaurantItemTemplate(restaurant, index, totalRest); 26 | }); 27 | }, 28 | }; 29 | 30 | export default Favorite; 31 | -------------------------------------------------------------------------------- /src/scripts/data/restaurant-idb.js: -------------------------------------------------------------------------------- 1 | import { openDB } from 'idb'; 2 | import CONFIG from '../global/config'; 3 | 4 | const { DATABASE_NAME, DATABASE_VERSION, OBJECT_STORE_NAME } = CONFIG; 5 | 6 | const dbPromise = openDB(DATABASE_NAME, DATABASE_VERSION, { 7 | upgrade(database) { 8 | database.createObjectStore(OBJECT_STORE_NAME, { keyPath: 'id' }); 9 | }, 10 | }); 11 | 12 | const FavRestaurantIdb = { 13 | async getRestaurant(id) { 14 | if (!id) { 15 | return; 16 | } 17 | 18 | return (await dbPromise).get(OBJECT_STORE_NAME, id); 19 | }, 20 | 21 | async getAllRestaurants() { 22 | return (await dbPromise).getAll(OBJECT_STORE_NAME); 23 | }, 24 | 25 | async putRestaurant(restaurant) { 26 | if (!restaurant.hasOwnProperty('id')) { 27 | return; 28 | } 29 | 30 | return (await dbPromise).put(OBJECT_STORE_NAME, restaurant); 31 | }, 32 | 33 | async deleteRestaurant(id) { 34 | return (await dbPromise).delete(OBJECT_STORE_NAME, id); 35 | }, 36 | }; 37 | 38 | export default FavRestaurantIdb; 39 | -------------------------------------------------------------------------------- /src/scripts/views/App.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable object-curly-newline */ 2 | import DrawerInitiator from '../utils/drawer-initiator'; 3 | import DarkMode from '../utils/dark-mode'; 4 | import UrlParser from '../routes/url-parser'; 5 | import routes from '../routes/routes'; 6 | 7 | class App { 8 | constructor({ button, drawer, content, toggle, currentTheme }) { 9 | this._button = button; 10 | this._drawer = drawer; 11 | this._content = content; 12 | this._toggle = toggle; 13 | this._currentTheme = currentTheme; 14 | 15 | this._initialAppShell(); 16 | this._initialDarkMode(); 17 | } 18 | 19 | _initialAppShell() { 20 | DrawerInitiator.init({ 21 | button: this._button, 22 | drawer: this._drawer, 23 | content: this._content, 24 | }); 25 | } 26 | 27 | _initialDarkMode() { 28 | DarkMode.init({ 29 | toggle: this._toggle, 30 | currentTheme: this._currentTheme, 31 | }); 32 | } 33 | 34 | async renderPage() { 35 | const url = UrlParser.parseActiveUrlWithCombiner(); 36 | const page = routes[url]; 37 | this._content.innerHTML = await page.render(); 38 | await page.afterRender(); 39 | } 40 | } 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /src/scripts/utils/dark-mode.js: -------------------------------------------------------------------------------- 1 | const DarkMode = { 2 | init({ toggle, currentTheme }) { 3 | toggle.addEventListener('click', (event) => { 4 | this._toggleSwitch(event); 5 | }); 6 | 7 | if (currentTheme) { 8 | document.documentElement.setAttribute('data-theme', currentTheme); 9 | } 10 | }, 11 | 12 | _toggleSwitch(e) { 13 | e.stopPropagation(); 14 | const cekTheme = (event) => 15 | event.target.classList.value === 'light' || event.path[1].classList.value === 'light'; 16 | 17 | if (cekTheme(e)) { 18 | document.documentElement.setAttribute('data-theme', 'dark'); 19 | e.target.classList.remove('light'); 20 | e.target.classList.add('dark'); 21 | e.path[1].classList.remove('light'); 22 | e.path[1].classList.add('dark'); 23 | localStorage.setItem('theme', 'dark'); 24 | } else { 25 | document.documentElement.setAttribute('data-theme', 'light'); 26 | e.target.classList.remove('dark'); 27 | e.target.classList.add('light'); 28 | e.path[1].classList.remove('dark'); 29 | e.path[1].classList.add('light'); 30 | localStorage.setItem('theme', 'light'); 31 | } 32 | }, 33 | }; 34 | 35 | export default DarkMode; 36 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime'; 2 | import './components/app-bar'; 3 | import './components/hero'; 4 | import './components/footer-ku'; 5 | import 'lazysizes'; 6 | import 'lazysizes/plugins/parent-fit/ls.parent-fit'; 7 | import '../styles/main.css'; 8 | import '../styles/responsive.css'; 9 | import '../styles/form.css'; 10 | import '../styles/like.css'; 11 | import '../styles/spinner.css'; 12 | import App from './views/App'; 13 | import swRegister from './utils/sw-register'; 14 | // import WebSocketInitiator from './utils/websocket-initiator'; 15 | // import CONFIG from './global/config'; 16 | 17 | const app = new App({ 18 | button: document.querySelector('.menu'), 19 | drawer: document.querySelector('.nav-list'), 20 | content: document.querySelector('#main-content'), 21 | toggle: document.querySelector('#dark-mode'), 22 | currentTheme: localStorage.getItem('theme'), 23 | }); 24 | 25 | window.addEventListener('hashchange', () => { 26 | document.querySelector('.container').scrollIntoView(); 27 | app.renderPage(); 28 | }); 29 | 30 | window.addEventListener('DOMContentLoaded', () => { 31 | app.renderPage(); 32 | swRegister(); 33 | // WebSocketInitiator.init(CONFIG.WEB_SOCKET_SERVER); 34 | }); 35 | -------------------------------------------------------------------------------- /src/scripts/utils/notif-helper.js: -------------------------------------------------------------------------------- 1 | const NotifHelper = { 2 | sendNotification({ title, options }) { 3 | if (!this._checkAvailability()) { 4 | console.log('Notification not supported in this browser'); 5 | return; 6 | } 7 | 8 | if (!this._checkPermission()) { 9 | console.log('User did not yet granted permission'); 10 | this._requestPermission(); 11 | return; 12 | } 13 | 14 | this._showNotification({ title, options }); 15 | }, 16 | 17 | _checkAvailability() { 18 | return 'Notification' in window; 19 | }, 20 | 21 | _checkPermission() { 22 | return Notification.permission === 'granted'; 23 | }, 24 | 25 | async _requestPermission() { 26 | const status = await Notification.requestPermission(); 27 | 28 | if (status === 'denied') { 29 | console.log('Notification Denied'); 30 | } 31 | 32 | if (status === 'default') { 33 | console.log('Permission closed'); 34 | } 35 | }, 36 | 37 | async _showNotification({ title, options }) { 38 | const serviceWorkerRegistration = await navigator.serviceWorker.ready; 39 | serviceWorkerRegistration.showNotification(title, options); 40 | }, 41 | }; 42 | 43 | export default NotifHelper; 44 | -------------------------------------------------------------------------------- /sharp.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const sharp = require('sharp'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const target = path.resolve(__dirname, 'src/public/images'); 7 | const destination = path.resolve(__dirname, 'dist/images'); 8 | 9 | if (!fs.existsSync(destination)) { 10 | fs.mkdirSync(destination); 11 | } 12 | 13 | fs.readdirSync(target).forEach((image) => { 14 | // mengubah ukuran gambar dengan lebar 900px, dengan prefix -large.jpg 15 | sharp(`${target}/${image}`) 16 | .resize(900) 17 | .toFile( 18 | path.resolve( 19 | __dirname, 20 | `${destination}/${image.split('.').slice(0, -1).join('.')}-large.jpg`, 21 | ), 22 | ); 23 | 24 | // mengubah ukuran gambar dengan lebar 400px, dengan prefix -small.jpg 25 | sharp(`${target}/${image}`) 26 | .resize(400) 27 | .toFile( 28 | path.resolve( 29 | __dirname, 30 | `${destination}/${image.split('.').slice(0, -1).join('.')}-small.jpg`, 31 | ), 32 | ); 33 | 34 | sharp(`${target}/${image}`) 35 | .resize(1800) 36 | .toFile( 37 | path.resolve(__dirname, `${destination}/${image.split('.').slice(0, -1).join('.')}-xl.jpg`), 38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /specs/favRestaurantArraySpec.js: -------------------------------------------------------------------------------- 1 | import { itActsAsFavoriteRestaurantModel } from './contract/favRestaurantContract'; 2 | 3 | let favoriteRestaurants = []; 4 | 5 | const FavoriteRestaurantArray = { 6 | getRestaurant(id) { 7 | if (!id) { 8 | return; 9 | } 10 | 11 | return favoriteRestaurants.find((restaurant) => restaurant.id === id); 12 | }, 13 | 14 | getAllRestaurants() { 15 | return favoriteRestaurants; 16 | }, 17 | 18 | putRestaurant(restaurant) { 19 | if (!restaurant.hasOwnProperty('id')) { 20 | return; 21 | } 22 | 23 | // pastikan id ini belum ada dalam daftar favoriteRestaurant 24 | if (this.getRestaurant(restaurant.id)) { 25 | return; 26 | } 27 | 28 | favoriteRestaurants.push(restaurant); 29 | }, 30 | 31 | deleteRestaurant(id) { 32 | // cara boros menghapus restaurant dengan meng-copy restaurant yang ada 33 | // kecuali restaurant dengan id === id 34 | favoriteRestaurants = favoriteRestaurants.filter((restaurant) => restaurant.id !== id); 35 | }, 36 | }; 37 | 38 | describe('Favorite Restaurant Array Contract Test Implementation', () => { 39 | // eslint-disable-next-line no-return-assign 40 | afterEach(() => (favoriteRestaurants = [])); 41 | 42 | itActsAsFavoriteRestaurantModel(FavoriteRestaurantArray); 43 | }); 44 | -------------------------------------------------------------------------------- /src/scripts/components/app-bar.js: -------------------------------------------------------------------------------- 1 | class AppBar extends HTMLElement { 2 | connectedCallback() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | this.innerHTML = ` 8 | 43 | `; 44 | } 45 | } 46 | 47 | customElements.define('app-bar', AppBar); 48 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const { merge } = require('webpack-merge'); 4 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 5 | const common = require('./webpack.common'); 6 | 7 | module.exports = merge(common, { 8 | mode: 'production', 9 | optimization: { 10 | splitChunks: { 11 | chunks: 'all', 12 | minSize: 20000, 13 | maxSize: 70000, 14 | minChunks: 1, 15 | maxAsyncRequests: 30, 16 | maxInitialRequests: 30, 17 | automaticNameDelimiter: '~', 18 | enforceSizeThreshold: 50000, 19 | cacheGroups: { 20 | defaultVendors: { 21 | test: /[\\/]node_modules[\\/]/, 22 | priority: -10, 23 | }, 24 | default: { 25 | minChunks: 2, 26 | priority: -20, 27 | reuseExistingChunk: true, 28 | }, 29 | }, 30 | }, 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.js$/, 36 | exclude: '/node_modules/', 37 | use: [ 38 | { 39 | loader: 'babel-loader', 40 | options: { 41 | presets: ['@babel/preset-env'], 42 | }, 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | plugins: [ 49 | new CleanWebpackPlugin(), 50 | new BundleAnalyzerPlugin({ 51 | analyzerMode: 'disabled', 52 | generateStatsFile: true, 53 | statsOptions: { source: false }, 54 | }), 55 | ], 56 | }); 57 | -------------------------------------------------------------------------------- /src/scripts/views/pages/home.js: -------------------------------------------------------------------------------- 1 | import RestaurantSource from '../../data/restaurant-source'; 2 | import { restaurantItemTemplate, createSkeletonItemTemplate } from '../templates/template-html'; 3 | import Spinner from '../templates/spinner-html'; 4 | 5 | const Home = { 6 | async render() { 7 | return ` 8 |
9 |
10 |
11 |

List Restaurant

12 |
13 | ${createSkeletonItemTemplate(20)} 14 |
15 |
16 |
17 | `; 18 | }, 19 | 20 | async afterRender() { 21 | const loading = document.querySelector('#loading'); 22 | const main = document.querySelector('.main'); 23 | loading.innerHTML = Spinner(); 24 | main.style.display = 'none'; 25 | const listContainer = document.querySelector('#list-rest'); 26 | listContainer.innerHTML = ''; 27 | 28 | try { 29 | const data = await RestaurantSource.listRestaurant(); 30 | const totalRest = data.restaurants.length; 31 | data.restaurants.forEach((restaurant, index) => { 32 | listContainer.innerHTML += restaurantItemTemplate(restaurant, index, totalRest); 33 | }); 34 | main.style.display = 'block'; 35 | loading.style.display = 'none'; 36 | } catch (err) { 37 | main.style.display = 'block'; 38 | loading.style.display = 'none'; 39 | listContainer.innerHTML = `Error: ${err}, swipe up to refresh!`; 40 | } 41 | }, 42 | }; 43 | 44 | export default Home; 45 | -------------------------------------------------------------------------------- /src/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nongki's", 3 | "short_name": "Nongki's", 4 | "description": "Web that provide list of cafe and restaurant", 5 | "start_url": "/index.html", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#db2a2a", 9 | "icons": [ 10 | { 11 | "src": "/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "any maskable" 21 | }, 22 | { 23 | "src": "/icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "any maskable" 27 | }, 28 | { 29 | "src": "/icons/icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "any maskable" 33 | }, 34 | { 35 | "src": "/icons/icon-152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "any maskable" 39 | }, 40 | { 41 | "src": "/icons/icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "any maskable" 45 | }, 46 | { 47 | "src": "/icons/icon-384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "any maskable" 51 | }, 52 | { 53 | "src": "/icons/icon-512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "any maskable" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/scripts/utils/like-button-presenter.js: -------------------------------------------------------------------------------- 1 | import { 2 | createLikeRestButtonTemp, 3 | createUnlikeRestButtonTemp, 4 | } from '../views/templates/button-html'; 5 | 6 | const LikeButtonPresenter = { 7 | async init({ likeButtonContainer, favoriteRestaurant, data }) { 8 | this._likeButtonContainer = likeButtonContainer; 9 | this._favoriteRestaurant = favoriteRestaurant; 10 | this._restaurant = data.restaurant; 11 | 12 | await this._renderButton(); 13 | }, 14 | 15 | async _renderButton() { 16 | const { id } = this._restaurant; 17 | 18 | if (await this._isRestaurantExist(id)) { 19 | this._renderLiked(); 20 | } else { 21 | this._renderLike(); 22 | } 23 | }, 24 | 25 | async _isRestaurantExist(id) { 26 | const restaurant = await this._favoriteRestaurant.getRestaurant(id); 27 | return !!restaurant; 28 | }, 29 | 30 | _renderLike() { 31 | this._likeButtonContainer.innerHTML = createLikeRestButtonTemp(); 32 | 33 | const likeButton = document.querySelector('#likeButton'); 34 | likeButton.addEventListener('click', async () => { 35 | await this._favoriteRestaurant.putRestaurant(this._restaurant); 36 | this._renderButton(); 37 | }); 38 | }, 39 | 40 | _renderLiked() { 41 | this._likeButtonContainer.innerHTML = createUnlikeRestButtonTemp(); 42 | 43 | const likeButton = document.querySelector('#likeButton'); 44 | likeButton.addEventListener('click', async () => { 45 | await this._favoriteRestaurant.deleteRestaurant(this._restaurant.id); 46 | this._renderButton(); 47 | }); 48 | }, 49 | }; 50 | 51 | export default LikeButtonPresenter; 52 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Nongki's 11 | 12 | 13 | 14 | 21 | 28 | 38 | 39 | 40 | 41 | 42 |
43 | 44 |
45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /specs/unlikeRestaurantSpec.js: -------------------------------------------------------------------------------- 1 | import FavRestaurantIdb from '../src/scripts/data/restaurant-idb'; 2 | import * as TestFactories from './helpers/testFactories'; 3 | 4 | const addLikeButtonContainer = () => { 5 | document.body.innerHTML = '
'; 6 | }; 7 | 8 | describe('Unliking a Restaurant', () => { 9 | beforeEach(async () => { 10 | addLikeButtonContainer(); 11 | await FavRestaurantIdb.putRestaurant({ id: 1 }); 12 | }); 13 | 14 | afterEach(async () => { 15 | await FavRestaurantIdb.deleteRestaurant(1); 16 | }); 17 | 18 | it('should display unlike widget when the restaurant has been liked', async () => { 19 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 20 | 21 | expect(document.querySelector('[aria-label="unlike this restaurant"]')).toBeTruthy(); 22 | }); 23 | 24 | it('should not display unlike widget when the restaurant has been liked', async () => { 25 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 26 | 27 | expect(document.querySelector('[aria-label="like this restaurant"]')).toBeFalsy(); 28 | }); 29 | 30 | it('should be able to remove liked restaurant from the list', async () => { 31 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 32 | 33 | document 34 | .querySelector('[aria-label="unlike this restaurant"]') 35 | .dispatchEvent(new Event('click')); 36 | 37 | expect(await FavRestaurantIdb.getAllRestaurants()).toEqual([]); 38 | }); 39 | 40 | it('should not throw error if the unliked restaurant is not in the list', async () => { 41 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 42 | 43 | await FavRestaurantIdb.deleteRestaurant(1); 44 | document 45 | .querySelector('[aria-label="unlike this restaurant"]') 46 | .dispatchEvent(new Event('click')); 47 | 48 | expect(await FavRestaurantIdb.getAllRestaurants()).toEqual([]); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const { InjectManifest } = require('workbox-webpack-plugin'); 5 | const ImageminWebpackPlugin = require('imagemin-webpack-plugin').default; 6 | const ImageminMozjpeg = require('imagemin-mozjpeg'); 7 | const imageminPngquant = require('imagemin-pngquant'); 8 | const path = require('path'); 9 | 10 | module.exports = { 11 | entry: path.resolve(__dirname, 'src/scripts/index.js'), 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: '[name].bundle.js', 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.css$/, 20 | use: [ 21 | { 22 | loader: 'style-loader', 23 | }, 24 | { 25 | loader: 'css-loader', 26 | }, 27 | ], 28 | }, 29 | ], 30 | }, 31 | plugins: [ 32 | new HtmlWebpackPlugin({ 33 | template: path.resolve(__dirname, 'src/templates/index.html'), 34 | filename: 'index.html', 35 | favicon: path.resolve(__dirname, 'src/public/favicon.ico'), 36 | }), 37 | new CopyWebpackPlugin({ 38 | patterns: [ 39 | { 40 | from: path.resolve(__dirname, 'src/public/'), 41 | to: path.resolve(__dirname, 'dist/'), 42 | globOptions: { 43 | ignore: ['**/images/**'], // CopyWebpackPlugin mengabaikan berkas yang berada di dalam folder images 44 | }, 45 | }, 46 | ], 47 | }), 48 | new InjectManifest({ 49 | swSrc: path.resolve(__dirname, 'src/scripts/sw.js'), 50 | }), 51 | new ImageminWebpackPlugin({ 52 | plugins: [ 53 | ImageminMozjpeg({ 54 | quality: 50, 55 | progressive: true, 56 | }), 57 | imageminPngquant({ 58 | quality: [0.3, 0.5], 59 | }), 60 | ], 61 | }), 62 | ], 63 | }; 64 | -------------------------------------------------------------------------------- /specs/contract/favRestaurantContract.js: -------------------------------------------------------------------------------- 1 | const itActsAsFavoriteRestaurantModel = (favoriteRestaurant) => { 2 | it('should return the restaurant that has been added', async () => { 3 | favoriteRestaurant.putRestaurant({ id: 1 }); 4 | favoriteRestaurant.putRestaurant({ id: 2 }); 5 | 6 | expect(await favoriteRestaurant.getRestaurant(1)).toEqual({ id: 1 }); 7 | expect(await favoriteRestaurant.getRestaurant(2)).toEqual({ id: 2 }); 8 | expect(await favoriteRestaurant.getRestaurant(3)).toEqual(undefined); 9 | }); 10 | 11 | it('should refuse a Restaurant from being added if it does not have the correct property', async () => { 12 | favoriteRestaurant.putRestaurant({ aProperty: 'property' }); 13 | 14 | expect(await favoriteRestaurant.getAllRestaurants()).toEqual([]); 15 | }); 16 | 17 | it('can return all of the Restaurants that have been added', async () => { 18 | favoriteRestaurant.putRestaurant({ id: 1 }); 19 | favoriteRestaurant.putRestaurant({ id: 2 }); 20 | 21 | expect(await favoriteRestaurant.getAllRestaurants()).toEqual([{ id: 1 }, { id: 2 }]); 22 | }); 23 | 24 | it('should remove favorite Restaurant', async () => { 25 | favoriteRestaurant.putRestaurant({ id: 1 }); 26 | favoriteRestaurant.putRestaurant({ id: 2 }); 27 | favoriteRestaurant.putRestaurant({ id: 3 }); 28 | 29 | await favoriteRestaurant.deleteRestaurant(1); 30 | 31 | expect(await favoriteRestaurant.getAllRestaurants()).toEqual([{ id: 2 }, { id: 3 }]); 32 | }); 33 | 34 | it('should handle request to remove a Restaurant even though the Restaurant has not been added', async () => { 35 | favoriteRestaurant.putRestaurant({ id: 1 }); 36 | favoriteRestaurant.putRestaurant({ id: 2 }); 37 | favoriteRestaurant.putRestaurant({ id: 3 }); 38 | 39 | await favoriteRestaurant.deleteRestaurant(4); 40 | 41 | expect(await favoriteRestaurant.getAllRestaurants()).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); 42 | }); 43 | }; 44 | 45 | // eslint-disable-next-line import/prefer-default-export 46 | export { itActsAsFavoriteRestaurantModel }; 47 | -------------------------------------------------------------------------------- /src/scripts/sw.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | import 'regenerator-runtime/runtime'; 3 | import { precacheAndRoute } from 'workbox-precaching/precacheAndRoute'; 4 | import { cleanupOutdatedCaches } from 'workbox-precaching'; 5 | import { registerRoute } from 'workbox-routing/registerRoute'; 6 | import { CacheFirst, NetworkFirst } from 'workbox-strategies'; 7 | import { ExpirationPlugin } from 'workbox-expiration'; 8 | import { skipWaiting, clientsClaim, setCacheNameDetails } from 'workbox-core'; 9 | 10 | skipWaiting(); 11 | clientsClaim(); 12 | 13 | setCacheNameDetails({ 14 | prefix: 'nongkis-app', 15 | precache: 'precache', 16 | runtime: 'runtime', 17 | }); 18 | 19 | precacheAndRoute( 20 | [ 21 | ...self.__WB_MANIFEST, 22 | { 23 | url: 24 | 'https://fonts.googleapis.com/css2?family=Comfortaa:wght@300;400;500;600;700&family=Unica+One&display=swap', 25 | revision: 1, 26 | }, 27 | { 28 | url: 'https://fonts.gstatic.com/s/comfortaa/v29/1Ptsg8LJRfWJmhDAuUs4TYFqL_KWxQ.woff2', 29 | revision: 1, 30 | }, 31 | { 32 | url: 'https://fonts.gstatic.com/s/unicaone/v7/DPEuYwWHyAYGVTSmalsRcd3emkUrFQ.woff2', 33 | revision: 1, 34 | }, 35 | { 36 | url: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/all.css', 37 | revision: 1, 38 | }, 39 | ], 40 | { 41 | ignoreURLParametersMatching: [/.*/], 42 | }, 43 | ); 44 | 45 | registerRoute( 46 | /^https:\/\/dicoding-restaurant-api\.el\.r\.appspot\.com\/(?:(list|detail))/, 47 | new NetworkFirst({ 48 | cacheName: 'dicoding-restaurant-api', 49 | plugins: [ 50 | new ExpirationPlugin({ 51 | maxAgeSeconds: 60 * 60 * 24 * 30 * 2, 52 | maxEntries: 100, 53 | }), 54 | ], 55 | }), 56 | ); 57 | 58 | registerRoute( 59 | ({ request }) => request.destination === 'image', 60 | new CacheFirst({ 61 | cacheName: 'images', 62 | plugins: [ 63 | new ExpirationPlugin({ 64 | maxEntries: 60, 65 | maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days 66 | }), 67 | ], 68 | }), 69 | ); 70 | 71 | cleanupOutdatedCaches(); 72 | -------------------------------------------------------------------------------- /specs/likeRestaurantSpec.js: -------------------------------------------------------------------------------- 1 | import FavRestaurantIdb from '../src/scripts/data/restaurant-idb'; 2 | import * as TestFactories from './helpers/testFactories'; 3 | 4 | const addLikeButtonContainer = () => { 5 | document.body.innerHTML = '
'; 6 | }; 7 | 8 | describe('Liking or Adding a Restaurant', () => { 9 | beforeEach(() => { 10 | addLikeButtonContainer(); 11 | }); 12 | 13 | it('should show the like button when the restaurant has not been liked before', async () => { 14 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 15 | 16 | expect(document.querySelector('[aria-label="like this restaurant"]')).toBeTruthy(); 17 | }); 18 | 19 | it('should not show the unlike button when the restaurant has not been liked before', async () => { 20 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 21 | 22 | expect(document.querySelector('[aria-label="unlike this restaurant"]')).toBeFalsy(); 23 | }); 24 | 25 | it('should be able to like the restaurant', async () => { 26 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 27 | 28 | document.querySelector('#likeButton').dispatchEvent(new Event('click')); 29 | const restaurant = await FavRestaurantIdb.getRestaurant(1); 30 | 31 | expect(restaurant).toEqual({ id: 1 }); 32 | FavRestaurantIdb.deleteRestaurant(1); 33 | }); 34 | 35 | it('should not add a restaurant again when its already liked', async () => { 36 | await TestFactories.createLikeButtonPresenterWithRestaurant({ id: 1 }); 37 | 38 | await FavRestaurantIdb.putRestaurant({ id: 1 }); 39 | document.querySelector('#likeButton').dispatchEvent(new Event('click')); 40 | expect(await FavRestaurantIdb.getAllRestaurants()).toEqual([{ id: 1 }]); 41 | 42 | FavRestaurantIdb.deleteRestaurant(1); 43 | }); 44 | 45 | // menggunakan metode xit, bukan it 46 | it('should not add a restaurant when it has no id', async () => { 47 | await TestFactories.createLikeButtonPresenterWithRestaurant({}); 48 | 49 | document.querySelector('#likeButton').dispatchEvent(new Event('click')); 50 | 51 | expect(await FavRestaurantIdb.getAllRestaurants()).toEqual([]); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nongkis", 3 | "version": "1.0.0", 4 | "description": "Web that provide list of cafe and restaurant", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "webpack-dev-server --config webpack.dev.js", 8 | "build": "webpack --config webpack.prod.js && node sharp.js", 9 | "build-image": "node sharp.js", 10 | "bundle-report": "webpack-bundle-analyzer --port 4200 dist/stats.json", 11 | "test": "karma start", 12 | "lint": "eslint ./src", 13 | "e2e": "codeceptjs run --steps" 14 | }, 15 | "keywords": [], 16 | "author": "Firman Abdul Jabar ", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@babel/core": "^7.10.5", 20 | "@babel/preset-env": "^7.10.4", 21 | "babel-loader": "^8.1.0", 22 | "clean-webpack-plugin": "^3.0.0", 23 | "codeceptjs": "^2.6.8", 24 | "copy-webpack-plugin": "^6.0.3", 25 | "css-loader": "^3.6.0", 26 | "eslint": "^7.6.0", 27 | "eslint-config-airbnb-base": "^14.2.0", 28 | "eslint-plugin-import": "^2.22.0", 29 | "html-webpack-plugin": "^4.3.0", 30 | "imagemin-mozjpeg": "^9.0.0", 31 | "imagemin-pngquant": "^9.0.0", 32 | "imagemin-webpack-plugin": "^2.4.2", 33 | "jasmine-ajax": "^4.0.0", 34 | "jasmine-core": "^3.5.0", 35 | "karma": "^5.1.0", 36 | "karma-chrome-launcher": "^3.1.0", 37 | "karma-firefox-launcher": "^1.3.0", 38 | "karma-jasmine": "^3.3.1", 39 | "karma-sourcemap-loader": "^0.3.7", 40 | "karma-webpack": "^4.0.2", 41 | "puppeteer": "^5.2.1", 42 | "sharp": "^0.25.4", 43 | "style-loader": "^1.2.1", 44 | "webpack": "^4.43.0", 45 | "webpack-bundle-analyzer": "^3.8.0", 46 | "webpack-cli": "^3.3.12", 47 | "webpack-dev-server": "^3.11.0", 48 | "webpack-merge": "^5.0.9", 49 | "whatwg-fetch": "^3.2.0", 50 | "workbox-webpack-plugin": "^5.1.3" 51 | }, 52 | "dependencies": { 53 | "idb": "^5.0.4", 54 | "lazysizes": "^5.2.2", 55 | "regenerator-runtime": "^0.13.5", 56 | "workbox-core": "^5.1.3", 57 | "workbox-expiration": "^5.1.3", 58 | "workbox-precaching": "^5.1.3", 59 | "workbox-routing": "^5.1.3", 60 | "workbox-strategies": "^5.1.3", 61 | "workbox-window": "^5.1.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Fri Jul 03 2020 20:15:52 GMT+0700 (Western Indonesia Time) 3 | module.exports = function (config) { 4 | config.set({ 5 | // base path that will be used to resolve all patterns (eg. files, exclude) 6 | basePath: '', 7 | 8 | // frameworks to use 9 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 10 | frameworks: ['jasmine'], 11 | 12 | // list of files / patterns to load in the browser 13 | files: ['specs/**/*Spec.js'], 14 | 15 | // list of files / patterns to exclude 16 | exclude: [], 17 | 18 | // preprocess matching files before serving them to the browser 19 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 20 | preprocessors: { 21 | 'specs/**/*Spec.js': ['webpack', 'sourcemap'], 22 | }, 23 | 24 | webpack: { 25 | // karma watches the test entry points 26 | // (you don't need to specify the entry option) 27 | // webpack watches dependencies 28 | // webpack configuration 29 | devtool: 'inline-source-map', 30 | mode: 'development', 31 | }, 32 | 33 | webpackMiddleware: { 34 | // webpack-dev-middleware configuration 35 | // i. e. 36 | stats: 'errors-only', 37 | }, 38 | 39 | // test results reporter to use 40 | // possible values: 'dots', 'progress' 41 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 42 | reporters: ['progress'], 43 | 44 | // web server port 45 | port: 9876, 46 | 47 | // enable / disable colors in the output (reporters and logs) 48 | colors: true, 49 | 50 | // level of logging 51 | /* possible values: config.LOG_DISABLE || config.LOG_ERROR 52 | || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG */ 53 | logLevel: config.LOG_INFO, 54 | 55 | // enable / disable watching file and executing tests whenever any file changes 56 | autoWatch: true, 57 | 58 | // start these browsers 59 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 60 | browsers: ['Chrome'], 61 | 62 | // Continuous Integration mode 63 | // if true, Karma captures browsers, runs the tests and exits 64 | singleRun: false, 65 | 66 | // Concurrency level 67 | // how many browser should be started simultaneous 68 | concurrency: Infinity, 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/scripts/views/pages/detail.js: -------------------------------------------------------------------------------- 1 | import UrlParser from '../../routes/url-parser'; 2 | import RestaurantSource from '../../data/restaurant-source'; 3 | import { restaurantDetailTemplate } from '../templates/template-html'; 4 | import LikeButtonPresenter from '../../utils/like-button-presenter'; 5 | import PostReview from '../../utils/post-review'; 6 | import Spinner from '../templates/spinner-html'; 7 | import FavRestaurantIdb from '../../data/restaurant-idb'; 8 | 9 | const Detail = { 10 | async render() { 11 | return ` 12 |
13 |
14 |
15 |

Detail Restaurant

16 |
17 | 18 |
19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 | 29 |
30 |
31 |
32 |
33 | `; 34 | }, 35 | 36 | async afterRender() { 37 | const url = UrlParser.parseActiveUrlWithoutCombiner(); 38 | const detailContainer = document.querySelector('#detail-rest'); 39 | const loading = document.querySelector('#loading'); 40 | const main = document.querySelector('.main'); 41 | loading.innerHTML = Spinner(); 42 | main.style.display = 'none'; 43 | 44 | try { 45 | const data = await RestaurantSource.detailRestaurant(url.id); 46 | detailContainer.innerHTML += restaurantDetailTemplate(data.restaurant); 47 | 48 | await LikeButtonPresenter.init({ 49 | likeButtonContainer: document.querySelector('#likeButtonContainer'), 50 | favoriteRestaurant: FavRestaurantIdb, 51 | data, 52 | }); 53 | 54 | main.style.display = 'block'; 55 | loading.style.display = 'none'; 56 | } catch (err) { 57 | detailContainer.innerHTML = `Error: ${err}, swipe up to refresh!`; 58 | main.style.display = 'block'; 59 | loading.style.display = 'none'; 60 | } 61 | 62 | const btnSubmit = document.querySelector('#submit-review'); 63 | const nameInput = document.querySelector('#inputName'); 64 | const reviewInput = document.querySelector('#inputReview'); 65 | 66 | btnSubmit.addEventListener('click', (e) => { 67 | e.preventDefault(); 68 | if (nameInput.value === '' || reviewInput.value === '') { 69 | // eslint-disable-next-line no-alert 70 | alert('Inputan tidak boleh ada yang kosong'); 71 | nameInput.value = ''; 72 | reviewInput.value = ''; 73 | } else { 74 | PostReview(url, nameInput.value, reviewInput.value); 75 | nameInput.value = ''; 76 | reviewInput.value = ''; 77 | } 78 | }); 79 | }, 80 | }; 81 | 82 | export default Detail; 83 | -------------------------------------------------------------------------------- /e2e/Favorite_Restaurant.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable no-shadow */ 3 | const assert = require('assert'); 4 | 5 | Feature('Favorite Restaurant'); 6 | 7 | Before((I) => { 8 | I.amOnPage('/#/favorite'); 9 | }); 10 | 11 | const firstCondition = "You don't have any Favorite Cafe or Restaurant"; 12 | 13 | Scenario('showing empty favorite restaurant', (I) => { 14 | I.seeElement('#list-rest'); 15 | I.see(firstCondition, '#list-rest'); 16 | }); 17 | 18 | Scenario('liking one restaurant', async (I) => { 19 | I.see(firstCondition, '#list-rest'); 20 | 21 | I.amOnPage('/'); 22 | 23 | I.seeElement('.card a'); 24 | const firstCard = locate('.card-title').first(); 25 | const firstCardTitle = await I.grabTextFrom(firstCard); 26 | I.click(firstCard); 27 | 28 | I.seeElement('#likeButton'); 29 | I.click('#likeButton'); 30 | 31 | I.amOnPage('/#/favorite'); 32 | I.seeElement('.card'); 33 | const likedCardTitle = await I.grabTextFrom('.card-title'); 34 | 35 | assert.strictEqual(firstCardTitle, likedCardTitle); 36 | }); 37 | 38 | Scenario('unliking one restaurant', async (I) => { 39 | I.see(firstCondition, '#list-rest'); 40 | 41 | I.amOnPage('/'); 42 | 43 | // melihat card restaurant pertama dan mengkliknya ke detail 44 | I.seeElement('.card a'); 45 | const firstCard = locate('.card-title').first(); 46 | const firstCardTitle = await I.grabTextFrom(firstCard); 47 | I.click(firstCard); 48 | 49 | // melike restaurant di detail 50 | I.seeElement('#likeButton'); 51 | I.click('#likeButton'); 52 | 53 | // kembali ke halaman fav dan membandingakan dg restaurant yg diklik 54 | I.amOnPage('/#/favorite'); 55 | I.seeElement('.card'); 56 | const likedCardTitle = await I.grabTextFrom('.card-title'); 57 | assert.strictEqual(firstCardTitle, likedCardTitle); 58 | 59 | // mengklik card restaurant yg ada di fav 60 | I.click(likedCardTitle); 61 | 62 | // mengunlike restaurant yang ada di fav 63 | I.seeElement('#likeButton'); 64 | I.click('#likeButton'); 65 | 66 | // kembali ke halaman fav 67 | I.amOnPage('/#/favorite'); 68 | I.seeElement('#list-rest'); 69 | const noFavRestaurant = await I.grabTextFrom('#list-rest'); 70 | 71 | // mencek halaman fav dan berhasil menghapus (unlike) 72 | assert.strictEqual(noFavRestaurant, firstCondition); 73 | }); 74 | 75 | // 1. Pastikan belum ada restaurant yang disukai (done) 76 | // 2. Buka halaman utama (done) 77 | // 3. Pilih salah satu restaurant, misalnya restaurant pertama (done) 78 | // 4. Click restaurant tersebut (done) 79 | // 5. Melihat form review (done) 80 | // 6. Kita mengisi isian nama dan review (done) 81 | // 7. Menekan tombol submit (done) 82 | // 8. Kita melihat review yang telah disubmit (done) 83 | 84 | Scenario('Customer review', async (I) => { 85 | I.see(firstCondition, '#list-rest'); 86 | 87 | I.amOnPage('/'); 88 | 89 | I.seeElement('.card a'); 90 | I.click(locate('.card a').first()); 91 | 92 | I.seeElement('.form-review form'); 93 | 94 | const textReview = 'Review from E2E testing'; 95 | I.fillField('inputName', 'firman jabar'); 96 | I.fillField('inputReview', textReview); 97 | 98 | I.click('#submit-review'); 99 | 100 | const lastReview = locate('.review-body').last(); 101 | const textLastReview = await I.grabTextFrom(lastReview); 102 | 103 | assert.strictEqual(textReview, textLastReview); 104 | }); 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Menjadi Front-End Web Developer Expert 2 | 3 | [![Website nongkis.firmanjabar.my.id](https://img.shields.io/website-up-down-green-red/http/nongkis.firmanjabar.my.id.svg)](https://nongkis.firmanjabar.my.id/) 4 | [![GitHub release](https://img.shields.io/github/release/firmanjabar/nongkis.svg)](https://GitHub.com/firmanjabar/nongkis/releases/) 5 | [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](https://lbesson.mit-license.org/) 6 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 7 | [![twitter](https://img.shields.io/twitter/follow/firmanjabar?style=social)](https://twitter.com/firmanjabar) 8 | [![GitHub followers](https://img.shields.io/github/followers/firmanjabar.svg?style=social&label=Follow&maxAge=2592000)](https://github.com/firmanjabar?tab=followers) 9 | [![Netlify Status](https://api.netlify.com/api/v1/badges/96a39bea-1725-42e8-8352-68e01209ab37/deploy-status)](https://app.netlify.com/sites/nongkis/deploys) 10 | 11 | --- 12 | 13 | ![SS](https://pbs.twimg.com/media/EfjFJfFVAAA3z13?format=jpg&name=large) 14 | 15 | --- 16 | 17 | #### Final Submission dari Kelas [Menjadi Front-End Web Developer Expert (MFWDE) - Dicoding](https://www.dicoding.com/academies/219). 18 | 19 | #### Link production: [https://nongkis.firmanjabar.my.id/](https://nongkis.firmanjabar.my.id/) 20 | 21 | #### Lighthouse Score: [100 - 100 - 100 -100 - PWA](https://lighthouse.firmanjabar.my.id/nongkis/) 22 | 23 | --- 24 | 25 | ![Sertifikat](https://pbs.twimg.com/media/EfjIwdIUMAA2Ejo?format=png&name=900x900) 26 | 27 | --- 28 | 29 | ### Configure 30 | 31 | Get the repo 32 | 33 | ```cmd 34 | git clone https://github.com/firmanjabar/nongkis.git 35 | cd nongkis 36 | yarn install or npm install 37 | ``` 38 | 39 | Script 40 | 41 | - `yarn dev` - to start locally 42 | - `yarn build` - to build into folder dist 43 | - `yarn bundle-report` - to analyze webpack bundle 44 | - `yarn lint` - linting 45 | 46 | Testing 47 | 48 | - `yarn test` - to start integration testing with karma or jasmine 49 | - `yarn e2e` - to start end to end testing with codecept.js 50 | 51 | --- 52 | 53 | ### Built With 54 | 55 | - [Webpack](https://webpack.js.org/) 56 | - [Workbox](https://developers.google.com/web/tools/workbox) 57 | - [PWA](https://developers.google.com/web/progressive-web-apps) 58 | - [IndexedDB](https://developers.google.com/web/ilt/pwa/working-with-indexeddb) 59 | - [HTML](https://www.w3schools.com/html/) 60 | - [CSS](https://www.w3schools.com/css/) 61 | - [JS](https://www.javascript.com/) 62 | 63 | ### Testing 64 | 65 | - Integration Testing : 66 | - [Jasmine](https://jasmine.github.io/) 67 | - [Karma](https://karma-runner.github.io) 68 | - End to End Testing (E2E) : 69 | - [Codecept](https://codecept.io/) 70 | - [Puppeteer](https://codecept.io/helpers/Puppeteer/#seeinsource) 71 | 72 | ### Tools 73 | 74 | - [EsLint](https://eslint.org/) 75 | - [imagemin](https://github.com/imagemin/imagemin) 76 | - [sharp](https://sharp.pixelplumbing.com/) 77 | - [lazysizes](https://www.npmjs.com/package/lazysizes) 78 | 79 | ### Code Convention 80 | 81 | [AirBnb JavaScript Style Guide](https://github.com/airbnb/javascript) 82 | 83 | ### Created By 84 | 85 | [firmanjabar](https://github.com/firmanjabar) 86 | 87 | --- 88 | 89 | ### Info Lebih Lengkap 90 | 91 | - Website : [firmanjabar.my.id](https://firmanjabar.my.id) 92 | - Twitter: [@firmanjabar](https://twitter.com/firmanjabar) 93 | - Instagram : [@firmanjabar](https://instagram.com/firmanjabar) 94 | - WhatsApp : [085780966635](https://wa.me/6285780966635) 95 | - Email : [hi@firmanjabar.my.id](mailto:hi@firmanjabar.my.id) 96 | 97 | --- 98 | 99 | ###### tags: `Workbox` `PWA` `Webpack` `MFWDE` `Dicoding` `IndexedDB` 100 | -------------------------------------------------------------------------------- /src/styles/responsive.css: -------------------------------------------------------------------------------- 1 | .grid-2 { 2 | display: grid; 3 | grid-gap: 1em; 4 | } 5 | 6 | .grid-3 { 7 | display: grid; 8 | grid-gap: 1em; 9 | } 10 | 11 | @media only screen and (max-width: 992px) { 12 | .hero { 13 | background: var(--image-color), url('/images/hero-large.jpg'); 14 | background-position: center; 15 | } 16 | } 17 | 18 | /* Extra small devices (phones, 600px and down) */ 19 | @media only screen and (max-width: 600px) { 20 | .menu { 21 | display: block; 22 | background-color: transparent; 23 | border: 1px solid transparent; 24 | text-align: center; 25 | } 26 | 27 | .menu-hp { 28 | display: flex; 29 | align-items: center; 30 | justify-content: space-between; 31 | width: 100%; 32 | } 33 | .menu span { 34 | text-align: center; 35 | color: var(--secondary-color); 36 | } 37 | 38 | nav { 39 | display: flex; 40 | flex-direction: column; 41 | padding: 1.5em 1em; 42 | flex-wrap: wrap; 43 | align-items: flex-start; 44 | } 45 | .nav-list { 46 | display: none; 47 | position: fixed; 48 | flex-direction: column; 49 | align-items: flex-end; 50 | width: 75%; 51 | height: 100vh; 52 | z-index: 9; 53 | left: 0; 54 | top: 0; 55 | background-color: var(--back-menu); 56 | } 57 | 58 | .nav-item a { 59 | padding: 1.3em; 60 | } 61 | 62 | .nav-item button { 63 | padding: 1.8em; 64 | } 65 | 66 | .nav-list-block { 67 | display: flex; 68 | } 69 | 70 | .truncate2 { 71 | -webkit-line-clamp: 8; 72 | } 73 | .truncate { 74 | -webkit-line-clamp: 8; 75 | } 76 | 77 | .hero { 78 | min-height: 500px; 79 | background: var(--image-color), url('/images/hero-small.jpg'); 80 | background-size: cover; 81 | background-position: center; 82 | } 83 | 84 | .hero__inner { 85 | padding-top: 1em; 86 | padding-bottom: 0; 87 | } 88 | 89 | .container { 90 | margin: 0.2em; 91 | } 92 | } 93 | 94 | /* Small devices (portrait tablets and large phones, 600px and up) */ 95 | @media only screen and (min-width: 600px) { 96 | #list-rest { 97 | display: grid; 98 | grid-template-columns: 1fr 1fr; 99 | grid-gap: 1.545em; 100 | } 101 | 102 | .box-ganjil { 103 | grid-column-start: 1; 104 | grid-column-end: 3; 105 | grid-row-start: 1; 106 | grid-row-end: 3; 107 | } 108 | } 109 | 110 | /* Medium devices (landscape tablets, 768px and up) */ 111 | @media only screen and (min-width: 768px) { 112 | .detail { 113 | grid-template-columns: 1.1fr 1fr; 114 | justify-content: center; 115 | } 116 | 117 | .detail h3 { 118 | grid-column-start: 1; 119 | grid-column-end: 3; 120 | margin: 0 auto; 121 | } 122 | 123 | .detail-menu { 124 | grid-column-start: 1; 125 | grid-column-end: 3; 126 | grid-row-start: 3; 127 | grid-row-end: 4; 128 | } 129 | 130 | .detail-review { 131 | grid-column-start: 1; 132 | grid-column-end: 3; 133 | grid-row-start: 5; 134 | } 135 | 136 | .title-review { 137 | grid-row-start: 4; 138 | } 139 | 140 | .grid-2 { 141 | display: grid; 142 | grid-template-columns: 1fr 1fr; 143 | grid-gap: 1.245em; 144 | } 145 | 146 | .grid-3 { 147 | display: grid; 148 | grid-template-columns: 1fr 1fr 1fr; 149 | grid-gap: 1.245em; 150 | } 151 | } 152 | 153 | /* Extra large devices (large laptops and desktops, 1200px and up) */ 154 | @media only screen and (min-width: 1400px) { 155 | main { 156 | max-width: 1400px; 157 | } 158 | 159 | #list-rest { 160 | display: grid; 161 | grid-template-columns: 1fr 1fr 1fr; 162 | } 163 | } 164 | 165 | @media only screen and (max-width: 320px) { 166 | nav { 167 | padding: 1.5em 0.1em; 168 | } 169 | 170 | .nav-item a { 171 | font-size: 1.2em; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/scripts/views/templates/template-html.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | import CONFIG from '../../global/config'; 3 | 4 | const restaurantItemTemplate = (restaurant, index, lastIndex) => { 5 | const firstBox = (numbIndex) => numbIndex === 0 && lastIndex % 2 !== 0; 6 | 7 | return ` 8 | 30 | `; 31 | }; 32 | 33 | const createSkeletonItemTemplate = (count) => { 34 | const firstBox = (numbIndex) => numbIndex === 0 && count % 2 !== 0; 35 | let template = ''; 36 | 37 | for (let i = 0; i < count; i += 1) { 38 | template += ` 39 |
40 |
41 | image skeleton 44 |

Title - City

45 | 46 | 47 | 5 48 | 49 |
50 |
51 |

Description :

52 |

Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro sequi ullam ad mollitia cupiditate aut iure officia, voluptate, sapiente modi quisquam est quod quas recusandae quo saepe atque nisi blanditiis.

55 |
56 |
57 | `; 58 | } 59 | return template; 60 | }; 61 | 62 | const restaurantDetailTemplate = (detail) => ` 63 |
64 |
65 |
66 | image ${detail.name} 69 |
70 |
71 |
    72 |
  •  ${detail.name}
  • 73 |
  •  ${detail.address}, ${ 74 | detail.city 75 | }
  • 76 |
  •  ${detail.rating}
  • 77 |
  • Description: ${detail.description}

  • 78 |
  • ${detail.categories 79 | .map( 80 | (category) => ` 81 | ${category.name} 82 | `, 83 | ) 84 | .join('')} 85 |
  • 86 |
87 |

Menu

88 |
89 |
90 |

Food

91 |
    92 | ${detail.menus.foods 93 | .map( 94 | (food) => ` 95 |
  • ${food.name}
  • 96 | `, 97 | ) 98 | .join('')} 99 |
100 |
101 |
102 |

Drink

103 |
    104 | ${detail.menus.drinks 105 | .map( 106 | (drink) => ` 107 |
  • ${drink.name}
  • 108 | `, 109 | ) 110 | .join('')} 111 |
112 |
113 |
114 |

Reviews

115 |
116 | ${detail.customerReviews 117 | .map( 118 | (review) => 119 | ` 120 |
121 |
122 |

 ${review.name}

123 |

${review.date}

124 |
125 |
126 | ${review.review} 127 |
128 |
129 | `, 130 | ) 131 | .join('')} 132 |
133 |
134 | `; 135 | 136 | export { restaurantItemTemplate, createSkeletonItemTemplate, restaurantDetailTemplate }; 137 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #ffffff; 3 | --secondary-color: #000000; 4 | --font-color: #000000; 5 | --image-color: linear-gradient(71.9deg, #354c6282 -3.77%, #9e2b2b2b 92.46%); 6 | --hover-navbar: #000000be; 7 | --box-shadow: #00000033; 8 | --btn: #b20000; 9 | --btn-hover: #dd1515; 10 | --back-menu: #f3f3f3; 11 | } 12 | 13 | [data-theme='dark'] { 14 | --primary-color: #000000; 15 | --secondary-color: #ffffff; 16 | --font-color: #ffffff; 17 | --image-color: linear-gradient(71.9deg, #8cc8ffb6 -3.77%, #ff6d6d94 92.46%); 18 | --hover-navbar: #f0f0f0be; 19 | --box-shadow: #ffffff22; 20 | --btn: #db2a2a; 21 | --btn-hover: #fa3a3a; 22 | --back-menu: #1d1d1d; 23 | } 24 | 25 | * { 26 | padding: 0; 27 | margin: 0; 28 | box-sizing: border-box; 29 | } 30 | 31 | body, 32 | html { 33 | margin: 0; 34 | padding: 0; 35 | width: 100%; 36 | height: 100%; 37 | background-color: var(--primary-color); 38 | scroll-behavior: smooth; 39 | } 40 | 41 | body { 42 | font-family: 'Comfortaa', cursive; 43 | font-size: 14px; 44 | color: var(--font-color); 45 | } 46 | 47 | /* 48 | * header 49 | */ 50 | 51 | .hero { 52 | display: flex; 53 | flex-direction: column; 54 | min-height: 500px; 55 | width: 100%; 56 | text-align: center; 57 | background: var(--image-color), url('/images/hero-xl.jpg'); 58 | background-position: center; 59 | background-color: var(--primary-color); 60 | object-fit: cover; 61 | padding: 0 10%; 62 | } 63 | 64 | .hero__inner { 65 | align-self: center; 66 | max-width: 600px; 67 | margin: auto 0; 68 | padding-bottom: 3em; 69 | } 70 | 71 | .hero__title { 72 | color: var(--primary-color); 73 | font-weight: 500; 74 | font-size: x-large; 75 | } 76 | 77 | .hero-bold { 78 | font-weight: bold; 79 | font-size: x-large; 80 | color: var(--secondary-color); 81 | background-color: var(--primary-color); 82 | padding: 0.3em 0.2em; 83 | border-radius: 0.5em; 84 | margin: 0.2em; 85 | display: inline-block; 86 | } 87 | 88 | .hero__tagline { 89 | color: var(--primary-color); 90 | margin: 16px 16px 22px 16px; 91 | font-size: 13pt; 92 | font-weight: 500; 93 | word-spacing: 2px; 94 | line-height: 1.36em; 95 | } 96 | 97 | /* 98 | * Top level navigation 99 | */ 100 | 101 | nav { 102 | position: relative; 103 | display: flex; 104 | flex-wrap: wrap; 105 | align-items: center; 106 | justify-content: space-between; 107 | align-items: center; 108 | padding: 0.3em 5em; 109 | height: 75px; 110 | font-family: 'Unica One', cursive; 111 | } 112 | 113 | .menu { 114 | font-size: 25px; 115 | display: none; 116 | padding: 0 0.3em; 117 | color: var(--primary-color); 118 | background-color: var(--secondary-color); 119 | } 120 | 121 | .logo-font { 122 | font-size: x-large; 123 | font-weight: bold; 124 | color: var(--secondary-color); 125 | text-decoration: none; 126 | padding: 0.378em 0.3em; 127 | } 128 | 129 | .nav-list { 130 | display: flex; 131 | padding-left: 0; 132 | margin-bottom: 0; 133 | list-style: none; 134 | flex-wrap: wrap; 135 | transition: ease; 136 | background-color: var(--primary-color); 137 | } 138 | 139 | .nav-item { 140 | box-sizing: border-box; 141 | line-height: 24px; 142 | } 143 | 144 | .nav-item a { 145 | padding: 0.665rem; 146 | display: inline-block; 147 | font-size: 1.38em; 148 | text-decoration: none; 149 | color: var(--secondary-color); 150 | } 151 | 152 | .nav-item button { 153 | padding: 0.9265rem; 154 | background-color: transparent; 155 | border: 0 solid transparent; 156 | color: var(--secondary-color); 157 | cursor: pointer; 158 | font-size: 1.38; 159 | vertical-align: middle; 160 | } 161 | 162 | .nav-item a:hover { 163 | text-decoration: underline; 164 | color: var(--btn-hover); 165 | } 166 | 167 | .nav-item button:hover { 168 | text-decoration: underline; 169 | color: var(--btn-hover); 170 | } 171 | 172 | /* 173 | * main 174 | */ 175 | 176 | main { 177 | width: 100%; 178 | margin: 0 auto; 179 | } 180 | 181 | .container { 182 | margin: 1.3em 10%; 183 | padding: 1em; 184 | } 185 | 186 | .title-container { 187 | margin-bottom: 1.545em; 188 | } 189 | 190 | /* 191 | * content 192 | */ 193 | 194 | .content { 195 | padding: 32px; 196 | } 197 | 198 | /* 199 | * Cards 200 | */ 201 | 202 | .card { 203 | width: 100%; 204 | text-align: center; 205 | box-shadow: 1px 4px 8px 2px var(--box-shadow); 206 | transition: 0.3s; 207 | border-radius: 1em; 208 | cursor: pointer; 209 | background-color: var(--primary-color); 210 | } 211 | 212 | .card a { 213 | text-decoration: none; 214 | color: var(--secondary-color); 215 | } 216 | 217 | .card-title { 218 | position: absolute; 219 | bottom: 0; 220 | left: 0; 221 | color: white; 222 | font-size: large; 223 | padding: 1em; 224 | background-color: #000000a8; 225 | width: 100%; 226 | z-index: 1; 227 | } 228 | 229 | .card-rating { 230 | position: absolute; 231 | top: 0; 232 | right: 0; 233 | color: white; 234 | font-size: large; 235 | background-color: var(--btn); 236 | padding: 0.4em 1.3em; 237 | border-bottom-left-radius: 1em; 238 | z-index: 1; 239 | border-top-right-radius: 0.891em; 240 | } 241 | 242 | .card-rating .fa { 243 | font-size: smaller; 244 | padding: 0.3em 0.8em 0.3em 0; 245 | color: gold; 246 | } 247 | 248 | .img-container { 249 | width: 100%; 250 | position: relative; 251 | overflow: hidden; 252 | transition: transform 0.5s ease; 253 | max-height: 600px; 254 | border-top-left-radius: 1em; 255 | border-top-right-radius: 1em; 256 | } 257 | 258 | .img-container:hover .img-res { 259 | transform: scale(1.1); 260 | } 261 | 262 | .img-container:hover .card-title p { 263 | transform: scale(0.858); 264 | } 265 | 266 | .img-container::after { 267 | position: absolute; 268 | content: ''; 269 | height: 100%; 270 | width: 100%; 271 | top: 0; 272 | left: 0; 273 | background: linear-gradient( 274 | 71.9deg, 275 | rgba(53, 76, 98, 0.71) -3.77%, 276 | rgba(158, 43, 43, 0.37) 92.46% 277 | ); 278 | border-top-left-radius: 1em; 279 | border-top-right-radius: 1em; 280 | } 281 | 282 | .img-res { 283 | object-fit: cover; 284 | height: 100%; 285 | width: 100%; 286 | border-top-left-radius: 1em; 287 | border-top-right-radius: 1em; 288 | } 289 | 290 | .img-res2 { 291 | object-fit: cover; 292 | height: 100%; 293 | width: 100%; 294 | border-bottom-left-radius: 3em; 295 | border-top-right-radius: 3em; 296 | border: 1em solid var(--secondary-color); 297 | } 298 | 299 | .card-content { 300 | padding: 1.4em 2em; 301 | font-size: 14px; 302 | text-align: left; 303 | } 304 | 305 | .card-content-title { 306 | font-weight: bold; 307 | padding-bottom: 0.376em; 308 | } 309 | 310 | .truncate { 311 | overflow: hidden; 312 | text-overflow: ellipsis; 313 | display: -webkit-box; 314 | -webkit-line-clamp: 5; 315 | -webkit-box-orient: vertical; 316 | background-color: var(--primary-color); 317 | } 318 | 319 | .truncate2 { 320 | overflow: hidden; 321 | text-overflow: ellipsis; 322 | display: -webkit-box; 323 | -webkit-line-clamp: 7; 324 | -webkit-box-orient: vertical; 325 | background-color: var(--primary-color); 326 | } 327 | 328 | /* HOME */ 329 | #list-rest { 330 | display: grid; 331 | grid-gap: 1.545em; 332 | background-color: var(--primary-color); 333 | } 334 | 335 | /* DETAIL */ 336 | #detail-rest { 337 | font-size: 1em; 338 | } 339 | 340 | .detail { 341 | display: grid; 342 | grid-gap: 1.545em; 343 | font-size: 1em; 344 | } 345 | 346 | .detail h3 { 347 | font-size: 1.5em; 348 | padding: 1em 1em 0 1em; 349 | background-color: var(--primary-color); 350 | font-weight: bold; 351 | } 352 | 353 | .category { 354 | padding: 0.3em 1em; 355 | background-color: var(--secondary-color); 356 | color: var(--primary-color); 357 | margin-right: 2px; 358 | border-radius: 1.5em; 359 | } 360 | 361 | .detail-menu h4 { 362 | font-weight: bold; 363 | font-size: 1.37em; 364 | padding: 1em; 365 | background-color: var(--primary-color); 366 | } 367 | 368 | .detail-food { 369 | display: flex; 370 | flex-direction: column; 371 | text-align: center; 372 | align-content: center; 373 | } 374 | 375 | .detail-food li { 376 | padding: 0.5em 0; 377 | display: block; 378 | text-decoration: none; 379 | background-color: var(--primary-color); 380 | border: 0.5px solid var(--secondary-color); 381 | border-width: 0 0 0.5px; 382 | } 383 | 384 | .detail-drink { 385 | display: flex; 386 | flex-direction: column; 387 | text-align: center; 388 | align-content: center; 389 | } 390 | 391 | .detail-drink li { 392 | padding: 0.5em 0; 393 | display: block; 394 | text-decoration: none; 395 | background-color: var(--primary-color); 396 | border: 0.5px solid var(--secondary-color); 397 | border-width: 0 0 0.5px; 398 | } 399 | 400 | .detail-info { 401 | display: flex; 402 | flex-direction: column; 403 | justify-content: space-evenly; 404 | padding-left: 0; 405 | margin-bottom: 0; 406 | } 407 | 408 | .detail-info li { 409 | position: relative; 410 | display: block; 411 | padding: 0.6em; 412 | color: var(--font-color); 413 | text-decoration: none; 414 | background-color: var(--primary-color); 415 | border: 0.5px solid var(--secondary-color); 416 | border-width: 0 0 0.5px; 417 | } 418 | 419 | /* DETAIL REVIEW */ 420 | .detail-review { 421 | max-width: 100%; 422 | font-size: 1em; 423 | text-align: center; 424 | } 425 | 426 | .detail-review-item { 427 | color: var(--secondary-color); 428 | border: 1px solid var(--secondary-color); 429 | background-color: var(--primary-color); 430 | border-radius: 1em; 431 | box-shadow: 1px 2px 4px 2px var(--box-shadow); 432 | } 433 | 434 | .review-header { 435 | display: flex; 436 | justify-content: space-between; 437 | align-items: center; 438 | padding: 1em 1em; 439 | color: var(--secondary-color); 440 | border-bottom: 1px solid var(--font-color); 441 | } 442 | 443 | .review-name { 444 | font-weight: bold; 445 | display: flex; 446 | align-items: center; 447 | white-space: nowrap; 448 | overflow: hidden; 449 | text-overflow: ellipsis; 450 | max-width: 130px; 451 | } 452 | 453 | .review-date { 454 | font-size: 0.8em; 455 | font-weight: lighter; 456 | } 457 | 458 | .review-body { 459 | padding: 1.5em; 460 | white-space: wrap; 461 | overflow: hidden; 462 | text-overflow: ellipsis; 463 | max-width: 250px; 464 | margin: auto; 465 | } 466 | 467 | /* 468 | * footer 469 | */ 470 | 471 | footer { 472 | padding: 2em; 473 | width: 100%; 474 | text-align: center; 475 | } 476 | 477 | footer ul { 478 | margin: 0 auto; 479 | display: flex; 480 | flex-direction: column; 481 | list-style: none; 482 | } 483 | 484 | footer li { 485 | margin: 0.3em 0; 486 | font-size: 14px; 487 | } 488 | 489 | /* Button */ 490 | 491 | .btn { 492 | font-family: 'Comfortaa', cursive; 493 | margin: 1.3em; 494 | padding: 0.375em 0.75em; 495 | font-weight: 700; 496 | color: white; 497 | text-align: center; 498 | vertical-align: middle; 499 | background-color: var(--btn); 500 | border: 1px solid transparent; 501 | margin: 1.3em 0; 502 | font-size: large; 503 | border-radius: 0.276em; 504 | cursor: pointer; 505 | text-decoration: none; 506 | } 507 | 508 | .btn:hover { 509 | background-color: var(--btn-hover); 510 | } 511 | 512 | .btn2 { 513 | font-family: 'Comfortaa', cursive; 514 | padding: 0.375em 0.75em; 515 | font-weight: 700; 516 | color: white; 517 | text-align: center; 518 | vertical-align: middle; 519 | background-color: var(--btn); 520 | border: 1px solid transparent; 521 | font-size: medium; 522 | border-radius: 0.276em; 523 | cursor: pointer; 524 | text-decoration: none; 525 | } 526 | 527 | .btn2:hover { 528 | background-color: var(--btn-hover); 529 | } 530 | 531 | /* Skip link */ 532 | .skip-link { 533 | position: absolute; 534 | top: -40px; 535 | left: 0; 536 | background-color: #bf1722; 537 | color: white; 538 | padding: 8px; 539 | z-index: 100; 540 | } 541 | 542 | .skip-link:focus { 543 | top: 0; 544 | } 545 | --------------------------------------------------------------------------------