├── .angular-cli.json ├── .editorconfig ├── .gitignore ├── .travis.yml ├── .vscode └── tasks.json ├── README.md ├── dev_cert.crt ├── dev_cert.key ├── e2e ├── app.e2e-spec.ts ├── models │ └── product.model.ts ├── store-front.page-object.ts └── tsconfig.e2e.json ├── gulpFile.js ├── karma.conf.js ├── package.json ├── protractor.conf.js ├── src ├── app │ ├── app.component.ts │ ├── app.module.ts │ ├── app.routing.ts │ ├── components │ │ ├── checkout │ │ │ ├── checkout.component.html │ │ │ ├── checkout.component.scss │ │ │ ├── checkout.component.spec.ts │ │ │ └── checkout.component.ts │ │ ├── order-confirmation │ │ │ ├── order-confirmation.component.html │ │ │ ├── order-confirmation.component.spec.ts │ │ │ └── order-confirmation.component.ts │ │ ├── shopping-cart │ │ │ ├── shopping-cart.component.html │ │ │ ├── shopping-cart.component.spec.ts │ │ │ └── shopping-cart.component.ts │ │ └── store-front │ │ │ ├── store-front.component.html │ │ │ ├── store-front.component.scss │ │ │ ├── store-front.component.spec.ts │ │ │ └── store-front.component.ts │ ├── models │ │ ├── cart-item.model.ts │ │ ├── delivery-option.model.ts │ │ ├── ingredient.model.ts │ │ ├── product.model.ts │ │ └── shopping-cart.model.ts │ ├── route-gaurds │ │ ├── populated-cart.route-gaurd.spec.ts │ │ └── populated-cart.route-gaurd.ts │ └── services │ │ ├── caching.service.ts │ │ ├── delivery-options.service.ts │ │ ├── products.service.ts │ │ ├── shopping-cart.service.ts │ │ ├── storage.service.ts │ │ └── tests │ │ ├── delivery-options.service.spec.ts │ │ ├── products.service.spec.ts │ │ └── shopping-cart.service.spec.ts ├── assets │ ├── .gitkeep │ ├── 58552daa-30f6-46fa-a808-f1a1d7667561.jpg │ ├── 7ef3b9dd-5a95-4415-af37-6871d6ff0262.jpg │ ├── 9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f.jpg │ ├── bdcbe438-ac85-4acf-8949-5627fd5b57df.jpg │ ├── d4666802-fd84-476f-9eea-c8dd29cfb633.jpg │ ├── delivery-options.json │ └── products.json ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.scss ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── typings.d.ts ├── tsconfig.json └── tslint.json /.angular-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "project": { 4 | "name": "cmc-markets-code-test" 5 | }, 6 | "apps": [ 7 | { 8 | "root": "src", 9 | "outDir": "dist", 10 | "assets": [ 11 | "assets", 12 | "favicon.ico" 13 | ], 14 | "index": "index.html", 15 | "main": "main.ts", 16 | "polyfills": "polyfills.ts", 17 | "test": "test.ts", 18 | "tsconfig": "tsconfig.app.json", 19 | "testTsconfig": "tsconfig.spec.json", 20 | "prefix": "app", 21 | "styles": [ 22 | "styles.scss" 23 | ], 24 | "scripts": [], 25 | "environmentSource": "environments/environment.ts", 26 | "environments": { 27 | "dev": "environments/environment.ts", 28 | "prod": "environments/environment.prod.ts" 29 | } 30 | } 31 | ], 32 | "e2e": { 33 | "protractor": { 34 | "config": "./protractor.conf.js" 35 | } 36 | }, 37 | "lint": [ 38 | { 39 | "project": "src/tsconfig.app.json" 40 | }, 41 | { 42 | "project": "src/tsconfig.spec.json" 43 | }, 44 | { 45 | "project": "e2e/tsconfig.e2e.json" 46 | } 47 | ], 48 | "test": { 49 | "karma": { 50 | "config": "./karma.conf.js" 51 | } 52 | }, 53 | "defaults": { 54 | "styleExt": "scss", 55 | "component": {} 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | testem.log 34 | /typings 35 | 36 | # e2e 37 | /e2e/*.js 38 | /e2e/*.map 39 | 40 | # System Files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | 4 | language: node_js 5 | 6 | node_js: 7 | - '7' 8 | 9 | before_install: 10 | - export CHROME_BIN=/usr/bin/google-chrome 11 | - export DISPLAY=:99.0 12 | - sh -e /etc/init.d/xvfb start 13 | - sudo apt-get update 14 | - sudo apt-get install -y libappindicator1 fonts-liberation 15 | - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb 16 | - sudo dpkg -i google-chrome*.deb 17 | 18 | before_deploy: 19 | - npm run build 20 | 21 | deploy: 22 | provider: pages 23 | skip_cleanup: true 24 | github_token: $GITHUB_TOKEN # Set in travis-ci.org dashboard 25 | local_dir: dist/ 26 | on: 27 | branch: master 28 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "0.1.0", 5 | "command": "tsc", 6 | "isShellCommand": true, 7 | "args": ["-p", "./src/tsconfig.app.json"], 8 | "showOutput": "silent", 9 | "problemMatcher": "$tsc" 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular (4) - Shopping Basket Example 2 | 3 | 4 | [![Build Status](https://travis-ci.org/jonsamwell/angular-simple-shopping-cart.svg?branch=master)](https://travis-ci.org/jonsamwell/angular-simple-shopping-cart) 5 | 6 | See it in action https://jonsamwell.github.io/angular-simple-shopping-cart/ 7 | 8 | # Architectural Summary 9 | 10 | * Angular 4 application (scaffolded with angular-cli) 11 | * Built around RxJS Observables 12 | * One way data flow and events based processing 13 | * Immutable shopping cart to increase performance by enabling the OnPush change detention strategy that drastically reduces the change subtree Angular needs to process. 14 | * Unit tested via Karma and Jasmine. 15 | * SinonJS used for test mocking. 16 | * Minimal styling with Foundation CSS used as the base framework and SCSS used to process custom styles. 17 | * Basic example of async e2e test using new (async/await) Typescript syntax. 18 | 19 | 20 | # Setup 21 | 22 | Install the npm dependencies 23 | 24 | ```bash 25 | npm install 26 | ``` 27 | 28 | # Build 29 | 30 | ```bash 31 | npm run build 32 | ``` 33 | 34 | # Run Tests 35 | ```bash 36 | npm run test 37 | ``` 38 | 39 | # Run E2E Tests 40 | ```bash 41 | npm run e2e 42 | ``` 43 | 44 | # Serve 45 | 46 | HTTP development server 47 | ```bash 48 | npm run start 49 | ``` 50 | 51 | Then navigate to http://localhost:4200/ 52 | 53 | 54 | 55 | HTTPS development server (note: the development certificate will have to be added as a trusted CA) 56 | ```bash 57 | npm run start:https 58 | ``` 59 | 60 | Then navigate to https://localhost:4200/ 61 | -------------------------------------------------------------------------------- /dev_cert.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEwzCCAqugAwIBAgIJAOX3+8pAnEiEMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 3 | BAMTCWxvY2FsaG9zdDAeFw0xNzA1MDIwNDIxNDlaFw0yNzA0MzAwNDIxNDlaMBQx 4 | EjAQBgNVBAMTCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC 5 | ggIBAPb4Ckc3zIUQzFp/1l6swlsn0FM5KEvs7AO7yYIXHGhkZb2SOJBhmp1kSecb 6 | yM1wvUyYNDuXzlxVKZtp+BKtrES6q7PA17t2KGeCMXPqVD0fyCXJvkvaEQapBDwG 7 | I7fZJIDJdBGUhe66OfQulN+TM3PevtESUVfpY8DQXoTr/XdeT6ekqHcxVdDW6hvY 8 | coHKD5JUTvsguC3XK04RAoTO31OoLfrEKChnurR26+duQ4wojgN4B4RTEqr9cuVZ 9 | p8ygE7TSdcy/bXaX0RLz5/OwFJW8vK809H87BpZgyn+HN64UxgM5cTBIa2104JTJ 10 | W6V08Ewnyp3Cc4Tmd26XRtqKj8z0hpuHWb/Z6JS7FRAbRrnGxvcGHgHWOPeOpFgc 11 | H+pLTXd+NM25qZPKFyzxyOuk2tyOAaRvZ1rcEhLx/3owCC4b2KRItqPHOW+6UITk 12 | CmfYAsXkuLdtJIJ3jAoTWIlFcT4v3khgpoH7sd/N0UscOEAFemLuVoryC8F7MQNj 13 | PxeZpCSF2nMYhf/BGehibAgmJlzbvlDbbup0Q4NbyKwjbA4GlNKMXCP3+96mhNe1 14 | mLfQv1D9+5e2qgk07VlVuIemFz8s7xsAe2k88XfrNltts2GiAL/S6nvd1p1jAz3P 15 | smxCQAQEeyZF7IGilaKPohaHRaoHm0L6FtBSsh/h+pI8eGmNAgMBAAGjGDAWMBQG 16 | A1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAgEAH1v4t+uLPJEj 17 | cQX9z9JTrG1egwdVr6dP06ksAchdbXdwbfNWpJbNG3JqfShjNoSBicnZRnY8lbfn 18 | rcrauIlybOyhd2b9rUTzwi1nasGFd+94DJcwn5nYphHbcP+Ff9f2AlN7+GfmOagw 19 | QFd5Km7k2c+evFiEzCF5DQfNktBuZ5kLNJAOmAqAcMB1hpE+y7igZKAi1Bhcq8LR 20 | e1IqmegbK6KNO0/jBDH5R7xBA7R14wryjy1Fql4YO3z+2RVG4KZo/FOQZ3PW8tw6 21 | JH1tlkfe/aKkCio79G/68hgGyjKc0ba4QmvK9i/vGO0U577aKYKiGuYkrRsUyKvK 22 | n5cncdHMHaNaY9dly5cdKZLjvIMSqfx66D8JOk3PopCFr67JFkTahQ782NFOh3Yf 23 | AgvNAsFIkgUQ0Df8cMGZfkRlkf26i1sQqp52ENVWHOlkNTWNB9Zjf31phWivJbtJ 24 | keURAS7sSZzusVY/7vQBzyyCVUiu77lFI+joITu2O6rcGT6iHepr902fwcuK0DlR 25 | xKVDbL4xaxVhKcvV0ZtOefPpQTJcWOmlJzc+O/oxDpN2jnWLNivXoIV/v2cYiBzI 26 | wX+zg/5EMpChdGENDBEZ/+1GSYTSJAxs4nS9+XVanLHvr282kQwwZtMQlFMpjV46 27 | rHsC/b4sNHX3VR83FC7fsGwBx9Ke4oc= 28 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /dev_cert.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJJwIBAAKCAgEA9vgKRzfMhRDMWn/WXqzCWyfQUzkoS+zsA7vJghccaGRlvZI4 3 | kGGanWRJ5xvIzXC9TJg0O5fOXFUpm2n4Eq2sRLqrs8DXu3YoZ4Ixc+pUPR/IJcm+ 4 | S9oRBqkEPAYjt9kkgMl0EZSF7ro59C6U35Mzc96+0RJRV+ljwNBehOv9d15Pp6So 5 | dzFV0NbqG9hygcoPklRO+yC4LdcrThEChM7fU6gt+sQoKGe6tHbr525DjCiOA3gH 6 | hFMSqv1y5VmnzKATtNJ1zL9tdpfREvPn87AUlby8rzT0fzsGlmDKf4c3rhTGAzlx 7 | MEhrbXTglMlbpXTwTCfKncJzhOZ3bpdG2oqPzPSGm4dZv9nolLsVEBtGucbG9wYe 8 | AdY4946kWBwf6ktNd340zbmpk8oXLPHI66Ta3I4BpG9nWtwSEvH/ejAILhvYpEi2 9 | o8c5b7pQhOQKZ9gCxeS4t20kgneMChNYiUVxPi/eSGCmgfux383RSxw4QAV6Yu5W 10 | ivILwXsxA2M/F5mkJIXacxiF/8EZ6GJsCCYmXNu+UNtu6nRDg1vIrCNsDgaU0oxc 11 | I/f73qaE17WYt9C/UP37l7aqCTTtWVW4h6YXPyzvGwB7aTzxd+s2W22zYaIAv9Lq 12 | e93WnWMDPc+ybEJABAR7JkXsgaKVoo+iFodFqgebQvoW0FKyH+H6kjx4aY0CAwEA 13 | AQKCAgBhR+0Mho86Lxa/4zE208gvDezCi3YzCh0hj1vdsWrQOBPXa3x6aufzbWiq 14 | 70fWnL0EKcQRYUe5GRatkD1WZjDAVeOCh8iyn+VkeGUwarJJ7XXyZJhf2oLwY602 15 | U5jIN3FohXIB5sYm9hYT+DFOK/aNgsUZJ6UBAv73GVzR8P9DgNPRjkuJv9Y00CMh 16 | Ws4oD8a4dhsyUn4aGHKHaq3aUH6pvkp2R8Qlvk5N/bYI0GctE2B1P1d+qRZmYjVa 17 | 4Ej3kmqQxIIAZ7sfhselNow8cjR5kwGj5jEU2NRMcc3yE/o7yRUCeiL6yNwAHpTE 18 | JtjBwOFSri3inJDSXdHXryEKvp/LbsK01iQslhsLZ6qZZUYCMS3T192aQPYoPc3c 19 | I1YHfY32joSphAj5iipH9hA6TpQbNh9DnO8h5VgWfEKqtliHRJVcMSAUFI+6dsTG 20 | H0OEZXmLsLkSQenLor/Rd6WiF/Z6B6DDABww/lDma0kXoyi6UwMhEGDcszAxZo1J 21 | NadR75ewqEn4UgiLlxe/htjxG5v6aa+1WzJ3TdPuGEP5lgS/0nf9J1GxKt5X1PvP 22 | c7cNlzryu76KJDLhGBid/sk4cSdD2Vnm6a5rCZvhArL45wiNzTQkQf1C/cgbdZ7d 23 | eJkG99Z8kwn7jlqntfssMz0+pNmS14zsyBY3CwNlOtHQj2DKYQKCAQEA/L7p7/MY 24 | 8NODsEVgrKlAwb1EFWwe+T9zNfI/MsQqVvsli39sj55o/oyRe/bhWIXLIYDjKztK 25 | vNTSvHi1qD+K6aLIfNUGIiVZ5CyXVe90avzmKcOBjXSoMZ5O8lqmJjLAH3htS3X3 26 | Jv4CYye9ma4cSYVDHF0GsFckEDKcwDpnx+8C50s8zP+mRz9t3Xo8zc8Rls1fxODh 27 | UU22XjUJumFpi+LkG++8jL4OUIbqEeL+uFFpuiQ3dOAkhM2XjHTDTAEE6xKudVp3 28 | pNC/YbxAngNMG819dxBYMavipTsyJzIXqEYqnF/6tLHoAvxwqfOFGi2tyn6BznlP 29 | uuLyqCmj5jpgRQKCAQEA+iYVwvwekpy44CZHBS+/5IA+QNZb0lmMZjOFW9rp4F/i 30 | lB7nKXGZ5AH7wg7s2PJ88tH0pJCrc+X+zg3NyuC6oNM6qjEJZ0eF5n2bG26U+CAS 31 | gFARTRPjEuSJOS14XNnbkVZKTMz3sQiBtwafNXGFgXROL4yuDhwju4K7YC8vGeAM 32 | Y00DELM/CB2CasJ99aEQclk+zmwiqMYkS5xSq4osS+PYkdqu1WKJ0yeBNv9w2/i/ 33 | ZyHXfP3BPGik2GAqjMOfDfgmBRnbJwdMVHtyi133JE2QTYmBuOOauRPjPjWA1NPB 34 | Sz4zNbIOYu3aI91+wIVXCPFbEViM0/Ww1p5pV4csqQKCAQBoR9z9w69mrd6HvBhH 35 | JQ4y4YRV0mZ1MFi4yVqJ96YAfV7gT5LbLuDjJdxg6VvQymMlT6hrDeuoPac1XBiX 36 | cqA+BYvy9XGyZPbRzhQiwMmn2vCcCq5JTviWgFrSY9RprkbWtTljCSkQTX1uq9bu 37 | sYe6TeGCsl8wIsQeasOCDJcFRvhLth8/9bsFaoZJ+0VbJTR8o///m0lb4lR8SiXZ 38 | YJfLv7GeVSvWZhRB5WhuONof8ndM9eRrtI9cu/brXMG3ejQtSWfxw4HZ3scX7DQ8 39 | /d9JGV/K4FODKwg4ZFQtF93q8AhkvLUUGNNBaCaT+IXSZ0ZtZgToy+S5lynHeGbH 40 | dw6BAoIBAB+Wt6DL3cB98gq8SrOo9/6PA02ExEun60bssqaK2oXvFdnGnUJqihh0 41 | 96nl4Jr05Dp1sQMnEb+tB6RVseswveCZTAs57goQyiP1MKUiLcW3px50/fpRzJcS 42 | LRH/X/e5uzR7RR61s4GzpU3LlEdXcpiKa2Utyr0VaJ3BQJBA5R3LYUUY8I4nVIpd 43 | z55TuTxPfpgyFPBUT3woqWSy9O2coUNkHnEswG9J5kW382VSlJnyq5kGeQPbt489 44 | V7PLURQ9j9Rfxc1XGomvLkBs5mYbE56N/O8Nskf61gsRK90rPH2j0AEEdcsOFSsB 45 | Bk8JJXyCWh9S+0ERgZcyq4YusvbOpyECggEAOl9aUXnL2656N7eIDk8o6FJcYo84 46 | 1RBaSQMdleuEnl4luQAsVjHRNZFOgN7iEcKArAq+cr3HGxgQUSWM0wZVXQ8ae5cp 47 | H9P7SeoyZYKEJAZO1MAQPzVqKrjlD4gl0Joy10EKltOiHExFZfKu7uTngiZPZfeA 48 | EnTeX1KVk0/3IoOrJyPzkH5cFGDguBkX3jFPF/nogLryu9HLwqfdnHaftCVB3+9t 49 | fXRRNnAZvkEnDO1L2w+HlLc9qfN06dCExjmZOQCw3aGuDo/HJ1xRmW4wBHswXKVg 50 | 09eP0VZtEV9JWMCucee1+riJsn/e2iCRczcG9IcKvLHbZTscSYqHzVZw3w== 51 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { StoreFrontPageObject } from "./store-front.page-object"; 2 | 3 | describe("Store front", () => { 4 | let page: StoreFrontPageObject; 5 | 6 | beforeEach(() => { 7 | page = new StoreFrontPageObject(); 8 | }); 9 | 10 | it("should display products", async () => { 11 | await page.navigateTo(); 12 | const products = await page.getProducts(); 13 | expect(products.length).toEqual(5); 14 | expect(products[0].name).toEqual("Greens"); 15 | expect(products[0].price).toEqual(3.5); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /e2e/models/product.model.ts: -------------------------------------------------------------------------------- 1 | export class Product { 2 | public name: string; 3 | public price: number; 4 | } 5 | -------------------------------------------------------------------------------- /e2e/store-front.page-object.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element, promise } from "protractor"; 2 | import { Product } from "./models/product.model"; 3 | 4 | export class StoreFrontPageObject { 5 | public navigateTo(): promise.Promise { 6 | return browser.get("/"); 7 | } 8 | 9 | public async getProducts(): promise.Promise { 10 | const defer = promise.defer(); 11 | try { 12 | const results = await element.all(by.css(".product-container")) 13 | .map(async (el) => { 14 | const product = new Product(); 15 | product.name = await el.element(by.css(".js-product-name")).getText(); 16 | const priceTxt = await el.element(by.css(".js-product-price")).getText(); 17 | product.price = parseFloat(priceTxt.replace(/[a-z,£: ]/gi, "")); 18 | return product; 19 | }); 20 | defer.fulfill(results); 21 | } catch (er) { 22 | defer.reject(er); 23 | } 24 | return defer.promise; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types":[ 8 | "jasmine", 9 | "node" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /gulpFile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var replace = require('gulp-replace'); 3 | var htmlmin = require('gulp-htmlmin'); 4 | 5 | gulp.task('js:minify', function () { 6 | gulp.src(["./dist/main.*.js", "./dist/polyfills.*.js", "./dist/inline.*.js"]) 7 | .pipe(replace(/\/\*([\s\S]*?)\*\/[\s\S]?/g, "")) 8 | .pipe(gulp.dest("./dist")); 9 | }); 10 | 11 | gulp.task("html:basehref", function () { 12 | return gulp.src('dist/*.html') 13 | .pipe(replace("", "")) 14 | .pipe(gulp.dest('./dist')); 15 | }); 16 | 17 | gulp.task("html:minify", ["html:basehref"], function () { 18 | return gulp.src('dist/*.html') 19 | .pipe(htmlmin({ collapseWhitespace: true })) 20 | .pipe(gulp.dest('./dist')); 21 | }); 22 | 23 | gulp.task("default", ["js:minify", "html:minify"]); 24 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/0.13/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular/cli'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular/cli/plugins/karma') 14 | ], 15 | client:{ 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | files: [ 19 | { pattern: './src/test.ts', watched: false } 20 | ], 21 | preprocessors: { 22 | './src/test.ts': ['@angular/cli'] 23 | }, 24 | mime: { 25 | 'text/x-typescript': ['ts','tsx'] 26 | }, 27 | coverageIstanbulReporter: { 28 | reports: [ 'html', 'lcovonly' ], 29 | fixWebpackSourcePaths: true 30 | }, 31 | angularCli: { 32 | environment: 'dev' 33 | }, 34 | reporters: config.angularCli && config.angularCli.codeCoverage 35 | ? ['progress', 'coverage-istanbul'] 36 | : ['progress', 'kjhtml'], 37 | port: 9876, 38 | colors: true, 39 | logLevel: config.LOG_INFO, 40 | autoWatch: true, 41 | browsers: ['Chrome'], 42 | singleRun: true 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-simple-shopping-cart", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "engines": { 6 | "node": ">= 7.0.0", 7 | "npm": ">= 3.10.8" 8 | }, 9 | "scripts": { 10 | "build": "ng build -prod -aot && gulp", 11 | "start": "ng serve", 12 | "start:https": "ng serve --ssl true --ssl-key ./dev_cert.key --ssl-cert ./dev_cert.crt", 13 | "test": "ng test --watch false", 14 | "e2e": "ng e2e" 15 | }, 16 | "private": true, 17 | "dependencies": { 18 | "@angular/common": "^4.0.0", 19 | "@angular/compiler": "^4.0.0", 20 | "@angular/core": "^4.0.0", 21 | "@angular/forms": "^4.0.0", 22 | "@angular/http": "^4.0.0", 23 | "@angular/platform-browser": "^4.0.0", 24 | "@angular/platform-browser-dynamic": "^4.0.0", 25 | "@angular/router": "^4.1.2", 26 | "core-js": "^2.4.1", 27 | "intl": "^1.2.5", 28 | "node-sass": "^4.5.2", 29 | "rxjs": "^5.1.0", 30 | "zone.js": "^0.8.4" 31 | }, 32 | "devDependencies": { 33 | "@angular/cli": "1.0.2", 34 | "@angular/compiler-cli": "^4.0.0", 35 | "@types/jasmine": "2.5.38", 36 | "@types/node": "~6.0.60", 37 | "@types/sinon": "^2.2.1", 38 | "codelyzer": "~2.0.0", 39 | "gulp": "^3.9.1", 40 | "gulp-htmlmin": "^3.0.0", 41 | "gulp-replace": "^0.5.4", 42 | "jasmine-core": "~2.5.2", 43 | "jasmine-spec-reporter": "~3.2.0", 44 | "karma": "~1.4.1", 45 | "karma-chrome-launcher": "~2.0.0", 46 | "karma-cli": "~1.0.1", 47 | "karma-coverage-istanbul-reporter": "^0.2.0", 48 | "karma-jasmine": "~1.1.0", 49 | "karma-jasmine-html-reporter": "^0.2.2", 50 | "protractor": "~5.1.0", 51 | "sinon": "^2.2.0", 52 | "ts-node": "~2.0.0", 53 | "tslint": "~4.5.0", 54 | "typescript": "~2.2.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './e2e/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'https://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | beforeLaunch: function() { 23 | require('ts-node').register({ 24 | project: 'e2e/tsconfig.e2e.json' 25 | }); 26 | }, 27 | onPrepare() { 28 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: "app-root", 5 | template: "" 6 | }) 7 | export class AppComponent {} 8 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { FormsModule } from "@angular/forms"; 3 | import { HttpModule } from "@angular/http"; 4 | import { BrowserModule } from "@angular/platform-browser"; 5 | 6 | import { AppComponent } from "./app.component"; 7 | import { AppRoutingModule } from "./app.routing"; 8 | import { CheckoutComponent } from "./components/checkout/checkout.component"; 9 | import { OrderConfirmationComponent } from "./components/order-confirmation/order-confirmation.component"; 10 | import { ShoppingCartComponent } from "./components/shopping-cart/shopping-cart.component"; 11 | import { StoreFrontComponent } from "./components/store-front/store-front.component"; 12 | import { PopulatedCartRouteGuard } from "./route-gaurds/populated-cart.route-gaurd"; 13 | import { DeliveryOptionsDataService } from "./services/delivery-options.service"; 14 | import { ProductsDataService } from "./services/products.service"; 15 | import { ShoppingCartService } from "./services/shopping-cart.service"; 16 | import { LocalStorageServie, StorageService } from "./services/storage.service"; 17 | 18 | @NgModule({ 19 | bootstrap: [AppComponent], 20 | declarations: [ 21 | AppComponent, 22 | ShoppingCartComponent, 23 | StoreFrontComponent, 24 | CheckoutComponent, 25 | OrderConfirmationComponent 26 | ], 27 | imports: [ 28 | BrowserModule, 29 | FormsModule, 30 | HttpModule, 31 | AppRoutingModule 32 | ], 33 | providers: [ 34 | ProductsDataService, 35 | DeliveryOptionsDataService, 36 | PopulatedCartRouteGuard, 37 | LocalStorageServie, 38 | { provide: StorageService, useClass: LocalStorageServie }, 39 | { 40 | deps: [StorageService, ProductsDataService, DeliveryOptionsDataService], 41 | provide: ShoppingCartService, 42 | useClass: ShoppingCartService 43 | } 44 | ] 45 | }) 46 | export class AppModule { } 47 | -------------------------------------------------------------------------------- /src/app/app.routing.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { RouterModule } from "@angular/router"; 3 | 4 | import { CheckoutComponent } from "./components/checkout/checkout.component"; 5 | import { OrderConfirmationComponent } from "./components/order-confirmation/order-confirmation.component"; 6 | import { StoreFrontComponent } from "./components/store-front/store-front.component"; 7 | import { PopulatedCartRouteGuard } from "./route-gaurds/populated-cart.route-gaurd"; 8 | 9 | @NgModule({ 10 | exports: [RouterModule], 11 | imports: [ 12 | RouterModule.forRoot([ 13 | { 14 | canActivate: [PopulatedCartRouteGuard], 15 | component: CheckoutComponent, 16 | path: "checkout" 17 | }, 18 | { 19 | canActivate: [PopulatedCartRouteGuard], 20 | component: OrderConfirmationComponent, 21 | path: "confirmed" 22 | }, 23 | { 24 | component: StoreFrontComponent, 25 | path: "**" 26 | }]) 27 | ] 28 | }) 29 | export class AppRoutingModule { } 30 | -------------------------------------------------------------------------------- /src/app/components/checkout/checkout.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | shopping_basket 5 | Checkout 6 |

7 |
8 |
9 |

10 | Order Total 11 | {{(cart | async).grossTotal | currency:'GBP':true}} 12 |

13 |
14 |
15 |
Please select a delivery option...
17 | Purchase Order 20 |
21 |
22 |
23 |
24 |
25 |

Delivery 1 of 1

26 | Dispatching to the UK.... 27 |
29 |
30 | 32 |
33 |
34 | {{item.product.name}} 35 |

{{item.product.description}}

36 |
37 |
38 |

{{item.quantity}} x {{item.product.price | currency:'GBP':true}}

39 |
40 |
41 |

{{item.totalCost | currency:'GBP':true}}

42 |
43 |
44 |
45 |
46 |

Delivery Options

47 | 48 |
50 |
51 | 56 |
57 |
58 | 59 |
60 |
61 |

{{option.price | currency:'GBP':true}}

62 |
63 |
64 |

{{option.description}}

65 |
66 |
67 |
68 | 69 | 70 |
71 |
72 | Continue Shopping 74 |
75 |
76 |
77 | -------------------------------------------------------------------------------- /src/app/components/checkout/checkout.component.scss: -------------------------------------------------------------------------------- 1 | .checkout_row { 2 | .product_image { 3 | max-height: 200px; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/app/components/checkout/checkout.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, inject, TestBed } from "@angular/core/testing"; 2 | import { HttpModule } from "@angular/http"; 3 | import { CartItem } from "app/models/cart-item.model"; 4 | import { DeliveryOption } from "app/models/delivery-option.model"; 5 | import { Product } from "app/models/product.model"; 6 | import { ShoppingCart } from "app/models/shopping-cart.model"; 7 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 8 | import { ProductsDataService } from "app/services/products.service"; 9 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 10 | import { LocalStorageServie, StorageService } from "app/services/storage.service"; 11 | import { Observable } from "rxjs/Observable"; 12 | import { Observer } from "rxjs/Observer"; 13 | import * as sinon from "sinon"; 14 | import { CheckoutComponent } from "./checkout.component"; 15 | 16 | const PRODUCT_1 = new Product(); 17 | PRODUCT_1.name = "Product 1"; 18 | PRODUCT_1.id = "1"; 19 | PRODUCT_1.price = 1; 20 | PRODUCT_1.description = "desc1"; 21 | 22 | const PRODUCT_2 = new Product(); 23 | PRODUCT_2.name = "Product 2"; 24 | PRODUCT_2.id = "2"; 25 | PRODUCT_2.price = 2; 26 | PRODUCT_2.description = "desc2"; 27 | 28 | const DELIVERY_OPT_1 = new DeliveryOption(); 29 | DELIVERY_OPT_1.name = "Delivery Option 1"; 30 | DELIVERY_OPT_1.id = "1"; 31 | DELIVERY_OPT_1.price = 1; 32 | 33 | const DELIVERY_OPT_2 = new DeliveryOption(); 34 | DELIVERY_OPT_2.name = "Delivery Option 2"; 35 | DELIVERY_OPT_2.id = "2"; 36 | DELIVERY_OPT_2.price = 2; 37 | 38 | class MockProductDataService extends ProductsDataService { 39 | public all(): Observable { 40 | return Observable.from([[PRODUCT_1, PRODUCT_2]]); 41 | } 42 | } 43 | 44 | // tslint:disable-next-line:max-classes-per-file 45 | class MockDeliveryOptionsDataService extends DeliveryOptionsDataService { 46 | public all(): Observable { 47 | return Observable.from([[DELIVERY_OPT_1, DELIVERY_OPT_2]]); 48 | } 49 | } 50 | 51 | // tslint:disable-next-line:max-classes-per-file 52 | class MockShoppingCartService { 53 | public unsubscriveCalled: boolean = false; 54 | public emptyCalled: boolean = false; 55 | 56 | private subscriptionObservable: Observable; 57 | private subscriber: Observer; 58 | private cart: ShoppingCart = new ShoppingCart(); 59 | 60 | public constructor() { 61 | this.subscriptionObservable = new Observable((observer: Observer) => { 62 | this.subscriber = observer; 63 | observer.next(this.cart); 64 | return () => this.unsubscriveCalled = true; 65 | }); 66 | } 67 | 68 | public get(): Observable { 69 | return this.subscriptionObservable; 70 | } 71 | 72 | public empty(): void { 73 | this.emptyCalled = true; 74 | } 75 | 76 | public dispatchCart(cart: ShoppingCart): void { 77 | this.cart = cart; 78 | if (this.subscriber) { 79 | this.subscriber.next(cart); 80 | } 81 | } 82 | } 83 | 84 | describe("CheckoutComponent", () => { 85 | beforeEach(async(() => { 86 | TestBed.configureTestingModule({ 87 | declarations: [ 88 | CheckoutComponent 89 | ], 90 | imports: [ 91 | HttpModule 92 | ], 93 | providers: [ 94 | { provide: ProductsDataService, useClass: MockProductDataService }, 95 | { provide: DeliveryOptionsDataService, useClass: MockDeliveryOptionsDataService }, 96 | { provide: StorageService, useClass: LocalStorageServie }, 97 | { provide: ShoppingCartService, useClass: MockShoppingCartService } 98 | ] 99 | }).compileComponents(); 100 | })); 101 | 102 | it("should create the component", async(() => { 103 | const fixture = TestBed.createComponent(CheckoutComponent); 104 | const component = fixture.debugElement.componentInstance; 105 | expect(component).toBeTruthy(); 106 | })); 107 | 108 | it("should display all the products in the cart", 109 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => { 110 | const newCart = new ShoppingCart(); 111 | const cartItem = new CartItem(); 112 | cartItem.productId = PRODUCT_1.id; 113 | cartItem.quantity = 2; 114 | newCart.grossTotal = 3; 115 | newCart.items = [cartItem]; 116 | service.dispatchCart(newCart); 117 | const fixture = TestBed.createComponent(CheckoutComponent); 118 | fixture.detectChanges(); 119 | 120 | const component = fixture.debugElement.componentInstance; 121 | const compiled = fixture.debugElement.nativeElement; 122 | const productElements = compiled.querySelectorAll(".checkout_row"); 123 | 124 | expect(productElements.length).toEqual(1); 125 | expect(productElements[0].querySelector(".js-product-name").textContent).toEqual(PRODUCT_1.name); 126 | expect(productElements[0].querySelector(".js-product-desc").textContent).toContain(PRODUCT_1.description); 127 | expect(productElements[0].querySelector(".js-product-costs").textContent) 128 | .toContain(`${cartItem.quantity} x £${PRODUCT_1.price}`); 129 | expect(productElements[0].querySelector(".js-product-total").textContent) 130 | .toContain(PRODUCT_1.price * cartItem.quantity); 131 | }))); 132 | 133 | it("should display all the delivery options", 134 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => { 135 | const fixture = TestBed.createComponent(CheckoutComponent); 136 | fixture.detectChanges(); 137 | 138 | const component = fixture.debugElement.componentInstance; 139 | const compiled = fixture.debugElement.nativeElement; 140 | const deliveryOptions = compiled.querySelectorAll(".delivery-option"); 141 | 142 | expect(deliveryOptions.length).toEqual(2); 143 | expect(deliveryOptions[0].querySelector(".js-option-name").textContent).toEqual(DELIVERY_OPT_1.name); 144 | expect(deliveryOptions[0].querySelector(".js-option-price").textContent).toContain(DELIVERY_OPT_1.price); 145 | expect(deliveryOptions[1].querySelector(".js-option-name").textContent).toEqual(DELIVERY_OPT_2.name); 146 | expect(deliveryOptions[1].querySelector(".js-option-price").textContent).toContain(DELIVERY_OPT_2.price); 147 | }))); 148 | }); 149 | -------------------------------------------------------------------------------- /src/app/components/checkout/checkout.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from "@angular/core"; 2 | import { CartItem } from "app/models/cart-item.model"; 3 | import { DeliveryOption } from "app/models/delivery-option.model"; 4 | import { Product } from "app/models/product.model"; 5 | import { ShoppingCart } from "app/models/shopping-cart.model"; 6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 7 | import { ProductsDataService } from "app/services/products.service"; 8 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 9 | import { Observable } from "rxjs/Observable"; 10 | import { Subscription } from "rxjs/Subscription"; 11 | 12 | interface ICartItemWithProduct extends CartItem { 13 | product: Product; 14 | totalCost: number; 15 | } 16 | 17 | @Component({ 18 | selector: "app-checkout", 19 | styleUrls: ["./checkout.component.scss"], 20 | templateUrl: "./checkout.component.html" 21 | }) 22 | export class CheckoutComponent implements OnInit, OnDestroy { 23 | public deliveryOptions: Observable; 24 | public cart: Observable; 25 | public cartItems: ICartItemWithProduct[]; 26 | public itemCount: number; 27 | 28 | private products: Product[]; 29 | private cartSubscription: Subscription; 30 | 31 | public constructor(private productsService: ProductsDataService, 32 | private deliveryOptionService: DeliveryOptionsDataService, 33 | private shoppingCartService: ShoppingCartService) { 34 | } 35 | 36 | public emptyCart(): void { 37 | this.shoppingCartService.empty(); 38 | } 39 | 40 | public setDeliveryOption(option: DeliveryOption): void { 41 | this.shoppingCartService.setDeliveryOption(option); 42 | } 43 | 44 | public ngOnInit(): void { 45 | this.deliveryOptions = this.deliveryOptionService.all(); 46 | this.cart = this.shoppingCartService.get(); 47 | this.cartSubscription = this.cart.subscribe((cart) => { 48 | this.itemCount = cart.items.map((x) => x.quantity).reduce((p, n) => p + n, 0); 49 | this.productsService.all().subscribe((products) => { 50 | this.products = products; 51 | this.cartItems = cart.items 52 | .map((item) => { 53 | const product = this.products.find((p) => p.id === item.productId); 54 | return { 55 | ...item, 56 | product, 57 | totalCost: product.price * item.quantity }; 58 | }); 59 | }); 60 | }); 61 | } 62 | 63 | public ngOnDestroy(): void { 64 | if (this.cartSubscription) { 65 | this.cartSubscription.unsubscribe(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/components/order-confirmation/order-confirmation.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Thank you for your order, it will be dispatched shortly!

4 |
5 |
6 |
7 |
8 |
9 |
10 | Continue Shopping! 11 |
12 |
13 | -------------------------------------------------------------------------------- /src/app/components/order-confirmation/order-confirmation.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, inject, TestBed } from "@angular/core/testing"; 2 | import { CartItem } from "app/models/cart-item.model"; 3 | import { Product } from "app/models/product.model"; 4 | import { ShoppingCart } from "app/models/shopping-cart.model"; 5 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 6 | import { ProductsDataService } from "app/services/products.service"; 7 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 8 | import { LocalStorageServie, StorageService } from "app/services/storage.service"; 9 | import { Observable } from "rxjs/Observable"; 10 | import { Observer } from "rxjs/Observer"; 11 | import * as sinon from "sinon"; 12 | import { OrderConfirmationComponent } from "./order-confirmation.component"; 13 | 14 | class MockShoppingCartService { 15 | public emptyCalled: boolean = false; 16 | 17 | public empty(): void { 18 | this.emptyCalled = true; 19 | } 20 | } 21 | 22 | describe("OrderConfirmationComponent", () => { 23 | beforeEach(async(() => { 24 | TestBed.configureTestingModule({ 25 | declarations: [ 26 | OrderConfirmationComponent 27 | ], 28 | providers: [ 29 | { provide: ProductsDataService, useValue: sinon.createStubInstance(ProductsDataService) }, 30 | { provide: DeliveryOptionsDataService, useValue: sinon.createStubInstance(DeliveryOptionsDataService) }, 31 | { provide: StorageService, useClass: LocalStorageServie }, 32 | { provide: ShoppingCartService, useClass: MockShoppingCartService } 33 | ] 34 | }).compileComponents(); 35 | })); 36 | 37 | it("should create the component", async(() => { 38 | const fixture = TestBed.createComponent(OrderConfirmationComponent); 39 | const component = fixture.debugElement.componentInstance; 40 | expect(component).toBeTruthy(); 41 | })); 42 | 43 | it("should call empty on shopping cart service when initialised", 44 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => { 45 | const fixture = TestBed.createComponent(OrderConfirmationComponent); 46 | fixture.detectChanges(); 47 | expect(service.emptyCalled).toBeTruthy(); 48 | }))); 49 | }); 50 | -------------------------------------------------------------------------------- /src/app/components/order-confirmation/order-confirmation.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core"; 2 | import { ShoppingCartService } from "../../services/shopping-cart.service"; 3 | 4 | @Component({ 5 | selector: "app-order-confirmation", 6 | templateUrl: "./order-confirmation.component.html" 7 | }) 8 | export class OrderConfirmationComponent implements OnInit { 9 | public constructor(private shoppingCartService: ShoppingCartService) {} 10 | 11 | public ngOnInit(): void { 12 | this.shoppingCartService.empty(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/components/shopping-cart/shopping-cart.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | shopping_basket 5 | Your Shopping Basket 6 |

7 |
8 |
9 |
10 |
11 |
12 |
13 | Sub Total ({{itemCount}} items): 14 | {{(cart | async).grossTotal | currency:'GBP':true}} 15 |
16 |
17 |
18 |
19 |
20 | 25 |
26 |
27 |
28 | or 29 |
30 |
31 |
32 |
33 | 39 |
40 |
41 | -------------------------------------------------------------------------------- /src/app/components/shopping-cart/shopping-cart.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, inject, TestBed } from "@angular/core/testing"; 2 | import { CartItem } from "app/models/cart-item.model"; 3 | import { Product } from "app/models/product.model"; 4 | import { ShoppingCart } from "app/models/shopping-cart.model"; 5 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 6 | import { ProductsDataService } from "app/services/products.service"; 7 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 8 | import { LocalStorageServie, StorageService } from "app/services/storage.service"; 9 | import { Observable } from "rxjs/Observable"; 10 | import { Observer } from "rxjs/Observer"; 11 | import * as sinon from "sinon"; 12 | import { ShoppingCartComponent } from "./shopping-cart.component"; 13 | 14 | class MockShoppingCartService { 15 | public unsubscriveCalled: boolean = false; 16 | public emptyCalled: boolean = false; 17 | private subscriptionObservable: Observable; 18 | private subscriber: Observer; 19 | private cart: ShoppingCart = new ShoppingCart(); 20 | 21 | public constructor() { 22 | this.subscriptionObservable = new Observable((observer: Observer) => { 23 | this.subscriber = observer; 24 | observer.next(this.cart); 25 | return () => this.unsubscriveCalled = true; 26 | }); 27 | } 28 | 29 | public get(): Observable { 30 | return this.subscriptionObservable; 31 | } 32 | 33 | public empty(): void { 34 | this.emptyCalled = true; 35 | } 36 | 37 | public dispatchCart(cart: ShoppingCart): void { 38 | this.cart = cart; 39 | if (this.subscriber) { 40 | this.subscriber.next(cart); 41 | } 42 | } 43 | } 44 | 45 | describe("ShoppingCartComponent", () => { 46 | beforeEach(async(() => { 47 | TestBed.configureTestingModule({ 48 | declarations: [ 49 | ShoppingCartComponent 50 | ], 51 | providers: [ 52 | { provide: ProductsDataService, useValue: sinon.createStubInstance(ProductsDataService) }, 53 | { provide: DeliveryOptionsDataService, useValue: sinon.createStubInstance(DeliveryOptionsDataService) }, 54 | { provide: StorageService, useClass: LocalStorageServie }, 55 | { provide: ShoppingCartService, useClass: MockShoppingCartService } 56 | ] 57 | }).compileComponents(); 58 | })); 59 | 60 | it("should create the component", async(() => { 61 | const fixture = TestBed.createComponent(ShoppingCartComponent); 62 | const component = fixture.debugElement.componentInstance; 63 | expect(component).toBeTruthy(); 64 | })); 65 | 66 | it("should render gross total of shopping cart", 67 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => { 68 | const fixture = TestBed.createComponent(ShoppingCartComponent); 69 | fixture.detectChanges(); 70 | const compiled = fixture.debugElement.nativeElement; 71 | expect(compiled.querySelector(".js-cart-total").textContent).toContain("£0.00"); 72 | 73 | const newCart = new ShoppingCart(); 74 | newCart.grossTotal = 1.5; 75 | service.dispatchCart(newCart); 76 | fixture.detectChanges(); 77 | expect(compiled.querySelector(".js-cart-total").textContent).toContain("£1.50"); 78 | }))); 79 | 80 | it("should empty the cart when empty shopping cart button pressed", 81 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => { 82 | const newCart = new ShoppingCart(); 83 | const cartItem = new CartItem(); 84 | cartItem.quantity = 1; 85 | newCart.grossTotal = 1.5; 86 | newCart.items = [cartItem]; 87 | service.dispatchCart(newCart); 88 | const fixture = TestBed.createComponent(ShoppingCartComponent); 89 | fixture.detectChanges(); 90 | fixture.debugElement.nativeElement.querySelector(".js-btn-empty-cart").click(); 91 | expect(service.emptyCalled).toBeTruthy(); 92 | }))); 93 | }); 94 | -------------------------------------------------------------------------------- /src/app/components/shopping-cart/shopping-cart.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from "@angular/core"; 2 | import { Product } from "app/models/product.model"; 3 | import { ShoppingCart } from "app/models/shopping-cart.model"; 4 | import { ProductsDataService } from "app/services/products.service"; 5 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 6 | import { Observable } from "rxjs/Observable"; 7 | import { Subscription } from "rxjs/Subscription"; 8 | 9 | @Component({ 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | selector: "app-shopping-cart", 12 | templateUrl: "./shopping-cart.component.html" 13 | }) 14 | export class ShoppingCartComponent implements OnInit, OnDestroy { 15 | public products: Observable; 16 | public cart: Observable; 17 | public itemCount: number; 18 | 19 | private cartSubscription: Subscription; 20 | 21 | public constructor(private productsService: ProductsDataService, 22 | private shoppingCartService: ShoppingCartService) { 23 | } 24 | 25 | public emptyCart(): void { 26 | this.shoppingCartService.empty(); 27 | } 28 | 29 | public ngOnInit(): void { 30 | this.products = this.productsService.all(); 31 | this.cart = this.shoppingCartService.get(); 32 | this.cartSubscription = this.cart.subscribe((cart) => { 33 | this.itemCount = cart.items.map((x) => x.quantity).reduce((p, n) => p + n, 0); 34 | }); 35 | } 36 | 37 | public ngOnDestroy(): void { 38 | if (this.cartSubscription) { 39 | this.cartSubscription.unsubscribe(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/components/store-front/store-front.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Pick your favourite juices...

4 |
    5 |
  • 7 |
    8 |
    9 | 11 |
    12 |
    13 |

    14 | {{product.name}} 15 | {{product.price | currency:'GBP':true}} 16 |

    17 |

    {{product.description}}

    18 |

    19 | ingredients: 20 |
    21 | 22 | {{ingredient.name}} ({{ingredient.percentage}}%) 23 |
    24 |
    25 |

    26 |

    27 | 30 | 34 |

    35 |
    36 |
    37 |
  • 38 |
39 |
40 |
41 | 42 |
43 |
44 | -------------------------------------------------------------------------------- /src/app/components/store-front/store-front.component.scss: -------------------------------------------------------------------------------- 1 | .product-list { 2 | list-style-type: none; 3 | margin: 0; 4 | } 5 | 6 | .product-container { 7 | padding: 2rem 3rem 0 0; 8 | position: relative; 9 | border: 1px solid #EFEFEF; 10 | margin: 10px 0; 11 | 12 | &:hover { 13 | box-shadow: 0 0 5px rgba(50,50,50,0.3); 14 | } 15 | 16 | .product_image { 17 | max-height: 250px; 18 | } 19 | 20 | .product_price { 21 | float: right; 22 | margin-top: 1rem; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/components/store-front/store-front.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, inject, TestBed } from "@angular/core/testing"; 2 | import { HttpModule } from "@angular/http"; 3 | import { CartItem } from "app/models/cart-item.model"; 4 | import { Product } from "app/models/product.model"; 5 | import { ShoppingCart } from "app/models/shopping-cart.model"; 6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 7 | import { ProductsDataService } from "app/services/products.service"; 8 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 9 | import { LocalStorageServie, StorageService } from "app/services/storage.service"; 10 | import { Observable } from "rxjs/Observable"; 11 | import { Observer } from "rxjs/Observer"; 12 | import * as sinon from "sinon"; 13 | import { ShoppingCartComponent } from "../shopping-cart/shopping-cart.component"; 14 | import { StoreFrontComponent } from "./store-front.component"; 15 | 16 | const PRODUCT_1 = new Product(); 17 | PRODUCT_1.name = "Product 1"; 18 | PRODUCT_1.id = "1"; 19 | PRODUCT_1.price = 1; 20 | PRODUCT_1.description = "desc1"; 21 | 22 | const PRODUCT_2 = new Product(); 23 | PRODUCT_2.name = "Product 2"; 24 | PRODUCT_2.id = "2"; 25 | PRODUCT_2.price = 2; 26 | PRODUCT_2.description = "desc2"; 27 | 28 | // tslint:disable-next-line:max-classes-per-file 29 | class MockProductDataService extends ProductsDataService { 30 | public all(): Observable { 31 | return Observable.from([[PRODUCT_1, PRODUCT_2]]); 32 | } 33 | } 34 | 35 | // tslint:disable-next-line:max-classes-per-file 36 | class MockShoppingCartService { 37 | public unsubscriveCalled: boolean = false; 38 | public emptyCalled: boolean = false; 39 | 40 | private subscriptionObservable: Observable; 41 | private subscriber: Observer; 42 | private cart: ShoppingCart = new ShoppingCart(); 43 | 44 | public constructor() { 45 | this.subscriptionObservable = new Observable((observer: Observer) => { 46 | this.subscriber = observer; 47 | observer.next(this.cart); 48 | return () => this.unsubscriveCalled = true; 49 | }); 50 | } 51 | 52 | public addItem(product: Product, quantity: number): void {} 53 | 54 | public get(): Observable { 55 | return this.subscriptionObservable; 56 | } 57 | 58 | public empty(): void { 59 | this.emptyCalled = true; 60 | } 61 | 62 | public dispatchCart(cart: ShoppingCart): void { 63 | this.cart = cart; 64 | if (this.subscriber) { 65 | this.subscriber.next(cart); 66 | } 67 | } 68 | } 69 | 70 | describe("StoreFrontComponent", () => { 71 | beforeEach(async(() => { 72 | TestBed.configureTestingModule({ 73 | declarations: [ 74 | ShoppingCartComponent, 75 | StoreFrontComponent 76 | ], 77 | imports: [ 78 | HttpModule 79 | ], 80 | providers: [ 81 | { provide: ProductsDataService, useClass: MockProductDataService }, 82 | { provide: DeliveryOptionsDataService, useValue: sinon.createStubInstance(DeliveryOptionsDataService) }, 83 | { provide: StorageService, useClass: LocalStorageServie }, 84 | { provide: ShoppingCartService, useClass: MockShoppingCartService } 85 | ] 86 | }).compileComponents(); 87 | })); 88 | 89 | it("should create the component", async(() => { 90 | const fixture = TestBed.createComponent(StoreFrontComponent); 91 | const component = fixture.debugElement.componentInstance; 92 | expect(component).toBeTruthy(); 93 | })); 94 | 95 | it("should display all the products", async(() => { 96 | const fixture = TestBed.createComponent(StoreFrontComponent); 97 | fixture.detectChanges(); 98 | 99 | const component = fixture.debugElement.componentInstance; 100 | const compiled = fixture.debugElement.nativeElement; 101 | const productElements = compiled.querySelectorAll(".product-container"); 102 | expect(productElements.length).toEqual(2); 103 | 104 | expect(productElements[0].querySelector(".js-product-name").textContent).toEqual(PRODUCT_1.name); 105 | expect(productElements[0].querySelector(".js-product-price").textContent).toContain(PRODUCT_1.price); 106 | expect(productElements[0].querySelector(".js-product-desc").textContent).toContain(PRODUCT_1.description); 107 | 108 | expect(productElements[1].querySelector(".js-product-name").textContent).toEqual(PRODUCT_2.name); 109 | expect(productElements[1].querySelector(".js-product-price").textContent).toContain(PRODUCT_2.price); 110 | expect(productElements[1].querySelector(".js-product-desc").textContent).toContain(PRODUCT_2.description); 111 | })); 112 | 113 | it("should not display the remove item button when the item is not in the cart", async(() => { 114 | const fixture = TestBed.createComponent(StoreFrontComponent); 115 | fixture.detectChanges(); 116 | 117 | const component = fixture.debugElement.componentInstance; 118 | const compiled = fixture.debugElement.nativeElement; 119 | const productElements = compiled.querySelectorAll(".product-container"); 120 | expect(productElements.length).toEqual(2); 121 | 122 | expect(productElements[0].querySelector(".js-product-name").textContent).toEqual(PRODUCT_1.name); 123 | expect(productElements[0].querySelector(".js-product-price").textContent).toContain(PRODUCT_1.price); 124 | expect(productElements[0].querySelector(".js-product-desc").textContent).toContain(PRODUCT_1.description); 125 | expect(productElements[0].querySelectorAll(".js-btn-remove").length).toEqual(0); 126 | })); 127 | 128 | it("should add the product to the cart when add item button is clicked", 129 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => { 130 | const fixture = TestBed.createComponent(StoreFrontComponent); 131 | fixture.detectChanges(); 132 | 133 | const addItemSpy = sinon.spy(service, "addItem"); 134 | 135 | const component = fixture.debugElement.componentInstance; 136 | const compiled = fixture.debugElement.nativeElement; 137 | const productElements = compiled.querySelectorAll(".product-container"); 138 | 139 | productElements[0].querySelector(".js-btn-add").click(); 140 | sinon.assert.calledOnce(addItemSpy); 141 | sinon.assert.calledWithExactly(addItemSpy, PRODUCT_1, 1); 142 | }))); 143 | 144 | it("should remove the product from the cart when remove item button is clicked", 145 | async(inject([ShoppingCartService], (service: MockShoppingCartService) => { 146 | const newCart = new ShoppingCart(); 147 | const cartItem = new CartItem(); 148 | cartItem.productId = PRODUCT_1.id; 149 | cartItem.quantity = 1; 150 | newCart.grossTotal = 1.5; 151 | newCart.items = [cartItem]; 152 | service.dispatchCart(newCart); 153 | const fixture = TestBed.createComponent(StoreFrontComponent); 154 | fixture.detectChanges(); 155 | 156 | const addItemSpy = sinon.spy(service, "addItem"); 157 | 158 | const component = fixture.debugElement.componentInstance; 159 | const compiled = fixture.debugElement.nativeElement; 160 | const productElements = compiled.querySelectorAll(".product-container"); 161 | 162 | productElements[0].querySelector(".js-btn-remove").click(); 163 | sinon.assert.calledOnce(addItemSpy); 164 | sinon.assert.calledWithExactly(addItemSpy, PRODUCT_1, -1); 165 | }))); 166 | }); 167 | -------------------------------------------------------------------------------- /src/app/components/store-front/store-front.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; 2 | import { Product } from "app/models/product.model"; 3 | import { ShoppingCart } from "app/models/shopping-cart.model"; 4 | import { ProductsDataService } from "app/services/products.service"; 5 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 6 | import { Observable } from "rxjs/Observable"; 7 | import { Observer } from "rxjs/Observer"; 8 | 9 | @Component({ 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | selector: "app-store-front", 12 | styleUrls: ["./store-front.component.scss"], 13 | templateUrl: "./store-front.component.html" 14 | }) 15 | export class StoreFrontComponent implements OnInit { 16 | public products: Observable; 17 | 18 | public constructor(private productsService: ProductsDataService, 19 | private shoppingCartService: ShoppingCartService) { 20 | } 21 | 22 | public addProductToCart(product: Product): void { 23 | this.shoppingCartService.addItem(product, 1); 24 | } 25 | 26 | public removeProductFromCart(product: Product): void { 27 | this.shoppingCartService.addItem(product, -1); 28 | } 29 | 30 | public productInCart(product: Product): boolean { 31 | return Observable.create((obs: Observer) => { 32 | const sub = this.shoppingCartService 33 | .get() 34 | .subscribe((cart) => { 35 | obs.next(cart.items.some((i) => i.productId === product.id)); 36 | obs.complete(); 37 | }); 38 | sub.unsubscribe(); 39 | }); 40 | } 41 | 42 | public ngOnInit(): void { 43 | this.products = this.productsService.all(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/models/cart-item.model.ts: -------------------------------------------------------------------------------- 1 | export class CartItem { 2 | public productId: string; 3 | public quantity: number = 0; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/models/delivery-option.model.ts: -------------------------------------------------------------------------------- 1 | export class DeliveryOption { 2 | public id: string; 3 | public name: string; 4 | public description: string; 5 | public price: number; 6 | 7 | public updateFrom(src: DeliveryOption): void { 8 | this.id = src.id; 9 | this.name = src.name; 10 | this.description = src.description; 11 | this.price = src.price; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/models/ingredient.model.ts: -------------------------------------------------------------------------------- 1 | export class Ingredient { 2 | public name: string; 3 | public percentage: number; 4 | 5 | public updateFrom(src: Ingredient): void { 6 | this.name = src.name; 7 | this.percentage = src.percentage; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/models/product.model.ts: -------------------------------------------------------------------------------- 1 | import { Ingredient } from "app/models/ingredient.model"; 2 | 3 | export class Product { 4 | public id: string; 5 | public name: string; 6 | public description: string; 7 | public price: number; 8 | public ingredients: Ingredient[]; 9 | 10 | public updateFrom(src: Product): void { 11 | this.id = src.id; 12 | this.name = src.name; 13 | this.description = src.description; 14 | this.price = src.price; 15 | this.ingredients = src.ingredients.map((i) => { 16 | let ingredient = new Ingredient(); 17 | ingredient.updateFrom(i); 18 | return ingredient; 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/models/shopping-cart.model.ts: -------------------------------------------------------------------------------- 1 | import { CartItem } from "app/models/cart-item.model"; 2 | 3 | export class ShoppingCart { 4 | public items: CartItem[] = new Array(); 5 | public deliveryOptionId: string; 6 | public grossTotal: number = 0; 7 | public deliveryTotal: number = 0; 8 | public itemsTotal: number = 0; 9 | 10 | public updateFrom(src: ShoppingCart) { 11 | this.items = src.items; 12 | this.deliveryOptionId = src.deliveryOptionId; 13 | this.grossTotal = src.grossTotal; 14 | this.deliveryTotal = src.deliveryTotal; 15 | this.itemsTotal = src.itemsTotal; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/route-gaurds/populated-cart.route-gaurd.spec.ts: -------------------------------------------------------------------------------- 1 | import { inject, TestBed } from "@angular/core/testing"; 2 | import { MockBackend } from "@angular/http/testing"; 3 | import { Router, RouterModule } from "@angular/router"; 4 | import { CartItem } from "app/models/cart-item.model"; 5 | import { ShoppingCart } from "app/models/shopping-cart.model"; 6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 7 | import { ProductsDataService } from "app/services/products.service"; 8 | import { ShoppingCartService } from "app/services/shopping-cart.service"; 9 | import { LocalStorageServie, StorageService } from "app/services/storage.service"; 10 | import { Observable } from "rxjs/Observable"; 11 | import { Observer } from "rxjs/Observer"; 12 | import * as sinon from "sinon"; 13 | import { SinonSpy } from "sinon"; 14 | import { PopulatedCartRouteGuard } from "./populated-cart.route-gaurd"; 15 | 16 | class MockShoppingCartService { 17 | public unsubscriveCalled: boolean = false; 18 | private subscriptionObservable: Observable; 19 | private subscriber: Observer; 20 | private cart: ShoppingCart = new ShoppingCart(); 21 | 22 | public constructor() { 23 | this.subscriptionObservable = new Observable((observer: Observer) => { 24 | this.subscriber = observer; 25 | observer.next(this.cart); 26 | return () => this.unsubscriveCalled = true; 27 | }); 28 | } 29 | 30 | public get(): Observable { 31 | return this.subscriptionObservable; 32 | } 33 | 34 | public dispatchCart(cart: ShoppingCart): void { 35 | this.cart = cart; 36 | if (this.subscriber) { 37 | this.subscriber.next(cart); 38 | } 39 | } 40 | } 41 | 42 | describe("PopulatedCartRouteGuard", () => { 43 | beforeEach(() => { 44 | TestBed.configureTestingModule({ 45 | imports: [ 46 | RouterModule 47 | ], 48 | providers: [ 49 | PopulatedCartRouteGuard, 50 | { provide: ShoppingCartService, useClass: MockShoppingCartService }, 51 | { provide: Router, useValue: sinon.createStubInstance(Router) } 52 | ] 53 | }); 54 | }); 55 | 56 | it("should be injectable", inject([PopulatedCartRouteGuard], (routeGaurd: PopulatedCartRouteGuard) => { 57 | expect(routeGaurd).toBeTruthy(); 58 | })); 59 | 60 | describe("canActivate", () => { 61 | it("should return true if there are items in the cart", 62 | inject([Router, ShoppingCartService, PopulatedCartRouteGuard], (router: Router, 63 | shoppingCartService: MockShoppingCartService, 64 | gaurd: PopulatedCartRouteGuard) => { 65 | const newCart = new ShoppingCart(); 66 | const cartItem = new CartItem(); 67 | cartItem.quantity = 1; 68 | newCart.items = [cartItem]; 69 | shoppingCartService.dispatchCart(newCart); 70 | 71 | gaurd.canActivate() 72 | .subscribe((result) => expect(result).toBeTruthy()); 73 | })); 74 | 75 | it("should return false and redirect to '/' if there are no items in the cart", 76 | inject([Router, PopulatedCartRouteGuard], (router: Router, gaurd: PopulatedCartRouteGuard) => { 77 | gaurd.canActivate() 78 | .subscribe((result) => expect(result).toBeFalsy()); 79 | 80 | sinon.assert.calledOnce(router.navigate as SinonSpy); 81 | sinon.assert.calledWithExactly(router.navigate as SinonSpy, ["/"]); 82 | })); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/app/route-gaurds/populated-cart.route-gaurd.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { CanActivate, Router } from "@angular/router"; 3 | import { Observable } from "rxjs/Observable"; 4 | import { Observer } from "rxjs/Observer"; 5 | import { Subscription } from "rxjs/Subscription"; 6 | import { ShoppingCartService } from "../services/shopping-cart.service"; 7 | 8 | @Injectable() 9 | export class PopulatedCartRouteGuard implements CanActivate { 10 | public constructor(private router: Router, 11 | private shoppingCartService: ShoppingCartService) { } 12 | 13 | public canActivate(): Observable { 14 | return new Observable((observer: Observer) => { 15 | const cartSubscription = this.shoppingCartService 16 | .get() 17 | .subscribe((cart) => { 18 | if (cart.items.length === 0) { 19 | observer.next(false); 20 | this.router.navigate(["/"]); 21 | } else { 22 | observer.next(true); 23 | } 24 | }); 25 | return () => cartSubscription.unsubscribe(); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/services/caching.service.ts: -------------------------------------------------------------------------------- 1 | import "rxjs/add/operator/share"; 2 | import { Observable } from "rxjs/Observable"; 3 | 4 | export abstract class CachcingServiceBase { 5 | protected cache(getter: () => Observable, 6 | setter: (val: Observable) => void, 7 | retreive: () => Observable): Observable { 8 | const cached = getter(); 9 | if (cached !== undefined) { 10 | return cached; 11 | } else { 12 | const val = retreive().share(); 13 | setter(val); 14 | return val; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/services/delivery-options.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { Http } from "@angular/http"; 3 | import "rxjs/add/operator/map"; 4 | import { Observable } from "rxjs/Observable"; 5 | import { DeliveryOption } from "../models/delivery-option.model"; 6 | import { CachcingServiceBase } from "./caching.service"; 7 | 8 | @Injectable() 9 | export class DeliveryOptionsDataService extends CachcingServiceBase { 10 | private deliveryOptions: Observable; 11 | 12 | public constructor(private http: Http) { 13 | super(); 14 | } 15 | 16 | public all(): Observable { 17 | return this.cache(() => this.deliveryOptions, 18 | (val: Observable) => this.deliveryOptions = val, 19 | () => this.http 20 | .get("./assets/delivery-options.json") 21 | .map((response) => response.json() 22 | .map((item) => { 23 | let model = new DeliveryOption(); 24 | model.updateFrom(item); 25 | return model; 26 | }))); 27 | 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/services/products.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { Http } from "@angular/http"; 3 | import { Product } from "app/models/product.model"; 4 | import "rxjs/add/operator/map"; 5 | import { Observable } from "rxjs/Observable"; 6 | import { CachcingServiceBase } from "./caching.service"; 7 | 8 | let count = 0; 9 | 10 | @Injectable() 11 | export class ProductsDataService extends CachcingServiceBase { 12 | private products: Observable; 13 | 14 | public constructor(private http: Http) { 15 | super(); 16 | } 17 | 18 | public all(): Observable { 19 | return this.cache(() => this.products, 20 | (val: Observable) => this.products = val, 21 | () => this.http 22 | .get("./assets/products.json") 23 | .map((response) => response.json() 24 | .map((item) => { 25 | let model = new Product(); 26 | model.updateFrom(item); 27 | return model; 28 | }))); 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/services/shopping-cart.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { StorageService } from "app/services/storage.service"; 3 | import { Observable } from "rxjs/Observable"; 4 | import { Observer } from "rxjs/Observer"; 5 | import { CartItem } from "../models/cart-item.model"; 6 | import { DeliveryOption } from "../models/delivery-option.model"; 7 | import { Product } from "../models/product.model"; 8 | import { ShoppingCart } from "../models/shopping-cart.model"; 9 | import { DeliveryOptionsDataService } from "../services/delivery-options.service"; 10 | import { ProductsDataService } from "../services/products.service"; 11 | 12 | const CART_KEY = "cart"; 13 | 14 | @Injectable() 15 | export class ShoppingCartService { 16 | private storage: Storage; 17 | private subscriptionObservable: Observable; 18 | private subscribers: Array> = new Array>(); 19 | private products: Product[]; 20 | private deliveryOptions: DeliveryOption[]; 21 | 22 | public constructor(private storageService: StorageService, 23 | private productService: ProductsDataService, 24 | private deliveryOptionsService: DeliveryOptionsDataService) { 25 | this.storage = this.storageService.get(); 26 | this.productService.all().subscribe((products) => this.products = products); 27 | this.deliveryOptionsService.all().subscribe((options) => this.deliveryOptions = options); 28 | 29 | this.subscriptionObservable = new Observable((observer: Observer) => { 30 | this.subscribers.push(observer); 31 | observer.next(this.retrieve()); 32 | return () => { 33 | this.subscribers = this.subscribers.filter((obs) => obs !== observer); 34 | }; 35 | }); 36 | } 37 | 38 | public get(): Observable { 39 | return this.subscriptionObservable; 40 | } 41 | 42 | public addItem(product: Product, quantity: number): void { 43 | const cart = this.retrieve(); 44 | let item = cart.items.find((p) => p.productId === product.id); 45 | if (item === undefined) { 46 | item = new CartItem(); 47 | item.productId = product.id; 48 | cart.items.push(item); 49 | } 50 | 51 | item.quantity += quantity; 52 | cart.items = cart.items.filter((cartItem) => cartItem.quantity > 0); 53 | if (cart.items.length === 0) { 54 | cart.deliveryOptionId = undefined; 55 | } 56 | 57 | this.calculateCart(cart); 58 | this.save(cart); 59 | this.dispatch(cart); 60 | } 61 | 62 | public empty(): void { 63 | const newCart = new ShoppingCart(); 64 | this.save(newCart); 65 | this.dispatch(newCart); 66 | } 67 | 68 | public setDeliveryOption(deliveryOption: DeliveryOption): void { 69 | const cart = this.retrieve(); 70 | cart.deliveryOptionId = deliveryOption.id; 71 | this.calculateCart(cart); 72 | this.save(cart); 73 | this.dispatch(cart); 74 | } 75 | 76 | private calculateCart(cart: ShoppingCart): void { 77 | cart.itemsTotal = cart.items 78 | .map((item) => item.quantity * this.products.find((p) => p.id === item.productId).price) 79 | .reduce((previous, current) => previous + current, 0); 80 | cart.deliveryTotal = cart.deliveryOptionId ? 81 | this.deliveryOptions.find((x) => x.id === cart.deliveryOptionId).price : 82 | 0; 83 | cart.grossTotal = cart.itemsTotal + cart.deliveryTotal; 84 | } 85 | 86 | private retrieve(): ShoppingCart { 87 | const cart = new ShoppingCart(); 88 | const storedCart = this.storage.getItem(CART_KEY); 89 | if (storedCart) { 90 | cart.updateFrom(JSON.parse(storedCart)); 91 | } 92 | 93 | return cart; 94 | } 95 | 96 | private save(cart: ShoppingCart): void { 97 | this.storage.setItem(CART_KEY, JSON.stringify(cart)); 98 | } 99 | 100 | private dispatch(cart: ShoppingCart): void { 101 | this.subscribers 102 | .forEach((sub) => { 103 | try { 104 | sub.next(cart); 105 | } catch (e) { 106 | // we want all subscribers to get the update even if one errors. 107 | } 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/services/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import "rxjs/add/operator/share"; 3 | import { Observable } from "rxjs/Observable"; 4 | 5 | export abstract class StorageService { 6 | public abstract get(): Storage; 7 | } 8 | 9 | // tslint:disable-next-line:max-classes-per-file 10 | @Injectable() 11 | export class LocalStorageServie extends StorageService { 12 | public get(): Storage { 13 | return localStorage; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/services/tests/delivery-options.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { inject, TestBed } from "@angular/core/testing"; 2 | import { HttpModule, Response, ResponseOptions, XHRBackend } from "@angular/http"; 3 | import { MockBackend } from "@angular/http/testing"; 4 | import { MockConnection } from "@angular/http/testing"; 5 | import { DeliveryOption } from "app/models/delivery-option.model"; 6 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 7 | 8 | describe("DeliveryOptionsDataService", () => { 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [ 12 | HttpModule 13 | ], 14 | providers: [ 15 | DeliveryOptionsDataService, 16 | { provide: XHRBackend, useClass: MockBackend } 17 | ] 18 | }); 19 | }); 20 | 21 | it("should be injectable", inject([DeliveryOptionsDataService], (service: DeliveryOptionsDataService) => { 22 | expect(service).toBeTruthy(); 23 | })); 24 | 25 | describe("all()", () => { 26 | it("should call the correct http endpoint", 27 | inject([DeliveryOptionsDataService, XHRBackend], 28 | (service: DeliveryOptionsDataService, mockBackend: MockBackend) => { 29 | 30 | mockBackend.connections.subscribe((connection: MockConnection) => { 31 | expect(connection.request.url).toEqual("./assets/delivery-options.json"); 32 | connection.mockRespond(new Response(new ResponseOptions({ 33 | body: JSON.stringify(createDeliveryOptions(2)) 34 | }))); 35 | }); 36 | 37 | service.all() 38 | .subscribe((options) => { 39 | expect(options.length).toBe(2); 40 | expect(options[0].id).toBe("0"); 41 | expect(options[1].id).toBe("1"); 42 | }); 43 | })); 44 | 45 | }); 46 | }); 47 | 48 | function createDeliveryOptions(count: number): DeliveryOption[] { 49 | const options = new Array(); 50 | for (let i = 0; i < count; i += 1) { 51 | const option = new DeliveryOption(); 52 | option.id = i.toString(); 53 | option.name = `name ${i}`; 54 | option.description = `description ${i}`; 55 | option.price = i; 56 | options.push(option); 57 | } 58 | 59 | return options; 60 | } 61 | -------------------------------------------------------------------------------- /src/app/services/tests/products.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { inject, TestBed } from "@angular/core/testing"; 2 | import { HttpModule, Response, ResponseOptions, XHRBackend } from "@angular/http"; 3 | import { MockBackend } from "@angular/http/testing"; 4 | import { Ingredient } from "app/models/ingredient.model"; 5 | import { Product } from "app/models/product.model"; 6 | import { ProductsDataService } from "app/services/products.service"; 7 | 8 | describe("ProductsService", () => { 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [ 12 | HttpModule 13 | ], 14 | providers: [ 15 | ProductsDataService, 16 | { provide: XHRBackend, useClass: MockBackend } 17 | ] 18 | }); 19 | }); 20 | 21 | it("should be injectable", inject([ProductsDataService], (service: ProductsDataService) => { 22 | expect(service).toBeTruthy(); 23 | })); 24 | 25 | describe("all()", () => { 26 | it("should call the correct http endpoint", 27 | inject([ProductsDataService, XHRBackend], 28 | (service: ProductsDataService, mockBackend: MockBackend) => { 29 | 30 | mockBackend.connections.subscribe((connection) => { 31 | expect(connection.request.url).toEqual("./assets/products.json"); 32 | connection.mockRespond(new Response(new ResponseOptions({ 33 | body: JSON.stringify(createProducts(2)) 34 | }))); 35 | }); 36 | 37 | service.all() 38 | .subscribe((products) => { 39 | expect(products.length).toBe(2); 40 | expect(products[0].id).toBe("0"); 41 | expect(products[1].id).toBe("1"); 42 | }); 43 | })); 44 | }); 45 | }); 46 | 47 | function createProducts(count: number): Product[] { 48 | const products = new Array(); 49 | for (let i = 0; i < count; i += 1) { 50 | const product = new Product(); 51 | product.id = i.toString(); 52 | product.name = `name ${i}`; 53 | product.description = `description ${i}`; 54 | product.price = i; 55 | product.ingredients = new Array(); 56 | products.push(product); 57 | } 58 | 59 | return products; 60 | } 61 | -------------------------------------------------------------------------------- /src/app/services/tests/shopping-cart.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { inject, TestBed } from "@angular/core/testing"; 2 | import { HttpModule, XHRBackend } from "@angular/http"; 3 | import { MockBackend } from "@angular/http/testing"; 4 | import { DeliveryOption } from "app/models/delivery-option.model"; 5 | import { Product } from "app/models/product.model"; 6 | import { ShoppingCart } from "app/models/shopping-cart.model"; 7 | import { DeliveryOptionsDataService } from "app/services/delivery-options.service"; 8 | import { ProductsDataService } from "app/services/products.service"; 9 | import "rxjs/add/observable/from"; 10 | import { Observable } from "rxjs/Observable"; 11 | import * as sinon from "sinon"; 12 | import { ShoppingCartService } from "../shopping-cart.service"; 13 | import { LocalStorageServie, StorageService } from "../storage.service"; 14 | 15 | const PRODUCT_1 = new Product(); 16 | PRODUCT_1.name = "Product 1"; 17 | PRODUCT_1.id = "1"; 18 | PRODUCT_1.price = 1; 19 | 20 | const PRODUCT_2 = new Product(); 21 | PRODUCT_2.name = "Product 2"; 22 | PRODUCT_2.id = "2"; 23 | PRODUCT_2.price = 2; 24 | 25 | const DELIVERY_OPT_1 = new DeliveryOption(); 26 | DELIVERY_OPT_1.name = "Delivery Option 1"; 27 | DELIVERY_OPT_1.id = "1"; 28 | DELIVERY_OPT_1.price = 1; 29 | 30 | class MockProductDataService extends ProductsDataService { 31 | public all(): Observable { 32 | return Observable.from([[PRODUCT_1, PRODUCT_2]]); 33 | } 34 | } 35 | 36 | // tslint:disable-next-line:max-classes-per-file 37 | class MockDeliveryOptionsDataService extends DeliveryOptionsDataService { 38 | public all(): Observable { 39 | return Observable.from([[DELIVERY_OPT_1]]); 40 | } 41 | } 42 | 43 | describe("ShoppingCartService", () => { 44 | let sandbox: sinon.SinonSandbox; 45 | 46 | beforeEach(() => { 47 | sandbox = sinon.sandbox.create(); 48 | 49 | TestBed.configureTestingModule({ 50 | imports: [ 51 | HttpModule 52 | ], 53 | providers: [ 54 | { provide: ProductsDataService, useClass: MockProductDataService }, 55 | { provide: DeliveryOptionsDataService, useClass: MockDeliveryOptionsDataService }, 56 | { provide: StorageService, useClass: LocalStorageServie }, 57 | ShoppingCartService 58 | ] 59 | }); 60 | }); 61 | 62 | afterEach(() => { 63 | sandbox.restore(); 64 | }); 65 | 66 | it("should be injectable", inject([ShoppingCartService], (service: ShoppingCartService) => { 67 | expect(service).toBeTruthy(); 68 | })); 69 | 70 | describe("get()", () => { 71 | it("should return an Observable", 72 | inject([ShoppingCartService], (service: ShoppingCartService) => { 73 | const obs = service.get(); 74 | expect(obs).toEqual(jasmine.any(Observable)); 75 | })); 76 | 77 | it("should return a ShoppingCart model instance when the observable is subscribed to", 78 | inject([ShoppingCartService], (service: ShoppingCartService) => { 79 | const obs = service.get(); 80 | obs.subscribe((cart) => { 81 | expect(cart).toEqual(jasmine.any(ShoppingCart)); 82 | expect(cart.items.length).toEqual(0); 83 | expect(cart.deliveryOptionId).toBeUndefined(); 84 | expect(cart.deliveryTotal).toEqual(0); 85 | expect(cart.itemsTotal).toEqual(0); 86 | expect(cart.grossTotal).toEqual(0); 87 | }); 88 | })); 89 | 90 | it("should return a populated ShoppingCart model instance when the observable is subscribed to", 91 | inject([ShoppingCartService], (service: ShoppingCartService) => { 92 | const shoppingCart = new ShoppingCart(); 93 | shoppingCart.deliveryOptionId = "deliveryOptionId"; 94 | shoppingCart.deliveryTotal = 1; 95 | shoppingCart.itemsTotal = 2; 96 | shoppingCart.grossTotal = 3; 97 | sandbox.stub(localStorage, "getItem") 98 | .returns(JSON.stringify(shoppingCart)); 99 | 100 | const obs = service.get(); 101 | obs.subscribe((cart) => { 102 | expect(cart).toEqual(jasmine.any(ShoppingCart)); 103 | expect(cart.deliveryOptionId).toEqual(shoppingCart.deliveryOptionId); 104 | expect(cart.deliveryTotal).toEqual(shoppingCart.deliveryTotal); 105 | expect(cart.itemsTotal).toEqual(shoppingCart.itemsTotal); 106 | expect(cart.grossTotal).toEqual(shoppingCart.grossTotal); 107 | }); 108 | })); 109 | }); 110 | 111 | describe("empty()", () => { 112 | it("should create empty cart and persist", 113 | inject([ShoppingCartService], (service: ShoppingCartService) => { 114 | 115 | const stub = sandbox.stub(localStorage, "setItem"); 116 | const obs = service.empty(); 117 | 118 | sinon.assert.calledOnce(stub); 119 | })); 120 | 121 | it("should dispatch empty cart", 122 | inject([ShoppingCartService], (service: ShoppingCartService) => { 123 | let dispatchCount = 0; 124 | 125 | const shoppingCart = new ShoppingCart(); 126 | shoppingCart.grossTotal = 3; 127 | sandbox.stub(localStorage, "getItem") 128 | .returns(JSON.stringify(shoppingCart)); 129 | 130 | service.get() 131 | .subscribe((cart) => { 132 | dispatchCount += 1; 133 | 134 | if (dispatchCount === 1) { 135 | expect(cart.grossTotal).toEqual(shoppingCart.grossTotal); 136 | } 137 | 138 | if (dispatchCount === 2) { 139 | expect(cart.grossTotal).toEqual(0); 140 | } 141 | }); 142 | 143 | service.empty(); 144 | expect(dispatchCount).toEqual(2); 145 | })); 146 | }); 147 | 148 | describe("addItem()", () => { 149 | beforeEach(() => { 150 | let persistedCart: string; 151 | const setItemStub = sandbox.stub(localStorage, "setItem") 152 | .callsFake((key, val) => persistedCart = val); 153 | sandbox.stub(localStorage, "getItem") 154 | .callsFake((key) => persistedCart); 155 | }); 156 | 157 | it("should add the item to the cart and persist", 158 | inject([ShoppingCartService], (service: ShoppingCartService) => { 159 | service.addItem(PRODUCT_1, 1); 160 | 161 | service.get() 162 | .subscribe((cart) => { 163 | expect(cart.items.length).toEqual(1); 164 | expect(cart.items[0].productId).toEqual(PRODUCT_1.id); 165 | }); 166 | })); 167 | 168 | it("should dispatch cart", 169 | inject([ShoppingCartService], (service: ShoppingCartService) => { 170 | let dispatchCount = 0; 171 | 172 | service.get() 173 | .subscribe((cart) => { 174 | dispatchCount += 1; 175 | 176 | if (dispatchCount === 2) { 177 | expect(cart.grossTotal).toEqual(PRODUCT_1.price); 178 | } 179 | }); 180 | 181 | service.addItem(PRODUCT_1, 1); 182 | expect(dispatchCount).toEqual(2); 183 | })); 184 | 185 | it("should set the correct quantity on products already added to the cart", 186 | inject([ShoppingCartService], (service: ShoppingCartService) => { 187 | service.addItem(PRODUCT_1, 1); 188 | service.addItem(PRODUCT_1, 3); 189 | 190 | service.get() 191 | .subscribe((cart) => { 192 | expect(cart.items[0].quantity).toEqual(4); 193 | }); 194 | })); 195 | }); 196 | 197 | describe("setDeliveryOption()", () => { 198 | beforeEach(() => { 199 | let persistedCart: string; 200 | const setItemStub = sandbox.stub(localStorage, "setItem") 201 | .callsFake((key, val) => persistedCart = val); 202 | sandbox.stub(localStorage, "getItem") 203 | .callsFake((key) => persistedCart); 204 | }); 205 | 206 | it("should add the delivery option to the cart and persist", 207 | inject([ShoppingCartService], (service: ShoppingCartService) => { 208 | service.setDeliveryOption(DELIVERY_OPT_1); 209 | 210 | service.get() 211 | .subscribe((cart) => { 212 | expect(cart.deliveryOptionId).toEqual(DELIVERY_OPT_1.id); 213 | }); 214 | })); 215 | 216 | it("should dispatch cart", 217 | inject([ShoppingCartService], (service: ShoppingCartService) => { 218 | let dispatchCount = 0; 219 | 220 | service.get() 221 | .subscribe((cart) => { 222 | dispatchCount += 1; 223 | 224 | if (dispatchCount === 2) { 225 | expect(cart.deliveryTotal).toEqual(DELIVERY_OPT_1.price); 226 | } 227 | }); 228 | 229 | service.setDeliveryOption(DELIVERY_OPT_1); 230 | expect(dispatchCount).toEqual(2); 231 | })); 232 | }); 233 | 234 | describe("totals calculation", () => { 235 | beforeEach(() => { 236 | let persistedCart: string; 237 | const setItemStub = sandbox.stub(localStorage, "setItem") 238 | .callsFake((key, val) => persistedCart = val); 239 | sandbox.stub(localStorage, "getItem") 240 | .callsFake((key) => persistedCart); 241 | }); 242 | 243 | it("should calculate the shopping cart totals correctly", 244 | inject([ShoppingCartService], (service: ShoppingCartService) => { 245 | service.addItem(PRODUCT_1, 2); 246 | service.addItem(PRODUCT_2, 1); 247 | service.addItem(PRODUCT_1, 1); 248 | service.setDeliveryOption(DELIVERY_OPT_1); 249 | 250 | service.get() 251 | .subscribe((cart) => { 252 | expect(cart.items.length).toEqual(2); 253 | expect(cart.deliveryTotal).toEqual(DELIVERY_OPT_1.price); 254 | expect(cart.itemsTotal).toEqual((PRODUCT_1.price * 3) + PRODUCT_2.price); 255 | expect(cart.grossTotal).toEqual((PRODUCT_1.price * 3) + PRODUCT_2.price + DELIVERY_OPT_1.price); 256 | }); 257 | })); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/251a3923edc2a64f4899f15b6c8607903a7739e7/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/58552daa-30f6-46fa-a808-f1a1d7667561.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/251a3923edc2a64f4899f15b6c8607903a7739e7/src/assets/58552daa-30f6-46fa-a808-f1a1d7667561.jpg -------------------------------------------------------------------------------- /src/assets/7ef3b9dd-5a95-4415-af37-6871d6ff0262.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/251a3923edc2a64f4899f15b6c8607903a7739e7/src/assets/7ef3b9dd-5a95-4415-af37-6871d6ff0262.jpg -------------------------------------------------------------------------------- /src/assets/9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/251a3923edc2a64f4899f15b6c8607903a7739e7/src/assets/9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f.jpg -------------------------------------------------------------------------------- /src/assets/bdcbe438-ac85-4acf-8949-5627fd5b57df.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/251a3923edc2a64f4899f15b6c8607903a7739e7/src/assets/bdcbe438-ac85-4acf-8949-5627fd5b57df.jpg -------------------------------------------------------------------------------- /src/assets/d4666802-fd84-476f-9eea-c8dd29cfb633.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/251a3923edc2a64f4899f15b6c8607903a7739e7/src/assets/d4666802-fd84-476f-9eea-c8dd29cfb633.jpg -------------------------------------------------------------------------------- /src/assets/delivery-options.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "f488db46-9380-45e2-b15e-4ace7bdb7c89", 4 | "name": "Drone", 5 | "description": "Get your package within an hour and have it flown in by a drone!", 6 | "price": 19.99 7 | }, 8 | { 9 | "id": "c7f6535c-c56b-4e57-94dc-0e95b936b00b", 10 | "name": "Express", 11 | "description": "The quickest of the normal delivery service", 12 | "price": 9.99 13 | }, 14 | { 15 | "id": "caa93bc4-d69a-4788-aff6-4a6fb538ace8", 16 | "name": "Standard", 17 | "description": "Standard shipping can take up to 4 days", 18 | "price": 5.99 19 | }, 20 | { 21 | "id": "c272fc43-28e4-4ffd-ae61-0cfdec565f6f", 22 | "name": "Pick-up", 23 | "description": "Pick it up tomorrow from you local post office", 24 | "price": 0 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /src/assets/products.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "9aa113b4-1e4e-4cde-bf9d-8358fc78ea4f", 4 | "price": 3.50, 5 | "name": "Greens", 6 | "description": "Looking for simple, clean and green? Four of the most nutrient dense leafy greens create the base in our low-sugar Greens 1.", 7 | "ingredients": [ 8 | { 9 | "name": "cucumber", 10 | "percentage": 50 11 | }, 12 | { 13 | "name": "celery", 14 | "percentage": 20 15 | }, 16 | { 17 | "name": "apple", 18 | "percentage": 20 19 | }, 20 | { 21 | "name": "lemon", 22 | "percentage": 10 23 | } 24 | ] 25 | }, 26 | { 27 | "id": "bdcbe438-ac85-4acf-8949-5627fd5b57df", 28 | "price": 2.75, 29 | "name": "Citrus", 30 | "description": "This enzyme rich juice is filled with phytonutrients and bromelin which helps to reduce inflammation. Drink it before a meal to get digestive juices flowing.", 31 | "ingredients": [ 32 | { 33 | "name": "pineapple", 34 | "percentage": 50 35 | }, 36 | { 37 | "name": "apple", 38 | "percentage": 20 39 | }, 40 | { 41 | "name": "mint", 42 | "percentage": 20 43 | }, 44 | { 45 | "name": "lemon", 46 | "percentage": 10 47 | } 48 | ] 49 | }, 50 | { 51 | "id": "58552daa-30f6-46fa-a808-f1a1d7667561", 52 | "price": 3, 53 | "name": "Roots", 54 | "description": "Beets help your body to release stomach acid which aids digestion! Drink this juice when you want a snack that's both pretty and nutritious!", 55 | "ingredients": [ 56 | { 57 | "name": "apple", 58 | "percentage": 50 59 | }, 60 | { 61 | "name": "beetroot", 62 | "percentage": 20 63 | }, 64 | { 65 | "name": "ginger", 66 | "percentage": 20 67 | }, 68 | { 69 | "name": "lemon", 70 | "percentage": 10 71 | } 72 | ] 73 | }, 74 | { 75 | "id": "d4666802-fd84-476f-9eea-c8dd29cfb633", 76 | "price": 1.99, 77 | "name": "Orange", 78 | "description": "Orange juice with a twist to boost you health!", 79 | "ingredients": [ 80 | { 81 | "name": "orange", 82 | "percentage": 50 83 | }, 84 | { 85 | "name": "lemon", 86 | "percentage": 20 87 | }, 88 | { 89 | "name": "apple", 90 | "percentage": 20 91 | }, 92 | { 93 | "name": "tumeric", 94 | "percentage": 10 95 | } 96 | ] 97 | }, 98 | { 99 | "id": "7ef3b9dd-5a95-4415-af37-6871d6ff0262", 100 | "price": 2.50, 101 | "name": "Coconut", 102 | "description": "Cinnamon lovers - this is your blend! Two nutritional powerhouses combine in a delicious, satiating elixir. Both cinnamon and coconut have been shown to reduce blood sugar. Raw coconut meat is a great source of medium chain fatty acids, which can lower bad cholesterol. Coconut also contains significant levels of fiber and manganese, a mineral that helps you metabolize fat and protein.", 103 | "ingredients": [ 104 | { 105 | "name": "Coconut", 106 | "percentage": 70 107 | }, 108 | { 109 | "name": "Cinnamon", 110 | "percentage": 20 111 | }, 112 | { 113 | "name": "water", 114 | "percentage": 10 115 | } 116 | ] 117 | } 118 | ] 119 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `.angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false 8 | }; 9 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonsamwell/angular-simple-shopping-cart/251a3923edc2a64f4899f15b6c8607903a7739e7/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Angular 4 Simple Shopping Cart 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | 28 |
29 |
30 |
31 |
32 | loading.... 33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from "@angular/core"; 2 | import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; 3 | 4 | import { AppModule } from "./app/app.module"; 5 | import { environment } from "./environments/environment"; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule); 12 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | import "core-js/es6/symbol"; 23 | import "core-js/es6/object"; 24 | import "core-js/es6/function"; 25 | import "core-js/es6/parse-int"; 26 | import "core-js/es6/parse-float"; 27 | import "core-js/es6/number"; 28 | import "core-js/es6/math"; 29 | import "core-js/es6/string"; 30 | import "core-js/es6/date"; 31 | import "core-js/es6/array"; 32 | import "core-js/es6/regexp"; 33 | import "core-js/es6/map"; 34 | import "core-js/es6/set"; 35 | 36 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 37 | // import "classlist.js"; // Run `npm install --save classlist.js`. 38 | 39 | /** IE10 and IE11 requires the following to support `@angular/animation`. */ 40 | // import "web-animations-js"; // Run `npm install --save web-animations-js`. 41 | 42 | 43 | /** Evergreen browsers require these. **/ 44 | import "core-js/es6/reflect"; 45 | import "core-js/es7/reflect"; 46 | 47 | 48 | /** ALL Firefox browsers require the following to support `@angular/animation`. **/ 49 | // import "web-animations-js"; // Run `npm install --save web-animations-js`. 50 | 51 | 52 | 53 | /*************************************************************************************************** 54 | * Zone JS is required by Angular itself. 55 | */ 56 | import "zone.js/dist/zone"; // Included with Angular CLI. 57 | 58 | 59 | 60 | /*************************************************************************************************** 61 | * APPLICATION IMPORTS 62 | */ 63 | 64 | /** 65 | * Date, currency, decimal and percent pipes. 66 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 67 | */ 68 | import "intl"; // Run `npm install --save intl`. 69 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | h1, h2, h3, h4, h5, h6 { 2 | font-family: 'Montserrat', sans-serif; 3 | } 4 | 5 | p { 6 | font-family: 'BioRhyme', serif; 7 | } 8 | 9 | .top-bar { 10 | background-color: rgba(51,51,51,0.9); 11 | color: white; 12 | box-shadow: 0 0 10px rgba(51,51,51,0.6); 13 | margin-bottom: 1rem; 14 | 15 | ul { 16 | background-color: transparent; 17 | } 18 | } 19 | 20 | .text--red { 21 | color: #b12704; 22 | } 23 | 24 | .text--bold { 25 | font-weight: bold; 26 | } 27 | 28 | .button { 29 | border-radius: 4px; 30 | vertical-align: bottom; 31 | 32 | &.success { 33 | background-color: #5D8B00; 34 | padding: 1rem 3rem; 35 | color: #fff; 36 | 37 | &:hover { 38 | color: #fff; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import "zone.js/dist/long-stack-trace-zone"; 4 | import "zone.js/dist/proxy.js"; 5 | import "zone.js/dist/sync-test"; 6 | import "zone.js/dist/jasmine-patch"; 7 | import "zone.js/dist/async-test"; 8 | import "zone.js/dist/fake-async-test"; 9 | import { getTestBed } from "@angular/core/testing"; 10 | import { 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting 13 | } from "@angular/platform-browser-dynamic/testing"; 14 | 15 | // Unfortunately there"s no typing for the `__karma__` variable. Just declare it as any. 16 | declare const __karma__: any; 17 | declare const require: any; 18 | 19 | // Prevent Karma from running prematurely. 20 | __karma__.loaded = () => {}; 21 | 22 | // First, initialize the Angular testing environment. 23 | getTestBed().initTestEnvironment( 24 | BrowserDynamicTestingModule, 25 | platformBrowserDynamicTesting() 26 | ); 27 | // Then we find all the tests. 28 | const context = require.context("./", true, /\.spec\.ts$/); 29 | // And load the modules. 30 | context.keys().map(context); 31 | // Finally, start Karma to run the tests. 32 | __karma__.start(); 33 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "es2015", 6 | "baseUrl": "", 7 | "types": [] 8 | }, 9 | "exclude": [ 10 | "test.ts", 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "baseUrl": "", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | }, 13 | "files": [ 14 | "test.ts" 15 | ], 16 | "include": [ 17 | "**/*.spec.ts", 18 | "**/*.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare var module: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "baseUrl": "src", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es5", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2016", 17 | "dom" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "rulesDirectory": [ 7 | "node_modules/codelyzer" 8 | ], 9 | "rules": { 10 | "trailing-comma": [ 11 | false 12 | ], 13 | "directive-selector": [ 14 | true, 15 | "attribute", 16 | "app", 17 | "camelCase" 18 | ], 19 | "component-selector": [ 20 | true, 21 | "element", 22 | "app", 23 | "kebab-case" 24 | ], 25 | "use-input-property-decorator": true, 26 | "use-output-property-decorator": true, 27 | "use-host-property-decorator": true, 28 | "no-input-rename": true, 29 | "no-output-rename": true, 30 | "use-life-cycle-interface": true, 31 | "use-pipe-transform-interface": true, 32 | "component-class-suffix": true, 33 | "directive-class-suffix": true, 34 | "no-access-missing-member": true, 35 | "templates-use-public": true, 36 | "invoke-injectable": true 37 | } 38 | } 39 | --------------------------------------------------------------------------------