├── .gitignore ├── jsconfig.json ├── src ├── public │ ├── favicon.ico │ ├── icons │ │ ├── 72x72.png │ │ ├── 96x96.png │ │ ├── 128x128.png │ │ ├── 144x144.png │ │ ├── 152x152.png │ │ ├── 192x192.png │ │ ├── 384x384.png │ │ └── 512x512.png │ ├── images │ │ ├── hero.jpg │ │ └── loading.jpg │ └── manifest.json ├── scripts │ ├── views │ │ ├── templates │ │ │ ├── spinner.js │ │ │ ├── like-button.js │ │ │ ├── resto-card.js │ │ │ └── resto-detail.js │ │ ├── App.js │ │ └── pages │ │ │ ├── favorite.js │ │ │ ├── home.js │ │ │ └── detail.js │ ├── global │ │ ├── api-endpoint.js │ │ └── config.js │ ├── routes │ │ ├── routes.js │ │ └── url-parser.js │ ├── components │ │ ├── custom-footer.js │ │ ├── hero.js │ │ └── navbar.js │ ├── utils │ │ ├── drawer-initiator.js │ │ ├── swal-initiator.js │ │ ├── websocket-initiator.js │ │ ├── sw-register.js │ │ ├── post-review.js │ │ ├── websocket-notif.js │ │ ├── like-button-presenter.js │ │ └── sw.js │ ├── data │ │ ├── resto-source.js │ │ └── resto-idb.js │ └── index.js ├── styles │ ├── resto-fav.css │ ├── footer.css │ ├── header.css │ ├── spinner.css │ ├── nav.css │ ├── root.css │ ├── main.css │ ├── responsive.css │ ├── resto-detail.css │ └── normalize.css └── templates │ └── index.html ├── steps_file.js ├── webpack.dev.js ├── .eslintrc.json ├── specs ├── favRestaurantIdbSpec.js ├── helpers │ └── testFactories.js ├── favRestaurantArraySpec.js ├── contract │ └── favRestoContract.js ├── unlikeRestaurant.spec.js └── likeRestaurant.spec.js ├── codecept.conf.js ├── e2e ├── Review_Resto.spec.js └── Favorite_Resto.spec.js ├── webpack.prod.js ├── README.md ├── webpack.common.js ├── sharp.js ├── karma.conf.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true 4 | } 5 | } -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /src/public/icons/72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/72x72.png -------------------------------------------------------------------------------- /src/public/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/96x96.png -------------------------------------------------------------------------------- /src/public/images/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/images/hero.jpg -------------------------------------------------------------------------------- /src/public/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/128x128.png -------------------------------------------------------------------------------- /src/public/icons/144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/144x144.png -------------------------------------------------------------------------------- /src/public/icons/152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/152x152.png -------------------------------------------------------------------------------- /src/public/icons/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/192x192.png -------------------------------------------------------------------------------- /src/public/icons/384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/384x384.png -------------------------------------------------------------------------------- /src/public/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/icons/512x512.png -------------------------------------------------------------------------------- /src/public/images/loading.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/menjadi-web-developer-expert/HEAD/src/public/images/loading.jpg -------------------------------------------------------------------------------- /src/scripts/views/templates/spinner.js: -------------------------------------------------------------------------------- 1 | const Spinner = () => ` 2 |
3 | `; 4 | 5 | export default Spinner; 6 | -------------------------------------------------------------------------------- /src/styles/resto-fav.css: -------------------------------------------------------------------------------- 1 | /* FAVORITE RESTO */ 2 | 3 | #fav-resto { 4 | display: grid; 5 | grid-row-gap: 1.5em; 6 | padding-top: 1.5em; 7 | } 8 | -------------------------------------------------------------------------------- /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/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 | '/favorite': Favorite, 8 | '/resto/:id': Detail, 9 | }; 10 | 11 | export default routes; 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 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 | compress: true, 11 | port: 9000, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "browser": true, 6 | "jasmine": true 7 | }, 8 | "extends": ["airbnb-base", "plugin:codeceptjs/recommended"], 9 | "parserOptions": { 10 | "ecmaVersion": 12 11 | }, 12 | "rules": { 13 | "no-console": "off", 14 | "linebreak-style": "off", 15 | "no-underscore-dangle": "off", 16 | "operator-linebreak": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/styles/footer.css: -------------------------------------------------------------------------------- 1 | /* 2 | * footer 3 | */ 4 | 5 | footer { 6 | padding: 2em; 7 | width: 100%; 8 | height: 100%; 9 | text-align: center; 10 | border-top: 3px groove var(--font-color); 11 | background-color: var(--secondary-color); 12 | } 13 | 14 | footer ul { 15 | margin: 0 auto; 16 | display: flex; 17 | flex-direction: column; 18 | list-style: none; 19 | } 20 | 21 | footer li { 22 | margin: 0.3em 0; 23 | font-size: 14px; 24 | } 25 | -------------------------------------------------------------------------------- /src/scripts/components/custom-footer.js: -------------------------------------------------------------------------------- 1 | class CustomFooter extends HTMLElement { 2 | connectedCallback() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | this.innerHTML = ` 8 | 14 | `; 15 | } 16 | } 17 | 18 | customElements.define('custom-footer', CustomFooter); 19 | -------------------------------------------------------------------------------- /specs/favRestaurantIdbSpec.js: -------------------------------------------------------------------------------- 1 | import { itActsAsFavoriteRestoModel } from './contract/favRestoContract'; 2 | import FavRestoIdb from '../src/scripts/data/resto-idb'; 3 | 4 | describe('Favorite Movie Idb Contract Test Implementation', () => { 5 | afterEach(async () => { 6 | (await FavRestoIdb.getAllResto()).forEach(async (resto) => { 7 | await FavRestoIdb.deleteResto(resto.id); 8 | }); 9 | }); 10 | 11 | itActsAsFavoriteRestoModel(FavRestoIdb); 12 | }); 13 | -------------------------------------------------------------------------------- /src/scripts/views/templates/like-button.js: -------------------------------------------------------------------------------- 1 | const createLikeButtonTemplate = () => ` 2 | 5 | `; 6 | 7 | const createLikedButtonTemplate = () => ` 8 | 11 | `; 12 | 13 | export { createLikeButtonTemplate, createLikedButtonTemplate }; 14 | -------------------------------------------------------------------------------- /src/scripts/components/hero.js: -------------------------------------------------------------------------------- 1 | class HeroContent extends HTMLElement { 2 | connectedCallback() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | this.innerHTML = ` 8 |
9 |

Welcome to Resto

10 | 11 |

12 | Our restaurants offer more than just great food 13 |

14 | 15 | Read More 16 |
17 | `; 18 | } 19 | } 20 | 21 | customElements.define('hero-content', HeroContent); 22 | -------------------------------------------------------------------------------- /specs/helpers/testFactories.js: -------------------------------------------------------------------------------- 1 | import LikeButtonPresenter from '../../src/scripts/utils/like-button-presenter'; 2 | import FavRestoIdb from '../../src/scripts/data/resto-idb'; 3 | 4 | const createLikeButtonPresenterWithResto = async (restaurant) => { 5 | await LikeButtonPresenter.init({ 6 | likeButtonContainer: document.querySelector('#likeButtonContainer'), 7 | favRestoIdb: FavRestoIdb, 8 | data: { 9 | restaurant, 10 | }, 11 | }); 12 | }; 13 | 14 | // eslint-disable-next-line import/prefer-default-export 15 | export { createLikeButtonPresenterWithResto }; 16 | -------------------------------------------------------------------------------- /src/scripts/global/config.js: -------------------------------------------------------------------------------- 1 | // see more 2 | // https://restaurant-api.dicoding.dev/ 3 | 4 | const CONFIG = { 5 | KEY: '12345', 6 | BASE_URL: 'https://restaurant-api.dicoding.dev/', 7 | BASE_IMAGE_URL_LG: 'https://restaurant-api.dicoding.dev/images/large/', 8 | BASE_IMAGE_URL: 'https://restaurant-api.dicoding.dev/images/medium/', 9 | BASE_IMAGE_URL_SM: 'https://restaurant-api.dicoding.dev/images/small/', 10 | DB_NAME: 'fav-resto', 11 | DB_VERSION: 1, 12 | OBJECT_STORE_NAME: 'resto', 13 | WEB_SOCKET_SERVER: 'wss://javascript.info/article/websocket/chat/ws', 14 | CACHE_NAME: new Date().toISOString(), 15 | }; 16 | 17 | export default CONFIG; 18 | -------------------------------------------------------------------------------- /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/utils/swal-initiator.js: -------------------------------------------------------------------------------- 1 | // implementing code splitting 2 | 3 | const initSwalSuccess = (title) => { 4 | import('sweetalert2') 5 | .then((module) => module.default) 6 | .then((swal) => { 7 | swal.fire({ 8 | title, 9 | toast: true, 10 | icon: 'success', 11 | confirmButtonText: 'Ok', 12 | }); 13 | }) 14 | .catch((err) => console.error(err)); 15 | }; 16 | 17 | const initSwalError = (title) => { 18 | import('sweetalert2') 19 | .then((module) => module.default) 20 | .then((swal) => { 21 | swal.fire({ 22 | title, 23 | toast: true, 24 | icon: 'error', 25 | confirmButtonText: 'Ok', 26 | }); 27 | }) 28 | .catch((err) => console.error(err)); 29 | }; 30 | 31 | export { initSwalSuccess, initSwalError }; 32 | -------------------------------------------------------------------------------- /src/scripts/data/resto-source.js: -------------------------------------------------------------------------------- 1 | import CONFIG from '../global/config'; 2 | import API_ENDPOINT from '../global/api-endpoint'; 3 | 4 | class RestaurantSource { 5 | static async getRestaurantList() { 6 | const response = await fetch(API_ENDPOINT.LIST); 7 | return response.json(); 8 | } 9 | 10 | static async getRestaurantDetail(id) { 11 | const response = await fetch(API_ENDPOINT.DETAIL(id)); 12 | return response.json(); 13 | } 14 | 15 | static async postRestaurantReview(data) { 16 | const response = await fetch(API_ENDPOINT.POST_REVIEW, { 17 | method: 'POST', 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | 'X-Auth-Token': CONFIG.KEY, 21 | }, 22 | body: JSON.stringify(data), 23 | }); 24 | return response.json(); 25 | } 26 | } 27 | 28 | export default RestaurantSource; 29 | -------------------------------------------------------------------------------- /src/styles/header.css: -------------------------------------------------------------------------------- 1 | /* 2 | * header 3 | */ 4 | 5 | .hero { 6 | display: flex; 7 | flex-direction: column; 8 | min-height: 600px; 9 | width: 100%; 10 | text-align: center; 11 | background-color: var(--primary-color); 12 | background: var(--image-color), url('./images/hero-1000.jpg'); 13 | background-position: center; 14 | object-fit: cover; 15 | padding: 0 10%; 16 | } 17 | 18 | .hero__text { 19 | align-self: center; 20 | max-width: 600px; 21 | margin: auto 0; 22 | padding-bottom: 3em; 23 | } 24 | 25 | .hero__title { 26 | color: var(--secondary-color); 27 | font-weight: 500; 28 | font-size: xx-large; 29 | } 30 | 31 | .hero__subtitle { 32 | color: var(--primary-color); 33 | margin: 16px; 34 | margin-bottom: 30px; 35 | font-size: 18px; 36 | font-weight: 300; 37 | word-spacing: 2px; 38 | line-height: 1.36em; 39 | } 40 | -------------------------------------------------------------------------------- /codecept.conf.js: -------------------------------------------------------------------------------- 1 | const { setHeadlessWhen } = require('@codeceptjs/configure'); 2 | 3 | // turn on headless mode when running with HEADLESS=true environment variable 4 | // export HEADLESS=true && npx codeceptjs run 5 | setHeadlessWhen(process.env.HEADLESS); 6 | 7 | exports.config = { 8 | tests: 'e2e/**/*.spec.js', 9 | output: 'e2e/outputs', 10 | helpers: { 11 | Playwright: { 12 | url: 'http://localhost:9000', 13 | show: true, 14 | browser: 'chromium', 15 | // restart: true, 16 | }, 17 | }, 18 | include: { 19 | I: './steps_file.js', 20 | }, 21 | bootstrap: null, 22 | mocha: {}, 23 | name: 'restaurant-apps', 24 | plugins: { 25 | pauseOnFail: {}, 26 | retryFailedStep: { 27 | enabled: true, 28 | }, 29 | tryTo: { 30 | enabled: true, 31 | }, 32 | screenshotOnFail: { 33 | enabled: true, 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/scripts/views/App.js: -------------------------------------------------------------------------------- 1 | import DrawerInitiator from '../utils/drawer-initiator'; 2 | import UrlParser from '../routes/url-parser'; 3 | import routes from '../routes/routes'; 4 | 5 | class App { 6 | constructor({ button, drawer, content }) { 7 | this._button = button; 8 | this._drawer = drawer; 9 | this._content = content; 10 | 11 | this._initialAppShell(); 12 | } 13 | 14 | _initialAppShell() { 15 | // init drawer 16 | DrawerInitiator.init({ 17 | button: this._button, 18 | drawer: this._drawer, 19 | content: this._content, 20 | }); 21 | 22 | // kita bisa menginisiasikan komponen lain bila ada 23 | } 24 | 25 | async renderPage() { 26 | const url = UrlParser.parseActiveUrlWithCombiner(); 27 | const page = routes[url]; 28 | this._content.innerHTML = await page.render(); 29 | await page.afterRender(); 30 | } 31 | } 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /e2e/Review_Resto.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | Feature('Review Resto'); 4 | 5 | // Perintah berjalan sebelum tiap metode tes dijalankan 6 | Before(({ I }) => { 7 | // root URL : http:localhost:9000 8 | I.amOnPage('/'); 9 | }); 10 | 11 | Scenario('Post resto review', async ({ I }) => { 12 | const reviewText = 'Automated reviewww'; 13 | 14 | // URL: / 15 | I.seeElement('.card a'); 16 | I.click(locate('.card a').first()); 17 | 18 | // URL: /resto/:id 19 | I.seeElement('.form-review form'); 20 | I.fillField('name-input', 'E2E testing'); 21 | I.fillField('review-input', reviewText); 22 | I.click('#submit-review'); 23 | 24 | // after submit review 25 | // I.refreshPage(); 26 | I.waitForResponse('https://restaurant-api.dicoding.dev/review'); 27 | const lastReview = locate('.review-body').last(); 28 | const lastReviewText = await I.grabTextFrom(lastReview); 29 | assert.strictEqual(reviewText, lastReviewText.trim()); 30 | }); 31 | -------------------------------------------------------------------------------- /src/scripts/views/pages/favorite.js: -------------------------------------------------------------------------------- 1 | import FavRestoIdb from '../../data/resto-idb'; 2 | import restoCard from '../templates/resto-card'; 3 | 4 | const Favorite = { 5 | async render() { 6 | return ` 7 |
8 |

Favorited Resto

9 | 10 |
11 |
12 | `; 13 | }, 14 | 15 | async afterRender() { 16 | // get fav resto 17 | const data = await FavRestoIdb.getAllResto(); 18 | 19 | const favRestoContainer = document.querySelector('#fav-resto'); 20 | 21 | // if data empty 22 | if (data.length === 0) { 23 | favRestoContainer.innerHTML = ` 24 | Empty favorite Resto. Put one, by clicking heart button in the detail page. 25 | `; 26 | } 27 | 28 | // display all fav resto 29 | data.forEach((resto) => { 30 | favRestoContainer.innerHTML += restoCard(resto); 31 | }); 32 | }, 33 | }; 34 | 35 | export default Favorite; 36 | -------------------------------------------------------------------------------- /src/scripts/views/templates/resto-card.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | import CONFIG from '../../global/config'; 3 | 4 | const restoCard = (resto) => ` 5 |
6 | 7 |
8 | ${
 9 |             resto.name
10 |           } 11 | 12 | 13 | ${resto.rating} 14 | 15 |
16 | 17 |
18 |

${resto.name} - ${resto.city}

19 |

${resto.description}

20 |
21 |
22 |
23 | `; 24 | 25 | export default restoCard; 26 | -------------------------------------------------------------------------------- /src/scripts/utils/websocket-initiator.js: -------------------------------------------------------------------------------- 1 | import WebsocketNotif from './websocket-notif'; 2 | 3 | let socket = null; 4 | 5 | const WebSocketInitiator = { 6 | init(url) { 7 | socket = new WebSocket(url); 8 | console.log('ws connected to => ', socket.url); 9 | 10 | socket.onmessage = this._onMessageHandler; 11 | }, 12 | 13 | _onMessageHandler(message) { 14 | console.log('websocket onmessage handler => ', message); 15 | 16 | const reviewData = JSON.parse(message.data); 17 | 18 | WebsocketNotif.sendNotification({ 19 | title: reviewData.name, 20 | options: { 21 | body: reviewData.review, 22 | icon: 'icons/192x192.png', 23 | image: 'https://i.ibb.co/nBh3jrM/roompy-android-web.png', 24 | vibrate: [200, 100, 200], 25 | }, 26 | }); 27 | }, 28 | }; 29 | 30 | const sendDataToWebsocket = (reviewData) => { 31 | const data = JSON.stringify(reviewData); 32 | 33 | socket.send(data); 34 | }; 35 | 36 | export { WebSocketInitiator, sendDataToWebsocket }; 37 | -------------------------------------------------------------------------------- /src/scripts/utils/sw-register.js: -------------------------------------------------------------------------------- 1 | import { Workbox } from 'workbox-window'; 2 | 3 | const swRegister = () => { 4 | if ('serviceWorker' in navigator) { 5 | const wb = new Workbox('./sw.js'); 6 | 7 | wb.addEventListener('waiting', () => { 8 | console.log( 9 | "A new service worker has installed, but it can't activate until all tabs running the current version have fully unloaded.", 10 | ); 11 | }); 12 | 13 | wb.addEventListener('activated', (event) => { 14 | // `event.isUpdate` will be true if another version of the service 15 | // worker was controlling the page when this version was registered. 16 | if (!event.isUpdate) { 17 | console.log('Service worker activated for the first time!'); 18 | 19 | // If your service worker is configured to precache assets, those 20 | // assets should all be available now. 21 | } 22 | }); 23 | 24 | // Register the service worker after event listeners have been added. 25 | wb.register(); 26 | } 27 | }; 28 | 29 | export default swRegister; 30 | -------------------------------------------------------------------------------- /src/styles/spinner.css: -------------------------------------------------------------------------------- 1 | .loader, 2 | .loader:after { 3 | border-radius: 50%; 4 | width: 10em; 5 | height: 10em; 6 | } 7 | 8 | .loader { 9 | margin: 60px auto; 10 | font-size: 10px; 11 | position: relative; 12 | text-indent: -9999em; 13 | border-top: 1.1em solid rgba(235, 10, 10, 0.2); 14 | border-right: 1.1em solid rgba(235, 10, 10, 0.2); 15 | border-bottom: 1.1em solid rgba(235, 10, 10, 0.2); 16 | border-left: 1.1em solid var(--btn); 17 | -webkit-transform: translateZ(0); 18 | -ms-transform: translateZ(0); 19 | transform: translateZ(0); 20 | -webkit-animation: load8 1.1s infinite linear; 21 | animation: load8 1.1s infinite linear; 22 | } 23 | 24 | @-webkit-keyframes load8 { 25 | 0% { 26 | -webkit-transform: rotate(0deg); 27 | transform: rotate(0deg); 28 | } 29 | 100% { 30 | -webkit-transform: rotate(360deg); 31 | transform: rotate(360deg); 32 | } 33 | } 34 | @keyframes load8 { 35 | 0% { 36 | -webkit-transform: rotate(0deg); 37 | transform: rotate(0deg); 38 | } 39 | 100% { 40 | -webkit-transform: rotate(360deg); 41 | transform: rotate(360deg); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/scripts/utils/post-review.js: -------------------------------------------------------------------------------- 1 | import RestaurantSource from '../data/resto-source'; 2 | 3 | const PostReview = async (url, name, review) => { 4 | const dataInput = { 5 | id: url.id, 6 | name, 7 | review, 8 | }; 9 | 10 | const reviewContainer = document.querySelector('.detail-review'); 11 | const date = new Date().toLocaleDateString('id-ID', { 12 | year: 'numeric', 13 | month: 'long', 14 | day: 'numeric', 15 | }); 16 | 17 | const newReview = ` 18 |
19 |
20 |

${name}

21 | 22 |

${date}

23 |
24 | 25 |
26 | ${review} 27 |
28 |
29 | `; 30 | 31 | // POST review 32 | const reviewResponse = await RestaurantSource.postRestaurantReview(dataInput); 33 | console.log( 34 | '🚀 ~ file: post-review.js ~ line 33 ~ PostReview ~ reviewResponse', 35 | reviewResponse, 36 | ); 37 | 38 | // append newReview to the review container 39 | reviewContainer.innerHTML += newReview; 40 | }; 41 | 42 | export default PostReview; 43 | -------------------------------------------------------------------------------- /specs/favRestaurantArraySpec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-prototype-builtins */ 2 | /* eslint-disable consistent-return */ 3 | import { itActsAsFavoriteRestoModel } from './contract/favRestoContract'; 4 | 5 | let favoriteRestos = []; 6 | 7 | const FavoriteRestoArray = { 8 | getResto(id) { 9 | if (!id) { 10 | return; 11 | } 12 | 13 | return favoriteRestos.find((restaurant) => restaurant.id === id); 14 | }, 15 | 16 | getAllResto() { 17 | return favoriteRestos; 18 | }, 19 | 20 | putResto(resto) { 21 | if (!resto.hasOwnProperty('id')) { 22 | return; 23 | } 24 | 25 | if (this.getResto(resto.id)) { 26 | return; 27 | } 28 | 29 | favoriteRestos.push(resto); 30 | }, 31 | 32 | deleteResto(id) { 33 | // cara boros menghapus restaurant dengan meng-copy restaurant yang ada 34 | // kecuali restaurant dengan id === id 35 | favoriteRestos = favoriteRestos.filter((resto) => resto.id !== id); 36 | }, 37 | }; 38 | 39 | describe('Favorite resto array contract test', () => { 40 | // eslint-disable-next-line no-return-assign 41 | afterEach(() => (favoriteRestos = [])); 42 | 43 | itActsAsFavoriteRestoModel(FavoriteRestoArray); 44 | }); 45 | -------------------------------------------------------------------------------- /src/scripts/components/navbar.js: -------------------------------------------------------------------------------- 1 | class Navbar extends HTMLElement { 2 | connectedCallback() { 3 | this.render(); 4 | } 5 | 6 | render() { 7 | this.innerHTML = ` 8 | 38 | `; 39 | } 40 | } 41 | 42 | customElements.define('nav-bar', Navbar); 43 | -------------------------------------------------------------------------------- /src/scripts/utils/websocket-notif.js: -------------------------------------------------------------------------------- 1 | const WebsocketNotif = { 2 | sendNotification({ title, options }) { 3 | const isAvailable = this._checkAvailability(); 4 | const isPermitted = this._checkPermission(); 5 | 6 | if (!isAvailable) { 7 | console.info('Notification not supported in this browser'); 8 | return; 9 | } 10 | 11 | if (!isPermitted) { 12 | console.info('User did not yet granted permission'); 13 | this._requestPermission(); 14 | return; 15 | } 16 | 17 | this._showNotification({ title, options }); 18 | }, 19 | 20 | _checkAvailability() { 21 | return Boolean('Notification' in window); 22 | }, 23 | 24 | _checkPermission() { 25 | return Notification.permission === 'granted'; 26 | }, 27 | 28 | async _requestPermission() { 29 | const status = await Notification.requestPermission(); 30 | 31 | if (status === 'denied') { 32 | console.error('Notification Denied'); 33 | } 34 | 35 | if (status === 'default') { 36 | console.warn('Permission closed'); 37 | } 38 | }, 39 | 40 | async _showNotification({ title, options }) { 41 | const serviceWorkerRegistration = await navigator.serviceWorker.ready; 42 | serviceWorkerRegistration.showNotification(title, options); 43 | }, 44 | }; 45 | 46 | export default WebsocketNotif; 47 | -------------------------------------------------------------------------------- /src/scripts/data/resto-idb.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-prototype-builtins */ 2 | /* eslint-disable consistent-return */ 3 | import { openDB } from 'idb'; 4 | import CONFIG from '../global/config'; 5 | 6 | const { DB_NAME, DB_VERSION, OBJECT_STORE_NAME } = CONFIG; 7 | 8 | const openIdb = openDB(DB_NAME, DB_VERSION, { 9 | upgrade(db) { 10 | // Create a store of objects 11 | db.createObjectStore(OBJECT_STORE_NAME, { 12 | // The 'id' property of the object will be the key. 13 | keyPath: 'id', 14 | // If it isn't explicitly set, create a value by auto incrementing. 15 | autoIncrement: true, 16 | }); 17 | }, 18 | }); 19 | 20 | const FavRestoIdb = { 21 | // get one resto 22 | async getResto(id) { 23 | if (!id) { 24 | return; 25 | } 26 | 27 | return (await openIdb).get(OBJECT_STORE_NAME, id); 28 | }, 29 | 30 | // get all resto 31 | async getAllResto() { 32 | return (await openIdb).getAll(OBJECT_STORE_NAME); 33 | }, 34 | 35 | // put resto 36 | async putResto(resto) { 37 | if (!resto.hasOwnProperty('id')) { 38 | return; 39 | } 40 | 41 | return (await openIdb).put(OBJECT_STORE_NAME, resto); 42 | }, 43 | 44 | // delete resto 45 | async deleteResto(id) { 46 | return (await openIdb).delete(OBJECT_STORE_NAME, id); 47 | }, 48 | }; 49 | 50 | export default FavRestoIdb; 51 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime'; 2 | import 'lazysizes'; 3 | import 'lazysizes/plugins/parent-fit/ls.parent-fit'; 4 | // css 5 | import '../styles/normalize.css'; 6 | import '../styles/root.css'; 7 | import '../styles/nav.css'; 8 | import '../styles/header.css'; 9 | import '../styles/main.css'; 10 | import '../styles/footer.css'; 11 | import '../styles/responsive.css'; 12 | import '../styles/spinner.css'; 13 | import '../styles/resto-detail.css'; 14 | import '../styles/resto-fav.css'; 15 | // js 16 | import App from './views/App'; 17 | import swRegister from './utils/sw-register'; 18 | // import { WebSocketInitiator } from './utils/websocket-initiator'; 19 | // import CONFIG from './global/config'; 20 | // components 21 | import './components/navbar'; 22 | import './components/hero'; 23 | import './components/custom-footer'; 24 | 25 | // init App 26 | const app = new App({ 27 | button: document.querySelector('.menu'), 28 | drawer: document.querySelector('.nav-list'), 29 | content: document.querySelector('#main-content'), 30 | }); 31 | 32 | window.addEventListener('hashchange', () => { 33 | document.querySelector('.container').scrollIntoView(); 34 | app.renderPage(); 35 | }); 36 | 37 | window.addEventListener('load', () => { 38 | app.renderPage(); 39 | swRegister(); 40 | // WebSocketInitiator.init(CONFIG.WEB_SOCKET_SERVER); 41 | }); 42 | -------------------------------------------------------------------------------- /src/styles/nav.css: -------------------------------------------------------------------------------- 1 | /* 2 | * nav 3 | */ 4 | 5 | nav { 6 | position: relative; 7 | display: flex; 8 | flex-wrap: wrap; 9 | align-items: center; 10 | justify-content: space-between; 11 | padding: 0.3em; 12 | height: 75px; 13 | } 14 | 15 | .menu { 16 | font-size: 25px; 17 | display: none; 18 | width: 44px; 19 | height: 44px; 20 | color: var(--primary-color); 21 | } 22 | 23 | .menu:hover { 24 | cursor: pointer; 25 | } 26 | 27 | .logo-font { 28 | font-size: x-large; 29 | font-weight: bold; 30 | color: var(--primary-color); 31 | text-decoration: none; 32 | padding: 0.4em 0; 33 | } 34 | 35 | .nav-list { 36 | display: flex; 37 | padding-left: 0; 38 | margin-bottom: 0; 39 | list-style: none; 40 | flex-wrap: wrap; 41 | transition: ease; 42 | } 43 | 44 | .nav-item { 45 | box-sizing: border-box; 46 | line-height: 24px; 47 | } 48 | 49 | .nav-item a { 50 | padding: 0.7rem; 51 | display: inline-block; 52 | font-size: 1.3em; 53 | text-decoration: none; 54 | color: var(--primary-color); 55 | } 56 | 57 | .nav-item button { 58 | padding: 0.9rem; 59 | background-color: transparent; 60 | border: 0 solid transparent; 61 | color: var(--primary-color); 62 | cursor: pointer; 63 | font-size: 1.3; 64 | vertical-align: middle; 65 | } 66 | 67 | .nav-item a:hover { 68 | text-decoration: underline; 69 | color: var(--secondary-color); 70 | cursor: pointer; 71 | } 72 | 73 | .nav-item button:hover { 74 | text-decoration: underline; 75 | color: var(--secondary-color); 76 | cursor: pointer; 77 | } 78 | -------------------------------------------------------------------------------- /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 | module: { 10 | rules: [ 11 | { 12 | test: /\.js$/, 13 | exclude: '/node_modules/', 14 | use: [ 15 | { 16 | loader: 'babel-loader', 17 | options: { 18 | presets: ['@babel/preset-env'], 19 | }, 20 | }, 21 | ], 22 | }, 23 | ], 24 | }, 25 | optimization: { 26 | splitChunks: { 27 | chunks: 'all', 28 | minSize: 20000, 29 | maxSize: 70000, 30 | minChunks: 1, 31 | maxAsyncRequests: 30, 32 | maxInitialRequests: 30, 33 | automaticNameDelimiter: '~', 34 | enforceSizeThreshold: 50000, 35 | cacheGroups: { 36 | defaultVendors: { 37 | test: /[\\/]node_modules[\\/]/, 38 | priority: -10, 39 | }, 40 | default: { 41 | minChunks: 2, 42 | priority: -20, 43 | reuseExistingChunk: true, 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/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Resto", 3 | "short_name": "Resto", 4 | "description": "Web application displaying list of awesome restaurants", 5 | "start_url": "/index.html", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#ff8303", 9 | "icons": [ 10 | { 11 | "src": "/icons/72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "/icons/96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "any maskable" 21 | }, 22 | { 23 | "src": "/icons/128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "any maskable" 27 | }, 28 | { 29 | "src": "/icons/144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "any maskable" 33 | }, 34 | { 35 | "src": "/icons/152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "any maskable" 39 | }, 40 | { 41 | "src": "/icons/192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "any maskable" 45 | }, 46 | { 47 | "src": "/icons/384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "any maskable" 51 | }, 52 | { 53 | "src": "/icons/512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "any maskable" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /e2e/Favorite_Resto.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | Feature('Favorite Resto'); 4 | 5 | // Perintah berjalan sebelum tiap metode tes dijalankan 6 | Before(({ I }) => { 7 | // root URL : http:localhost:9000 8 | I.amOnPage('/#/favorite'); 9 | }); 10 | 11 | const emptyFavoriteRestoText = 'Empty favorite Resto'; 12 | 13 | Scenario('showing empty favorite restaurant', ({ I }) => { 14 | I.seeElement('#fav-resto'); 15 | I.see(emptyFavoriteRestoText, '#fav-resto'); 16 | }); 17 | 18 | Scenario('liking one restaurant', async ({ I }) => { 19 | I.see(emptyFavoriteRestoText, '#fav-resto'); 20 | 21 | // URL: / 22 | I.amOnPage('/'); 23 | I.seeElement('.card a'); 24 | const firstRestoCard = locate('.card-content-title').first(); 25 | const firstRestoCardTitle = await I.grabTextFrom(firstRestoCard); 26 | I.click(firstRestoCard); 27 | 28 | // URL: /resto/:id 29 | I.seeElement('#likeButton'); 30 | I.click('#likeButton'); 31 | 32 | // URL: /#/favorite 33 | I.amOnPage('/#/favorite'); 34 | I.seeElement('.card'); 35 | const likedCardTitle = await I.grabTextFrom('.card-content-title'); 36 | assert.strictEqual(firstRestoCardTitle, likedCardTitle); // membandingkan 37 | }); 38 | 39 | Scenario('unliking one restaurant', async ({ I }) => { 40 | I.seeElement('.card'); 41 | const likedCardTitle = await I.grabTextFrom('.card-content-title'); 42 | I.click(likedCardTitle); 43 | 44 | // URL: /resto/:id 45 | I.seeElement('#likeButton'); 46 | I.click('#likeButton'); 47 | 48 | // URL: /#/favorite 49 | I.amOnPage('/#/favorite'); 50 | I.seeElement('#fav-resto'); 51 | I.dontSeeElement('.card'); 52 | I.dontSeeElement('.card-content-title'); 53 | }); 54 | -------------------------------------------------------------------------------- /src/styles/root.css: -------------------------------------------------------------------------------- 1 | /* 2 | * root settings 3 | */ 4 | 5 | :root { 6 | --primary-color: #ffffff; 7 | --secondary-color: #f0e3ca; 8 | --font-color: #1b1a17; 9 | --btn: #ff8303; 10 | --btn-hover: #a35709; 11 | --image-color: linear-gradient( 12 | 1deg, 13 | rgb(255 207 126 / 40%) 3%, 14 | rgb(0 0 0 / 65%) 30% 15 | ); 16 | } 17 | 18 | * { 19 | padding: 0; 20 | margin: 0; 21 | box-sizing: border-box; 22 | } 23 | 24 | body, 25 | html { 26 | margin: 0; 27 | padding: 0; 28 | width: 100%; 29 | height: 100%; 30 | background-color: var(--primary-color); 31 | scroll-behavior: smooth; 32 | } 33 | 34 | body { 35 | font-family: 'Poppins', sans-serif; 36 | font-size: 14px; 37 | color: var(--font-color); 38 | } 39 | 40 | /* Skip link */ 41 | .skip-link { 42 | position: absolute; 43 | top: -80px; 44 | left: 0; 45 | background-color: var(--btn); 46 | color: white; 47 | padding: 8px; 48 | z-index: 10; 49 | } 50 | 51 | .skip-link:focus { 52 | top: 0; 53 | } 54 | 55 | /* Button */ 56 | 57 | .btn { 58 | margin: 2em; 59 | padding: 0.75em 1em; 60 | font-weight: 700; 61 | color: white; 62 | text-align: center; 63 | vertical-align: middle; 64 | background-color: var(--btn); 65 | border: 1px solid transparent; 66 | font-size: large; 67 | cursor: pointer; 68 | text-decoration: none; 69 | } 70 | 71 | .submit-btn { 72 | padding: 0.75em 1em; 73 | font-weight: 700; 74 | color: white; 75 | text-align: center; 76 | vertical-align: middle; 77 | background-color: var(--btn); 78 | border: 1px solid transparent; 79 | font-size: large; 80 | cursor: pointer; 81 | text-decoration: none; 82 | } 83 | 84 | .btn:hover, 85 | .submit-btn:hover { 86 | background-color: var(--btn-hover); 87 | } 88 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | * main 3 | */ 4 | 5 | main { 6 | width: 100%; 7 | margin: 0 auto; 8 | } 9 | 10 | .container { 11 | margin: 2em 10%; 12 | padding: 1em; 13 | } 14 | 15 | .main-content__title { 16 | text-align: center; 17 | margin-bottom: 0.5em; 18 | } 19 | 20 | #explore-restaurant { 21 | display: grid; 22 | grid-row-gap: 1.5em; 23 | padding-top: 1.5em; 24 | } 25 | 26 | /* 27 | * Cards 28 | */ 29 | 30 | .card { 31 | width: 100%; 32 | text-align: center; 33 | transition: 0.3s; 34 | cursor: pointer; 35 | background-color: var(--primary-color); 36 | } 37 | 38 | .card:hover { 39 | box-shadow: 10px 10px 5px 0 var(--secondary-color); 40 | } 41 | 42 | .card-a-tag { 43 | text-decoration: none; 44 | color: var(--font-color); 45 | } 46 | 47 | .card-rating { 48 | position: absolute; 49 | top: 0; 50 | left: 0; 51 | color: white; 52 | font-size: large; 53 | background-color: var(--btn); 54 | padding: 0.4em 1.3em; 55 | z-index: 1; 56 | } 57 | 58 | .card-rating .fa { 59 | font-size: smaller; 60 | padding: 0.3em 0.8em 0.3em 0; 61 | color: yellow; 62 | } 63 | 64 | .img-container { 65 | width: 100%; 66 | position: relative; 67 | overflow: hidden; 68 | max-height: 600px; 69 | } 70 | 71 | .card-image { 72 | width: 100%; 73 | height: 270px; 74 | object-fit: cover; 75 | object-position: center; 76 | } 77 | 78 | .card-content { 79 | padding: 1.4em 2em; 80 | font-size: small; 81 | text-align: center; 82 | } 83 | 84 | .card-content-title { 85 | font-weight: bold; 86 | padding-bottom: 0.376em; 87 | color: var(--btn); 88 | } 89 | 90 | .truncate { 91 | overflow: hidden; 92 | text-overflow: ellipsis; 93 | display: -webkit-box; 94 | -webkit-line-clamp: 2; 95 | -webkit-box-orient: vertical; 96 | } 97 | -------------------------------------------------------------------------------- /src/scripts/views/pages/home.js: -------------------------------------------------------------------------------- 1 | import Spinner from '../templates/spinner'; 2 | import RestaurantSource from '../../data/resto-source'; 3 | import restoCard from '../templates/resto-card'; 4 | import { initSwalError } from '../../utils/swal-initiator'; 5 | 6 | const Home = { 7 | async render() { 8 | return ` 9 |
10 |
11 | 12 |
13 |

Explore Restaurant

14 | 15 |
16 |
17 |
18 | `; 19 | }, 20 | 21 | // Fungsi ini akan dipanggil setelah render() 22 | async afterRender() { 23 | const loading = document.querySelector('#loading'); 24 | const mainContainer = document.querySelector('#main-container'); 25 | const listContainer = document.querySelector('#explore-restaurant'); 26 | 27 | // change main display to spinner 28 | mainContainer.style.display = 'none'; 29 | loading.innerHTML = Spinner(); 30 | 31 | try { 32 | const data = await RestaurantSource.getRestaurantList(); // fetch restaurant list 33 | 34 | // loop restaurants data 35 | data.restaurants.forEach((restaurant) => { 36 | listContainer.innerHTML += restoCard(restaurant); 37 | }); 38 | 39 | // change spinner display to main 40 | loading.style.display = 'none'; 41 | mainContainer.style.display = 'block'; 42 | } catch (err) { 43 | console.error(err); 44 | 45 | mainContainer.style.display = 'block'; 46 | loading.style.display = 'none'; 47 | listContainer.innerHTML = `Error: ${err.message}`; 48 | initSwalError(err.message); 49 | } 50 | }, 51 | }; 52 | 53 | export default Home; 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 |

5 | 6 |

7 | Resto 8 |

9 |
10 | 11 | --- 12 | 13 | # 📃 Description 14 | 15 | Projek ini adalah submission dari Dicoding untuk kelas Menjadi Web Developer Expert. Kelas ini memiliki total 3 submission yang harus diselesaikan untuk mendapatkan sertifikat. 16 | 17 | > **_PERINGATAN: Jadikan repo ini sebagai rujukan/referensi._** 18 | > 19 | > - Sesuai dengan terms of use di Dicoding, submission kelas Dicoding Academy haruslah hasil karya Anda sendiri. 20 | > 21 | > - Kode yang didapatkan dari sumber lain (website, buku, forum, GitHub, dan lain-lain) hanya digunakan sebagai referensi. Tingkat kesamaannya tidak boleh lebih dari 70%. 22 | 23 | ## Submission 1️⃣: Katalog Restoran 24 | 25 | Anda dapat melihat dan mendownload source code dari submission 1 di [link ini](https://github.com/rifandani/menjadi-web-developer-expert/archive/refs/tags/v0.1.2.zip) 26 | 27 | ## Submission 2️⃣: Katalog Restoran + PWA 28 | 29 | Anda dapat melihat dan mendownload source code dari submission 2 di [link ini](https://github.com/rifandani/menjadi-web-developer-expert/archive/refs/tags/v0.2.1.zip) 30 | 31 | ## Submission 3️⃣: Katalog Restoran + PWA + Testing and Performance Optimized 32 | 33 | Anda dapat melihat dan mendownload source code dari submission 3 di [link ini](https://github.com/rifandani/menjadi-web-developer-expert/archive/refs/tags/v0.3.0.zip) 34 | 35 | --- 36 | 37 | > Untuk melihat semua daftar releases, kunjungi [releases](https://github.com/rifandani/menjadi-web-developer-expert/releases) 38 | > 39 | > Jika ada pertanyaan atau issue, kunjungi [new issue](https://github.com/rifandani/menjadi-web-developer-expert/issues/new) 40 | -------------------------------------------------------------------------------- /specs/contract/favRestoContract.js: -------------------------------------------------------------------------------- 1 | const itActsAsFavoriteRestoModel = (favRestoIdb) => { 2 | it('should return the resto that has been added', async () => { 3 | favRestoIdb.putResto({ id: 1 }); 4 | favRestoIdb.putResto({ id: 2 }); 5 | 6 | expect(await favRestoIdb.getResto(1)).toEqual({ id: 1 }); 7 | expect(await favRestoIdb.getResto(2)).toEqual({ id: 2 }); 8 | expect(await favRestoIdb.getResto(3)).toEqual(undefined); 9 | }); 10 | 11 | it('should refuse a resto from being added if it does not have the correct property', async () => { 12 | favRestoIdb.putResto({ aProperty: 'property' }); 13 | 14 | expect(await favRestoIdb.getAllResto()).toEqual([]); 15 | }); 16 | 17 | it('can return all of the resto that have been added', async () => { 18 | favRestoIdb.putResto({ id: 1 }); 19 | favRestoIdb.putResto({ id: 2 }); 20 | 21 | expect(await favRestoIdb.getAllResto()).toEqual([{ id: 1 }, { id: 2 }]); 22 | }); 23 | 24 | it('should remove favorite resto', async () => { 25 | favRestoIdb.putResto({ id: 1 }); 26 | favRestoIdb.putResto({ id: 2 }); 27 | favRestoIdb.putResto({ id: 3 }); 28 | 29 | await favRestoIdb.deleteResto(1); 30 | 31 | expect(await favRestoIdb.getAllResto()).toEqual([{ id: 2 }, { id: 3 }]); 32 | }); 33 | 34 | it('should handle request to remove a resto even though the resto has not been added', async () => { 35 | favRestoIdb.putResto({ id: 1 }); 36 | favRestoIdb.putResto({ id: 2 }); 37 | favRestoIdb.putResto({ id: 3 }); 38 | 39 | await favRestoIdb.deleteResto(4); 40 | 41 | expect(await favRestoIdb.getAllResto()).toEqual([ 42 | { id: 1 }, 43 | { id: 2 }, 44 | { id: 3 }, 45 | ]); 46 | }); 47 | }; 48 | 49 | // eslint-disable-next-line import/prefer-default-export 50 | export { itActsAsFavoriteRestoModel }; 51 | -------------------------------------------------------------------------------- /specs/unlikeRestaurant.spec.js: -------------------------------------------------------------------------------- 1 | import FavRestoIdb from '../src/scripts/data/resto-idb'; 2 | import * as TestFactories from './helpers/testFactories'; 3 | 4 | const addLikeButtonContainer = () => { 5 | document.body.innerHTML = '
'; 6 | }; 7 | 8 | describe('Unliking Resto', () => { 9 | beforeEach(async () => { 10 | addLikeButtonContainer(); 11 | await FavRestoIdb.putResto({ id: 1 }); 12 | }); 13 | 14 | afterEach(async () => { 15 | await FavRestoIdb.deleteResto(1); 16 | }); 17 | 18 | it('should display unlike widget when the resto has been liked', async () => { 19 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 20 | 21 | expect( 22 | document.querySelector('[aria-label="unlike this resto"]'), 23 | ).toBeTruthy(); 24 | }); 25 | 26 | it('should not display unlike widget when the resto has been liked', async () => { 27 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 28 | 29 | expect( 30 | document.querySelector('[aria-label="like this resto"]'), 31 | ).toBeFalsy(); 32 | }); 33 | 34 | it('should be able to remove liked resto from the list', async () => { 35 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 36 | 37 | document 38 | .querySelector('[aria-label="unlike this resto"]') 39 | .dispatchEvent(new Event('click')); 40 | const allResto = await FavRestoIdb.getAllResto(); 41 | 42 | expect(allResto).toEqual([]); 43 | }); 44 | 45 | it('should not throw error if the unliked resto is not in the list', async () => { 46 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 47 | 48 | await FavRestoIdb.deleteResto(1); 49 | document 50 | .querySelector('[aria-label="unlike this resto"]') 51 | .dispatchEvent(new Event('click')); 52 | const allResto = await FavRestoIdb.getAllResto(); 53 | 54 | expect(allResto).toEqual([]); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/scripts/utils/like-button-presenter.js: -------------------------------------------------------------------------------- 1 | import { 2 | createLikeButtonTemplate, 3 | createLikedButtonTemplate, 4 | } from '../views/templates/like-button'; 5 | import { initSwalError, initSwalSuccess } from './swal-initiator'; 6 | 7 | const LikeButtonPresenter = { 8 | async init({ likeButtonContainer, data, favRestoIdb }) { 9 | this._likeButtonContainer = likeButtonContainer; 10 | this._restaurant = data.restaurant; 11 | this._favRestoIdb = favRestoIdb; 12 | 13 | await this._renderButton(); 14 | }, 15 | 16 | async _renderButton() { 17 | try { 18 | const { id } = this._restaurant; 19 | 20 | // get resto in indexed db 21 | const restaurant = await this._favRestoIdb.getResto(id); 22 | 23 | if (restaurant) { 24 | this._renderLikedButtonTemplate(); 25 | } else { 26 | this._renderLikeButtonTemplate(); 27 | } 28 | } catch (err) { 29 | console.error(err); 30 | initSwalError(err.message); 31 | 32 | throw new Error(err); 33 | } 34 | }, 35 | 36 | _renderLikeButtonTemplate() { 37 | this._likeButtonContainer.innerHTML = createLikeButtonTemplate(); // append html 38 | 39 | const likeButton = document.querySelector('#likeButton'); 40 | 41 | likeButton.addEventListener('click', async () => { 42 | // onClick fav the selected resto 43 | await this._favRestoIdb.putResto(this._restaurant); 44 | initSwalSuccess('Resto favorited!'); 45 | this._renderButton(); 46 | }); 47 | }, 48 | 49 | _renderLikedButtonTemplate() { 50 | this._likeButtonContainer.innerHTML = createLikedButtonTemplate(); // append html 51 | 52 | const likeButton = document.querySelector('#likeButton'); 53 | 54 | likeButton.addEventListener('click', async () => { 55 | // onClick unfav the selected resto 56 | await this._favRestoIdb.deleteResto(this._restaurant.id); 57 | initSwalSuccess('Resto unfavorited!'); 58 | this._renderButton(); 59 | }); 60 | }, 61 | }; 62 | 63 | export default LikeButtonPresenter; 64 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Resto 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 21 | 28 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | 47 | 48 | 49 | 50 | 51 |
52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | const { InjectManifest } = require('workbox-webpack-plugin'); 6 | const ImageminWebpackPlugin = require('imagemin-webpack-plugin').default; 7 | const ImageminMozjpeg = require('imagemin-mozjpeg'); 8 | const imageminPngquant = require('imagemin-pngquant'); 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 | options: { 27 | url: false, 28 | }, 29 | }, 30 | ], 31 | }, 32 | ], 33 | }, 34 | plugins: [ 35 | new HtmlWebpackPlugin({ 36 | template: path.resolve(__dirname, 'src/templates/index.html'), 37 | filename: 'index.html', 38 | favicon: path.resolve(__dirname, 'src/public/favicon.ico'), 39 | }), 40 | new CopyWebpackPlugin({ 41 | patterns: [ 42 | { 43 | from: path.resolve(__dirname, 'src/public/'), 44 | to: path.resolve(__dirname, 'dist/'), 45 | globOptions: { 46 | ignore: ['**/images/**'], // CopyWebpackPlugin mengabaikan berkas yang berada di dalam folder images (sharp) 47 | }, 48 | }, 49 | ], 50 | }), 51 | new InjectManifest({ 52 | swSrc: path.resolve(__dirname, 'src/scripts/utils/sw.js'), 53 | swDest: 'sw.js', 54 | }), 55 | new ImageminWebpackPlugin({ 56 | plugins: [ 57 | ImageminMozjpeg({ 58 | quality: 50, 59 | progressive: true, 60 | }), 61 | imageminPngquant({ 62 | quality: [0.3, 0.5], 63 | }), 64 | ], 65 | }), 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /specs/likeRestaurant.spec.js: -------------------------------------------------------------------------------- 1 | import FavRestoIdb from '../src/scripts/data/resto-idb'; 2 | import * as TestFactories from './helpers/testFactories'; 3 | 4 | const addLikeButtonContainer = () => { 5 | document.body.innerHTML = '
'; 6 | }; 7 | 8 | describe('Liking Resto', () => { 9 | beforeEach(() => { 10 | addLikeButtonContainer(); 11 | }); 12 | 13 | it('should show the like button when the resto has not been liked before', async () => { 14 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 15 | 16 | expect( 17 | document.querySelector('[aria-label="like this resto"]'), 18 | ).toBeTruthy(); 19 | }); 20 | 21 | it('should not show the unlike button when the resto has not been liked before', async () => { 22 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 23 | 24 | expect( 25 | document.querySelector('[aria-label="unlike this resto"]'), 26 | ).toBeFalsy(); 27 | }); 28 | 29 | it('should be able to like the resto', async () => { 30 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 31 | 32 | document.querySelector('#likeButton').dispatchEvent(new Event('click')); 33 | const resto = await FavRestoIdb.getResto(1); 34 | expect(resto).toEqual({ id: 1 }); 35 | 36 | await FavRestoIdb.deleteResto(1); 37 | }); 38 | 39 | it('should not add a resto again when its already liked', async () => { 40 | await TestFactories.createLikeButtonPresenterWithResto({ id: 1 }); 41 | 42 | await FavRestoIdb.putResto({ id: 1 }); 43 | document.querySelector('#likeButton').dispatchEvent(new Event('click')); 44 | const allResto = await FavRestoIdb.getAllResto(); 45 | expect(allResto).toEqual([{ id: 1 }]); 46 | 47 | await FavRestoIdb.deleteResto(1); 48 | }); 49 | 50 | // menggunakan metode xit, bukan it 51 | it('should not add a resto when it has no id', async () => { 52 | await TestFactories.createLikeButtonPresenterWithResto({}); 53 | 54 | document.querySelector('#likeButton').dispatchEvent(new Event('click')); 55 | const allResto = await FavRestoIdb.getAllResto(); 56 | expect(allResto).toEqual([]); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /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 | if (image.includes('hero')) { 15 | // convert hero image lebar 1200px 16 | sharp(`${target}/${image}`) 17 | .resize(1200) 18 | .toFile( 19 | path.resolve( 20 | __dirname, 21 | `${destination}/${image.split('.').slice(0, -1).join('.')}-1200.jpg`, 22 | ), 23 | ); 24 | 25 | // convert hero image lebar 1000px 26 | sharp(`${target}/${image}`) 27 | .resize(1000) 28 | .toFile( 29 | path.resolve( 30 | __dirname, 31 | `${destination}/${image.split('.').slice(0, -1).join('.')}-1000.jpg`, 32 | ), 33 | ); 34 | 35 | // convert hero image lebar 600px 36 | sharp(`${target}/${image}`) 37 | .resize(600) 38 | .toFile( 39 | path.resolve( 40 | __dirname, 41 | `${destination}/${image.split('.').slice(0, -1).join('.')}-600.jpg`, 42 | ), 43 | ); 44 | } else { 45 | // convert loading image lebar 400px 46 | sharp(`${target}/${image}`) 47 | .resize(400) 48 | .toFile( 49 | path.resolve( 50 | __dirname, 51 | `${destination}/${image.split('.').slice(0, -1).join('.')}-400.jpg`, 52 | ), 53 | ); 54 | 55 | // convert loading image lebar 300px 56 | sharp(`${target}/${image}`) 57 | .resize(300) 58 | .toFile( 59 | path.resolve( 60 | __dirname, 61 | `${destination}/${image.split('.').slice(0, -1).join('.')}-300.jpg`, 62 | ), 63 | ); 64 | 65 | // convert loading image lebar 200px 66 | sharp(`${target}/${image}`) 67 | .resize(200) 68 | .toFile( 69 | path.resolve( 70 | __dirname, 71 | `${destination}/${image.split('.').slice(0, -1).join('.')}-200.jpg`, 72 | ), 73 | ); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /src/styles/responsive.css: -------------------------------------------------------------------------------- 1 | /* Extra small devices ( <= 320px ) */ 2 | @media only screen and (max-width: 340px) { 3 | nav { 4 | display: flex; 5 | flex-direction: row; 6 | } 7 | 8 | .logo-font, 9 | .hero__title { 10 | font-size: 1.5em; 11 | } 12 | 13 | .nav-item a, 14 | .hero__subtitle, 15 | .btn { 16 | font-size: 1em; 17 | } 18 | } 19 | 20 | /* Smaller devices ( <= 600px ) */ 21 | @media only screen and (max-width: 600px) { 22 | .menu { 23 | display: block; 24 | background-color: transparent; 25 | border: 1px solid transparent; 26 | text-align: center; 27 | } 28 | 29 | .menu-hp { 30 | display: flex; 31 | align-items: center; 32 | justify-content: space-between; 33 | width: 100%; 34 | } 35 | 36 | .menu span { 37 | text-align: center; 38 | } 39 | 40 | nav { 41 | display: flex; 42 | flex-direction: column; 43 | padding: 3em 0; 44 | height: 175px; 45 | flex-wrap: wrap; 46 | } 47 | 48 | .nav-list { 49 | display: none; 50 | } 51 | 52 | .nav-list-block { 53 | display: flex; 54 | } 55 | 56 | .hero { 57 | min-height: 500px; 58 | background: var(--image-color), url('./images/hero-600.jpg'); 59 | background-size: cover; 60 | background-position: center; 61 | } 62 | } 63 | 64 | /* Small devices ( >= 600px ) */ 65 | @media only screen and (min-width: 600px) { 66 | .main-content__title { 67 | margin-bottom: 1em; 68 | } 69 | 70 | #explore-restaurant, 71 | #fav-resto { 72 | display: grid; 73 | grid-template-columns: 1fr 1fr; 74 | grid-column-gap: 1.545em; 75 | grid-row-gap: 1.545em; 76 | } 77 | } 78 | 79 | /* Medium devices ( >= 768px ) */ 80 | @media only screen and (min-width: 768px) { 81 | } 82 | 83 | /* Large devices ( >= 992px ) */ 84 | @media only screen and (min-width: 992px) { 85 | #explore-restaurant, 86 | #fav-resto { 87 | display: grid; 88 | grid-template-columns: 1fr 1fr 1fr; 89 | } 90 | 91 | .hero { 92 | background: var(--image-color), url('./images/hero-1200.jpg'); 93 | background-position: center; 94 | } 95 | } 96 | 97 | /* Extra large devices ( >= 1200px ) */ 98 | @media only screen and (min-width: 1200px) { 99 | main { 100 | max-width: 1200px; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "restaurant-apps", 3 | "version": "0.3.0", 4 | "description": "Dicoding front end path submission kelas menjadi web developer expert", 5 | "main": "index.js", 6 | "scripts": { 7 | "start-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 | "test": "karma start", 11 | "lint": "eslint ./src/scripts", 12 | "e2e": "codeceptjs run --steps" 13 | }, 14 | "keywords": [], 15 | "author": "Tri Rizeki Rifandani", 16 | "license": "ISC", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/rifandani/menjadi-web-developer-expert.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/rifandani/menjadi-web-developer-expert/issues" 23 | }, 24 | "homepage": "https://github.com/rifandani/menjadi-web-developer-expert#readme", 25 | "devDependencies": { 26 | "@babel/core": "^7.10.5", 27 | "@babel/preset-env": "^7.10.4", 28 | "babel-loader": "^8.1.0", 29 | "clean-webpack-plugin": "^4.0.0-alpha.0", 30 | "codeceptjs": "^3.0.7", 31 | "copy-webpack-plugin": "^6.0.3", 32 | "css-loader": "^3.6.0", 33 | "eslint": "^7.25.0", 34 | "eslint-config-airbnb-base": "^14.2.1", 35 | "eslint-plugin-codeceptjs": "^1.3.0", 36 | "eslint-plugin-import": "^2.22.1", 37 | "html-webpack-plugin": "^4.3.0", 38 | "imagemin-mozjpeg": "^9.0.0", 39 | "imagemin-pngquant": "^9.0.2", 40 | "imagemin-webpack-plugin": "^2.4.2", 41 | "jasmine-ajax": "^4.0.0", 42 | "jasmine-core": "^3.5.0", 43 | "karma": "^5.1.0", 44 | "karma-chrome-launcher": "^3.1.0", 45 | "karma-firefox-launcher": "^1.3.0", 46 | "karma-jasmine": "^3.3.1", 47 | "karma-sourcemap-loader": "^0.3.7", 48 | "karma-webpack": "^4.0.2", 49 | "playwright": "^1.11.0", 50 | "sharp": "^0.28.2", 51 | "style-loader": "^1.2.1", 52 | "webpack": "^4.43.0", 53 | "webpack-bundle-analyzer": "^4.4.1", 54 | "webpack-cli": "^3.3.12", 55 | "webpack-dev-server": "^3.11.0", 56 | "webpack-merge": "^5.0.9", 57 | "whatwg-fetch": "^3.2.0", 58 | "workbox-webpack-plugin": "^6.1.5" 59 | }, 60 | "dependencies": { 61 | "idb": "^6.0.0", 62 | "lazysizes": "^5.3.2", 63 | "regenerator-runtime": "^0.13.5", 64 | "sweetalert2": "^10.16.7", 65 | "workbox-core": "^6.1.5", 66 | "workbox-expiration": "^6.1.5", 67 | "workbox-precaching": "^6.1.5", 68 | "workbox-routing": "^6.1.5", 69 | "workbox-strategies": "^6.1.5", 70 | "workbox-window": "^6.1.5" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/scripts/views/templates/resto-detail.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable indent */ 2 | import CONFIG from '../../global/config'; 3 | 4 | const restoDetail = (resto) => ` 5 |
6 |
7 | ${resto.name} 10 |
11 | 12 | 41 | 42 |

Menu

43 | 44 |
45 |
46 |

Food

47 |
    48 | ${resto.menus.foods 49 | .map( 50 | (food, i) => ` 51 |
  • ${i + 1}) ${food.name}

  • 52 | `, 53 | ) 54 | .join('')} 55 |
      56 |
57 | 58 |
59 |

Drink

60 |
    61 | ${resto.menus.drinks 62 | .map( 63 | (drink, i) => ` 64 |
  • ${i + 1}) ${drink.name}

  • 65 | `, 66 | ) 67 | .join('')} 68 |
      69 |
70 |
71 | 72 |

Reviews

73 | 74 |
75 | ${resto.customerReviews 76 | .map( 77 | (review) => ` 78 |
79 |
80 |

${review.name}

81 | 82 |

${review.date}

83 |
84 | 85 |
86 | ${review.review} 87 |
88 |
89 | `, 90 | ) 91 | .join('')} 92 |
93 |
94 | `; 95 | 96 | export default restoDetail; 97 | -------------------------------------------------------------------------------- /src/scripts/utils/sw.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | /* eslint-disable implicit-arrow-linebreak */ 3 | import 'regenerator-runtime/runtime'; 4 | import { setCacheNameDetails } from 'workbox-core'; 5 | import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'; 6 | import { registerRoute } from 'workbox-routing'; 7 | import { 8 | StaleWhileRevalidate, 9 | CacheFirst, 10 | NetworkFirst, 11 | } from 'workbox-strategies'; 12 | import { ExpirationPlugin } from 'workbox-expiration'; 13 | 14 | setCacheNameDetails({ 15 | prefix: 'resto-app', 16 | suffix: 'v1', 17 | precache: 'precache', 18 | runtime: 'runtime', 19 | }); 20 | 21 | precacheAndRoute(self.__WB_MANIFEST); 22 | 23 | // Cache page navigations (html) with a Network First strategy 24 | registerRoute( 25 | // Check to see if the request is a navigation to a new page 26 | ({ request }) => request.mode === 'navigate', 27 | // Use a Network First caching strategy 28 | new NetworkFirst({ 29 | // Put all cached files in a cache named 'pages' 30 | cacheName: 'my-pages-cache', 31 | }), 32 | ); 33 | 34 | // cache dynamic routes (API) when the user visits the page that calls API 35 | registerRoute( 36 | /^https:\/\/restaurant-api\.dicoding\.dev\/(?:(list|detail))/, 37 | new NetworkFirst({ 38 | cacheName: 'dicoding-restaurant-api-cache', 39 | plugins: [ 40 | // Don't cache more than 100 items, and expire them after 30 days 41 | new ExpirationPlugin({ 42 | maxAgeSeconds: 60 * 60 * 24 * 30, 43 | maxEntries: 100, 44 | }), 45 | ], 46 | }), 47 | ); 48 | 49 | // Cache images with a Cache First strategy 50 | registerRoute( 51 | ({ request }) => request.destination === 'image', 52 | new CacheFirst({ 53 | cacheName: 'my-image-cache', 54 | plugins: [ 55 | // Don't cache more than 50 items, and expire them after 30 days 56 | new ExpirationPlugin({ 57 | maxAgeSeconds: 60 * 60 * 24 * 30, 58 | maxEntries: 50, 59 | }), 60 | ], 61 | }), 62 | ); 63 | 64 | // cache font-awesome 65 | registerRoute( 66 | new RegExp( 67 | 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.css', 68 | ), 69 | new CacheFirst({ 70 | cacheName: 'my-font-awesome-css-cache', 71 | }), 72 | ); 73 | 74 | // cache fonts request 75 | registerRoute( 76 | ({ url }) => 77 | url.origin === 'https://fonts.googleapis.com' || 78 | url.origin === 'https://fonts.gstatic.com', 79 | new StaleWhileRevalidate({ 80 | cacheName: 'my-google-fonts-cache', 81 | // Don't cache more than 50 items 82 | plugins: [new ExpirationPlugin({ maxEntries: 50 })], 83 | }), 84 | ); 85 | 86 | // Cache CSS, JS, and Web Worker requests with a Stale While Revalidate strategy 87 | registerRoute( 88 | ({ request }) => 89 | request.destination === 'style' || 90 | request.destination === 'script' || 91 | request.destination === 'worker', 92 | // Use a Stale While Revalidate caching strategy 93 | new StaleWhileRevalidate({ 94 | // Put all cached files in a cache named 'assets' 95 | cacheName: 'my-assets-cache', 96 | }), 97 | ); 98 | 99 | cleanupOutdatedCaches(); 100 | 101 | self.addEventListener('message', (event) => { 102 | if (event.data && event.data.type === 'SKIP_WAITING') { 103 | self.skipWaiting(); 104 | } 105 | }); 106 | -------------------------------------------------------------------------------- /src/scripts/views/pages/detail.js: -------------------------------------------------------------------------------- 1 | import UrlParser from '../../routes/url-parser'; 2 | import Spinner from '../templates/spinner'; 3 | import RestaurantSource from '../../data/resto-source'; 4 | import restoDetail from '../templates/resto-detail'; 5 | import LikeButtonPresenter from '../../utils/like-button-presenter'; 6 | import PostReview from '../../utils/post-review'; 7 | import { initSwalError } from '../../utils/swal-initiator'; 8 | import { sendDataToWebsocket } from '../../utils/websocket-initiator'; 9 | import favRestoIdb from '../../data/resto-idb'; 10 | 11 | const Detail = { 12 | async render() { 13 | return ` 14 |
15 |
16 | 17 |
18 | 19 |
20 |

Resto Detail

21 | 22 |
23 | 24 |
25 |
26 |
27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 | 37 |
38 |
39 |
40 |
41 | `; 42 | }, 43 | 44 | // Fungsi ini akan dipanggil setelah render() 45 | async afterRender() { 46 | const url = UrlParser.parseActiveUrlWithoutCombiner(); 47 | 48 | const loading = document.querySelector('#loading'); 49 | const mainContainer = document.querySelector('#main-container'); 50 | const detailContainer = document.querySelector('#detail-resto'); 51 | 52 | // change main display to spinner 53 | mainContainer.style.display = 'none'; 54 | loading.innerHTML = Spinner(); 55 | 56 | try { 57 | const data = await RestaurantSource.getRestaurantDetail(url.id); 58 | 59 | // use the detail data 60 | console.info(data); 61 | detailContainer.innerHTML += restoDetail(data.restaurant); 62 | 63 | // init like button 64 | LikeButtonPresenter.init({ 65 | data, 66 | favRestoIdb, 67 | likeButtonContainer: document.querySelector('#likeButtonContainer'), 68 | }); 69 | 70 | // change spinner display to main 71 | mainContainer.style.display = 'block'; 72 | loading.style.display = 'none'; 73 | 74 | // review form 75 | const btnSubmitReview = document.querySelector('#submit-review'); 76 | const nameInput = document.querySelector('#name-input'); 77 | const reviewInput = document.querySelector('#review-input'); 78 | 79 | btnSubmitReview.addEventListener('click', async (e) => { 80 | e.preventDefault(); 81 | 82 | // POST review 83 | await PostReview(url, nameInput.value, reviewInput.value); 84 | 85 | // Send message to websocket server 86 | sendDataToWebsocket({ 87 | name: nameInput.value, 88 | review: reviewInput.value, 89 | }); 90 | 91 | // clear form input 92 | nameInput.value = ''; 93 | reviewInput.value = ''; 94 | }); 95 | } catch (err) { 96 | console.error(err); 97 | 98 | mainContainer.style.display = 'block'; 99 | loading.style.display = 'none'; 100 | detailContainer.innerHTML = `Error: ${err.message}`; 101 | initSwalError(err.message); 102 | } 103 | }, 104 | }; 105 | 106 | export default Detail; 107 | -------------------------------------------------------------------------------- /src/styles/resto-detail.css: -------------------------------------------------------------------------------- 1 | /* DETAIL */ 2 | .detail { 3 | display: grid; 4 | grid-gap: 1.545em; 5 | font-size: 1em; 6 | margin-top: 1em; 7 | } 8 | 9 | .detail-img { 10 | width: 100%; 11 | height: 370px; 12 | object-fit: cover; 13 | object-position: center; 14 | } 15 | 16 | .detail-name-address-rating { 17 | display: inline-block; 18 | } 19 | 20 | .icon-primary { 21 | color: var(--btn); 22 | } 23 | 24 | .detail h3 { 25 | font-size: 1.5em; 26 | padding: 1em 0 0 0; 27 | background-color: var(--primary-color); 28 | font-weight: bold; 29 | } 30 | 31 | .detail-desc { 32 | text-align: justify; 33 | color: darkgrey; 34 | margin-bottom: 0.5em; 35 | font-style: italic; 36 | } 37 | 38 | .category { 39 | padding: 0.3em 1em; 40 | color: var(--primary-color); 41 | background-color: var(--btn); 42 | margin-right: 2px; 43 | border: 1px solid transparent; 44 | border-radius: 0px 10px 0px 10px; 45 | } 46 | 47 | .detail-menu h4 { 48 | font-weight: bold; 49 | font-size: 1.37em; 50 | padding: 1em; 51 | color: var(--btn); 52 | background-color: var(--primary-color); 53 | } 54 | 55 | .detail-food { 56 | display: flex; 57 | flex-direction: column; 58 | text-align: center; 59 | align-content: center; 60 | } 61 | 62 | .detail-food li { 63 | padding: 0.5em 0; 64 | display: block; 65 | text-decoration: none; 66 | background-color: var(--primary-color); 67 | border: 0.5px solid var(--secondary-color); 68 | border-width: 0 0 0.5px; 69 | } 70 | 71 | .detail-drink { 72 | display: flex; 73 | flex-direction: column; 74 | text-align: center; 75 | align-content: center; 76 | } 77 | 78 | .detail-drink li { 79 | padding: 0.5em 0; 80 | display: block; 81 | text-decoration: none; 82 | background-color: var(--primary-color); 83 | border: 0.5px solid var(--secondary-color); 84 | border-width: 0 0 0.5px; 85 | } 86 | 87 | .detail-info { 88 | display: flex; 89 | flex-direction: column; 90 | justify-content: space-evenly; 91 | padding-left: 0; 92 | margin-bottom: 0; 93 | } 94 | 95 | .detail-info li { 96 | position: relative; 97 | display: block; 98 | padding: 0.6em; 99 | color: var(--font-color); 100 | text-decoration: none; 101 | background-color: var(--primary-color); 102 | } 103 | 104 | /* DETAIL REVIEW */ 105 | .detail-review { 106 | max-width: 100%; 107 | font-size: 1em; 108 | text-align: center; 109 | } 110 | 111 | .detail-review-item { 112 | color: var(--font-color); 113 | background-color: var(--primary-color); 114 | margin-bottom: 1.5em; 115 | padding: 1em 1em; 116 | } 117 | 118 | .detail-review-item:last-child { 119 | margin-bottom: 0; 120 | } 121 | 122 | .review-header { 123 | display: flex; 124 | justify-content: space-between; 125 | align-items: center; 126 | padding: 1em 1em; 127 | color: var(--btn); 128 | border-bottom: 1px solid var(--font-color); 129 | } 130 | 131 | .review-name { 132 | font-weight: bold; 133 | display: flex; 134 | align-items: center; 135 | } 136 | 137 | .review-date { 138 | font-size: 0.8em; 139 | font-weight: lighter; 140 | } 141 | 142 | .review-body { 143 | padding: 1em; 144 | text-align: left; 145 | } 146 | 147 | .category:hover, 148 | .detail-food li:hover, 149 | .detail-drink li:hover, 150 | .detail-review-item:hover { 151 | box-shadow: 0px 10px 13px -7px var(--secondary-color), 152 | 5px 5px 15px 5px var(--secondary-color); 153 | } 154 | 155 | /* FORM */ 156 | form { 157 | margin: 2em 0; 158 | padding: 1.5em; 159 | border: 3px dotted var(--btn-hover); 160 | border-radius: 0 15px 0 15px; 161 | } 162 | 163 | .form-control { 164 | display: block; 165 | width: 100%; 166 | min-height: 1em; 167 | padding: 0.3em; 168 | font-size: 1em; 169 | font-weight: normal; 170 | color: var(--font-color); 171 | background-color: #fff; 172 | appearance: none; 173 | outline: none; 174 | border: 1px solid transparent; 175 | border-bottom: 1px solid var(--secondary-color); 176 | } 177 | 178 | .form-control:hover { 179 | border-bottom: 1px solid var(--btn); 180 | } 181 | 182 | .form-label { 183 | padding-bottom: 10px; 184 | font-weight: bold; 185 | font-style: normal; 186 | font-size: small; 187 | color: var(--btn); 188 | } 189 | 190 | .mb-3 { 191 | margin-bottom: 1.3em; 192 | margin-top: 0.5em; 193 | } 194 | 195 | /* LIKE BUTOON */ 196 | .like { 197 | font-size: 18px; 198 | background-color: var(--btn); 199 | color: white; 200 | border: 0; 201 | border-radius: 50%; 202 | width: 55px; 203 | height: 55px; 204 | cursor: pointer; 205 | display: flex; 206 | align-items: center; 207 | justify-content: center; 208 | position: fixed; 209 | bottom: 16px; 210 | right: 16px; 211 | z-index: 10; 212 | } 213 | 214 | .like:hover { 215 | background-color: var(--btn-hover); 216 | } 217 | -------------------------------------------------------------------------------- /src/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | /* Document 3 | ========================================================================== */ 4 | /** 5 | * 1. Correct the line height in all browsers. 6 | * 2. Prevent adjustments of font size after orientation changes in iOS. 7 | */ 8 | html { 9 | line-height: 1.15; 10 | /* 1 */ 11 | -webkit-text-size-adjust: 100%; 12 | /* 2 */ 13 | } 14 | /* Sections 15 | ========================================================================== */ 16 | /** 17 | * Remove the margin in all browsers. 18 | */ 19 | body { 20 | margin: 0; 21 | } 22 | /** 23 | * Render the `main` element consistently in IE. 24 | */ 25 | main { 26 | display: block; 27 | } 28 | /** 29 | * Correct the font size and margin on `h1` elements within `section` and 30 | * `article` contexts in Chrome, Firefox, and Safari. 31 | */ 32 | h1 { 33 | font-size: 2em; 34 | margin: 0.67em 0; 35 | } 36 | /* Grouping content 37 | ========================================================================== */ 38 | /** 39 | * 1. Add the correct box sizing in Firefox. 40 | * 2. Show the overflow in Edge and IE. 41 | */ 42 | hr { 43 | box-sizing: content-box; 44 | /* 1 */ 45 | height: 0; 46 | /* 1 */ 47 | overflow: visible; 48 | /* 2 */ 49 | } 50 | /** 51 | * 1. Correct the inheritance and scaling of font size in all browsers. 52 | * 2. Correct the odd `em` font sizing in all browsers. 53 | */ 54 | pre { 55 | font-family: monospace, monospace; 56 | /* 1 */ 57 | font-size: 1em; 58 | /* 2 */ 59 | } 60 | /* Text-level semantics 61 | ========================================================================== */ 62 | /** 63 | * Remove the gray background on active links in IE 10. 64 | */ 65 | a { 66 | background-color: transparent; 67 | } 68 | /** 69 | * 1. Remove the bottom border in Chrome 57- 70 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 71 | */ 72 | abbr[title] { 73 | border-bottom: none; 74 | /* 1 */ 75 | text-decoration: underline; 76 | /* 2 */ 77 | text-decoration: underline dotted; 78 | /* 2 */ 79 | } 80 | /** 81 | * Add the correct font weight in Chrome, Edge, and Safari. 82 | */ 83 | b, 84 | strong { 85 | font-weight: bolder; 86 | } 87 | /** 88 | * 1. Correct the inheritance and scaling of font size in all browsers. 89 | * 2. Correct the odd `em` font sizing in all browsers. 90 | */ 91 | code, 92 | kbd, 93 | samp { 94 | font-family: monospace, monospace; 95 | /* 1 */ 96 | font-size: 1em; 97 | /* 2 */ 98 | } 99 | /** 100 | * Add the correct font size in all browsers. 101 | */ 102 | small { 103 | font-size: 80%; 104 | } 105 | /** 106 | * Prevent `sub` and `sup` elements from affecting the line height in 107 | * all browsers. 108 | */ 109 | sub, 110 | sup { 111 | font-size: 75%; 112 | line-height: 0; 113 | position: relative; 114 | vertical-align: baseline; 115 | } 116 | sub { 117 | bottom: -0.25em; 118 | } 119 | sup { 120 | top: -0.5em; 121 | } 122 | /* Embedded content 123 | ========================================================================== */ 124 | /** 125 | * Remove the border on images inside links in IE 10. 126 | */ 127 | img { 128 | border-style: none; 129 | } 130 | /* Forms 131 | ========================================================================== */ 132 | /** 133 | * 1. Change the font styles in all browsers. 134 | * 2. Remove the margin in Firefox and Safari. 135 | */ 136 | button, 137 | input, 138 | optgroup, 139 | select, 140 | textarea { 141 | font-family: inherit; 142 | /* 1 */ 143 | font-size: 100%; 144 | /* 1 */ 145 | line-height: 1.15; 146 | /* 1 */ 147 | margin: 0; 148 | /* 2 */ 149 | } 150 | /** 151 | * Show the overflow in IE. 152 | * 1. Show the overflow in Edge. 153 | */ 154 | button, 155 | input { 156 | /* 1 */ 157 | overflow: visible; 158 | } 159 | /** 160 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 161 | * 1. Remove the inheritance of text transform in Firefox. 162 | */ 163 | button, 164 | select { 165 | /* 1 */ 166 | text-transform: none; 167 | } 168 | /** 169 | * Correct the inability to style clickable types in iOS and Safari. 170 | */ 171 | button, 172 | [type='button'], 173 | [type='reset'], 174 | [type='submit'] { 175 | -webkit-appearance: button; 176 | } 177 | /** 178 | * Remove the inner border and padding in Firefox. 179 | */ 180 | button::-moz-focus-inner, 181 | [type='button']::-moz-focus-inner, 182 | [type='reset']::-moz-focus-inner, 183 | [type='submit']::-moz-focus-inner { 184 | border-style: none; 185 | padding: 0; 186 | } 187 | /** 188 | * Restore the focus styles unset by the previous rule. 189 | */ 190 | button:-moz-focusring, 191 | [type='button']:-moz-focusring, 192 | [type='reset']:-moz-focusring, 193 | [type='submit']:-moz-focusring { 194 | outline: 1px dotted ButtonText; 195 | } 196 | /** 197 | * Correct the padding in Firefox. 198 | */ 199 | fieldset { 200 | padding: 0.35em 0.75em 0.625em; 201 | } 202 | /** 203 | * 1. Correct the text wrapping in Edge and IE. 204 | * 2. Correct the color inheritance from `fieldset` elements in IE. 205 | * 3. Remove the padding so developers are not caught out when they zero out 206 | * `fieldset` elements in all browsers. 207 | */ 208 | legend { 209 | box-sizing: border-box; 210 | /* 1 */ 211 | color: inherit; 212 | /* 2 */ 213 | display: table; 214 | /* 1 */ 215 | max-width: 100%; 216 | /* 1 */ 217 | padding: 0; 218 | /* 3 */ 219 | white-space: normal; 220 | /* 1 */ 221 | } 222 | /** 223 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 224 | */ 225 | progress { 226 | vertical-align: baseline; 227 | } 228 | /** 229 | * Remove the default vertical scrollbar in IE 10+. 230 | */ 231 | textarea { 232 | overflow: auto; 233 | } 234 | /** 235 | * 1. Add the correct box sizing in IE 10. 236 | * 2. Remove the padding in IE 10. 237 | */ 238 | [type='checkbox'], 239 | [type='radio'] { 240 | box-sizing: border-box; 241 | /* 1 */ 242 | padding: 0; 243 | /* 2 */ 244 | } 245 | /** 246 | * Correct the cursor style of increment and decrement buttons in Chrome. 247 | */ 248 | [type='number']::-webkit-inner-spin-button, 249 | [type='number']::-webkit-outer-spin-button { 250 | height: auto; 251 | } 252 | /** 253 | * 1. Correct the odd appearance in Chrome and Safari. 254 | * 2. Correct the outline style in Safari. 255 | */ 256 | [type='search'] { 257 | -webkit-appearance: textfield; 258 | /* 1 */ 259 | outline-offset: -2px; 260 | /* 2 */ 261 | } 262 | /** 263 | * Remove the inner padding in Chrome and Safari on macOS. 264 | */ 265 | [type='search']::-webkit-search-decoration { 266 | -webkit-appearance: none; 267 | } 268 | /** 269 | * 1. Correct the inability to style clickable types in iOS and Safari. 270 | * 2. Change font properties to `inherit` in Safari. 271 | */ 272 | ::-webkit-file-upload-button { 273 | -webkit-appearance: button; 274 | /* 1 */ 275 | font: inherit; 276 | /* 2 */ 277 | } 278 | /* Interactive 279 | ========================================================================== */ 280 | /* 281 | * Add the correct display in Edge, IE 10+, and Firefox. 282 | */ 283 | details { 284 | display: block; 285 | } 286 | /* 287 | * Add the correct display in all browsers. 288 | */ 289 | summary { 290 | display: list-item; 291 | } 292 | /* Misc 293 | ========================================================================== */ 294 | /** 295 | * Add the correct display in IE 10+. 296 | */ 297 | template { 298 | display: none; 299 | } 300 | /** 301 | * Add the correct display in IE 10. 302 | */ 303 | [hidden] { 304 | display: none; 305 | } 306 | --------------------------------------------------------------------------------