├── .editorconfig ├── .gitignore ├── README.md ├── SECURITY.md ├── angular.json ├── data ├── demo_data.json └── firebase_security.json ├── e2e ├── app.e2e-spec.ts ├── app.po.ts ├── products.po.ts └── tsconfig.e2e.json ├── karma.conf.js ├── package-lock.json ├── package.json ├── protractor.conf.js ├── src ├── app │ ├── account │ │ ├── account.component.html │ │ ├── account.component.scss │ │ ├── account.component.ts │ │ ├── account.module.ts │ │ ├── orders │ │ │ ├── orders.component.html │ │ │ ├── orders.component.scss │ │ │ ├── orders.component.ts │ │ │ └── shared │ │ │ │ └── order.service.ts │ │ ├── profile │ │ │ ├── profile.component.html │ │ │ ├── profile.component.scss │ │ │ └── profile.component.ts │ │ ├── register-login │ │ │ ├── register-login.component.html │ │ │ ├── register-login.component.scss │ │ │ └── register-login.component.ts │ │ └── shared │ │ │ ├── auth.service.ts │ │ │ └── user.guard.ts │ ├── admin │ │ ├── add-edit │ │ │ ├── add-edit.component.html │ │ │ ├── add-edit.component.scss │ │ │ ├── add-edit.component.spec.ts │ │ │ └── add-edit.component.ts │ │ ├── admin.module.ts │ │ └── shared │ │ │ └── admin.guard.ts │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── app.module.ts │ ├── cart │ │ ├── cart.component.html │ │ ├── cart.component.scss │ │ ├── cart.component.ts │ │ └── shared │ │ │ ├── cart.service.spec.ts │ │ │ └── cart.service.ts │ ├── checkout │ │ ├── address │ │ │ ├── address.component.html │ │ │ ├── address.component.scss │ │ │ └── address.component.ts │ │ ├── checkout.component.html │ │ ├── checkout.component.scss │ │ ├── checkout.component.ts │ │ ├── checkout.module.ts │ │ ├── complete │ │ │ ├── complete.component.html │ │ │ ├── complete.component.scss │ │ │ └── complete.component.ts │ │ ├── footer │ │ │ ├── footer.component.html │ │ │ ├── footer.component.scss │ │ │ └── footer.component.ts │ │ ├── payment │ │ │ ├── payment.component.html │ │ │ ├── payment.component.scss │ │ │ └── payment.component.ts │ │ ├── review │ │ │ ├── review.component.html │ │ │ ├── review.component.scss │ │ │ └── review.component.ts │ │ ├── shared │ │ │ ├── checkout.service.spec.ts │ │ │ └── checkout.service.ts │ │ ├── shipping │ │ │ ├── shipping.component.html │ │ │ ├── shipping.component.scss │ │ │ └── shipping.component.ts │ │ └── sidebar │ │ │ ├── sidebar.component.html │ │ │ ├── sidebar.component.scss │ │ │ └── sidebar.component.ts │ ├── core │ │ ├── content │ │ │ ├── content.component.html │ │ │ ├── content.component.scss │ │ │ ├── content.component.ts │ │ │ └── footer │ │ │ │ ├── footer.component.html │ │ │ │ ├── footer.component.scss │ │ │ │ └── footer.component.ts │ │ ├── core.module.ts │ │ ├── header │ │ │ ├── header.component.html │ │ │ ├── header.component.scss │ │ │ ├── header.component.ts │ │ │ ├── navigation-main │ │ │ │ ├── navigation-main.component.html │ │ │ │ ├── navigation-main.component.scss │ │ │ │ └── navigation-main.component.ts │ │ │ ├── search │ │ │ │ ├── search.component.html │ │ │ │ ├── search.component.scss │ │ │ │ └── search.component.ts │ │ │ └── toolbar │ │ │ │ └── cart │ │ │ │ ├── cart.component.html │ │ │ │ ├── cart.component.scss │ │ │ │ └── cart.component.ts │ │ ├── home │ │ │ ├── home.component.html │ │ │ ├── home.component.scss │ │ │ ├── home.component.ts │ │ │ ├── main-slider │ │ │ │ ├── main-slider.component.html │ │ │ │ ├── main-slider.component.scss │ │ │ │ └── main-slider.component.ts │ │ │ ├── product-widget │ │ │ │ ├── product-widget.component.html │ │ │ │ ├── product-widget.component.scss │ │ │ │ └── product-widget.component.ts │ │ │ └── promo │ │ │ │ ├── promo.component.html │ │ │ │ ├── promo.component.scss │ │ │ │ └── promo.component.ts │ │ ├── module-import-guard.ts │ │ ├── navigation-off-canvas │ │ │ ├── navigation-off-canvas.component.html │ │ │ ├── navigation-off-canvas.component.scss │ │ │ └── navigation-off-canvas.component.ts │ │ ├── page-title │ │ │ ├── page-title.component.html │ │ │ ├── page-title.component.scss │ │ │ └── page-title.component.ts │ │ ├── shared │ │ │ ├── offcanvas.service.ts │ │ │ └── promo.service.ts │ │ └── top-bar │ │ │ ├── top-bar.component.html │ │ │ ├── top-bar.component.scss │ │ │ └── top-bar.component.ts │ ├── messages │ │ ├── message.service.spec.ts │ │ └── message.service.ts │ ├── models │ │ ├── cart-item.model.ts │ │ ├── customer.model.ts │ │ ├── order.model.ts │ │ ├── product.model.ts │ │ ├── promo.model.ts │ │ ├── rating.model.ts │ │ └── user.model.ts │ ├── page-not-found │ │ ├── page-not-found.component.html │ │ ├── page-not-found.component.scss │ │ └── page-not-found.component.ts │ ├── pager │ │ └── pager.service.ts │ ├── products │ │ ├── product-detail │ │ │ ├── product-detail.component.html │ │ │ ├── product-detail.component.scss │ │ │ └── product-detail.component.ts │ │ ├── products-list-item │ │ │ ├── products-list-item.component.html │ │ │ ├── products-list-item.component.scss │ │ │ └── products-list-item.component.ts │ │ ├── products-list │ │ │ ├── products-list.component.html │ │ │ ├── products-list.component.scss │ │ │ └── products-list.component.ts │ │ ├── products.module.ts │ │ └── shared │ │ │ ├── file-upload.service.ts │ │ │ ├── product-rating.service.spec.ts │ │ │ ├── product-rating.service.ts │ │ │ ├── product.service.ts │ │ │ ├── products-cache.service.ts │ │ │ ├── productsUrl.ts │ │ │ ├── rating-stars │ │ │ ├── rating-stars.component.html │ │ │ ├── rating-stars.component.scss │ │ │ └── rating-stars.component.ts │ │ │ ├── sort.pipe.ts │ │ │ └── ui.service.ts │ └── shared │ │ ├── price │ │ ├── price.component.html │ │ ├── price.component.scss │ │ └── price.component.ts │ │ └── shared.module.ts ├── assets │ ├── .gitkeep │ ├── fonts │ │ ├── Pe-icon-7-stroke.eot │ │ ├── Pe-icon-7-stroke.svg │ │ ├── Pe-icon-7-stroke.ttf │ │ ├── Pe-icon-7-stroke.woff │ │ ├── feather-webfont.eot │ │ ├── feather-webfont.svg │ │ ├── feather-webfont.ttf │ │ ├── feather-webfont.woff │ │ ├── socicon.eot │ │ ├── socicon.svg │ │ ├── socicon.ttf │ │ └── socicon.woff │ └── js │ │ └── modernizr.js ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── img │ ├── 404_art.jpg │ ├── coming-soon-bg.jpg │ ├── credit-cards.png │ ├── default-skin.png │ ├── default-skin.svg │ ├── loading.gif │ ├── main-bg.jpg │ ├── map-marker.png │ ├── payment_methods.png │ ├── paypal.svg │ ├── preloader.gif │ ├── services │ │ ├── 01.png │ │ ├── 02.png │ │ ├── 03.png │ │ └── 04.png │ ├── user-ava-md.jpg │ └── user-cover-img.jpg ├── index.html ├── main.ts ├── polyfills.ts ├── scss │ ├── base │ │ ├── _scaffolding.scss │ │ └── _utilities.scss │ ├── components │ │ ├── _accordion.scss │ │ ├── _alert.scss │ │ ├── _banners.scss │ │ ├── _buttons.scss │ │ ├── _card.scss │ │ ├── _comments.scss │ │ ├── _countdown.scss │ │ ├── _dropdown.scss │ │ ├── _forms.scss │ │ ├── _gallery.scss │ │ ├── _icons.scss │ │ ├── _list-group.scss │ │ ├── _modal.scss │ │ ├── _navs.scss │ │ ├── _pagination.scss │ │ ├── _progress.scss │ │ ├── _social-buttons.scss │ │ ├── _steps.scss │ │ ├── _tables.scss │ │ ├── _tooltips.scss │ │ ├── _typography.scss │ │ └── _widgets.scss │ ├── helpers │ │ ├── _mixins.scss │ │ ├── _placeholders.scss │ │ └── _variables.scss │ ├── layout │ │ ├── _grid.scss │ │ ├── _header.scss │ │ ├── _offcanvas-menu.scss │ │ └── _section.scss │ └── styles.scss ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── typings.d.ts ├── tsconfig.json ├── tslint.json └── ux-testing ├── erika.jpg ├── task1_step1.png ├── task1_step2.png ├── task2_step1.png ├── ux-test.md └── uxTest.jpg /.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 | /server/compiled 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | #config values 13 | .env 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | 31 | # misc 32 | /.angular/cache 33 | /.sass-cache 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | npm-debug.log 38 | testem.log 39 | /typings 40 | .env 41 | server/compiled/ 42 | src/img/uploads/ 43 | src/assets/uploads 44 | 45 | # e2e 46 | /e2e/*.js 47 | /e2e/*.map 48 | 49 | # System Files 50 | .DS_Store 51 | Thumbs.db 52 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /data/firebase_security.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | ".read": false, 4 | ".write": false, 5 | "products": { 6 | ".read": true, 7 | ".write": "auth != null && root.child('users/' + auth.uid + '/roles/admin').exists() && root.child('users/' + auth.uid + '/roles/admin').val() === true", 8 | ".indexOn": ["date", "sale", "currentRating", "name"], 9 | "ratings": { 10 | ".write": "auth != null" 11 | } 12 | }, 13 | "users": { 14 | ".write": "auth != null", 15 | "$uid": { 16 | ".read": "$uid === auth.uid", 17 | ".write": "$uid === auth.uid" 18 | } 19 | }, 20 | "promos": { 21 | ".read": true, 22 | ".write": "auth != null && root.child('users/' + auth.uid + '/roles/admin').exists() && root.child('users/' + auth.uid + '/roles/admin').val() === true", 23 | }, 24 | "featured": { 25 | ".read": true, 26 | ".write": "auth != null && root.child('users/' + auth.uid + '/roles/admin').exists() && root.child('users/' + auth.uid + '/roles/admin').val() === true" 27 | }, 28 | "orders": { 29 | ".read": "auth != null && root.child('users/' + auth.uid + '/roles/admin').exists() && root.child('users/' + auth.uid + '/roles/admin').val() === true", 30 | ".write": true 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { ProductsPage } from './products.po'; 2 | 3 | describe('products page', () => { 4 | let page: ProductsPage; 5 | 6 | beforeEach(() => { 7 | page = new ProductsPage(); 8 | }); 9 | 10 | it('should have the right page title', () => { 11 | page.navigateTo(); 12 | expect(page.getTitleText()).toEqual('Products'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/products.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class ProductsPage { 4 | navigateTo() { 5 | return browser.get('/products'); 6 | } 7 | 8 | getTitleText() { 9 | browser.waitForAngularEnabled(false); 10 | return element(by.css('h1')).getText(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 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-devkit/build-angular/plugins/karma') 14 | ], 15 | client:{ 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ], 20 | fixWebpackSourcePaths: true 21 | }, 22 | 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9999, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shop", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve", 8 | "build": "ng build", 9 | "test": "ng test", 10 | "lint": "ng lint", 11 | "e2e": "ng e2e" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "^14.2.4", 16 | "@angular/common": "^14.2.4", 17 | "@angular/compiler": "^14.2.4", 18 | "@angular/core": "^14.2.4", 19 | "@angular/forms": "^14.2.4", 20 | "@angular/platform-browser": "^14.2.4", 21 | "@angular/platform-browser-dynamic": "^14.2.4", 22 | "@angular/router": "^14.2.4", 23 | "@angular/fire": "^7.4", 24 | "bootstrap": "^4.3.1", 25 | "core-js": "^3.3.2", 26 | "firebase": "^9.0", 27 | "modernizr": "^3.7.1", 28 | "ngx-siema": "^2.0.1", 29 | "ngx-toastr": "13.2.1", 30 | "rxjs": "^6.5.3", 31 | "rxjs-compat": "^6.5.3", 32 | "tslib": "^2.0.0", 33 | "zone.js": "~0.11.4" 34 | }, 35 | "devDependencies": { 36 | "@angular-devkit/build-angular": "^14.2.4", 37 | "@angular/cli": "^14.2.4", 38 | "@angular/compiler-cli": "^14.2.4", 39 | "@angular/language-service": "^14.2.4", 40 | "@types/dotenv": "^6.1.1", 41 | "@types/jasmine": "^3.4.4", 42 | "@types/jasminewd2": "^2.0.8", 43 | "@types/node": "^12.11.1", 44 | "@types/uuid": "^3.4.5", 45 | "codelyzer": "^5.1.2", 46 | "jasmine-core": "~3.5.0", 47 | "jasmine-spec-reporter": "~5.0.0", 48 | "karma": "~6.4.1", 49 | "karma-chrome-launcher": "~3.1.0", 50 | "karma-coverage-istanbul-reporter": "~3.0.2", 51 | "karma-jasmine": "~4.0.0", 52 | "karma-jasmine-html-reporter": "^1.5.0", 53 | "nodemon": "^1.19.4", 54 | "protractor": "~7.0.0", 55 | "ts-node": "~7.0.0", 56 | "tslint": "~6.1.0", 57 | "typescript": "~4.6.4" 58 | }, 59 | "resolutions": { 60 | "minimist": "^1.2.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /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: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: 'e2e/tsconfig.e2e.json' 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/account/account.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |
7 | 21 | 30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 | -------------------------------------------------------------------------------- /src/app/account/account.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .user-info-wrapper { 5 | position: relative; 6 | width: 100%; 7 | margin-bottom: -1px; 8 | padding: { 9 | top: ($cover-height - 30); 10 | bottom: 30px; 11 | } 12 | border: 1px solid $border-color; 13 | border-top-left-radius: $border-radius-lg; 14 | border-top-right-radius: $border-radius-lg; 15 | overflow: hidden; 16 | .user-cover { 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | width: 100%; 21 | height: $cover-height; 22 | background: { 23 | position: center; 24 | color: $gray-lighter; 25 | repeat: no-repeat; 26 | size: cover; 27 | } 28 | .tooltip .tooltip-inner { 29 | width: 230px; 30 | max-width: 100%; 31 | padding: 10px 15px; 32 | } 33 | } 34 | .info-label { 35 | display: block; 36 | position: absolute; 37 | top: 18px; 38 | right: 18px; 39 | height: 26px; 40 | padding: 0 12px; 41 | border-radius: 13px; 42 | background-color: $white-color; 43 | color: $gray-dark; 44 | font-size: floor(($font-size-base / 1.33)); //~12px 45 | line-height: 26px; 46 | box-shadow: 0 1px 5px 0 rgba(0, 0, 0, .18); 47 | cursor: pointer; 48 | > i { 49 | display: inline-block; 50 | margin-right: 3px; 51 | font-size: 1.2em; 52 | vertical-align: middle; 53 | } 54 | } 55 | .user-info { 56 | display: table; 57 | position: relative; 58 | width: 100%; 59 | padding: 0 18px; 60 | z-index: 5; 61 | .user-avatar, 62 | .user-data { 63 | display: table-cell; 64 | vertical-align: top; 65 | } 66 | .user-avatar { 67 | position: relative; 68 | width: $user-ava-size; 69 | > img { 70 | display: block; 71 | width: 100%; 72 | border: 5px solid $white-color; 73 | border-radius: 50%; 74 | } 75 | .edit-avatar { 76 | display: block; 77 | position: absolute; 78 | top: -2px; 79 | right: 2px; 80 | width: $btn-sm-height; 81 | height: $btn-sm-height; 82 | transition: opacity .3s; 83 | border-radius: 50%; 84 | background-color: $white-color; 85 | color: $gray-dark; 86 | line-height: $btn-sm-height - 2; 87 | box-shadow: 0 1px 5px 0 rgba(0, 0, 0, .2); 88 | cursor: pointer; 89 | opacity: 0; 90 | text: { 91 | align: center; 92 | decoration: none; 93 | } 94 | &::before { 95 | font: { 96 | family: feather; 97 | size: $font-size-base + 1; 98 | } 99 | content: '\e058'; 100 | } 101 | } 102 | &:hover .edit-avatar { opacity: 1; } 103 | } 104 | .user-data { 105 | padding: { 106 | top: 48px; 107 | left: 12px; 108 | } 109 | h4 { margin-bottom: 2px; } 110 | span { 111 | display: block; 112 | color: $gray; 113 | font-size: $font-size-xs; 114 | } 115 | } 116 | } 117 | & + .list-group .list-group-item:first-child { border-radius: 0; } 118 | } 119 | -------------------------------------------------------------------------------- /src/app/account/account.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { AuthService } from './shared/auth.service'; 4 | import { Router } from '@angular/router'; 5 | import { OrderService } from './orders/shared/order.service'; 6 | 7 | import { User } from '../models/user.model'; 8 | 9 | @Component({ 10 | selector: 'app-account', 11 | templateUrl: './account.component.html', 12 | styleUrls: ['./account.component.scss'] 13 | }) 14 | export class AccountComponent { 15 | public user: User; 16 | 17 | constructor( 18 | private authService: AuthService, 19 | public router: Router, 20 | public orderService: OrderService 21 | ) {} 22 | } 23 | -------------------------------------------------------------------------------- /src/app/account/account.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { SharedModule } from '../shared/shared.module'; 6 | 7 | import { ProfileComponent } from './profile/profile.component'; 8 | import { OrdersComponent } from './orders/orders.component'; 9 | import { RegisterLoginComponent } from './register-login/register-login.component'; 10 | import { AccountComponent } from './account.component'; 11 | 12 | @NgModule({ 13 | declarations: [ 14 | AccountComponent, 15 | ProfileComponent, 16 | OrdersComponent, 17 | RegisterLoginComponent 18 | ], 19 | imports: [ 20 | CommonModule, 21 | SharedModule, 22 | FormsModule, 23 | ReactiveFormsModule 24 | ], 25 | exports: [ 26 | SharedModule 27 | ] 28 | }) 29 | export class AccountModule {} 30 | -------------------------------------------------------------------------------- /src/app/account/orders/orders.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 23 | 26 | 27 | 28 | 29 |
Order #Date PurchasedStatusTotal
17 | {{order.number}} 18 | {{order.date | date:'mediumDate'}} 21 | {{order.status || 'In Progress'}} 22 | 24 | {{order.total | currency}} 25 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /src/app/account/orders/orders.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/account/orders/orders.component.scss -------------------------------------------------------------------------------- /src/app/account/orders/orders.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { 3 | CommonModule, 4 | DatePipe, 5 | DecimalPipe, 6 | CurrencyPipe 7 | } from '@angular/common'; 8 | 9 | import { Subscription } from 'rxjs'; 10 | 11 | import { OrderService } from './shared/order.service'; 12 | 13 | import { Order } from '../../models/order.model'; 14 | 15 | @Component({ 16 | selector: 'app-orders', 17 | templateUrl: './orders.component.html', 18 | styleUrls: ['./orders.component.scss'] 19 | }) 20 | export class OrdersComponent implements OnInit, OnDestroy { 21 | public orders: Order[]; 22 | private ordersSubscription: Subscription; 23 | 24 | constructor(public orderService: OrderService) {} 25 | 26 | ngOnInit() { 27 | this.ordersSubscription = this.orderService 28 | .getOrders() 29 | .subscribe((orders: Order[]) => { 30 | if (orders) { 31 | this.orders = orders.reverse(); 32 | } 33 | }); 34 | } 35 | 36 | ngOnDestroy() { 37 | this.ordersSubscription.unsubscribe(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/account/orders/shared/order.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnInit } from '@angular/core'; 2 | import { Observable , of , from as fromPromise } from 'rxjs'; 3 | import { switchMap } from 'rxjs/operators'; 4 | import { AngularFireDatabase } from '@angular/fire/compat/database'; 5 | 6 | import { Order } from '../../../models/order.model'; 7 | 8 | import { MessageService } from '../../../messages/message.service'; 9 | import { AuthService } from '../../shared/auth.service'; 10 | 11 | @Injectable() 12 | export class OrderService { 13 | constructor( 14 | private messageService: MessageService, 15 | private authService: AuthService, 16 | private store: AngularFireDatabase 17 | ) {} 18 | 19 | public getOrders() { 20 | return this.authService.user 21 | .pipe( 22 | switchMap((user) => { 23 | if (user) { 24 | const remoteUserOrders = `/users/${user.uid}/orders`; 25 | return this.store.list(remoteUserOrders).valueChanges(); 26 | } else { 27 | return of(null); 28 | } 29 | }) 30 | ); 31 | } 32 | 33 | public addUserOrder(order: Order, total: number, user: string) { 34 | const orderWithMetaData = { 35 | ...order, 36 | ...this.constructOrderMetaData(order), 37 | total 38 | }; 39 | 40 | const databaseOperation = this.store 41 | .list(`users/${user}/orders`) 42 | .push(orderWithMetaData) 43 | .then((response) => response, (error) => error); 44 | 45 | return fromPromise(databaseOperation); 46 | } 47 | 48 | public addAnonymousOrder(order: Order, total: number) { 49 | const orderWithMetaData = { 50 | ...order, 51 | ...this.constructOrderMetaData(order), 52 | total 53 | }; 54 | 55 | const databaseOperation = this.store 56 | .list('orders') 57 | .push(orderWithMetaData) 58 | .then((response) => response, (error) => error); 59 | 60 | return fromPromise(databaseOperation); 61 | } 62 | 63 | private constructOrderMetaData(order: Order) { 64 | return { 65 | number: (Math.random() * 10000000000).toString().split('.')[0], 66 | date: new Date().toString(), 67 | status: 'In Progress' 68 | }; 69 | } 70 | 71 | private handleError(operation = 'operation', result?: T) { 72 | return (error: any): Observable => { 73 | // TODO: send the error to remote logging infrastructure 74 | console.error(error); // log to console instead 75 | 76 | // TODO: better job of transforming error for user consumption 77 | this.messageService.addError(`${operation} failed: ${error.message}`); 78 | 79 | // Let the app keep running by returning an empty result. 80 | return of(result as T); 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/app/account/profile/profile.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | 7 | 8 |
9 |
10 |
11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |
26 |
27 | 28 | 29 |
30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |
39 |
40 | 43 |
44 |
45 | 46 |
47 |
48 |
49 | 50 | -------------------------------------------------------------------------------- /src/app/account/profile/profile.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/account/profile/profile.component.scss -------------------------------------------------------------------------------- /src/app/account/profile/profile.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { UntypedFormGroup, Validators, UntypedFormControl } from '@angular/forms'; 3 | 4 | import { AuthService } from '../shared/auth.service'; 5 | 6 | import { User } from '../../models/user.model'; 7 | import { Subscription } from 'rxjs'; 8 | 9 | @Component({ 10 | selector: 'app-profile', 11 | templateUrl: './profile.component.html', 12 | styleUrls: ['./profile.component.scss'] 13 | }) 14 | export class ProfileComponent implements OnInit, OnDestroy { 15 | private authSubscription: Subscription; 16 | public formProfile: UntypedFormGroup; 17 | public profileErrors: string; 18 | private user: User; 19 | 20 | constructor(private authService: AuthService) { } 21 | 22 | ngOnInit() { 23 | this.initFormGroup(); 24 | this.authSubscription = this.authService.user.subscribe( 25 | user => { 26 | if (user) { 27 | this.formProfile.patchValue({ 28 | firstName: user.firstName, 29 | lastName: user.lastName, 30 | email: user.email 31 | }); 32 | this.user = user; 33 | } 34 | } 35 | ); 36 | } 37 | 38 | private initFormGroup() { 39 | this.formProfile = new UntypedFormGroup({ 40 | firstName: new UntypedFormControl(null, Validators.required), 41 | lastName: new UntypedFormControl(null, Validators.required), 42 | email: new UntypedFormControl(null, Validators.email), 43 | password: new UntypedFormControl(null), 44 | confirmPassword: new UntypedFormControl(null), 45 | }); 46 | } 47 | 48 | public onSubmit() { 49 | 50 | // Update Email 51 | if (this.user.email !== this.formProfile.value.email) { 52 | this.authService.updateEmail(this.formProfile.value.email) 53 | .catch( 54 | error => { 55 | this.profileErrors = error.message; 56 | this.formProfile.patchValue({ email: this.user.email }); 57 | } 58 | ); 59 | } 60 | 61 | // Update Profile (Firstname, Lastname) 62 | if (this.user.firstName !== this.formProfile.value.firstName || this.user.lastName !== this.formProfile.value.lastName) { 63 | this.authService.updateProfile(this.formProfile.value); 64 | } 65 | 66 | // Update password 67 | if (this.formProfile.value.password && this.formProfile.value.confirmPassword 68 | && (this.formProfile.value.password === this.formProfile.value.confirmPassword)) { 69 | this.authService.updatePassword(this.formProfile.value.password) 70 | .catch( 71 | error => { 72 | this.profileErrors = error.message; 73 | } 74 | ); 75 | } 76 | } 77 | 78 | ngOnDestroy() { 79 | this.authSubscription.unsubscribe(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app/account/register-login/register-login.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | -------------------------------------------------------------------------------- /src/app/account/register-login/register-login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild, OnChanges } from '@angular/core'; 2 | import { 3 | UntypedFormGroup, 4 | ReactiveFormsModule, 5 | UntypedFormControl, 6 | Validators 7 | } from '@angular/forms'; 8 | import { Router, ActivatedRoute, Params } from '@angular/router'; 9 | import { MessageService } from '../../messages/message.service'; 10 | import { AuthService } from '../shared/auth.service'; 11 | 12 | @Component({ 13 | selector: 'app-register-login', 14 | templateUrl: './register-login.component.html', 15 | styleUrls: ['./register-login.component.scss'] 16 | }) 17 | export class RegisterLoginComponent implements OnInit { 18 | public loginForm: UntypedFormGroup; 19 | public registerForm: UntypedFormGroup; 20 | public registerErrors: string; 21 | 22 | constructor( 23 | private authenticationService: AuthService, 24 | private router: Router, 25 | private messageService: MessageService 26 | ) {} 27 | 28 | ngOnInit() { 29 | this.initLoginForm(); 30 | this.initRegisterForm(); 31 | } 32 | 33 | private initLoginForm() { 34 | this.loginForm = new UntypedFormGroup({ 35 | email: new UntypedFormControl(null, [Validators.required, Validators.email]), 36 | password: new UntypedFormControl(null, Validators.required) 37 | }); 38 | } 39 | 40 | private initRegisterForm() { 41 | this.registerForm = new UntypedFormGroup({ 42 | email: new UntypedFormControl(null, [Validators.required, Validators.email]), 43 | password: new UntypedFormControl(null, Validators.required), 44 | confirmPassword: new UntypedFormControl(null, Validators.required) 45 | }); 46 | } 47 | 48 | public onRegister() { 49 | if (this.registerForm.value.password !== this.registerForm.value.confirmPassword) { 50 | this.registerErrors = 'Passwords don\'t match!'; 51 | this.registerForm.controls.password.setErrors({ password: true }); 52 | this.registerForm.controls.confirmPassword.setErrors({ confirmPassword: true }); 53 | } else { 54 | this.authenticationService.emailSignUp(this.registerForm.value.email, this.registerForm.value.password) 55 | .then( 56 | () => { 57 | this.messageService.add('Account created successfully. Please login with your new credentials!'); 58 | this.loginForm.setValue({ email: this.registerForm.value.email, password: ''}); 59 | this.initRegisterForm(); 60 | }, 61 | (error) => { 62 | this.registerErrors = error.message; 63 | if (error.code === 'auth/weak-password') { 64 | this.registerForm.controls.password.setErrors({ password: true }); 65 | this.registerForm.controls.confirmPassword.setErrors({ confirmPassword: true }); 66 | } 67 | if (error.code === 'auth/email-already-in-use') { 68 | this.registerForm.controls.email.setErrors({ email: true }); 69 | } 70 | } 71 | ); 72 | } 73 | } 74 | 75 | public onLogin() { 76 | this.authenticationService 77 | .emailLogin(this.loginForm.value.email, this.loginForm.value.password) 78 | .then( 79 | () => { 80 | this.messageService.add('Login successful!'); 81 | this.router.navigate(['/home']); 82 | }, 83 | (error) => { 84 | if (error.code === 'auth/user-not-found') { 85 | this.loginForm.controls.email.setErrors({ email: true }); 86 | } 87 | if (error.code === 'auth/wrong-password') { 88 | this.loginForm.controls.password.setErrors({ password: true }); 89 | } 90 | } 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app/account/shared/user.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | CanActivate, 4 | ActivatedRouteSnapshot, 5 | RouterStateSnapshot, 6 | Router 7 | } from '@angular/router'; 8 | import { Observable } from 'rxjs'; 9 | import { AuthService } from './auth.service'; 10 | 11 | import { take , tap , map } from 'rxjs/operators'; 12 | 13 | @Injectable() 14 | export class UserGuard implements CanActivate { 15 | constructor(private authService: AuthService, private router: Router) {} 16 | 17 | public canActivate( 18 | next: ActivatedRouteSnapshot, 19 | state: RouterStateSnapshot 20 | ): Observable | Promise | boolean { 21 | return this.authService.user.pipe( 22 | take(1), 23 | map((user) => (user ? true : false)), 24 | tap((authorized) => { 25 | if (!authorized) { 26 | this.router.navigate(['/register-login']); 27 | } 28 | }) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/admin/add-edit/add-edit.component.scss: -------------------------------------------------------------------------------- 1 | // Progress bars 2 | @import '../../../scss/helpers/_variables.scss'; 3 | 4 | // 5 | // Progress Bars 6 | // -------------------------------------------------- 7 | 8 | .progress { 9 | height: auto; 10 | border-radius: ceil($progress-height / 2); 11 | background-color: darken($gray-lighter, 2%); 12 | font: { 13 | size: $font-size-xs; 14 | weight: 500; 15 | } 16 | line-height: $progress-height; 17 | } 18 | .progress-bar { 19 | height: $progress-height; 20 | background-color: $progress-bg; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/admin/admin.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { AddEditComponent } from './add-edit/add-edit.component'; 3 | import { SharedModule } from '../shared/shared.module'; 4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 5 | import { ProductsModule } from '../products/products.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | AddEditComponent 10 | ], 11 | imports: [ 12 | SharedModule, 13 | FormsModule, 14 | ReactiveFormsModule, 15 | ProductsModule 16 | ], 17 | exports: [ 18 | SharedModule, 19 | ProductsModule 20 | ] 21 | }) 22 | export class AdminModule {} 23 | -------------------------------------------------------------------------------- /src/app/admin/shared/admin.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | CanActivate, 4 | ActivatedRouteSnapshot, 5 | RouterStateSnapshot, 6 | Router 7 | } from '@angular/router'; 8 | import { Observable } from 'rxjs'; 9 | import { AuthService } from '../../account/shared/auth.service'; 10 | 11 | import { take , map , tap } from 'rxjs/operators'; 12 | 13 | @Injectable() 14 | export class AdminGuard implements CanActivate { 15 | constructor(private authService: AuthService, private router: Router) {} 16 | 17 | public canActivate( 18 | next: ActivatedRouteSnapshot, 19 | state: RouterStateSnapshot 20 | ): Observable | Promise | boolean { 21 | return this.authService.user.pipe( 22 | take(1), 23 | map((user) => (user && user.roles.admin ? true : false)), 24 | tap((authorized) => { 25 | if (!authorized) { 26 | this.router.navigate(['/register-login']); 27 | } 28 | }) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { AppComponent } from './app.component'; 4 | import { HomeComponent } from './core/home/home.component'; 5 | import { CartComponent } from './cart/cart.component'; 6 | import { AddEditComponent } from './admin/add-edit/add-edit.component'; 7 | import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; 8 | import { AdminGuard } from './admin/shared/admin.guard'; 9 | import { CheckoutComponent } from './checkout/checkout.component'; 10 | import { RegisterLoginComponent } from './account/register-login/register-login.component'; 11 | import { OrdersComponent } from './account/orders/orders.component'; 12 | import { ProfileComponent } from './account/profile/profile.component'; 13 | import { AccountComponent } from './account/account.component'; 14 | import { ProductsListComponent } from './products/products-list/products-list.component'; 15 | import { ProductDetailComponent } from './products/product-detail/product-detail.component'; 16 | import { CompleteComponent } from './checkout/complete/complete.component'; 17 | 18 | const routes: Routes = [ 19 | { path: '', redirectTo: '/home', pathMatch: 'full' }, 20 | { path: 'home', component: HomeComponent }, 21 | { path: 'products', component: ProductsListComponent }, 22 | { path: 'products/:id', component: ProductDetailComponent }, 23 | { path: 'cart', component: CartComponent }, 24 | { path: 'admin/add', component: AddEditComponent, canActivate: [AdminGuard] }, 25 | { 26 | path: 'admin/edit/:id', 27 | component: AddEditComponent, 28 | canActivate: [AdminGuard] 29 | }, 30 | { path: 'checkout', component: CheckoutComponent }, 31 | { path: 'register-login', component: RegisterLoginComponent }, 32 | { 33 | path: 'account', 34 | component: AccountComponent, 35 | children: [ 36 | { path: '', redirectTo: 'profile', pathMatch: 'full' }, 37 | { path: 'orders', component: OrdersComponent }, 38 | { path: 'profile', component: ProfileComponent } 39 | ] 40 | }, 41 | { path: 'order-complete', component: CompleteComponent }, 42 | { path: '**', component: PageNotFoundComponent } 43 | ]; 44 | 45 | @NgModule({ 46 | imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], 47 | exports: [RouterModule], 48 | providers: [ 49 | AdminGuard, 50 | ] 51 | }) 52 | export class AppRoutingModule { } 53 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { OffcanvasService } from './core/shared/offcanvas.service'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.scss'] 8 | }) 9 | export class AppComponent { 10 | public products: any; 11 | 12 | constructor(public offcanvasService: OffcanvasService) {} 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | // Modules 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { NgModule } from '@angular/core'; 5 | import { HttpClientModule } from '@angular/common/http'; 6 | import { ToastrModule } from 'ngx-toastr'; 7 | import { ProductsModule } from './products/products.module'; 8 | import { SharedModule } from './shared/shared.module'; 9 | import { CoreModule } from './core/core.module'; 10 | import { CheckoutModule } from './checkout/checkout.module'; 11 | import { AccountModule } from './account/account.module'; 12 | import { AdminModule } from './admin/admin.module'; 13 | import { AngularFireModule } from '@angular/fire/compat/'; 14 | import { AngularFireDatabaseModule } from '@angular/fire/compat/database'; 15 | import { AngularFireStorageModule } from '@angular/fire/compat/storage'; 16 | import { AngularFireAuthModule } from '@angular/fire/compat/auth'; 17 | import { environment } from '../environments/environment'; 18 | 19 | // Components 20 | import { AppComponent } from './app.component'; 21 | import { CartComponent } from './cart/cart.component'; 22 | import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; 23 | 24 | @NgModule({ 25 | declarations: [ 26 | AppComponent, 27 | CartComponent, 28 | PageNotFoundComponent 29 | ], 30 | imports: [ 31 | BrowserModule, 32 | BrowserAnimationsModule, 33 | AngularFireModule.initializeApp(environment.firebase), 34 | AngularFireDatabaseModule, 35 | AngularFireAuthModule, // imports firebase/auth, only needed for auth features, 36 | AngularFireStorageModule, // imports firebase/storage only needed for storage features 37 | HttpClientModule, 38 | SharedModule, 39 | ToastrModule.forRoot(), 40 | CoreModule, 41 | ProductsModule, 42 | CheckoutModule, 43 | AccountModule, 44 | AdminModule 45 | ], 46 | bootstrap: [AppComponent] 47 | }) 48 | export class AppModule {} 49 | -------------------------------------------------------------------------------- /src/app/cart/cart.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | 3 | import { Subscription } from 'rxjs'; 4 | 5 | import { CartService } from './shared/cart.service'; 6 | import { CartItem } from '../models/cart-item.model'; 7 | 8 | @Component({ 9 | selector: 'app-cart', 10 | templateUrl: './cart.component.html', 11 | styleUrls: ['./cart.component.scss'] 12 | }) 13 | export class CartComponent implements OnInit, OnDestroy { 14 | private cartSubscription: Subscription; 15 | public items: CartItem[]; 16 | public total: number; 17 | 18 | constructor(private cartService: CartService) {} 19 | 20 | ngOnInit() { 21 | this.items = this.cartService.getItems(); 22 | this.total = this.cartService.getTotal(); 23 | this.cartSubscription = this.cartService.itemsChanged.subscribe( 24 | (items: CartItem[]) => { 25 | this.items = items; 26 | this.total = this.cartService.getTotal(); 27 | } 28 | ); 29 | } 30 | 31 | public onClearCart(event) { 32 | event.preventDefault(); 33 | event.stopPropagation(); 34 | this.cartService.clearCart(); 35 | } 36 | 37 | public onRemoveItem(event, item: CartItem) { 38 | event.preventDefault(); 39 | event.stopPropagation(); 40 | this.cartService.removeItem(item); 41 | } 42 | 43 | public increaseAmount(item: CartItem) { 44 | this.cartService.updateItemAmount(item, item.amount + 1); 45 | } 46 | 47 | public decreaseAmount(item: CartItem) { 48 | const newAmount = item.amount === 1 ? 1 : item.amount - 1; 49 | this.cartService.updateItemAmount(item, newAmount); 50 | } 51 | 52 | public checkAmount(item: CartItem) { 53 | this.cartService.updateItemAmount( 54 | item, 55 | item.amount < 1 || !item.amount || isNaN(item.amount) ? 1 : item.amount 56 | ); 57 | } 58 | 59 | ngOnDestroy() { 60 | this.cartSubscription.unsubscribe(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/cart/shared/cart.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter, Injectable } from '@angular/core'; 2 | import { Product } from '../../models/product.model'; 3 | import { CartItem } from '../../models/cart-item.model'; 4 | import { MessageService } from '../../messages/message.service'; 5 | 6 | @Injectable() 7 | export class CartService { 8 | // Init and generate some fixtures 9 | private cartItems: CartItem[]; 10 | public itemsChanged: EventEmitter = new EventEmitter(); 11 | 12 | constructor(private messageService: MessageService) { 13 | this.cartItems = []; 14 | } 15 | 16 | public getItems() { 17 | return this.cartItems.slice(); 18 | } 19 | 20 | // Get Product ids out of CartItem[] in a new array 21 | private getItemIds() { 22 | return this.getItems().map(cartItem => cartItem.product.id); 23 | } 24 | 25 | public addItem(item: CartItem) { 26 | // If item is already in cart, add to the amount, otherwise push item into cart 27 | if (this.getItemIds().includes(item.product.id)) { 28 | this.cartItems.forEach(function (cartItem) { 29 | if (cartItem.product.id === item.product.id) { 30 | cartItem.amount += item.amount; 31 | } 32 | }); 33 | this.messageService.add('Amount in cart changed for: ' + item.product.name); 34 | } else { 35 | this.cartItems.push(item); 36 | this.messageService.add('Added to cart: ' + item.product.name); 37 | } 38 | this.itemsChanged.emit(this.cartItems.slice()); 39 | } 40 | 41 | public addItems(items: CartItem[]) { 42 | items.forEach((cartItem) => { 43 | this.addItem(cartItem); 44 | }); 45 | } 46 | 47 | public removeItem(item: CartItem) { 48 | const indexToRemove = this.cartItems.findIndex(element => element === item); 49 | this.cartItems.splice(indexToRemove, 1); 50 | this.itemsChanged.emit(this.cartItems.slice()); 51 | this.messageService.add('Deleted from cart: ' + item.product.name); 52 | } 53 | 54 | public updateItemAmount(item: CartItem, newAmount: number) { 55 | this.cartItems.forEach((cartItem) => { 56 | if (cartItem.product.id === item.product.id) { 57 | cartItem.amount = newAmount; 58 | } 59 | }); 60 | this.itemsChanged.emit(this.cartItems.slice()); 61 | this.messageService.add('Updated amount for: ' + item.product.name); 62 | } 63 | 64 | public clearCart() { 65 | this.cartItems = []; 66 | this.itemsChanged.emit(this.cartItems.slice()); 67 | this.messageService.add('Cleared cart'); 68 | } 69 | 70 | public getTotal() { 71 | let total = 0; 72 | this.cartItems.forEach((cartItem) => { 73 | total += cartItem.amount * cartItem.product.price; 74 | }); 75 | return total; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/app/checkout/address/address.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/checkout/address/address.component.scss -------------------------------------------------------------------------------- /src/app/checkout/address/address.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, OnDestroy } from '@angular/core'; 2 | import { NgForm, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms'; 3 | 4 | import { Subscription } from 'rxjs'; 5 | 6 | import { AuthService } from '../../account/shared/auth.service'; 7 | import { CheckoutService } from '../shared/checkout.service'; 8 | 9 | @Component({ 10 | selector: 'app-checkout-address', 11 | templateUrl: './address.component.html', 12 | styleUrls: ['./address.component.scss'] 13 | }) 14 | export class AddressComponent implements OnInit, OnDestroy { 15 | private authSubscription: Subscription; 16 | @Input() public user; 17 | public formAddress: UntypedFormGroup; 18 | public countries: string[]; 19 | 20 | constructor( 21 | private checkoutService: CheckoutService, 22 | private authService: AuthService 23 | ) {} 24 | 25 | ngOnInit() { 26 | this.initFormGroup(); 27 | 28 | this.authSubscription = this.authService.user.subscribe((user) => { 29 | if (user) { 30 | this.user = user; 31 | this.initFormGroup(); 32 | } 33 | }); 34 | } 35 | 36 | private initFormGroup() { 37 | this.countries = ['Switzerland']; 38 | this.formAddress = new UntypedFormGroup({ 39 | firstname: new UntypedFormControl( 40 | this.user && this.user.firstName, 41 | Validators.required 42 | ), 43 | lastname: new UntypedFormControl( 44 | this.user && this.user.lastName, 45 | Validators.required 46 | ), 47 | address1: new UntypedFormControl(null, Validators.required), 48 | address2: new UntypedFormControl(null), 49 | zip: new UntypedFormControl(null, [ 50 | Validators.required, 51 | Validators.pattern(/^\d\d\d\d$/) 52 | ]), 53 | city: new UntypedFormControl(null, Validators.required), 54 | email: new UntypedFormControl( 55 | this.user && this.user.email, 56 | Validators.email 57 | ), 58 | phone: new UntypedFormControl(null), 59 | company: new UntypedFormControl(null), 60 | country: new UntypedFormControl({ value: this.countries[0], disabled: false }) 61 | }); 62 | } 63 | 64 | public onContinue() { 65 | this.checkoutService.setCustomer(this.formAddress.value); 66 | this.checkoutService.nextStep(); 67 | } 68 | 69 | // Debug: Fill Form Helper MEthod 70 | public onFillForm(event: Event) { 71 | event.preventDefault(); 72 | this.formAddress.setValue({ 73 | firstname: 'Hans', 74 | lastname: 'Muster', 75 | address1: 'Musterstrasse 13', 76 | address2: '', 77 | zip: 1234, 78 | city: 'Musterhausen', 79 | email: 'hans.muster@muster.com', 80 | phone: '+41791234567', 81 | company: '', 82 | country: 'Switzerland' 83 | }); 84 | } 85 | 86 | ngOnDestroy() { 87 | this.authSubscription.unsubscribe(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/checkout/checkout.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | 7 |
8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | 19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /src/app/checkout/checkout.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .checkout-steps { 5 | margin-bottom: floor($grid-vertical-step * 1.7); // ~40px 6 | @include clearfix; 7 | 8 | > a, 9 | > button { 10 | display: block; 11 | position: relative; 12 | padding-right: 18px; 13 | width: 25%; 14 | height: 55px; 15 | float: left; 16 | transition: color .3s; 17 | border: none; 18 | 19 | border: { 20 | top: 1px solid $border-color; 21 | bottom: 1px solid $border-color; 22 | } 23 | 24 | background-color: $body-bg; 25 | color: $nav-link-color; 26 | 27 | font: { 28 | size: $nav-link-font-size; 29 | weight: $nav-link-font-weight; 30 | } 31 | 32 | line-height: 53px; 33 | 34 | text: { 35 | decoration: none; 36 | align: center; 37 | } 38 | 39 | > .angle { 40 | display: block; 41 | position: absolute; 42 | top: 0; 43 | right: 0px; 44 | width: 27px; 45 | height: 53px; 46 | background-color: $body-bg; 47 | 48 | &::before, 49 | &::after { 50 | position: absolute; 51 | top: 0; 52 | left: 0; 53 | width: 0; 54 | height: 0; 55 | border: solid transparent; 56 | content: ''; 57 | pointer-events: none; 58 | } 59 | 60 | &::after { 61 | border-width: 26px; 62 | border-color: transparent; 63 | border-left-color: $body-bg; 64 | } 65 | 66 | &::before { 67 | margin-top: -1px; 68 | border-width: 27px; 69 | border-color: transparent; 70 | border-left-color: darken($border-color, 3%); 71 | } 72 | } 73 | 74 | &.active { 75 | background-color: $nav-link-active-color; 76 | color: $white-color; 77 | cursor: default; 78 | pointer-events: none; 79 | 80 | > .angle::after { border-left-color: $nav-link-active-color; } 81 | } 82 | 83 | &.active-sibling { 84 | > .angle { background-color: $nav-link-active-color; } 85 | } 86 | 87 | &:first-child { 88 | border-left: 1px solid $border-color; 89 | border-top-left-radius: $border-radius-lg; 90 | border-bottom-left-radius: $border-radius-lg; 91 | } 92 | 93 | &:last-child { 94 | border-right: 1px solid $border-color; 95 | border-top-right-radius: $border-radius-lg; 96 | border-bottom-right-radius: $border-radius-lg; 97 | } 98 | } 99 | 100 | @media (max-width: $screen-sm) { 101 | 102 | > a { 103 | width: 100%; 104 | margin-bottom: 10px; 105 | float: none; 106 | border: 1px solid $border-color; 107 | border-radius: $border-radius-lg; 108 | > .angle { display: none; } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/app/checkout/checkout.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | 3 | import { Subscription } from 'rxjs'; 4 | 5 | import { CheckoutService } from './shared/checkout.service'; 6 | 7 | @Component({ 8 | selector: 'app-checkout', 9 | templateUrl: './checkout.component.html', 10 | styleUrls: ['./checkout.component.scss'] 11 | }) 12 | export class CheckoutComponent implements OnInit, OnDestroy { 13 | checkoutSubscription: Subscription; 14 | steps: string[]; 15 | activeStep: number; 16 | 17 | constructor(private checkoutService: CheckoutService) {} 18 | 19 | ngOnInit() { 20 | this.steps = ['1. Address', '2. Shipping', '3. Payment', '4. Review']; 21 | this.activeStep = this.checkoutService.activeStep; 22 | this.checkoutSubscription = this.checkoutService.stepChanged.subscribe((step: number) => { 23 | this.activeStep = step; 24 | }); 25 | } 26 | 27 | ngOnDestroy() { 28 | this.checkoutSubscription.unsubscribe(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/checkout/checkout.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { AddressComponent } from './address/address.component'; 3 | import { FooterComponent } from './footer/footer.component'; 4 | import { PaymentComponent } from './payment/payment.component'; 5 | import { ReviewComponent } from './review/review.component'; 6 | import { ShippingComponent } from './shipping/shipping.component'; 7 | import { SidebarComponent } from './sidebar/sidebar.component'; 8 | import { CheckoutComponent } from './checkout.component'; 9 | import { SharedModule } from '../shared/shared.module'; 10 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 11 | import { CompleteComponent } from './complete/complete.component'; 12 | 13 | @NgModule({ 14 | declarations: [ 15 | CheckoutComponent, 16 | AddressComponent, 17 | FooterComponent, 18 | PaymentComponent, 19 | ReviewComponent, 20 | ShippingComponent, 21 | SidebarComponent, 22 | CompleteComponent 23 | ], 24 | imports: [ 25 | SharedModule, 26 | FormsModule, 27 | ReactiveFormsModule 28 | ], 29 | exports: [ 30 | SharedModule, 31 | CheckoutComponent, 32 | AddressComponent, 33 | FooterComponent, 34 | PaymentComponent, 35 | ReviewComponent, 36 | ShippingComponent, 37 | SidebarComponent, 38 | FormsModule, 39 | ReactiveFormsModule 40 | ] 41 | }) 42 | export class CheckoutModule {} 43 | -------------------------------------------------------------------------------- /src/app/checkout/complete/complete.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |
7 |

Thank you for your order!

8 |

Your order has been placed and will be processed as soon as possible.

9 |

You will be receiving an email shortly with confirmation of your order. 10 |

11 |
12 | Go Back Shopping 13 |
14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /src/app/checkout/complete/complete.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/checkout/complete/complete.component.scss -------------------------------------------------------------------------------- /src/app/checkout/complete/complete.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-complete', 5 | templateUrl: './complete.component.html', 6 | styleUrls: ['./complete.component.scss'] 7 | }) 8 | export class CompleteComponent {} 9 | -------------------------------------------------------------------------------- /src/app/checkout/footer/footer.component.html: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /src/app/checkout/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .checkout-footer { 5 | display: table; 6 | width: 100%; 7 | margin-top: floor($grid-vertical-step * 1.2); 8 | padding: { 9 | top: 5px; 10 | bottom: 5px; 11 | } 12 | border: 1px solid $border-color; 13 | border-radius: $border-radius-lg; 14 | table-layout: fixed; 15 | 16 | > .column { 17 | display: table-cell; 18 | padding: 10px 15px; 19 | vertical-align: middle; 20 | &:last-child { text-align: right; } 21 | &:first-child { text-align: left; } 22 | } 23 | 24 | .btn { margin: 0; } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/checkout/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-checkout-footer', 5 | templateUrl: './footer.component.html', 6 | styleUrls: ['./footer.component.scss'] 7 | }) 8 | export class FooterComponent { 9 | @Input() buttons: string[]; 10 | @Input() continueEnabled: boolean; 11 | @Output() back: EventEmitter = new EventEmitter(); 12 | @Output() continue: EventEmitter = new EventEmitter(); 13 | @Output() completeOrder: EventEmitter = new EventEmitter(); 14 | 15 | onBack(e: Event) { 16 | this.back.emit(); 17 | } 18 | 19 | onContinue(e: Event) { 20 | this.continue.emit(); 21 | } 22 | 23 | onCompleteOrder(e: Event) { 24 | this.completeOrder.emit(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/checkout/payment/payment.component.html: -------------------------------------------------------------------------------- 1 |

2 | Choose Payment Method

3 |
4 |
5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 24 | 25 | 26 |
Payment method
16 | {{paymentMethod}}{{paymentMethod}} 17 | 19 | 23 |
27 |
28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /src/app/checkout/payment/payment.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/checkout/payment/payment.component.scss -------------------------------------------------------------------------------- /src/app/checkout/payment/payment.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; 3 | 4 | import { CheckoutService } from '../shared/checkout.service'; 5 | 6 | @Component({ 7 | selector: 'app-checkout-payment', 8 | templateUrl: './payment.component.html', 9 | styleUrls: ['./payment.component.scss'] 10 | }) 11 | export class PaymentComponent implements OnInit { 12 | public formPayment: UntypedFormGroup; 13 | public paypalLoggedIn: boolean; 14 | public paymentMethods: string[]; 15 | 16 | constructor(private checkoutService: CheckoutService) { } 17 | 18 | ngOnInit() { 19 | this.paypalLoggedIn = false; 20 | this.paymentMethods = ['Paypal', 'Prepayment']; 21 | this.formPayment = new UntypedFormGroup({ 22 | 'paymentMethod': new UntypedFormControl(this.paymentMethods[0], Validators.required) 23 | }); 24 | } 25 | 26 | public onPaypalLogin(event: Event) { 27 | this.paypalLoggedIn = true; 28 | } 29 | 30 | public onBack() { 31 | this.checkoutService.previousStep(); 32 | } 33 | 34 | public onContinue() { 35 | this.checkoutService.setPaymentMethod(this.formPayment.controls.paymentMethod.value); 36 | this.checkoutService.nextStep(); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/app/checkout/review/review.component.html: -------------------------------------------------------------------------------- 1 |

Review Your Order

2 |
3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 28 | 29 | 32 | 33 | 34 |
Product NameSubtotal
15 | 27 | {{ (item.product.price * item.amount) | currency }} 30 | Edit 31 |
35 |
36 | 42 |
43 |
44 |
Shipping to:
45 |
    46 |
  • 47 | Client:{{customer.firstname}} {{customer.lastname}}
  • 48 |
  • 49 | Address:{{customer.address1}} {{customer.address2}}
  • 50 |
  • 51 | Phone:{{customer.phone}}
  • 52 |
53 |
54 |
55 |
Payment method:
56 |
    57 |
  • 58 | {{ paymentMethod }}
  • 59 |
60 |
61 |
62 | 63 | 64 | -------------------------------------------------------------------------------- /src/app/checkout/review/review.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .shopping-cart { 5 | margin-bottom: floor($grid-vertical-step / 1.2); //~20px 6 | .table { margin-bottom: 0; } 7 | .btn { margin: 0; } 8 | > table > thead > tr, 9 | > table > tbody > tr { 10 | > th, 11 | > td { vertical-align: middle !important; } 12 | } 13 | > table thead th { 14 | padding: { 15 | top: 17px; 16 | bottom: 17px; 17 | } 18 | border-width: 1px; 19 | } 20 | .remove-from-cart { 21 | display: inline-block; 22 | color: $brand-danger; 23 | font-size: $font-size-lead; 24 | line-height: 1; 25 | text-decoration: none; 26 | } 27 | .count-input { 28 | display: inline-block; 29 | width: 100%; 30 | width: 170px; 31 | } 32 | .product-item { 33 | display: table; 34 | width: 100%; 35 | min-width: 150px; 36 | margin: { 37 | top: 5px; 38 | bottom: 3px; 39 | } 40 | .product-thumb, 41 | .product-info { 42 | display: table-cell; 43 | vertical-align: middle; 44 | } 45 | .product-thumb { 46 | width: ($cart-thumb-size + 20); 47 | padding-right: 20px; 48 | > img { 49 | display: block; 50 | width: 100%; 51 | } 52 | @media screen and (max-width: 860px) { display: none; } 53 | } 54 | .product-info span { 55 | display: block; 56 | font-size: $font-size-xs; 57 | > em { 58 | font: { 59 | weight: 500; 60 | style: normal; 61 | } 62 | } 63 | } 64 | .product-title { 65 | margin-bottom: floor($grid-vertical-step / 4); 66 | padding-top: 5px; 67 | font: { 68 | size: $font-size-base; 69 | weight: 500; 70 | } 71 | > a { 72 | transition: color .3s; 73 | color: $product-title-color; 74 | line-height: $line-height-base; 75 | text-decoration: none; 76 | &:hover { color: $nav-link-hover-color; } 77 | } 78 | small { 79 | display: inline; 80 | margin-left: 6px; 81 | font-weight: 500; 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/app/checkout/shared/checkout.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { CheckoutService } from './checkout.service'; 2 | import { Customer } from '../../models/customer.model'; 3 | import { Product } from '../../models/product.model'; 4 | import { CartItem } from '../../models/cart-item.model'; 5 | 6 | describe('CheckoutService', () => { 7 | it('should initialize with active step-number 0', () => { 8 | const comp = new CheckoutService(); 9 | 10 | expect(comp.activeStep).toBe(0); 11 | }); 12 | 13 | describe('should handle steps and', () => { 14 | let comp; 15 | beforeEach(() => { 16 | comp = new CheckoutService(); 17 | spyOn(comp.stepChanged, 'emit'); 18 | }); 19 | 20 | it('should increment steps on nextStep', () => { 21 | comp.nextStep(); 22 | 23 | expect(comp.activeStep).toBe(1); 24 | expect(comp.stepChanged.emit).toHaveBeenCalled(); 25 | }); 26 | 27 | it('should decrement steps on previousStep', () => { 28 | comp.nextStep(); 29 | comp.nextStep(); 30 | comp.previousStep(); 31 | 32 | expect(comp.activeStep).toBe(1); 33 | expect(comp.stepChanged.emit).toHaveBeenCalled(); 34 | }); 35 | 36 | it('should reset steps on reset', () => { 37 | comp.nextStep(); 38 | comp.nextStep(); 39 | comp.resetSteps(); 40 | 41 | expect(comp.activeStep).toBe(0); 42 | expect(comp.stepChanged.emit).toHaveBeenCalled(); 43 | }); 44 | }); 45 | 46 | describe('should handle additions and', () => { 47 | let comp; 48 | beforeEach(() => { 49 | comp = new CheckoutService(); 50 | spyOn(comp.orderInProgressChanged, 'emit'); 51 | }); 52 | 53 | it('should handle a shipping method', () => { 54 | comp.setPaymentMethod('pay-pal'); 55 | 56 | expect(comp.getOrderInProgress().paymentMethod).toBe('pay-pal'); 57 | expect(comp.orderInProgressChanged.emit).toHaveBeenCalled(); 58 | }); 59 | 60 | it('should handle a payment method', () => { 61 | comp.setShippingMethod('air freight'); 62 | 63 | expect(comp.getOrderInProgress().shippingMethod).toBe('air freight'); 64 | expect(comp.orderInProgressChanged.emit).toHaveBeenCalled(); 65 | }); 66 | 67 | it('should handle a customer', () => { 68 | const testCustomer = new Customer( 69 | 'Hans', 70 | 'Meier', 71 | '', 72 | '', 73 | 8000, 74 | '', 75 | '', 76 | '', 77 | '' 78 | ); 79 | 80 | comp.setCustomer(testCustomer); 81 | 82 | expect(comp.getOrderInProgress().customer).toEqual( 83 | new Customer('Hans', 'Meier', '', '', 8000, '', '', '', '') 84 | ); 85 | expect(comp.orderInProgressChanged.emit).toHaveBeenCalled(); 86 | }); 87 | 88 | it('should handle a cart item', () => { 89 | const testProduct = new Product(); 90 | const testCartItem = new CartItem(testProduct, 10); 91 | 92 | comp.setOrderItems([testCartItem]); 93 | 94 | expect(comp.getOrderInProgress().items).toEqual([ 95 | new CartItem(testProduct, 10) 96 | ]); 97 | expect(comp.orderInProgressChanged.emit).toHaveBeenCalled(); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/app/checkout/shared/checkout.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, EventEmitter } from '@angular/core'; 2 | import { Order } from '../../models/order.model'; 3 | import { Customer } from '../../models/customer.model'; 4 | import { CartItem } from '../../models/cart-item.model'; 5 | 6 | @Injectable() 7 | export class CheckoutService { 8 | private orderInProgress: Order; 9 | public orderInProgressChanged: EventEmitter = new EventEmitter(); 10 | public stepChanged: EventEmitter = new EventEmitter(); 11 | public activeStep: number; 12 | 13 | constructor() { 14 | this.orderInProgress = new Order(new Customer()); 15 | this.activeStep = 0; 16 | } 17 | 18 | public gotoStep(number) { 19 | this.activeStep = number; 20 | this.stepChanged.emit(this.activeStep); 21 | } 22 | 23 | public nextStep() { 24 | this.activeStep++; 25 | this.stepChanged.emit(this.activeStep); 26 | } 27 | 28 | previousStep() { 29 | this.activeStep--; 30 | this.stepChanged.emit(this.activeStep); 31 | } 32 | 33 | public resetSteps() { 34 | this.activeStep = 0; 35 | } 36 | 37 | public setCustomer(customer: Customer) { 38 | this.orderInProgress.customer = customer; 39 | this.orderInProgressChanged.emit(this.orderInProgress); 40 | } 41 | 42 | public setShippingMethod(shippingMethod: string) { 43 | this.orderInProgress.shippingMethod = shippingMethod; 44 | this.orderInProgressChanged.emit(this.orderInProgress); 45 | } 46 | 47 | public setOrderItems(items: CartItem[]) { 48 | this.orderInProgress.items = items; 49 | this.orderInProgressChanged.emit(this.orderInProgress); 50 | } 51 | 52 | public getOrderInProgress() { 53 | return this.orderInProgress; 54 | } 55 | 56 | public setPaymentMethod(paymentMethod: string) { 57 | this.orderInProgress.paymentMethod = paymentMethod; 58 | this.orderInProgressChanged.emit(this.orderInProgress); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/checkout/shipping/shipping.component.html: -------------------------------------------------------------------------------- 1 |

Choose Shipping Method

2 |
3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 27 | 28 | 29 |
Shipping methodDelivery timeHandling fee
17 | {{shippingMethod.method}} 18 | {{shippingMethod.time}}{{shippingMethod.fee | currency}} 22 | 26 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/app/checkout/shipping/shipping.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/checkout/shipping/shipping.component.scss -------------------------------------------------------------------------------- /src/app/checkout/shipping/shipping.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms'; 3 | import { CheckoutService } from '../shared/checkout.service'; 4 | import { Customer } from '../../models/customer.model'; 5 | 6 | @Component({ 7 | selector: 'app-checkout-shipping', 8 | templateUrl: './shipping.component.html', 9 | styleUrls: ['./shipping.component.scss'] 10 | }) 11 | export class ShippingComponent implements OnInit { 12 | public formShipping: UntypedFormGroup; 13 | public shippingMethods: {method: string, time: string, fee: number, value: string}[]; 14 | 15 | constructor(private checkoutService: CheckoutService) { } 16 | 17 | ngOnInit() { 18 | this.shippingMethods = [ 19 | { 20 | method: 'Swiss Post Priority', 21 | time: '1 - 2 days', 22 | fee: 11, 23 | value: 'priority' 24 | }, 25 | { 26 | method: 'Swiss Post Economy', 27 | time: 'up to one week', 28 | fee: 9, 29 | value: 'economy' 30 | } 31 | ]; 32 | this.formShipping = new UntypedFormGroup({ 33 | 'shippingMethod': new UntypedFormControl(this.shippingMethods[1].value, Validators.required) 34 | }); 35 | } 36 | 37 | public onBack() { 38 | this.checkoutService.previousStep(); 39 | } 40 | 41 | public onContinue() { 42 | this.checkoutService.setShippingMethod(this.formShipping.controls.shippingMethod.value); 43 | this.checkoutService.nextStep(); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/app/checkout/sidebar/sidebar.component.html: -------------------------------------------------------------------------------- 1 | 2 | 23 | 24 | -------------------------------------------------------------------------------- /src/app/checkout/sidebar/sidebar.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/checkout/sidebar/sidebar.component.scss -------------------------------------------------------------------------------- /src/app/checkout/sidebar/sidebar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { CartService } from '../../cart/shared/cart.service'; 4 | 5 | @Component({ 6 | selector: 'app-checkout-sidebar', 7 | templateUrl: './sidebar.component.html', 8 | styleUrls: ['./sidebar.component.scss'] 9 | }) 10 | export class SidebarComponent implements OnInit { 11 | public cartSubtotal: number; 12 | public shipping: number; 13 | public orderTotal: number; 14 | 15 | constructor(private cartService: CartService) {} 16 | 17 | ngOnInit() { 18 | this.cartSubtotal = this.cartService.getTotal(); 19 | // TODO: shipping, hardcoded for now 20 | this.shipping = 9; 21 | this.orderTotal = this.cartSubtotal + this.shipping; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/core/content/content.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | -------------------------------------------------------------------------------- /src/app/core/content/content.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/core/content/content.component.scss -------------------------------------------------------------------------------- /src/app/core/content/content.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { OffcanvasService } from '../shared/offcanvas.service'; 4 | 5 | @Component({ 6 | selector: 'app-content', 7 | templateUrl: './content.component.html', 8 | styleUrls: ['./content.component.scss'] 9 | }) 10 | export class ContentComponent { 11 | constructor(private offcanvasService: OffcanvasService) {} 12 | 13 | onMenuClose(e: Event) { 14 | this.offcanvasService.closeOffcanvasNavigation(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/core/content/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .site-footer { 5 | padding-top: floor($grid-vertical-step * 3); //~72px; 6 | background-color: $gray-darker; 7 | @media (max-width: $screen-md) { 8 | padding-top: floor($grid-vertical-step * 2); //~48px; 9 | } 10 | } 11 | .footer-copyright { 12 | margin: 0; 13 | padding: { 14 | top: 10px; 15 | bottom: $grid-vertical-step; // ~24px 16 | } 17 | color: rgba($white-color, .5); 18 | font: { 19 | size: $font-size-sm; 20 | weight: normal; 21 | } 22 | > a { 23 | transition: color .25s; 24 | color: rgba($white-color, .5); 25 | text-decoration: none; 26 | &:hover { color: $link-hover-color; } 27 | } 28 | } 29 | 30 | // Light Footer 31 | .footer-light { 32 | background-color: $gray-lighter; 33 | .footer-copyright { 34 | color: $gray; 35 | > a { 36 | color: $gray; 37 | &:hover { color: $link-hover-color; } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/core/content/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer', 5 | templateUrl: './footer.component.html', 6 | styleUrls: ['./footer.component.scss'] 7 | }) 8 | export class FooterComponent {} 9 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, Optional, SkipSelf } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { SharedModule } from '../shared/shared.module'; 4 | import { NgxSiemaModule } from 'ngx-siema'; 5 | 6 | import { ContentComponent } from './content/content.component'; 7 | import { HeaderComponent } from './header/header.component'; 8 | import { NavigationOffCanvasComponent } from './navigation-off-canvas/navigation-off-canvas.component'; 9 | import { TopBarComponent } from './top-bar/top-bar.component'; 10 | import { FooterComponent } from './content/footer/footer.component'; 11 | import { NavigationMainComponent } from './header/navigation-main/navigation-main.component'; 12 | import { ToolbarCartComponent } from './header/toolbar/cart/cart.component'; 13 | import { HomeComponent } from './home/home.component'; 14 | import { MainSliderComponent } from './home/main-slider/main-slider.component'; 15 | import { ProductWidgetComponent } from './home/product-widget/product-widget.component'; 16 | import { PromoComponent } from './home/promo/promo.component'; 17 | import { SearchComponent } from './header/search/search.component'; 18 | 19 | import { ProductService } from '../products/shared/product.service'; 20 | import { MessageService } from '../messages/message.service'; 21 | import { CartService } from '../cart/shared/cart.service'; 22 | import { PagerService } from '../pager/pager.service'; 23 | import { OrderService } from '../account/orders/shared/order.service'; 24 | import { CheckoutService } from '../checkout/shared/checkout.service'; 25 | import { AuthService } from '../account/shared/auth.service'; 26 | import { OffcanvasService } from './shared/offcanvas.service'; 27 | import { PromoService } from './shared/promo.service'; 28 | import { UiService } from '../products/shared/ui.service'; 29 | import { ProductsCacheService } from '../products/shared/products-cache.service'; 30 | 31 | import { throwIfAlreadyLoaded } from './module-import-guard'; 32 | 33 | 34 | @NgModule({ 35 | declarations: [ 36 | ContentComponent, 37 | HeaderComponent, 38 | NavigationOffCanvasComponent, 39 | TopBarComponent, 40 | FooterComponent, 41 | NavigationMainComponent, 42 | ToolbarCartComponent, 43 | HomeComponent, 44 | MainSliderComponent, 45 | ProductWidgetComponent, 46 | PromoComponent, 47 | SearchComponent 48 | ], 49 | imports: [ 50 | CommonModule, 51 | SharedModule, 52 | NgxSiemaModule.forRoot() 53 | ], 54 | exports: [ 55 | CommonModule, 56 | SharedModule, 57 | NavigationOffCanvasComponent, 58 | TopBarComponent, 59 | HeaderComponent, 60 | ContentComponent 61 | ], 62 | providers: [ 63 | ProductService, 64 | ProductsCacheService, 65 | MessageService, 66 | CartService, 67 | PagerService, 68 | OrderService, 69 | CheckoutService, 70 | AuthService, 71 | OffcanvasService, 72 | PromoService, 73 | UiService 74 | ] 75 | }) 76 | export class CoreModule { 77 | constructor(@Optional() @SkipSelf() parentModule: CoreModule) { 78 | throwIfAlreadyLoaded(parentModule, 'CoreModule'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/app/core/header/header.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | -------------------------------------------------------------------------------- /src/app/core/header/header.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | -------------------------------------------------------------------------------- /src/app/core/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | 4 | import { Subscription } from 'rxjs'; 5 | 6 | import { AuthService } from '../../account/shared/auth.service'; 7 | import { OffcanvasService } from '../shared/offcanvas.service'; 8 | 9 | import { User } from '../../models/user.model'; 10 | 11 | @Component({ 12 | selector: 'app-header', 13 | templateUrl: './header.component.html', 14 | styleUrls: ['./header.component.scss'] 15 | }) 16 | export class HeaderComponent implements OnInit, OnDestroy { 17 | private authSubscription: Subscription; 18 | public user: User; 19 | public showSearch; 20 | 21 | constructor( 22 | private authService: AuthService, 23 | private router: Router, 24 | private offcanvasService: OffcanvasService 25 | ) {} 26 | 27 | ngOnInit() { 28 | this.authSubscription = this.authService.user.subscribe((user) => { 29 | this.user = user; 30 | }); 31 | } 32 | 33 | public onLogOut(e: Event) { 34 | this.authService.signOut(); 35 | this.router.navigate(['/register-login']); 36 | e.preventDefault(); 37 | } 38 | 39 | public onMenuToggle(e: Event) { 40 | this.offcanvasService.openOffcanvasNavigation(); 41 | e.preventDefault(); 42 | } 43 | 44 | ngOnDestroy() { 45 | this.authSubscription.unsubscribe(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/core/header/navigation-main/navigation-main.component.html: -------------------------------------------------------------------------------- 1 | 2 | 26 | -------------------------------------------------------------------------------- /src/app/core/header/navigation-main/navigation-main.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | @import '~scss/helpers/placeholders'; 4 | 5 | // Main Navigation 6 | .site-menu { 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | width: 100%; 11 | height: 100%; 12 | text-align: center; 13 | z-index: 1; 14 | 15 | ul { 16 | margin: 0 auto; 17 | padding: 0; 18 | list-style: none; 19 | > li { 20 | 21 | > a { 22 | padding: 0 15px; 23 | transition: color .3s; 24 | color: $nav-link-color; 25 | 26 | font: { 27 | size: $nav-link-font-size; 28 | weight: $nav-link-font-weight; 29 | } 30 | 31 | text-decoration: none; 32 | } 33 | &:hover > a { color: $nav-link-hover-color; } 34 | &.active > a { color: $nav-link-active-color; } 35 | } 36 | } 37 | > ul { 38 | display: table; 39 | height: 100%; 40 | min-height: 100%; 41 | 42 | > li { 43 | display: table-cell; 44 | position: relative; 45 | vertical-align: middle; 46 | 47 | > a { 48 | display: table; 49 | height: 100%; 50 | min-height: 100%; 51 | border-top: 1px solid transparent; 52 | letter-spacing: .05em; 53 | text-transform: uppercase; 54 | > span { 55 | display: table-cell; 56 | vertical-align: middle; 57 | } 58 | } 59 | 60 | &.active > a { 61 | border-top-color: $nav-link-active-color; 62 | } 63 | } 64 | } 65 | } 66 | 67 | // Sub Menu 68 | .sub-menu { @extend %sub-menu; } 69 | .site-menu ul > li:hover { 70 | 71 | > .sub-menu { 72 | display: block; 73 | animation: submenu-show .3s cubic-bezier(.68, -.55, .265, 1.55); 74 | } 75 | 76 | > .mega-menu { 77 | display: table; 78 | animation: megamenu-show .45s cubic-bezier(.68, -.55, .265, 1.55); 79 | .sub-menu { 80 | animation: none; 81 | } 82 | } 83 | } 84 | 85 | // Mega Menu 86 | .mega-menu { @extend %mega-menu; } 87 | .site-menu > ul > li.has-megamenu { position: static; } 88 | -------------------------------------------------------------------------------- /src/app/core/header/navigation-main/navigation-main.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | 3 | import { Subscription } from 'rxjs'; 4 | 5 | import { AuthService } from '../../../account/shared/auth.service'; 6 | 7 | import { User } from '../../../models/user.model'; 8 | 9 | @Component({ 10 | selector: 'app-navigation-main', 11 | templateUrl: './navigation-main.component.html', 12 | styleUrls: ['./navigation-main.component.scss'] 13 | }) 14 | export class NavigationMainComponent implements OnInit, OnDestroy { 15 | public user: User; 16 | private authSubscription: Subscription; 17 | 18 | constructor(public authService: AuthService) {} 19 | 20 | ngOnInit() { 21 | this.authService.user.subscribe((user) => { 22 | this.user = user; 23 | }); 24 | } 25 | 26 | ngOnDestroy() { 27 | this.authSubscription.unsubscribe(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/core/header/search/search.component.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/app/core/header/search/search.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/core/header/search/search.component.scss -------------------------------------------------------------------------------- /src/app/core/header/search/search.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | OnInit, 4 | Input, 5 | OnChanges, 6 | EventEmitter, 7 | Output 8 | } from '@angular/core'; 9 | 10 | import { Subject } from 'rxjs'; 11 | import { debounceTime , distinctUntilChanged , filter , switchMap } from 'rxjs/operators'; 12 | 13 | import { ProductService } from '../../../products/shared/product.service'; 14 | 15 | @Component({ 16 | selector: 'app-search', 17 | templateUrl: './search.component.html', 18 | styleUrls: ['./search.component.scss'] 19 | }) 20 | export class SearchComponent implements OnInit { 21 | products: any[]; 22 | term$ = new Subject(); 23 | @Input() showSearch: boolean; 24 | @Output() onHideSearch = new EventEmitter(); 25 | 26 | constructor(private productService: ProductService) {} 27 | 28 | ngOnInit() { 29 | this.term$ 30 | .pipe( 31 | debounceTime(400), 32 | distinctUntilChanged(), 33 | filter((term) => term.length > 0), 34 | switchMap((term) => this.search(term)) 35 | ) 36 | .subscribe((results) => { 37 | this.products = results; 38 | }); 39 | } 40 | 41 | public search(term: string) { 42 | return this.productService.findProducts(term); 43 | } 44 | 45 | public onSearchInput(event) { 46 | let term = event.target.value; 47 | if (term.length > 0) { 48 | term = term.charAt(0).toUpperCase() + term.slice(1); 49 | this.term$.next(term); 50 | } else { 51 | this.products = []; 52 | this.term$.next(''); 53 | } 54 | } 55 | 56 | public onCloseSearch() { 57 | this.showSearch = false; 58 | this.onHideSearch.emit(false); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/core/header/toolbar/cart/cart.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | {{items.length}} 5 | {{ total | currency }} 6 |
7 |
8 | 20 |
21 | 22 |

23 | There are no items in your cart.. 24 |

25 |
26 |
27 |
28 | Total: 29 |
30 |
31 |   32 |
33 |
34 |
35 |
36 | View Cart 37 |
38 |
39 | Checkout 40 |
41 |
42 |
43 |
44 | -------------------------------------------------------------------------------- /src/app/core/header/toolbar/cart/cart.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/core/header/toolbar/cart/cart.component.scss -------------------------------------------------------------------------------- /src/app/core/header/toolbar/cart/cart.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | 3 | import { Subscription } from 'rxjs'; 4 | 5 | import { CartService } from '../../../../cart/shared/cart.service'; 6 | 7 | import { CartItem } from '../../../../models/cart-item.model'; 8 | 9 | @Component({ 10 | selector: 'app-toolbar-cart', 11 | templateUrl: './cart.component.html', 12 | styleUrls: ['./cart.component.scss'] 13 | }) 14 | export class ToolbarCartComponent implements OnInit, OnDestroy { 15 | public items: CartItem[]; 16 | public total: number; 17 | private cartSubscription: Subscription; 18 | 19 | constructor(private cartService: CartService) {} 20 | 21 | ngOnInit() { 22 | this.items = this.cartService.getItems(); 23 | this.total = this.cartService.getTotal(); 24 | this.cartSubscription = this.cartService.itemsChanged.subscribe( 25 | (items: CartItem[]) => { 26 | this.items = items; 27 | this.total = this.cartService.getTotal(); 28 | } 29 | ); 30 | } 31 | 32 | public onRemoveItem(event, item: CartItem) { 33 | event.stopPropagation(); 34 | this.cartService.removeItem(item); 35 | } 36 | 37 | ngOnDestroy() { 38 | this.cartSubscription.unsubscribe(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/core/home/home.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 |
17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 | Shipping 25 |
Free Worldwide Shipping
26 |

Free shipping for all orders over $100

27 |
28 |
29 | Money Back 30 |
Money Back Guarantee
31 |

We return money within 30 days

32 |
33 |
34 | Support 35 |
24/7 Customer Support
36 |

Friendly 24/7 customer support

37 |
38 |
39 | Payment 40 |
Secure Online Payment
41 |

We posess SSL / Secure Certificate

42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /src/app/core/home/home.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/core/home/home.component.scss -------------------------------------------------------------------------------- /src/app/core/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | 3 | import { Subject } from 'rxjs'; 4 | import { takeUntil } from 'rxjs/operators'; 5 | 6 | import { MessageService } from '../../messages/message.service'; 7 | import { ProductService } from '../../products/shared/product.service'; 8 | import { ProductsCacheService } from '../../products/shared/products-cache.service'; 9 | import { PromoService } from '../shared/promo.service'; 10 | 11 | import { Product } from '../../models/product.model'; 12 | import { Promo } from '../../models/promo.model'; 13 | 14 | @Component({ 15 | selector: 'app-home', 16 | templateUrl: './home.component.html', 17 | styleUrls: ['./home.component.scss'] 18 | }) 19 | export class HomeComponent implements OnInit, OnDestroy { 20 | private unsubscribe$ = new Subject(); 21 | public products: Product[]; 22 | public productsFeatured: any; 23 | public productsNewArrivals: Product[]; 24 | public productsOnSale: Product[]; 25 | public productsBestRated: Product[]; 26 | public promos: Promo[]; 27 | 28 | constructor( 29 | private messageService: MessageService, 30 | private productsCache: ProductsCacheService, 31 | private productService: ProductService, 32 | private promoService: PromoService 33 | ) {} 34 | 35 | ngOnInit() { 36 | this.productService 37 | .getProducts() 38 | .pipe(takeUntil(this.unsubscribe$)) 39 | .subscribe((products) => { 40 | this.products = products; 41 | }); 42 | 43 | this.productService 44 | .getFeaturedProducts() 45 | .pipe(takeUntil(this.unsubscribe$)) 46 | .subscribe( 47 | (products) => { 48 | this.productsFeatured = products; 49 | }, 50 | (err) => console.error(err) 51 | ); 52 | 53 | this.productService 54 | .getProductsByDate(3) 55 | .pipe(takeUntil(this.unsubscribe$)) 56 | .subscribe( 57 | (products) => { 58 | this.productsNewArrivals = products; 59 | }, 60 | (err) => console.error(err) 61 | ); 62 | 63 | this.productService 64 | .getProductsByRating(3) 65 | .pipe(takeUntil(this.unsubscribe$)) 66 | .subscribe( 67 | (products) => { 68 | this.productsBestRated = products; 69 | }, 70 | (err) => console.error(err) 71 | ); 72 | 73 | this.productService 74 | .getProductsQuery('sale', true, 3) 75 | .pipe(takeUntil(this.unsubscribe$)) 76 | .subscribe( 77 | (products) => { 78 | this.productsOnSale = products; 79 | }, 80 | (err) => console.error(err) 81 | ); 82 | 83 | this.promoService 84 | .getPromos() 85 | .pipe(takeUntil(this.unsubscribe$)) 86 | .subscribe((promos) => { 87 | this.promos = promos; 88 | }); 89 | } 90 | 91 | ngOnDestroy() { 92 | this.unsubscribe$.next(); 93 | this.unsubscribe$.complete(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/app/core/home/main-slider/main-slider.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
{{ item.name }}
11 |
starting at 12 | 13 |
14 |
15 | Shop now 16 |
17 |
18 |
19 | {{ item.name }} 20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 |
37 | -------------------------------------------------------------------------------- /src/app/core/home/main-slider/main-slider.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | @keyframes fadeOut { 5 | 0% { opacity: 1; } 6 | 100% { opacity: 0; } 7 | } 8 | 9 | // Hero Slider 10 | .hero-slider { 11 | width: 100%; 12 | min-height: $hero-slider-min-height; 13 | background: { 14 | position: center; 15 | color: $gray-lighter; 16 | repeat: no-repeat; 17 | size: cover; 18 | } 19 | overflow: hidden; 20 | 21 | @media (max-width: 1100px) { 22 | min-height: $hero-slider-min-height - 150; 23 | } 24 | } 25 | 26 | 27 | .hero-slider { 28 | position: relative; 29 | } 30 | 31 | .hero-slider-prev, 32 | .hero-slider-next { 33 | position: absolute; 34 | top: 50%; 35 | transform: translateY(-50%); 36 | border: none; 37 | background: #ffffff; 38 | border-radius: 50%; 39 | width: 55px; 40 | height: 55px; 41 | border: 1px solid #e1e7ec; 42 | color: #374250; 43 | cursor: pointer; 44 | opacity: 0.7; 45 | 46 | &:hover { 47 | opacity: 1; 48 | } 49 | 50 | @media screen and (max-width: 992px) { 51 | display: none; 52 | } 53 | } 54 | 55 | .hero-slider-prev { 56 | left: 15px; 57 | 58 | &::before { 59 | font: { 60 | family: feather; 61 | size: 19px; 62 | } 63 | color: #374250; 64 | content: '\e094'; 65 | } 66 | } 67 | 68 | .hero-slider-next { 69 | right: 15px; 70 | 71 | &::before { 72 | font-family: feather; 73 | font-size: 19px; 74 | color: #374250; 75 | content: '\e095'; 76 | } 77 | } 78 | 79 | .hero-slider-dots { 80 | position: absolute; 81 | bottom: 0; 82 | margin: 0; 83 | text-align: center; 84 | transform: translateX(-50%); 85 | border-top-left-radius: 7px; 86 | border-top-right-radius: 7px; 87 | background-color: #606975; 88 | display: inline-block; 89 | left: 50%; 90 | width: auto; 91 | padding: 10px 22px 14px; 92 | } 93 | 94 | .hero-slider-dot { 95 | cursor: pointer; 96 | display: inline-block; 97 | width: 6px; 98 | height: 6px; 99 | margin: 0 6px; 100 | transition: opacity .25s; 101 | border-radius: 50%; 102 | background-color: #ffffff; 103 | opacity: 0.5; 104 | 105 | &.active { 106 | opacity: 1; 107 | } 108 | } 109 | 110 | .image-wrapper { 111 | 112 | &.loading { 113 | min-height: 400px; 114 | background: url('../../../../img/loading.gif') center center no-repeat; 115 | } 116 | } 117 | 118 | -------------------------------------------------------------------------------- /src/app/core/home/main-slider/main-slider.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, OnDestroy } from '@angular/core'; 2 | import { NgxSiemaOptions, NgxSiemaService } from 'ngx-siema'; 3 | 4 | import { Subject } from 'rxjs'; 5 | import { takeUntil } from 'rxjs/operators'; 6 | 7 | @Component({ 8 | selector: 'app-main-slider', 9 | templateUrl: './main-slider.component.html', 10 | styleUrls: ['./main-slider.component.scss'] 11 | }) 12 | export class MainSliderComponent implements OnInit, OnDestroy { 13 | private unsubscribe$ = new Subject(); 14 | @Input() public items: any[]; 15 | public currentSlide: number; 16 | public imagesLoaded: string[]; 17 | 18 | public options: NgxSiemaOptions = { 19 | selector: '.siema', 20 | duration: 200, 21 | easing: 'ease-out', 22 | perPage: 1, 23 | startIndex: 0, 24 | draggable: true, 25 | threshold: 20, 26 | loop: false, 27 | onInit: () => { 28 | // runs immediately after first initialization 29 | }, 30 | onChange: () => { 31 | // runs after slide change 32 | } 33 | }; 34 | 35 | constructor(private ngxSiemaService: NgxSiemaService) {} 36 | 37 | ngOnInit() { 38 | this.currentSlide = 0; 39 | this.imagesLoaded = []; 40 | } 41 | 42 | public prev() { 43 | if (this.currentSlide > 0) { 44 | this.ngxSiemaService 45 | .prev(1) 46 | .pipe(takeUntil(this.unsubscribe$)) 47 | .subscribe((data: any) => { 48 | this.currentSlide = data.currentSlide; 49 | }); 50 | } 51 | } 52 | 53 | public next() { 54 | if (this.currentSlide < this.items.length - 1) { 55 | this.ngxSiemaService 56 | .next(1) 57 | .pipe(takeUntil(this.unsubscribe$)) 58 | .subscribe((data: any) => { 59 | this.currentSlide = data.currentSlide; 60 | }); 61 | } 62 | } 63 | 64 | public goTo(index: number) { 65 | this.ngxSiemaService 66 | .goTo(index) 67 | .pipe(takeUntil(this.unsubscribe$)) 68 | .subscribe((data: any) => { 69 | this.currentSlide = data.currentSlide; 70 | }); 71 | } 72 | 73 | public onImageLoad(e: any) { 74 | this.imagesLoaded.push(e.target.src); 75 | } 76 | 77 | ngOnDestroy() { 78 | this.unsubscribe$.next(); 79 | this.unsubscribe$.complete(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app/core/home/product-widget/product-widget.component.html: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/app/core/home/product-widget/product-widget.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/core/home/product-widget/product-widget.component.scss -------------------------------------------------------------------------------- /src/app/core/home/product-widget/product-widget.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { Product } from '../../../models/product.model'; 4 | 5 | @Component({ 6 | selector: 'app-product-widget', 7 | templateUrl: './product-widget.component.html', 8 | styleUrls: ['./product-widget.component.scss'] 9 | }) 10 | export class ProductWidgetComponent { 11 | @Input() public products: Product[]; 12 | @Input() public widgetTitle: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/core/home/promo/promo.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |
7 |

{{ promo.preHeading }}

8 |

{{ promo.heading }}

9 |

{{ promo.afterHeading }}

10 |
11 | {{ promo.buttonText }} 12 |
13 |
14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /src/app/core/home/promo/promo.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/core/home/promo/promo.component.scss -------------------------------------------------------------------------------- /src/app/core/home/promo/promo.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Promo } from '../../../models/promo.model'; 3 | 4 | @Component({ 5 | selector: 'app-promo', 6 | templateUrl: './promo.component.html', 7 | styleUrls: ['./promo.component.scss'] 8 | }) 9 | export class PromoComponent { 10 | @Input() public promo: Promo; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/core/module-import-guard.ts: -------------------------------------------------------------------------------- 1 | export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) { 2 | if (parentModule) { 3 | throw new Error(`${moduleName} has already been loaded. Import Core modules in the AppModule only.`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/app/core/navigation-off-canvas/navigation-off-canvas.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/core/navigation-off-canvas/navigation-off-canvas.component.scss -------------------------------------------------------------------------------- /src/app/core/navigation-off-canvas/navigation-off-canvas.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | 4 | import { Subscription } from 'rxjs'; 5 | 6 | import { AuthService } from '../../account/shared/auth.service'; 7 | import { OffcanvasService } from '../shared/offcanvas.service'; 8 | 9 | import { User } from '../../models/user.model'; 10 | 11 | @Component({ 12 | selector: 'app-navigation-off-canvas', 13 | templateUrl: './navigation-off-canvas.component.html', 14 | styleUrls: ['./navigation-off-canvas.component.scss'] 15 | }) 16 | export class NavigationOffCanvasComponent implements OnInit, OnDestroy { 17 | private authSubscription: Subscription; 18 | public user: User; 19 | 20 | constructor( 21 | public offcanvasService: OffcanvasService, 22 | public authService: AuthService, 23 | private router: Router 24 | ) {} 25 | 26 | ngOnInit() { 27 | this.authSubscription = this.authService.user.subscribe((user) => { 28 | this.user = user; 29 | }); 30 | } 31 | 32 | public onLogout(e: Event) { 33 | this.offcanvasService.closeOffcanvasNavigation(); 34 | this.authService.signOut(); 35 | this.router.navigate(['/register-login']); 36 | e.preventDefault(); 37 | } 38 | 39 | public onNavigationClick() { 40 | this.offcanvasService.closeOffcanvasNavigation(); 41 | } 42 | 43 | ngOnDestroy() { 44 | this.authSubscription.unsubscribe(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/core/page-title/page-title.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |

{{ title }}

6 |
7 |
8 | 20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /src/app/core/page-title/page-title.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | // Page Title 5 | .page-title { 6 | width: 100%; 7 | margin-bottom: ceil($grid-vertical-step * 2.5); //~60px 8 | padding: floor($grid-vertical-step * 1.5) 0; //~36px 0 9 | border-bottom: 1px solid $border-color; 10 | background-color: $gray-lighter; 11 | > .container, 12 | > .container-fluid { display: table; } 13 | .column { 14 | display: table-cell; 15 | vertical-align: middle; 16 | &:first-child { padding-right: 20px; } 17 | } 18 | h1, h2, h3 { 19 | margin: 0; 20 | font: { 21 | size: $font-size-h3; 22 | weight: normal; 23 | } 24 | line-height: $line-height-h3; 25 | } 26 | @media (max-width: $screen-sm) { margin-bottom: ceil($grid-vertical-step * 2.2); } 27 | } 28 | 29 | // Breadcrumbs 30 | .breadcrumbs { 31 | display: block; 32 | margin: 0; 33 | padding: 0; 34 | list-style: none; 35 | text-align: right; 36 | > li { 37 | display: inline-block; 38 | margin-left: 5px; 39 | padding: 5px 0; 40 | color: $gray; 41 | font-size: $font-size-sm; 42 | cursor: default; 43 | vertical-align: middle; 44 | &.separator { 45 | width: 3px; 46 | height: 3px; 47 | margin-top: 2px; 48 | padding: 0; 49 | border-radius: 50%; 50 | background-color: $gray;; 51 | } 52 | > a { 53 | transition: color .25s; 54 | color: $nav-link-color; 55 | text-decoration: none; 56 | &:hover { color: $nav-link-hover-color; } 57 | } 58 | } 59 | } 60 | 61 | // Media query (max-width: 768px) 62 | @media (max-width: $screen-md) { 63 | .page-title { 64 | > .container, 65 | > .container-fluid { display: block; } 66 | .column { 67 | display: block; 68 | width: 100%; 69 | text-align: center; 70 | &:first-child { padding-right: 0; } 71 | } 72 | } 73 | .breadcrumbs { 74 | padding-top: 10px; 75 | text-align: center; 76 | > li { 77 | margin: { 78 | left: 3px; 79 | margin-right: 3px; 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/app/core/page-title/page-title.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-page-title', 5 | templateUrl: './page-title.component.html', 6 | styleUrls: ['./page-title.component.scss'] 7 | }) 8 | export class PageTitleComponent { 9 | @Input() public title: string; 10 | @Input() public children: {title: string, link: string}[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/core/shared/offcanvas.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject } from 'rxjs'; 3 | 4 | @Injectable() 5 | export class OffcanvasService { 6 | public offcanvasNavigationOpen: BehaviorSubject = new BehaviorSubject(false); 7 | 8 | public toggleOffcanvasNavigation() { 9 | const state = !this.offcanvasNavigationOpen.getValue(); 10 | this.offcanvasNavigationOpen.next(state); 11 | } 12 | 13 | public openOffcanvasNavigation() { 14 | this.offcanvasNavigationOpen.next(true); 15 | } 16 | 17 | public closeOffcanvasNavigation() { 18 | this.offcanvasNavigationOpen.next(false); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/core/shared/promo.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AngularFireDatabase } from '@angular/fire/compat/database'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | import { Promo } from '../../models/promo.model'; 7 | 8 | @Injectable() 9 | export class PromoService { 10 | constructor(private angularFireDatabase: AngularFireDatabase) {} 11 | 12 | getPromos(): Observable { 13 | return this.angularFireDatabase.list('promos').valueChanges(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/core/top-bar/top-bar.component.html: -------------------------------------------------------------------------------- 1 | 2 | 22 | -------------------------------------------------------------------------------- /src/app/core/top-bar/top-bar.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .topbar { 5 | display: table; 6 | position: relative; 7 | width: 100%; 8 | height: 40px; 9 | padding: 0 30px; 10 | border-bottom: 1px solid $border-color; 11 | background-color: $gray-lighter; 12 | z-index: 9010; 13 | .topbar-column { 14 | display: table-cell; 15 | width: 50%; 16 | vertical-align: middle; 17 | &:last-child { text-align: right; } 18 | &:first-child { text-align: left; } 19 | a:not(.social-button), span, p { 20 | color: $nav-link-color; 21 | font-size: $font-size-xs; 22 | } 23 | > a:not(.social-button), > span, > p { 24 | display: inline-block; 25 | margin: { 26 | top: 5px; 27 | bottom: 5px; 28 | } 29 | > i { margin-top: -3px; } 30 | > i.icon-download { margin-top: -4px; } 31 | } 32 | a:not(.social-button) { 33 | transition: color .3s; 34 | text-decoration: none; 35 | &:hover { color: $nav-link-hover-color; } 36 | } 37 | } 38 | .topbar-column:last-child { 39 | > a:not(.social-button), > span, > p { 40 | margin-left: 20px; 41 | } 42 | } 43 | .topbar-column:first-child { 44 | > a:not(.social-button), > span, > p { 45 | margin-right: 20px; 46 | } 47 | } 48 | 49 | // Ghost Version 50 | &.topbar-ghost { 51 | position: absolute; 52 | top: 0; 53 | left: 0; 54 | border-bottom-color: rgba($white-color, .15); 55 | background-color: rgba($white-color, .05); 56 | .topbar-column { 57 | a:not(.social-button):not(.dropdown-item), span, p { color: $white-color; } 58 | a:not(.social-button):not(.dropdown-item):hover { 59 | color: $nav-link-hover-color; 60 | } 61 | } 62 | .lang-currency-switcher-wrap .lang-currency-switcher > .currency { 63 | border-left-color: rgba($white-color, .15); 64 | } 65 | .dropdown-toggle::after { color: $white-color; } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/core/top-bar/top-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-top-bar', 5 | templateUrl: './top-bar.component.html', 6 | styleUrls: ['./top-bar.component.scss'] 7 | }) 8 | export class TopBarComponent {} 9 | -------------------------------------------------------------------------------- /src/app/messages/message.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { MessageService } from './message.service'; 4 | import { ToastrService } from 'ngx-toastr'; 5 | 6 | describe('MessageService', () => { 7 | let messageService: MessageService; 8 | let toastrServiceSpy: jasmine.SpyObj; 9 | 10 | beforeEach(() => { 11 | const spy = jasmine.createSpyObj('ToastrService', ['success', 'error']); 12 | 13 | TestBed.configureTestingModule({ 14 | providers: [MessageService, { provide: ToastrService, useValue: spy }] 15 | }); 16 | 17 | messageService = TestBed.get(MessageService); 18 | toastrServiceSpy = TestBed.get(ToastrService); 19 | }); 20 | 21 | it('should be created', () => { 22 | expect(messageService).toBeTruthy(); 23 | }); 24 | 25 | it('should init messages array', () => { 26 | expect(messageService['messages']).toEqual([]); 27 | }); 28 | 29 | it('should handle adding messages', () => { 30 | messageService.add('hello world'); 31 | expect(messageService['messages']).toEqual(['hello world']); 32 | expect(toastrServiceSpy.success).toHaveBeenCalled(); 33 | }); 34 | 35 | it('should handle adding errors', () => { 36 | messageService.addError('My nasty error!'); 37 | expect(messageService['messages']).toEqual([]); 38 | expect(toastrServiceSpy.success).toHaveBeenCalledTimes(0); 39 | expect(toastrServiceSpy.error).toHaveBeenCalled(); 40 | }); 41 | 42 | it('should handle clearing', () => { 43 | messageService.add('hello world'); 44 | expect(messageService['messages']).toEqual(['hello world']); 45 | messageService.clear(); 46 | expect(messageService['messages']).toEqual([]); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/app/messages/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ToastrService } from 'ngx-toastr'; 3 | 4 | @Injectable() 5 | export class MessageService { 6 | private messages: string[] = []; 7 | private toastrConfig: {} = { 8 | disableTimeOut: false, 9 | closeButton: false, 10 | positionClass: 'toast-bottom-right' 11 | }; 12 | 13 | constructor(private toastr: ToastrService) {} 14 | 15 | public add(message: string): void { 16 | this.messages.push(message); 17 | this.toastr.success(message, 'Message:', this.toastrConfig); 18 | } 19 | 20 | public addError(message: string): void { 21 | this.toastr.error(message, 'Message:', this.toastrConfig); 22 | } 23 | 24 | public clear(): void { 25 | this.messages = []; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/models/cart-item.model.ts: -------------------------------------------------------------------------------- 1 | import { Product } from './product.model'; 2 | 3 | export class CartItem { 4 | constructor(public product: Product, public amount: number) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/app/models/customer.model.ts: -------------------------------------------------------------------------------- 1 | export class Customer { 2 | constructor( 3 | public firstname: string = '', 4 | public lastname: string = '', 5 | public address1: string = '', 6 | public address2: string = '', 7 | public zip: number = null, 8 | public city: string = '', 9 | public email: string = '', 10 | public phone: string = '', 11 | public company: string = '', 12 | public country: string = '' 13 | ) {} 14 | } 15 | -------------------------------------------------------------------------------- /src/app/models/order.model.ts: -------------------------------------------------------------------------------- 1 | import { CartItem } from './cart-item.model'; 2 | import { Customer } from './customer.model'; 3 | 4 | export class Order { 5 | constructor( 6 | public customer: Customer = null, 7 | public items: CartItem[] = null, 8 | public total: number = null, 9 | public status: string = '', 10 | public number: string = '', 11 | public date: string = new Date().toISOString().split('T')[0], 12 | public shippingMethod: string = '', 13 | public paymentMethod: string = '' 14 | ) {} 15 | } 16 | -------------------------------------------------------------------------------- /src/app/models/product.model.ts: -------------------------------------------------------------------------------- 1 | export class Product { 2 | public imageFeaturedUrl?; 3 | 4 | constructor( 5 | public id: number = 1, 6 | public date: string = new Date().toISOString().split('T')[0], 7 | public name: string = '', 8 | public description: string = '', 9 | public price: number = 0, 10 | public priceNormal: number = 0, 11 | public reduction: number = 0, 12 | public imageURLs: string[] = [], 13 | public imageRefs: string[] = [], 14 | public categories: {} = {}, 15 | public ratings: {} = {}, 16 | public currentRating: number = 0, 17 | public sale: boolean = false 18 | ) {} 19 | } 20 | -------------------------------------------------------------------------------- /src/app/models/promo.model.ts: -------------------------------------------------------------------------------- 1 | export class Promo { 2 | public preHeading?: string; 3 | public heading: string; 4 | public afterHeading?: string; 5 | public imageUrl: string; 6 | public buttonText?: string; 7 | public link?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/models/rating.model.ts: -------------------------------------------------------------------------------- 1 | export class Rating { 2 | public userUid: string; 3 | public rating: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Order } from './order.model'; 2 | 3 | export interface Roles { 4 | admin: boolean; 5 | } 6 | 7 | export class User { 8 | public email: string; 9 | public photoURL?: string; 10 | public roles?: Roles; 11 | public firstName?: string; 12 | public lastName?: string; 13 | public password?: string; 14 | public orders?: object; 15 | public confirmPassword?: string; 16 | public uid?: string; 17 | 18 | constructor(authData) { 19 | this.email = authData.email; 20 | this.firstName = authData.firstName ? authData.firstName : ''; 21 | this.lastName = authData.lastName ? authData.lastName : ''; 22 | this.roles = { 23 | admin: false 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/page-not-found/page-not-found.component.html: -------------------------------------------------------------------------------- 1 |
2 | 404 3 |
4 |

Page Not Found

5 |

It seems we can’t find page you are looking for. 6 | Go back to Homepage 7 |
Or try using search at the top right corner of the page.

8 |
9 |
10 | -------------------------------------------------------------------------------- /src/app/page-not-found/page-not-found.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/page-not-found/page-not-found.component.scss -------------------------------------------------------------------------------- /src/app/page-not-found/page-not-found.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-page-not-found', 5 | templateUrl: './page-not-found.component.html', 6 | styleUrls: ['./page-not-found.component.scss'] 7 | }) 8 | export class PageNotFoundComponent {} 9 | -------------------------------------------------------------------------------- /src/app/pager/pager.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | @Injectable() 3 | export class PagerService { 4 | public getPager( 5 | totalItems: number, 6 | currentPage: number = 1, 7 | pageSize: number = 10 8 | ) { 9 | const totalPages = Math.ceil(totalItems / pageSize); 10 | let startPage: number, endPage: number; 11 | 12 | if (totalPages <= 10) { 13 | // less than 10 total pages so show all 14 | startPage = 1; 15 | endPage = totalPages; 16 | } else { 17 | // more than 10 total pages so calculate start and end pages 18 | if (currentPage <= 6) { 19 | startPage = 1; 20 | endPage = 10; 21 | } else if (currentPage + 4 >= totalPages) { 22 | startPage = totalPages - 9; 23 | endPage = totalPages; 24 | } else { 25 | startPage = currentPage - 5; 26 | endPage = currentPage + 4; 27 | } 28 | } 29 | 30 | // calculate start and end item indexes 31 | const startIndex = (currentPage - 1) * pageSize; 32 | const endIndex = Math.min(startIndex + pageSize - 1, totalItems - 1); 33 | 34 | // create an array of pages to ng-repeat in the pager control 35 | const pages = Array.from(Array(totalPages), (_, i) => 1 + i); 36 | 37 | // return object with all pager properties required by the view 38 | return { 39 | totalItems: totalItems, 40 | currentPage: currentPage, 41 | pageSize: pageSize, 42 | totalPages: totalPages, 43 | startPage: startPage, 44 | endPage: endPage, 45 | startIndex: startIndex, 46 | endIndex: endIndex, 47 | pages: pages 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/products/product-detail/product-detail.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | // Buttons 5 | .sp-buttons { 6 | .btn { 7 | margin: 0; 8 | margin-left: 8px; 9 | 10 | &.btn-wishlist { 11 | width: $btn-sm-height; 12 | padding: 0; 13 | padding-left: 1px; 14 | border-radius: 50%; 15 | 16 | > i { 17 | font-size: 1.2em; 18 | } 19 | 20 | &.active { 21 | color: $brand-danger; 22 | } 23 | } 24 | } 25 | } 26 | 27 | // Product Gallery 28 | .product-gallery { 29 | position: relative; 30 | 31 | padding: { 32 | top: 15px; 33 | right: 15px; 34 | bottom: 15px; 35 | left: 15px; 36 | } 37 | 38 | border: 1px solid $border-color; 39 | border-radius: $border-radius-lg; 40 | 41 | .product-badge { 42 | top: 25px; 43 | left: 15px; 44 | } 45 | .product-thumbnails { 46 | display: block; 47 | margin: 0; 48 | margin-top: $grid-vertical-step; //~24px 49 | padding: 0; 50 | list-style: none; 51 | text-align: center; 52 | 53 | > li { 54 | display: inline-block; 55 | margin: 0 3px 10px; 56 | 57 | > a { 58 | display: block; 59 | width: 94px; 60 | transition: border-color .25s; 61 | border: 1px solid $border-color; 62 | border-radius: $border-radius-base; 63 | overflow: hidden; 64 | } 65 | 66 | &.active > a { 67 | border-color: $brand-primary; 68 | cursor: default; 69 | } 70 | } 71 | } 72 | 73 | .product-thumbnail-image { 74 | &.loading { 75 | min-height: 80px; 76 | background: url('../../../img/loading.gif') center center no-repeat; 77 | } 78 | } 79 | } 80 | 81 | .product-gallery-image { 82 | 83 | img { 84 | min-width: 100%; 85 | } 86 | 87 | &.loading { 88 | min-height: 200px; 89 | background: url('../../../img/loading.gif') center center no-repeat; 90 | } 91 | } 92 | 93 | .product-detail { 94 | 95 | &.loading { 96 | min-height: 200px; 97 | background: url('../../../img/loading.gif') center center no-repeat; 98 | } 99 | } 100 | 101 | // IE10+ specific styles 102 | @media screen and (-ms-high-contrast: active), screen and (-ms-high-contrast: none) { 103 | .product-gallery .gallery-item { display: none !important; } 104 | } 105 | // Microsoft Edge specific styles 106 | @supports (-ms-ime-align: auto) { 107 | .product-gallery .gallery-item { display: none !important; } 108 | } 109 | -------------------------------------------------------------------------------- /src/app/products/products-list-item/products-list-item.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
{{ product.reduction }}% Off
4 | 5 |
6 | 7 |
8 | 9 | 10 | {{product.name}} 11 | 12 |
13 |

14 | {{product.name}} 15 |

16 |

17 |

{{product.description}}

18 |
19 | 22 | 23 | 24 | 27 |
28 |
29 |
30 | -------------------------------------------------------------------------------- /src/app/products/products-list-item/products-list-item.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .product-card { 5 | display: block; 6 | position: relative; 7 | width: 100%; 8 | padding: 18px; 9 | border: 1px solid $border-color; 10 | border-radius: $border-radius-lg; 11 | background-color: $white-color; 12 | margin-bottom: 20px; 13 | 14 | .product-badge { 15 | display: inline-block; 16 | position: absolute; 17 | top: 15px; 18 | left: 20px; 19 | z-index: 1; 20 | } 21 | 22 | .product-thumb { 23 | display: block; 24 | width: 100%; 25 | margin-top: 10px; 26 | margin-bottom: 10px; 27 | 28 | > img { 29 | display: block; 30 | width: 100%; 31 | } 32 | 33 | &.loading { 34 | min-height: 150px; 35 | background: url(../../../img/loading.gif) center center no-repeat; 36 | } 37 | } 38 | 39 | .product-title { 40 | margin-bottom: 10px; 41 | 42 | font: { 43 | size: $product-title-font-size; 44 | weight: normal; 45 | } 46 | text-align: center; 47 | 48 | > a { 49 | transition: color .3s; 50 | color: $product-title-color; 51 | text-decoration: none; 52 | 53 | &:hover { 54 | color: $product-title-hover-color; 55 | } 56 | } 57 | } 58 | 59 | .product-price { 60 | margin-bottom: 10px; 61 | color: $product-price-color; 62 | 63 | font: { 64 | size: $product-price-font-size; 65 | weight: 500; 66 | } 67 | text-align: center; 68 | 69 | > del { 70 | margin-right: 5px; 71 | color: $gray; 72 | } 73 | } 74 | 75 | .product-buttons { 76 | padding: 12px 0 8px; 77 | text-align: center; 78 | 79 | > .btn { 80 | margin: 0 4px; 81 | 82 | &.btn-wishlist { 83 | width: $btn-sm-height; 84 | padding: 0; 85 | padding-left: 1px; 86 | border-radius: 50%; 87 | 88 | > i { 89 | font-size: 1.2em; 90 | } 91 | 92 | &.active { color: $brand-danger; } 93 | } 94 | } 95 | } 96 | 97 | &.product-list { 98 | margin-bottom: 30px; 99 | } 100 | 101 | @media (min-width: $screen-sm) { 102 | 103 | &.product-list { 104 | display: table; 105 | width: 100%; 106 | padding: 0; 107 | 108 | .product-thumb, 109 | .product-info { 110 | display: table-cell; 111 | vertical-align: middle; 112 | } 113 | .product-thumb { 114 | position: relative; 115 | width: 270px; 116 | padding: 20px 18px; 117 | border-right: 1px solid $border-color; 118 | } 119 | 120 | .product-info { 121 | padding: 20px 22px; 122 | 123 | .product-title, 124 | .product-price, 125 | .product-buttons { 126 | text-align: left; 127 | } 128 | 129 | .product-buttons { 130 | 131 | padding: { 132 | top: 20px; 133 | bottom: 0; 134 | } 135 | border-top: 1px solid $border-color; 136 | 137 | > .btn { 138 | margin: 0; 139 | margin-right: 8px; 140 | } 141 | } 142 | } 143 | 144 | .product-title { 145 | font-size: $font-size-lead; 146 | } 147 | } 148 | } 149 | } 150 | 151 | 152 | :host ::ng-deep .rating-stars { 153 | position: absolute; 154 | top: 15px; 155 | right: 18px; 156 | } 157 | -------------------------------------------------------------------------------- /src/app/products/products-list-item/products-list-item.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnDestroy, OnInit } from '@angular/core'; 2 | 3 | import { Subscription } from 'rxjs'; 4 | 5 | import { AuthService } from '../../account/shared/auth.service'; 6 | import { CartService } from '../../cart/shared/cart.service'; 7 | 8 | import { CartItem } from '../../models/cart-item.model'; 9 | import { Product } from '../../models/product.model'; 10 | import { User } from '../../models/user.model'; 11 | 12 | @Component({ 13 | selector: 'app-products-list-item', 14 | templateUrl: './products-list-item.component.html', 15 | styleUrls: ['./products-list-item.component.scss'] 16 | }) 17 | export class ProductsListItemComponent implements OnInit, OnDestroy { 18 | private userSubscription: Subscription; 19 | @Input() public product: Product; 20 | @Input() public displayMode: string; 21 | public user: User; 22 | public imageLoading: boolean; 23 | 24 | constructor( 25 | private cartService: CartService, 26 | private authService: AuthService 27 | ) {} 28 | 29 | ngOnInit() { 30 | this.imageLoading = true; 31 | this.userSubscription = this.authService.user.subscribe((user) => { 32 | this.user = user; 33 | }); 34 | } 35 | 36 | public onAddToCart() { 37 | this.cartService.addItem(new CartItem(this.product, 1)); 38 | } 39 | 40 | public onImageLoad() { 41 | this.imageLoading = false; 42 | } 43 | 44 | ngOnDestroy() { 45 | this.userSubscription.unsubscribe(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/products/products-list/products-list.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .products-list { 5 | 6 | &.loading { 7 | min-height: 200px; 8 | background: url('../../../img/loading.gif') center center no-repeat; 9 | } 10 | } 11 | 12 | .shop-toolbar { 13 | display: table; 14 | width: 100%; 15 | > .column { 16 | display: table-cell; 17 | vertical-align: middle; 18 | &:last-child { text-align: right; } 19 | } 20 | @media (max-width: $screen-sm) { 21 | > .column { 22 | display: block; 23 | width: 100%; 24 | text-align: center; 25 | &:last-child { 26 | padding-top: $grid-vertical-step; 27 | text-align: center; 28 | } 29 | } 30 | } 31 | } 32 | .shop-sorting { 33 | label, 34 | .form-control, 35 | span { 36 | display: inline-block; 37 | vertical-align: middle; 38 | } 39 | span { padding: 8px 0; } 40 | label { 41 | margin: 0; 42 | padding: 8px 5px 8px 0; 43 | color: $gray; 44 | font: { 45 | size: $font-size-sm; 46 | weight: normal; 47 | } 48 | } 49 | .form-control { 50 | width: 100%; 51 | max-width: 186px; 52 | margin-right: 10px; 53 | } 54 | @media (max-width: $screen-sm) { 55 | label, .form-control { 56 | display: block; 57 | width: 100%; 58 | max-width: 100%; 59 | margin: 0; 60 | padding: { 61 | top: 0; 62 | right: 0; 63 | } 64 | } 65 | } 66 | } 67 | .shop-view { 68 | display: inline-block; 69 | @include clearfix; 70 | > a { 71 | display: block; 72 | width: $shop-view-size; 73 | height: $shop-view-size; 74 | margin-left: 10px; 75 | padding: 13px; 76 | float: left; 77 | transition: background-color .35s; 78 | border: 1px solid $border-color; 79 | border-radius: 50%; 80 | background-color: $shop-view-bg-color; 81 | span { 82 | display: block; 83 | position: relative; 84 | width: 3px; 85 | height: 3px; 86 | margin-bottom: 3px; 87 | background-color: $shop-view-color; 88 | &::before, 89 | &::after { 90 | display: block; 91 | position: absolute; 92 | background-color: $shop-view-color; 93 | } 94 | &:last-child { margin-bottom: 0; } 95 | } 96 | &:hover { background-color: $shop-view-hover-bg-color; } 97 | &.active { 98 | border-color: $shop-view-active-bg-color; 99 | background-color: $shop-view-active-bg-color; 100 | cursor: default; 101 | pointer-events: none; 102 | span, 103 | span::before, 104 | span::after { background-color: $white-color; } 105 | } 106 | &.grid-view span { 107 | &::before, 108 | &::after { 109 | top: 0; 110 | width: 3px; 111 | height: 3px; 112 | content: ''; 113 | } 114 | &::before { left: 6px; } 115 | &::after { left: 12px; } 116 | } 117 | &.list-view span { 118 | &::before { 119 | top: 1px; 120 | left: 6px; 121 | width: 9px; 122 | height: 1px; 123 | content: ''; 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/app/products/products-list/products-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | 3 | import { Subject } from 'rxjs'; 4 | import { takeUntil } from 'rxjs/operators'; 5 | 6 | import { AuthService } from '../../account/shared/auth.service'; 7 | import { PagerService } from '../../pager/pager.service'; 8 | import { ProductsCacheService } from '../shared/products-cache.service'; 9 | import { ProductService } from '../shared/product.service'; 10 | import { UiService } from '../shared/ui.service'; 11 | import { SortPipe } from '../shared/sort.pipe'; 12 | 13 | import { Product } from '../../models/product.model'; 14 | import { User } from '../../models/user.model'; 15 | 16 | @Component({ 17 | selector: 'app-products', 18 | templateUrl: './products-list.component.html', 19 | styleUrls: ['./products-list.component.scss'] 20 | }) 21 | export class ProductsListComponent implements OnInit, OnDestroy { 22 | unsubscribe$ = new Subject(); 23 | products: Product[]; 24 | productsPaged: Product[]; 25 | pager: any = {}; 26 | user: User; 27 | productsLoading: boolean; 28 | currentPagingPage: number; 29 | 30 | constructor( 31 | private productService: ProductService, 32 | private productsCacheService: ProductsCacheService, 33 | private pagerService: PagerService, 34 | private sortPipe: SortPipe, 35 | private authService: AuthService, 36 | public uiService: UiService 37 | ) {} 38 | 39 | ngOnInit() { 40 | this.authService.user 41 | .pipe(takeUntil(this.unsubscribe$)) 42 | .subscribe((user) => { 43 | this.user = user; 44 | }); 45 | this.uiService.currentPagingPage$ 46 | .pipe(takeUntil(this.unsubscribe$)) 47 | .subscribe((page) => { 48 | this.currentPagingPage = page; 49 | }); 50 | this.getProducts(); 51 | } 52 | 53 | getProducts() { 54 | this.productsLoading = true; 55 | this.productService 56 | .getProducts() 57 | .pipe(takeUntil(this.unsubscribe$)) 58 | .subscribe((products) => { 59 | this.products = products; 60 | this.setPage(this.currentPagingPage); 61 | this.productsLoading = false; 62 | }); 63 | } 64 | 65 | onDisplayModeChange(mode: string, e: Event) { 66 | this.uiService.displayMode$.next(mode); 67 | e.preventDefault(); 68 | } 69 | 70 | setPage(page: number) { 71 | if (page < 1 || page > this.pager.totalPages) { 72 | return; 73 | } 74 | this.pager = this.pagerService.getPager(this.products.length, page, 8); 75 | this.productsPaged = this.products.slice( 76 | this.pager.startIndex, 77 | this.pager.endIndex + 1 78 | ); 79 | this.uiService.currentPagingPage$.next(page); 80 | } 81 | 82 | onSort(sortBy: string) { 83 | this.sortPipe.transform( 84 | this.products, 85 | sortBy.replace(':reverse', ''), 86 | sortBy.endsWith(':reverse') 87 | ); 88 | this.uiService.sorting$.next(sortBy); 89 | this.setPage(1); 90 | } 91 | 92 | ngOnDestroy() { 93 | this.unsubscribe$.next(); 94 | this.unsubscribe$.complete(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/app/products/products.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { AngularFireDatabaseModule } from '@angular/fire/compat/database'; 4 | import { SharedModule } from '../shared/shared.module'; 5 | 6 | import { ProductsListComponent } from './products-list/products-list.component'; 7 | import { ProductDetailComponent } from './product-detail/product-detail.component'; 8 | import { ProductsListItemComponent } from './products-list-item/products-list-item.component'; 9 | import { RatingStarsComponent } from './shared/rating-stars/rating-stars.component'; 10 | 11 | import { FileUploadService } from './shared/file-upload.service'; 12 | import { ProductsCacheService } from './shared/products-cache.service'; 13 | import { ProductRatingService } from './shared/product-rating.service'; 14 | 15 | import { SortPipe } from './shared/sort.pipe'; 16 | 17 | @NgModule({ 18 | declarations: [ 19 | ProductDetailComponent, 20 | ProductsListComponent, 21 | ProductsListItemComponent, 22 | SortPipe, 23 | RatingStarsComponent 24 | ], 25 | imports: [SharedModule], 26 | exports: [ 27 | ProductDetailComponent, 28 | ProductsListComponent, 29 | ProductsListItemComponent, 30 | SortPipe, 31 | RatingStarsComponent 32 | ], 33 | providers: [SortPipe, FileUploadService, ProductsCacheService, ProductRatingService] 34 | }) 35 | export class ProductsModule {} 36 | -------------------------------------------------------------------------------- /src/app/products/shared/file-upload.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AngularFireStorage, AngularFireUploadTask } from '@angular/fire/compat/storage'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable() 6 | export class FileUploadService { 7 | public task$: AngularFireUploadTask; 8 | 9 | // Progress monitoring 10 | public percentage$: Observable; 11 | 12 | public snapshot: Observable; 13 | 14 | // Download URL 15 | public downloadURL: Observable; 16 | 17 | constructor(public storage: AngularFireStorage) {} 18 | 19 | public startUpload(data) { 20 | // The File object 21 | const file = data.files.item(0); 22 | 23 | // Client-side validation example 24 | if (file.type.split('/')[0] !== 'image') { 25 | console.error('unsupported file type :( '); 26 | throw new Error('upload failed, unsupported file type'); 27 | } 28 | 29 | // The storage path 30 | const path = `product-images/${new Date().getTime()}_${file}`; 31 | 32 | // The main task 33 | this.task$ = this.storage.upload(path, file); 34 | 35 | // the percentage 36 | this.percentage$ = this.task$.percentageChanges(); 37 | 38 | return this.task$; 39 | } 40 | 41 | public deleteFile(files: string[]) { 42 | if (files) { 43 | return files.map((filePath) => { 44 | return this.storage.ref(filePath).delete(); 45 | }); 46 | } 47 | } 48 | 49 | // Determines if the upload task is active 50 | public isActive(snapshot) { 51 | return ( 52 | snapshot.state === 'running' && 53 | snapshot.bytesTransferred < snapshot.totalBytes 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/products/shared/product-rating.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AngularFireDatabase } from '@angular/fire/compat/database'; 3 | 4 | import { Observable } from 'rxjs/observable'; 5 | import { of } from 'rxjs/observable/of'; 6 | 7 | import { AuthService } from '../../account/shared/auth.service'; 8 | import { MessageService } from '../../messages/message.service'; 9 | import { ProductRatingService } from './product-rating.service'; 10 | 11 | import { Product } from '../../models/product.model'; 12 | import { User, Roles } from '../../models/user.model'; 13 | 14 | class MockAuthService { 15 | user: Observable; 16 | 17 | constructor() { 18 | this.user = of({ 19 | email: 'foo@bar.com', 20 | firstName: 'foo', 21 | lastName: 'bar', 22 | uid: '123456789' 23 | }); 24 | } 25 | 26 | } 27 | 28 | describe('Rating', () => { 29 | let productRatingService: ProductRatingService; 30 | let angularFireDatabase: AngularFireDatabase; 31 | let authService: MockAuthService; 32 | 33 | beforeEach(() => { 34 | TestBed.configureTestingModule({ 35 | providers: [ 36 | ProductRatingService, 37 | { provide: MessageService }, 38 | { provide: AngularFireDatabase }, 39 | { provide: AuthService, useClass: MockAuthService } 40 | ] 41 | }); 42 | 43 | productRatingService = TestBed.get(ProductRatingService); 44 | angularFireDatabase = TestBed.get(AngularFireDatabase); 45 | authService = TestBed.get(AuthService); 46 | }); 47 | 48 | it('should be created', () => { 49 | expect(productRatingService).toBeTruthy(); 50 | }); 51 | 52 | describe('should handle rating actions and', () => { 53 | 54 | it('should handle a first rating', () => { 55 | const product = new Product(); 56 | 57 | const result = productRatingService['constructRating'](product, 5); 58 | expect(result).toEqual({ 59 | '/ratings/123456789/': 5, 60 | '/currentRating/': 5 61 | }); 62 | }); 63 | 64 | it('should handle a new rating from same user', () => { 65 | const product = new Product(); 66 | product.currentRating = 5; 67 | product.ratings['123456789'] = 5; 68 | 69 | const result = productRatingService['constructRating'](product, 1); 70 | 71 | expect(result).toEqual({ 72 | '/ratings/123456789/': 1, 73 | '/currentRating/': 1 74 | }); 75 | }); 76 | 77 | it('should handle a rating from a second user', () => { 78 | const product = new Product(); 79 | product.currentRating = 5; 80 | product.ratings = {'987654321': 5}; 81 | 82 | const result = productRatingService['constructRating'](product, 1); 83 | 84 | expect(result).toEqual({ 85 | '/currentRating/': 3, 86 | '/ratings/123456789/': 1 87 | }); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/app/products/shared/product-rating.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AngularFireDatabase } from '@angular/fire/compat/database'; 3 | 4 | import { Observable , from as fromPromise , of } from 'rxjs'; 5 | 6 | import { AuthService } from '../../account/shared/auth.service'; 7 | import { MessageService } from '../../messages/message.service'; 8 | import { FileUploadService } from './file-upload.service'; 9 | 10 | import { ProductsUrl } from './productsUrl'; 11 | import { Product } from '../../models/product.model'; 12 | import { User } from '../../models/user.model'; 13 | 14 | @Injectable() 15 | export class ProductRatingService { 16 | private productsUrl = ProductsUrl.productsUrl; 17 | private user: User; 18 | 19 | constructor( 20 | private messageService: MessageService, 21 | private angularFireDatabase: AngularFireDatabase, 22 | public authService: AuthService 23 | ) { 24 | this.authService.user.subscribe(user => this.user = user); 25 | } 26 | 27 | /** Log a ProductService message with the MessageService */ 28 | private log(message: string) { 29 | this.messageService.add('ProductService: ' + message); 30 | } 31 | 32 | /** 33 | * Handle Http operation that failed. 34 | * Let the app continue. 35 | * @param operation - name of the operation that failed 36 | * @param result - optional value to return as the observable result 37 | */ 38 | private handleError(operation = 'operation', result?: T) { 39 | return (error: any): Observable => { 40 | console.error(error); // log to console instead 41 | this.log(`${operation} failed: ${error.message}`); 42 | // Let the app keep running by returning an empty result. 43 | return of(result as T); 44 | }; 45 | } 46 | 47 | public rateProduct(product: Product, rating: number) { 48 | const url = `${this.productsUrl}/${product.id}`; 49 | const updates = this.constructRating(product, rating); 50 | 51 | return fromPromise( 52 | this.angularFireDatabase 53 | .object(url) 54 | .update(updates) 55 | .then(() => this.log(`Rated Product ${product.name} width: ${rating}`)) 56 | .catch((error) => { 57 | this.handleError(error); 58 | }) 59 | ); 60 | } 61 | // pure helper functions start here 62 | private constructRating(product: Product, rating: number) { 63 | // construct container for update content 64 | const updates = {}; 65 | 66 | // Add user rating to local version of ratings 67 | if (product.ratings) { 68 | product.ratings[this.user.uid] = rating; 69 | } else { 70 | product['ratings'] = []; 71 | product['ratings'][this.user.uid] = rating; 72 | } 73 | 74 | // Add user rating 75 | updates['/ratings/' + this.user.uid + '/'] = rating; 76 | 77 | // calculate current overall rating 78 | updates['/currentRating/'] = this.calculateOverallRating(product, rating); 79 | return updates; 80 | } 81 | 82 | private calculateOverallRating(product: Product, rating: number): number { 83 | // Calculate and add new overall rating 84 | const currentRating = 85 | Object.values(product.ratings).reduce( 86 | (a: number, b: number) => a + b, 87 | 0 88 | ) / Object.values(product.ratings).length; 89 | 90 | return currentRating; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/app/products/shared/products-cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Observable , of } from 'rxjs'; 4 | import { publishReplay , refCount , switchMap } from 'rxjs/operators'; 5 | 6 | import { Product } from '../../models/product.model'; 7 | 8 | @Injectable() 9 | export class ProductsCacheService { 10 | private products: Observable; 11 | 12 | public get(key: string | number, fallback: Observable) { 13 | if (typeof key === 'string') { 14 | return this.getProducts(fallback); 15 | } else { 16 | return this.getProduct(key, fallback); 17 | } 18 | } 19 | 20 | private getProducts(fallback: Observable): Observable { 21 | if (!this.products) { 22 | this.products = fallback.pipe(publishReplay(1), refCount()); 23 | } 24 | return this.products; 25 | } 26 | 27 | private getProduct(key: any, fallback: Observable) { 28 | return this.getProducts(fallback).pipe( 29 | switchMap((products) => { 30 | const selectedProduct = products.find((product) => { 31 | return product.id === key; 32 | }); 33 | return of(selectedProduct); 34 | }), 35 | publishReplay(1), 36 | refCount() 37 | ); 38 | } 39 | 40 | public clearCache() { 41 | this.products = null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/products/shared/productsUrl.ts: -------------------------------------------------------------------------------- 1 | export class ProductsUrl { 2 | static productsUrl = '/products'; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/products/shared/rating-stars/rating-stars.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 |
8 | -------------------------------------------------------------------------------- /src/app/products/shared/rating-stars/rating-stars.component.scss: -------------------------------------------------------------------------------- 1 | @import '~scss/helpers/variables'; 2 | @import '~scss/helpers/mixins'; 3 | 4 | .rating-stars { 5 | display: inline-block; 6 | 7 | > i { 8 | display: inline-block; 9 | margin-right: 2px; 10 | color: lighten($gray, 15%); 11 | font-size: $font-size-sm; 12 | &.filled { color: $brand-warning; } 13 | &:last-child { margin-right: 0; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/products/shared/rating-stars/rating-stars.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-rating-stars', 5 | templateUrl: './rating-stars.component.html', 6 | styleUrls: ['./rating-stars.component.scss'] 7 | }) 8 | export class RatingStarsComponent { 9 | @Input() public rating: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/products/shared/sort.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'sort' 5 | }) 6 | export class SortPipe implements PipeTransform { 7 | transform(array: any[], field: string, reverse?: boolean): any[] { 8 | if (!array) { 9 | return; 10 | } 11 | array.sort((a: any, b: any) => { 12 | if (a[field] < b[field]) { 13 | return -1; 14 | } else if (a[field] > b[field]) { 15 | return 1; 16 | } else { 17 | return 0; 18 | } 19 | }); 20 | if (reverse) { 21 | return array.reverse(); 22 | } 23 | return array; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/products/shared/ui.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject } from 'rxjs'; 3 | 4 | @Injectable() 5 | export class UiService { 6 | public sorting$: BehaviorSubject; 7 | public displayMode$: BehaviorSubject; 8 | public currentPagingPage$: BehaviorSubject; 9 | 10 | constructor() { 11 | this.sorting$ = new BehaviorSubject('date:reverse'); 12 | this.displayMode$ = new BehaviorSubject('grid'); 13 | this.currentPagingPage$ = new BehaviorSubject(1); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/shared/price/price.component.html: -------------------------------------------------------------------------------- 1 | {{product.priceNormal | currency }}{{product.price | currency }} 2 | -------------------------------------------------------------------------------- /src/app/shared/price/price.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/app/shared/price/price.component.scss -------------------------------------------------------------------------------- /src/app/shared/price/price.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Product } from '../../models/product.model'; 3 | 4 | @Component({ 5 | selector: 'app-price', 6 | templateUrl: './price.component.html', 7 | styleUrls: ['./price.component.scss'] 8 | }) 9 | export class PriceComponent { 10 | @Input() public product: Product; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterModule } from '@angular/router'; 4 | import { AppRoutingModule } from '../app-routing.module'; 5 | import { FormsModule } from '@angular/forms'; 6 | 7 | import { PriceComponent } from './price/price.component'; 8 | import { PageTitleComponent } from '../core/page-title/page-title.component'; 9 | 10 | @NgModule({ 11 | declarations: [ 12 | PriceComponent, 13 | PageTitleComponent 14 | ], 15 | imports: [ 16 | CommonModule, 17 | AppRoutingModule, 18 | FormsModule 19 | ], 20 | exports: [ 21 | PriceComponent, 22 | PageTitleComponent, 23 | CommonModule, 24 | AppRoutingModule, 25 | FormsModule 26 | ] 27 | }) 28 | export class SharedModule {} 29 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/fonts/Pe-icon-7-stroke.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/Pe-icon-7-stroke.eot -------------------------------------------------------------------------------- /src/assets/fonts/Pe-icon-7-stroke.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/Pe-icon-7-stroke.ttf -------------------------------------------------------------------------------- /src/assets/fonts/Pe-icon-7-stroke.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/Pe-icon-7-stroke.woff -------------------------------------------------------------------------------- /src/assets/fonts/feather-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/feather-webfont.eot -------------------------------------------------------------------------------- /src/assets/fonts/feather-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/feather-webfont.ttf -------------------------------------------------------------------------------- /src/assets/fonts/feather-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/feather-webfont.woff -------------------------------------------------------------------------------- /src/assets/fonts/socicon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/socicon.eot -------------------------------------------------------------------------------- /src/assets/fonts/socicon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/socicon.ttf -------------------------------------------------------------------------------- /src/assets/fonts/socicon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/assets/fonts/socicon.woff -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | firebase: { 4 | apiKey: 'AIzaSyCXVTjTuBUXf-UWlpKfKHjomfPezutmPwI', 5 | authDomain: 'cas-fee-shop.firebaseapp.com', 6 | databaseURL: 'https://cas-fee-shop.firebaseio.com', 7 | projectId: 'cas-fee-shop', 8 | storageBucket: 'cas-fee-shop.appspot.com', 9 | messagingSenderId: '323643286137' 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /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 | firebase: { 9 | apiKey: 'AIzaSyCXVTjTuBUXf-UWlpKfKHjomfPezutmPwI', 10 | authDomain: 'cas-fee-shop.firebaseapp.com', 11 | databaseURL: 'https://cas-fee-shop.firebaseio.com', 12 | projectId: 'cas-fee-shop', 13 | storageBucket: 'cas-fee-shop.appspot.com', 14 | messagingSenderId: '323643286137' 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/favicon.ico -------------------------------------------------------------------------------- /src/img/404_art.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/404_art.jpg -------------------------------------------------------------------------------- /src/img/coming-soon-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/coming-soon-bg.jpg -------------------------------------------------------------------------------- /src/img/credit-cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/credit-cards.png -------------------------------------------------------------------------------- /src/img/default-skin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/default-skin.png -------------------------------------------------------------------------------- /src/img/default-skin.svg: -------------------------------------------------------------------------------- 1 | default-skin 2 -------------------------------------------------------------------------------- /src/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/loading.gif -------------------------------------------------------------------------------- /src/img/main-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/main-bg.jpg -------------------------------------------------------------------------------- /src/img/map-marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/map-marker.png -------------------------------------------------------------------------------- /src/img/payment_methods.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/payment_methods.png -------------------------------------------------------------------------------- /src/img/preloader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/preloader.gif -------------------------------------------------------------------------------- /src/img/services/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/services/01.png -------------------------------------------------------------------------------- /src/img/services/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/services/02.png -------------------------------------------------------------------------------- /src/img/services/03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/services/03.png -------------------------------------------------------------------------------- /src/img/services/04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/services/04.png -------------------------------------------------------------------------------- /src/img/user-ava-md.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/user-ava-md.jpg -------------------------------------------------------------------------------- /src/img/user-cover-img.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/src/img/user-cover-img.jpg -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Shop 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /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 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /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/weak-map'; 35 | // import 'core-js/es6/set'; 36 | 37 | /** IE10 and IE11 requires the following for the Reflect API. */ 38 | // import 'core-js/es6/reflect'; 39 | 40 | 41 | /** Evergreen browsers require these. **/ 42 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 43 | import 'core-js/es/reflect'; 44 | 45 | 46 | 47 | /*************************************************************************************************** 48 | * Zone JS is required by Angular itself. 49 | */ 50 | import 'zone.js'; // Included with Angular CLI. 51 | 52 | 53 | 54 | /*************************************************************************************************** 55 | * APPLICATION IMPORTS 56 | */ 57 | 58 | /** 59 | * Date, currency, decimal and percent pipes. 60 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 61 | */ 62 | // import 'intl'; // Run `npm install --save intl`. 63 | /** 64 | * Need to import at least one locale-data with intl. 65 | */ 66 | // import 'intl/locale-data/jsonp/en'; 67 | -------------------------------------------------------------------------------- /src/scss/base/_scaffolding.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Scaffolding 3 | // -------------------------------------------------- 4 | 5 | html * { 6 | text-rendering: optimizeLegibility; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | body { 12 | background: { 13 | position: center; 14 | color: $body-bg; 15 | repeat: no-repeat; 16 | size: cover; 17 | } 18 | color: $body-color; 19 | font: { 20 | family: $font-family-base; 21 | size: $font-size-sm; 22 | weight: $font-weight-base; 23 | } 24 | text-transform: $text-transform-base; 25 | line-height: $line-height-base; 26 | } 27 | 28 | // Links 29 | a { 30 | color: $link-color; 31 | text-decoration: underline; 32 | 33 | &:hover { 34 | color: $link-hover-color; 35 | text-decoration: none; 36 | } 37 | &:focus { outline: none; } 38 | } 39 | 40 | .small, small { font-size: 85%; } 41 | 42 | // Navigation Links 43 | .navi-link, 44 | .navi-link-light { 45 | transition: color .3s; 46 | color: $nav-link-color; 47 | text-decoration: none; 48 | &:hover { color: $nav-link-hover-color; } 49 | } 50 | .navi-link-light { color: $white-color; } 51 | 52 | // Images 53 | // Responsive images (ensure images don't scale beyond their parents) 54 | img, 55 | figure { 56 | max-width: 100%; 57 | height: auto; 58 | vertical-align: middle; 59 | } 60 | svg { max-width: 100%; } 61 | 62 | /* Responsive iframes */ 63 | iframe { width: 100%; } 64 | 65 | /* Box Model */ 66 | * { box-sizing: border-box; } 67 | *::before, 68 | *::after { box-sizing: border-box; } 69 | 70 | // Horizontal rules 71 | hr { 72 | margin: 0; 73 | border: 0; 74 | border-top: 1px solid $border-color; 75 | &.hr-light { border-top-color: $border-light-color; } 76 | } 77 | 78 | // Pre tag 79 | pre { 80 | display: block; 81 | padding: 15px; 82 | border: 1px solid $border-color; 83 | border-radius: $border-radius-lg; 84 | background-color: $gray-lighter; 85 | } 86 | 87 | // Text Selection Color 88 | ::selection { 89 | background: $gray-darker; 90 | color: $white-color; 91 | } 92 | ::-moz-selection { 93 | background: $gray-darker; 94 | color: $white-color; 95 | } 96 | 97 | 98 | // Image with caption 99 | // ------------------------------------------------------- 100 | 101 | figure { 102 | position: relative; 103 | margin: 0; 104 | figcaption { 105 | display: block; 106 | position: absolute; 107 | bottom: 0; 108 | left: 0; 109 | width: 100%; 110 | margin: 0; 111 | padding: floor($grid-vertical-step / 2); // ~12px 112 | font-size: $font-size-sm; 113 | } 114 | } 115 | 116 | 117 | /* Bootstrap Overrides */ 118 | @media (min-width: $screen-xl) { 119 | .container { 120 | width: 1170px; 121 | max-width: 1170px; 122 | } 123 | } 124 | @media (max-width: $screen-xl) { 125 | .container { 126 | width: 100% !important; 127 | max-width: 100% !important; 128 | } 129 | } 130 | .container-fluid { 131 | max-width: 1920px; 132 | margin: { 133 | right: auto; 134 | left: auto; 135 | } 136 | padding: { 137 | right: 30px; 138 | left: 30px; 139 | } 140 | @media (max-width: $screen-xl) { padding: 0 15px; } 141 | } 142 | -------------------------------------------------------------------------------- /src/scss/components/_accordion.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Accordion 3 | // -------------------------------------------------- 4 | 5 | .accordion { 6 | .card { 7 | margin-bottom: floor($grid-vertical-step / 3); //~12px 8 | } 9 | [data-toggle='collapse'] { 10 | display: block; 11 | position: relative; 12 | color: $nav-link-color; 13 | text-decoration: none; 14 | &::after { 15 | position: absolute; 16 | top: 50%; 17 | right: 0; 18 | width: 0; 19 | height: 0; 20 | margin-top: -2px; 21 | transition: transform .25s; 22 | border: { 23 | right: 5px solid transparent; 24 | bottom: 5px dashed; 25 | left: 5px solid transparent; 26 | } 27 | content: ''; 28 | } 29 | &.collapsed::after { 30 | transform: rotate(-180deg); 31 | } 32 | > i { 33 | margin: { 34 | top: -4px; 35 | right: 7px; 36 | } 37 | &.socicon-paypal { 38 | display: inline-block; 39 | margin-top: 1px; 40 | font-size: .8em; 41 | vertical-align: middle; 42 | } 43 | &.icon-medal { 44 | width: 16px; 45 | height: 16px; 46 | background-size: 16px; 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/scss/components/_banners.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Banners 3 | // -------------------------------------------------- 4 | 5 | .promo-box { 6 | position: relative; 7 | padding: { 8 | right: 15px; 9 | left: 15px; 10 | } 11 | background: { 12 | position: center; 13 | color: $gray-lighter; 14 | repeat: no-repeat; 15 | size: cover; 16 | } 17 | 18 | // Overlay 19 | .overlay-dark, 20 | .overlay-light { 21 | @include overlay-block(1, $black-color, .5); 22 | } 23 | .overlay-light { background-color: $white-color; } 24 | 25 | // Content 26 | .promo-box-content { 27 | position: relative; 28 | z-index: 5; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/scss/components/_comments.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Comments 3 | // -------------------------------------------------- 4 | 5 | .comment { 6 | display: block; 7 | position: relative; 8 | margin-bottom: 30px; 9 | padding-left: ($comment-author-ava-size + 16); 10 | .comment-author-ava { 11 | display: block; 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | width: $comment-author-ava-size; 16 | border-radius: 50%; 17 | overflow: hidden; 18 | > img { 19 | display: block; 20 | width: 100%; 21 | } 22 | } 23 | .comment-body { 24 | position: relative; 25 | padding: $grid-vertical-step; 26 | border: 1px solid $border-color; 27 | border-radius: $border-radius-lg; 28 | background-color: $body-bg; 29 | &::after, &::before { 30 | position: absolute; 31 | top: 12px; 32 | right: 100%; 33 | width: 0; 34 | height: 0; 35 | border: solid transparent; 36 | content: ''; 37 | pointer-events: none; 38 | } 39 | &::after { 40 | border-width: 9px; 41 | border-color: transparent; 42 | border-right-color: $body-bg; 43 | } 44 | &::before { 45 | margin-top: -1px; 46 | border-width: 10px; 47 | border-color: transparent; 48 | border-right-color: $border-color; 49 | } 50 | } 51 | .comment-title { 52 | margin-bottom: floor($grid-vertical-step / 3); //~8px 53 | color: $gray-dark; 54 | font: { 55 | size: $comment-title-size; 56 | weight: $comment-title-weight; 57 | } 58 | } 59 | .comment-text { 60 | margin-bottom: floor($grid-vertical-step / 2); //~12px 61 | } 62 | .comment-footer { 63 | display: table; 64 | width: 100%; 65 | > .column { 66 | display: table-cell; 67 | vertical-align: middle; 68 | &:last-child { text-align: right; } 69 | } 70 | } 71 | .comment-meta { 72 | color: $gray; 73 | font-size: $font-size-xs; 74 | } 75 | .reply-link { 76 | transition: color .3s; 77 | color: $nav-link-color; 78 | font: { 79 | size: $nav-link-font-size; 80 | weight: $nav-link-font-weight; 81 | } 82 | letter-spacing: .07em; 83 | text: { 84 | transform: uppercase; 85 | decoration: none; 86 | } 87 | > i { 88 | display: inline-block; 89 | margin: { 90 | top: -3px; 91 | right: 4px; 92 | } 93 | vertical-align: middle; 94 | } 95 | &:hover { color: $nav-link-hover-color; } 96 | } 97 | &.comment-reply { 98 | margin: { 99 | top: 30px; 100 | bottom: 0; 101 | } 102 | } 103 | @media (max-width: $screen-sm) { 104 | padding-left: 0; 105 | .comment-author-ava { display: none; } 106 | .comment-body { 107 | padding: 15px; 108 | &::before, &::after { display: none; } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/scss/components/_countdown.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Countdown 3 | // -------------------------------------------------- 4 | 5 | .countdown { 6 | display: inline-block; 7 | @include clearfix; 8 | .item { 9 | display: block; 10 | margin: 7px; 11 | float: left; 12 | text-align: center; 13 | .days, .hours, 14 | .minutes, .seconds { 15 | width: $countdown-box-size; 16 | height: $countdown-box-size; 17 | margin-bottom: 5px; 18 | border: 1px solid $border-color; 19 | border-radius: $border-radius-lg; 20 | font-size: $countdown-font-size; 21 | line-height: ($countdown-box-size - 2); 22 | } 23 | .days_ref, .hours_ref, 24 | .minutes_ref, .seconds_ref { 25 | font-size: $countdown-label-size; 26 | text-transform: uppercase; 27 | } 28 | } 29 | &.countdown-inverse { 30 | .item { 31 | .days, .hours, 32 | .minutes, .seconds { 33 | border-color: $border-light-color; 34 | color: $white-color; 35 | } 36 | .days_ref, .hours_ref, 37 | .minutes_ref, .seconds_ref { color: rgba($white-color, .8); } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/scss/components/_dropdown.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Dropdown 3 | // -------------------------------------------------- 4 | 5 | .dropdown-menu { 6 | border: { 7 | color: $border-color; 8 | radius: $border-radius-base; 9 | } 10 | font-size: $nav-link-font-size; 11 | box-shadow: $sub-menu-shadow; 12 | .dropdown-item { 13 | padding: { 14 | right: 20px; 15 | left: 20px; 16 | } 17 | transition: color .3s; 18 | color: $nav-link-color; 19 | text-decoration: none; 20 | &:hover, &.active, &:focus, &:active { background: 0; } 21 | &:hover { color: $nav-link-hover-color; } 22 | &.active { color: $nav-link-active-color; } 23 | } 24 | a.dropdown-item { font-weight: $nav-link-font-weight; } 25 | } 26 | .dropdown-toggle::after { 27 | margin: { 28 | top: 1px; 29 | left: .3em; 30 | } 31 | vertical-align: .2em; 32 | } 33 | 34 | // Inside Button Group 35 | .btn.dropdown-toggle::after { vertical-align: .2em; } 36 | 37 | // Show Animation 38 | .show .dropdown-menu { 39 | animation: dropdown-show .25s; 40 | } 41 | @keyframes dropdown-show { 42 | from { opacity: 0; } 43 | to { opacity: 1; } 44 | } 45 | -------------------------------------------------------------------------------- /src/scss/components/_gallery.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Gallery (Photoswipe) 3 | // -------------------------------------------------- 4 | 5 | // Gallery Item 6 | .gallery-item { 7 | margin-bottom: 30px; 8 | > a { 9 | display: block; 10 | position: relative; 11 | width: 100%; 12 | border-radius: $border-radius-lg; 13 | text-decoration: none; 14 | overflow: hidden; 15 | > img { 16 | display: block; 17 | width: 100%; 18 | } 19 | &::before { 20 | @include overlay-block(1, $black-color, 0); 21 | transition: opacity .3s; 22 | } 23 | &::after { 24 | display: block; 25 | position: absolute; 26 | top: 50%; 27 | left: 0; 28 | width: 100%; 29 | margin-top: -19px; 30 | transform: translateY(15px); 31 | transition: all .35s; 32 | color: $white-color; 33 | font: { 34 | family: feather; 35 | size: 26px; 36 | } 37 | text-align: center; 38 | content: '\e036'; 39 | opacity: 0; 40 | z-index: 5; 41 | } 42 | &:hover { 43 | &::before { opacity: .45; } 44 | &::after { 45 | transform: translateY(0); 46 | opacity: 1; 47 | } 48 | } 49 | &[data-type='video'] { 50 | &::after { 51 | left: 50%; 52 | width: 46px; 53 | height: 46px; 54 | margin: { 55 | top: -22px; 56 | left: -22px; 57 | } 58 | padding-left: 5px; 59 | transform: none; 60 | border-radius: 50%; 61 | background-color: $white-color; 62 | color: $gray-dark; 63 | font-size: 27px; 64 | line-height: 42px; 65 | box-shadow: 0px 4px 15px 0px rgba($black-color, .25); 66 | content: '\e052'; 67 | opacity: 1; 68 | } 69 | } 70 | } 71 | .caption { display: none; } 72 | &.no-hover-effect > a::before { display: none; } 73 | } 74 | .grid-no-gap .gallery-item { 75 | margin-bottom: 0; 76 | > a { border-radius: 0; } 77 | } 78 | .owl-carousel .gallery-item { margin-bottom: 0; } 79 | 80 | // Photoswipe 81 | .pswp__zoom-wrap { 82 | text-align: center; 83 | &::before { 84 | content: ''; 85 | display: inline-block; 86 | height: 100%; 87 | vertical-align: middle; 88 | } 89 | } 90 | .wrapper { 91 | line-height: 0; 92 | width: 100%; 93 | max-width: 900px; 94 | position: relative; 95 | display: inline-block; 96 | vertical-align: middle; 97 | margin: 0 auto; 98 | text-align: left; 99 | z-index: 1045; 100 | } 101 | .video-wrapper { 102 | position: relative; 103 | padding-bottom: 56.25%; /* 16:9 */ 104 | padding-top: 25px; 105 | height: 0; 106 | width: 100%; 107 | iframe { 108 | position: absolute; 109 | top: 0; 110 | left: 0; 111 | width: 100%; 112 | height: 100%; 113 | } 114 | } 115 | video { 116 | width: 100% !important; 117 | height: auto !important; 118 | } 119 | .pswp__caption__center { 120 | padding: 20px 10px; 121 | font: { 122 | size: $font-size-sm; 123 | weight: 500; 124 | } 125 | text-align: center; 126 | } -------------------------------------------------------------------------------- /src/scss/components/_list-group.scss: -------------------------------------------------------------------------------- 1 | // 2 | // List Group 3 | // ---------------------------------------------------- 4 | 5 | .list-group-item { 6 | border-color: $border-color; 7 | background-color: $white-color; 8 | text-decoration: none; 9 | &:first-child { 10 | border-top-left-radius: $border-radius-lg; 11 | border-top-right-radius: $border-radius-lg; 12 | } 13 | &:last-child { 14 | border-bottom-left-radius: $border-radius-lg; 15 | border-bottom-right-radius: $border-radius-lg; 16 | } 17 | i { 18 | margin-top: -4px; 19 | margin-right: 8px; 20 | font-size: 1.1em; 21 | } 22 | p, ul, ol, li, span { font-weight: normal !important; } 23 | } 24 | a.list-group-item, 25 | .list-group-item-action { 26 | transition: all .25s; 27 | color: $nav-link-color; 28 | font-weight: 500; 29 | &:hover, 30 | &:focus, &:active { 31 | background-color: $gray-lighter; 32 | color: $nav-link-color; 33 | } 34 | } 35 | a.list-group-item { 36 | padding: { 37 | top: .87rem; 38 | bottom: .87rem; 39 | } 40 | } 41 | 42 | // With badges 43 | .with-badge { 44 | position: relative; 45 | padding-right: 3.3rem; 46 | .badge { 47 | position: absolute; 48 | top: 50%; 49 | right: 1.15rem; 50 | transform: translateY(-50%); 51 | } 52 | } 53 | 54 | // Badges 55 | .badge { 56 | color: $white-color; 57 | font: { 58 | size: 90%; 59 | weight: 500; 60 | } 61 | &.badge-default { 62 | background-color: lighten($gray-light, 3%); 63 | color: $gray-dark; 64 | } 65 | &.badge-primary { background-color: $brand-primary; } 66 | &.badge-info { background-color: $brand-info; } 67 | &.badge-success { background-color: $brand-success; } 68 | &.badge-warning { background-color: $brand-warning; } 69 | &.badge-danger { background-color: $brand-danger; } 70 | } 71 | 72 | // Active state 73 | .list-group-item.active { 74 | border-color: $brand-primary; 75 | background-color: $brand-primary; 76 | color: $white-color; 77 | cursor: default; 78 | pointer-events: none; 79 | h1, .h1, h2, .h2, h3, .h3, 80 | h4, .h4, h5, .h5, h6, .h6 { color: $white-color; } 81 | .badge { 82 | background-color: $white-color !important; 83 | color: $gray-dark !important; 84 | } 85 | } 86 | 87 | // Contextual classes 88 | .list-group-item-info { 89 | @include list-group-variant($brand-info, rgba($brand-info, .12)); 90 | } 91 | .list-group-item-success { 92 | @include list-group-variant(darken($brand-success, 3%), rgba($brand-success, .12)); 93 | } 94 | .list-group-item-warning { 95 | @include list-group-variant(darken($brand-warning, 3%), rgba($brand-warning, .12)); 96 | } 97 | .list-group-item-danger { 98 | @include list-group-variant($brand-danger, rgba($brand-danger, .12)); 99 | } 100 | .list-group-item-action:hover, 101 | .list-group-item-action.active { 102 | &.list-group-item-info { background-color: rgba($brand-info, .24); } 103 | &.list-group-item-success { background-color: rgba($brand-success, .24); } 104 | &.list-group-item-warning { background-color: rgba($brand-warning, .24); } 105 | &.list-group-item-danger { background-color: rgba($brand-danger, .24); } 106 | } 107 | 108 | // Next to card 109 | .card:not([class*='mb-']):not([class*='margin-bottom-']) + .list-group { 110 | margin-top: -1px; 111 | .list-group-item:first-child { border-radius: 0; } 112 | } 113 | -------------------------------------------------------------------------------- /src/scss/components/_modal.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Modal 3 | // -------------------------------------------------- 4 | 5 | .modal { z-index: 9200; } 6 | .modal-content { 7 | border-radius: $border-radius-lg; 8 | border-color: $border-color; 9 | } 10 | .modal-header, 11 | .modal-body, 12 | .modal-footer { 13 | padding: { 14 | right: 20px; 15 | left: 20px; 16 | } 17 | } 18 | .modal-footer { 19 | padding: { 20 | top: floor($grid-vertical-step / 2); 21 | bottom: floor($grid-vertical-step / 2); 22 | } 23 | .btn { 24 | margin: { 25 | right: 0; 26 | left: 12px; 27 | } 28 | } 29 | } 30 | .modal-open.hasScrollbar .navbar-stuck { 31 | width: calc(100% - 15px); 32 | } 33 | .modal-backdrop { z-index: 9100; } 34 | 35 | // For demo purpose only. TODO: remove on production 36 | .example-modal .modal { 37 | display: block; 38 | position: relative; 39 | top: auto; 40 | right: auto; 41 | bottom: auto; 42 | left: auto; 43 | z-index: 1; 44 | } 45 | -------------------------------------------------------------------------------- /src/scss/components/_pagination.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Pagination + Post Navigation 3 | // -------------------------------------------------- 4 | 5 | // Pagination 6 | .pagination { 7 | display: table; 8 | width: 100%; 9 | border-top: 1px solid $border-color; 10 | 11 | > .column { 12 | display: table-cell; 13 | padding-top: ceil($grid-vertical-step / 1.5); //~16px 14 | vertical-align: middle; 15 | } 16 | 17 | .pages { 18 | display: block; 19 | margin: 0; 20 | padding: 0; 21 | list-style: none; 22 | 23 | > li { 24 | display: inline-block; 25 | width: $pagination-link-size; 26 | height: $pagination-link-size; 27 | font: { 28 | size: $pagination-link-font-size; 29 | weight: $pagination-link-font-weight; 30 | } 31 | line-height: $pagination-link-size - 2; 32 | text-align: center; 33 | 34 | > a { 35 | display: block; 36 | width: $pagination-link-size; 37 | height: $pagination-link-size; 38 | transition: all .3s; 39 | border: 1px solid transparent; 40 | border-radius: 50%; 41 | color: $pagination-link-color; 42 | line-height: $pagination-link-size - 2; 43 | text-decoration: none; 44 | cursor: pointer; 45 | 46 | &:hover { 47 | border-color: $border-color; 48 | background-color: $pagination-link-hover-bg; 49 | } 50 | } 51 | 52 | &.active > a { 53 | border-color: $pagination-link-active-bg; 54 | background-color: $pagination-link-active-bg; 55 | color: $pagination-link-active-color; 56 | } 57 | } 58 | } 59 | 60 | .btn > i { margin-top: -5px; } 61 | } 62 | 63 | // Entry Navigation 64 | .entry-navigation { 65 | display: table; 66 | width: 100%; 67 | border: { 68 | top: 1px solid $border-color; 69 | bottom: 1px solid $border-color; 70 | } 71 | table-layout: fixed; 72 | > .column { 73 | display: table-cell; 74 | padding: { 75 | top: 15px; 76 | bottom: 15px; 77 | } 78 | text-align: center; 79 | vertical-align: middle; 80 | } 81 | .btn { 82 | margin: 0; 83 | > i { margin-top: -4px; } 84 | &.view-all { 85 | width: $btn-height; 86 | padding: { 87 | right: 0; 88 | left: 1px; 89 | } 90 | > i { 91 | margin-top: -6px; 92 | font-size: 1.4em; 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/scss/components/_progress.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Progress Bars 3 | // -------------------------------------------------- 4 | 5 | // .progress { 6 | // height: auto; 7 | // border-radius: ceil($progress-height / 2); 8 | // background-color: darken($gray-lighter, 2%); 9 | // font: { 10 | // size: $font-size-xs; 11 | // weight: 500; 12 | // } 13 | // line-height: $progress-height; 14 | // } 15 | // .progress-bar { 16 | // height: $progress-height; 17 | // background-color: $progress-bg; 18 | // } 19 | -------------------------------------------------------------------------------- /src/scss/components/_steps.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Steps 3 | // ---------------------------------------------------- 4 | 5 | .steps { 6 | .step { 7 | display: block; 8 | width: 100%; 9 | margin-bottom: 35px; 10 | text-align: center; 11 | .step-icon-wrap { 12 | display: block; 13 | position: relative; 14 | width: 100%; 15 | height: $step-circle-size; 16 | text-align: center; 17 | &::before, 18 | &::after { 19 | display: block; 20 | position: absolute; 21 | top: 50%; 22 | width: 50%; 23 | height: $step-connect-height; 24 | margin-top: -(floor($step-connect-height / 2)); 25 | background-color: $border-color; 26 | content: ''; 27 | z-index: 1; 28 | } 29 | &::before { left: 0; } 30 | &::after { right: 0; } 31 | } 32 | .step-icon { 33 | display: inline-block; 34 | position: relative; 35 | width: $step-circle-size; 36 | height: $step-circle-size; 37 | border: 1px solid $border-color; 38 | border-radius: 50%; 39 | background-color: $step-icon-default-bg; 40 | color: $step-icon-default-color; 41 | font-size: $step-icon-size; 42 | line-height: ($step-circle-size + 1); 43 | z-index: 5; 44 | } 45 | .step-title { 46 | margin: { 47 | top: floor($grid-vertical-step / 1.5); //~16px 48 | bottom: 0; 49 | } 50 | color: $step-title-color; 51 | font: { 52 | size: $step-title-size; 53 | weight: 500; 54 | } 55 | } 56 | &:first-child .step-icon-wrap::before { display: none; } 57 | &:last-child .step-icon-wrap::after { display: none; } 58 | &.completed { 59 | .step-icon-wrap { 60 | &::before, 61 | &::after { background-color: $brand-primary; } 62 | } 63 | .step-icon { 64 | border-color: $brand-primary; 65 | background-color: $brand-primary; 66 | color: $white-color; 67 | } 68 | } 69 | } 70 | } 71 | @media (max-width: $screen-sm) { 72 | .flex-sm-nowrap .step .step-icon-wrap { 73 | &::before, &::after { display: none; } 74 | } 75 | } 76 | @media (max-width: $screen-md) { 77 | .flex-md-nowrap .step .step-icon-wrap { 78 | &::before, &::after { display: none; } 79 | } 80 | } 81 | @media (max-width: $screen-lg) { 82 | .flex-lg-nowrap .step .step-icon-wrap { 83 | &::before, &::after { display: none; } 84 | } 85 | } 86 | @media (max-width: $screen-xl) { 87 | .flex-xl-nowrap .step .step-icon-wrap { 88 | &::before, &::after { display: none; } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/scss/components/_tables.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Tables 3 | // -------------------------------------------------- 4 | 5 | .table { 6 | thead th, td, th { border-color: $border-color; } 7 | &.table-inverse { 8 | background-color: $gray-darker; 9 | color: $white-color; 10 | thead th, td, th { border-color: $border-light-color; } 11 | } 12 | } 13 | 14 | // Table inverse 15 | .thead-inverse th { 16 | background-color: $gray-darker; 17 | color: $white-color; 18 | } 19 | .thead-default th { 20 | background-color: $gray-lighter; 21 | color: $body-color; 22 | } 23 | 24 | // Table striped 25 | .table-striped { 26 | tbody tr:nth-of-type(odd) { background-color: $gray-lighter; } 27 | &.table-inverse { 28 | tbody tr:nth-of-type(odd) { background-color: rgba($black-color, .08); } 29 | } 30 | } 31 | 32 | // Table hover 33 | .table-hover { 34 | tbody tr:hover { background-color: $gray-lighter; } 35 | &.table-inverse { 36 | tbody tr:hover { background-color: rgba($black-color, .08); } 37 | } 38 | } 39 | 40 | // Contextual classes 41 | .table-active, 42 | .table-active td, 43 | .table-active th { background-color: rgba($black-color, .05); } 44 | .table-success, 45 | .table-success td, 46 | .table-success th { background-color: rgba($brand-success, .09); } 47 | .table-info, 48 | .table-info td, 49 | .table-info th { background-color: rgba($brand-info, .09); } 50 | .table-warning, 51 | .table-warning td, 52 | .table-warning th { background-color: rgba($brand-warning, .09); } 53 | .table-danger, 54 | .table-danger td, 55 | .table-danger th { background-color: rgba($brand-danger, .09); } -------------------------------------------------------------------------------- /src/scss/components/_tooltips.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Tooltips and Popovers 3 | // -------------------------------------------------- 4 | 5 | // Tooltip 6 | .tooltip { 7 | font-family: $font-family-base; 8 | &.bs-tooltip-top { 9 | .arrow::before { border-top-color: $tooltip-arrow-color; } 10 | } 11 | &.bs-tooltip-right { 12 | .arrow::before { border-right-color: $tooltip-arrow-color; } 13 | } 14 | &.bs-tooltip-bottom { 15 | .arrow::before { border-bottom-color: $tooltip-arrow-color; } 16 | } 17 | &.bs-tooltip-left { 18 | .arrow::before { border-left-color: $tooltip-arrow-color; } 19 | } 20 | &.show { opacity: $tooltip-opacity; } 21 | } 22 | .tooltip-inner { 23 | border-radius: $border-radius-sm; 24 | background-color: $tooltip-bg; 25 | color: $tooltip-color; 26 | font-size: $font-size-xs; 27 | } 28 | 29 | // Popover 30 | .popover { 31 | border-radius: $border-radius-lg; 32 | border-color: $border-color; 33 | font-family: $font-family-base; 34 | &.bs-popover-top .arrow::before { border-top-color: darken($border-color, 4%); } 35 | &.bs-popover-right .arrow::before { border-right-color: darken($border-color, 4%); } 36 | &.bs-popover-bottom .arrow { 37 | &::before { border-bottom-color: darken($border-color, 4%); } 38 | &::after { border-bottom-color: #f7f7f7; } 39 | } 40 | &.bs-popover-left .arrow::before { border-left-color: darken($border-color, 4%); } 41 | } 42 | .popover-header { 43 | color: $headings-color; 44 | font-family: $font-family-headings; 45 | } 46 | .popover-body { color: $body-color; } 47 | 48 | // For demo purpose only. TODO: remove on production 49 | .example-tooltip .tooltip { 50 | display: inline-block; 51 | position: relative; 52 | margin: 10px 20px; 53 | opacity: 1; 54 | } 55 | .example-popover .popover { 56 | display: block; 57 | position: relative; 58 | width: 260px; 59 | margin: 1.25rem; 60 | float: left; 61 | } 62 | .bs-tooltip-bottom-demo, 63 | .bs-tooltip-top-demo { 64 | .arrow { 65 | left: 50%; 66 | margin-left: -2px; 67 | } 68 | } 69 | .bs-tooltip-left-demo, 70 | .bs-tooltip-right-demo { 71 | .arrow { 72 | top: 50%; 73 | margin-top: -2px; 74 | } 75 | } 76 | .bs-popover-bottom-demo, 77 | .bs-popover-top-demo { 78 | .arrow { 79 | left: 50%; 80 | margin-left: -11px; 81 | } 82 | } 83 | .bs-popover-left-demo, 84 | .bs-popover-right-demo { 85 | .arrow { 86 | top: 50%; 87 | margin-top: -8px; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/scss/layout/_offcanvas-menu.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Off-Canvas Menu 3 | // -------------------------------------------------- 4 | .offcanvas-wrapper { 5 | position: relative; 6 | min-height: 100vh; 7 | background-color: $body-bg; 8 | z-index: 10; 9 | } 10 | .offcanvas-container { 11 | position: fixed; 12 | top: 0; 13 | left: 0; 14 | width: $offcanvas-width; 15 | height: 100%; 16 | background-color: $gray-darker; 17 | box-shadow: inset -4px 0 17px 0 rgba(0, 0, 0, .35); 18 | //visibility: hidden; 19 | opacity: 0; 20 | z-index: 1; 21 | overflow-y: auto; 22 | transition: opacity 0.4s ease-out; 23 | &.active { 24 | //visibility: visible; 25 | opacity: 1; 26 | } 27 | } 28 | .offcanvas-header { 29 | padding: 28px 20px; 30 | border-bottom: 1px solid $border-light-color; 31 | .offcanvas-title { 32 | margin-bottom: 0; 33 | color: rgba($white-color, .5); 34 | font: { 35 | size: $font-size-base; 36 | weight: $font-weight-headings; 37 | } 38 | } 39 | } 40 | .account-link { 41 | display: table; 42 | width: 100%; 43 | padding: 20px 18px; 44 | transition: background-color .3s; 45 | border-bottom: 1px solid $border-light-color; 46 | background-color: darken($gray-darker, 3%); 47 | text-decoration: none; 48 | .user-ava, 49 | .user-info { 50 | display: table-cell; 51 | vertical-align: middle; 52 | } 53 | .user-ava { 54 | width: 48px; 55 | > img { 56 | display: block; 57 | width: 48px; 58 | padding: 3px; 59 | border: 1px solid $border-light-color; 60 | border-radius: 50%; 61 | } 62 | } 63 | .user-info { 64 | padding-left: 8px; 65 | > .user-name { 66 | margin-bottom: 2px; 67 | color: $white-color; 68 | } 69 | > span { display: block; } 70 | } 71 | &:hover { background-color: lighten($gray-darker, 1%); } 72 | } 73 | .offcanvas-menu { 74 | @extend %offcanvas-menu; 75 | } 76 | 77 | // Site Backdrop 78 | .site-backdrop { 79 | @include overlay-block(9980, $white-color, 0); 80 | position: fixed; 81 | transition: opacity .35s, visibility .35s; 82 | cursor: pointer; 83 | visibility: hidden; 84 | } 85 | 86 | // Off-Canvas open 87 | .no-csstransforms3d { 88 | .offcanvas-wrapper, 89 | .navbar, .topbar { 90 | transition: left .4s ease-in-out, background-color .2s; 91 | } 92 | .site-backdrop { 93 | transition: left .4s ease-in-out, opacity .35s, visibility .35s; 94 | } 95 | .offcanvas-open { 96 | .offcanvas-wrapper, 97 | .site-backdrop, 98 | .navbar, .topbar { left: 290px; } 99 | .site-backdrop { 100 | opacity: .2; 101 | visibility: visible; 102 | } 103 | } 104 | } 105 | .csstransforms3d { 106 | .offcanvas-wrapper, 107 | .navbar, .topbar { 108 | transition: transform .4s ease-in-out, background-color .2s; 109 | } 110 | .site-backdrop { 111 | transition: transform .4s ease-in-out, opacity .35s, visibility .35s; 112 | } 113 | .offcanvas-open { 114 | .offcanvas-wrapper, 115 | .site-backdrop, 116 | .navbar, .topbar { 117 | transform: translate3d(290px, 0, 0); 118 | } 119 | .site-backdrop { 120 | opacity: .2; 121 | visibility: visible; 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/scss/layout/_section.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Full Width Section / Full Height Section 3 | // -------------------------------------------------- 4 | 5 | .fw-section, 6 | .fh-section { 7 | position: relative; 8 | background: { 9 | position: 50% 50%; 10 | repeat: no-repeat; 11 | size: cover; 12 | } 13 | 14 | // Overlay 15 | > .overlay { @include overlay-block(1, $black-color, .6); } 16 | 17 | // Content 18 | > .container, 19 | > .container-fluid, 20 | > div { 21 | position: relative; 22 | z-index: 5; 23 | } 24 | 25 | // Fixed Background 26 | &.bg-fixed { background-attachment: fixed; } 27 | 28 | // No cover background 29 | &.no-cover-bg { background-size: auto; } 30 | } 31 | 32 | .fw-section { width: 100%; } 33 | 34 | .fh-section { height: 100vh; } -------------------------------------------------------------------------------- /src/scss/styles.scss: -------------------------------------------------------------------------------- 1 | //Import Google Fonts (Maven Pro) 2 | @import url('https://fonts.googleapis.com/css?family=Maven+Pro:400,500,700,900'); 3 | 4 | 5 | // Import Bootstrap Modules 6 | @import "~bootstrap/scss/functions"; 7 | @import "~bootstrap/scss/variables"; 8 | @import "~bootstrap/scss/mixins"; 9 | @import "~bootstrap/scss/root"; 10 | @import "~bootstrap/scss/reboot"; 11 | @import "~bootstrap/scss/type"; 12 | @import "~bootstrap/scss/images"; 13 | @import "~bootstrap/scss/code"; 14 | @import "~bootstrap/scss/grid"; 15 | @import "~bootstrap/scss/tables"; 16 | @import "~bootstrap/scss/forms"; 17 | @import "~bootstrap/scss/buttons"; 18 | @import "~bootstrap/scss/transitions"; 19 | @import "~bootstrap/scss/dropdown"; 20 | @import "~bootstrap/scss/button-group"; 21 | // @import "~bootstrap/scss/input-group"; 22 | @import "~bootstrap/scss/custom-forms"; 23 | @import "~bootstrap/scss/nav"; 24 | @import "~bootstrap/scss/navbar"; 25 | @import "~bootstrap/scss/card"; 26 | @import "~bootstrap/scss/breadcrumb"; 27 | @import "~bootstrap/scss/pagination"; 28 | @import "~bootstrap/scss/badge"; 29 | @import "~bootstrap/scss/jumbotron"; 30 | @import "~bootstrap/scss/alert"; 31 | @import "~bootstrap/scss/progress"; 32 | @import "~bootstrap/scss/media"; 33 | @import "~bootstrap/scss/list-group"; 34 | @import "~bootstrap/scss/close"; 35 | @import "~bootstrap/scss/modal"; 36 | @import "~bootstrap/scss/tooltip"; 37 | @import "~bootstrap/scss/popover"; 38 | @import "~bootstrap/scss/carousel"; 39 | @import "~bootstrap/scss/utilities"; 40 | @import "~bootstrap/scss/print"; 41 | 42 | 43 | // Helpers: Variables, Mixins and Placeholders 44 | @import 'helpers/variables'; 45 | @import 'helpers/mixins'; 46 | @import 'helpers/placeholders'; 47 | 48 | 49 | // Import third party lib styles 50 | @import "~ngx-toastr/toastr-bs4-alert"; 51 | 52 | 53 | // Base 54 | @import 'base/scaffolding'; 55 | @import 'base/utilities'; 56 | 57 | 58 | // Components 59 | @import 'components/icons'; 60 | @import 'components/typography'; 61 | @import 'components/forms'; 62 | @import 'components/tables'; 63 | @import 'components/buttons'; 64 | @import 'components/social-buttons'; 65 | @import 'components/navs'; 66 | @import 'components/card'; 67 | @import 'components/accordion'; 68 | @import 'components/pagination'; 69 | @import 'components/comments'; 70 | @import 'components/tooltips'; 71 | @import 'components/dropdown'; 72 | @import 'components/list-group'; 73 | @import 'components/alert'; 74 | @import 'components/modal'; 75 | @import 'components/progress'; 76 | @import 'components/gallery'; 77 | @import 'components/countdown'; 78 | @import 'components/widgets'; 79 | @import 'components/steps'; 80 | @import 'components/banners'; 81 | 82 | 83 | // Layout 84 | @import 'layout/section'; 85 | @import 'layout/header'; 86 | @import 'layout/offcanvas-menu'; 87 | -------------------------------------------------------------------------------- /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 = function () {}; 21 | 22 | // First, initialize the Angular testing environment. 23 | getTestBed().initTestEnvironment( 24 | BrowserDynamicTestingModule, 25 | platformBrowserDynamicTesting(), { 26 | teardown: { destroyAfterEach: false } 27 | } 28 | ); 29 | // Then we find all the tests. 30 | const context = require.context('./', true, /\.spec\.ts$/); 31 | // And load the modules. 32 | context.keys().map(context); 33 | // Finally, start Karma to run the tests. 34 | __karma__.start(); 35 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "baseUrl": "./", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "main.ts", 10 | "polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "./", 6 | "target": "es2020", 7 | "types": [ 8 | "jasmine", 9 | "node" 10 | ] 11 | }, 12 | "files": [ 13 | "test.ts", 14 | "polyfills.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 | "baseUrl": "src", 5 | "module": "es2020", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "target": "es2020", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2017", 17 | "dom" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "eofline": true, 15 | "forin": true, 16 | "deprecation": { 17 | "severity": "warning" 18 | }, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-shadowed-variable": true, 69 | "no-string-literal": false, 70 | "no-string-throw": true, 71 | "no-switch-case-fall-through": true, 72 | "no-trailing-whitespace": true, 73 | "no-unnecessary-initializer": true, 74 | "no-unused-expression": true, 75 | "no-var-keyword": true, 76 | "object-literal-sort-keys": false, 77 | "one-line": [ 78 | true, 79 | "check-open-brace", 80 | "check-catch", 81 | "check-else", 82 | "check-whitespace" 83 | ], 84 | "prefer-const": true, 85 | "quotemark": [ 86 | true, 87 | "single" 88 | ], 89 | "radix": true, 90 | "semicolon": [ 91 | true, 92 | "always" 93 | ], 94 | "triple-equals": [ 95 | true, 96 | "allow-null-check" 97 | ], 98 | "typedef-whitespace": [ 99 | true, 100 | { 101 | "call-signature": "nospace", 102 | "index-signature": "nospace", 103 | "parameter": "nospace", 104 | "property-declaration": "nospace", 105 | "variable-declaration": "nospace" 106 | } 107 | ], 108 | "typeof-compare": true, 109 | "unified-signatures": true, 110 | "variable-name": false, 111 | "whitespace": [ 112 | true, 113 | "check-branch", 114 | "check-decl", 115 | "check-operator", 116 | "check-separator", 117 | "check-type" 118 | ], 119 | "directive-selector": [ 120 | true, 121 | "attribute", 122 | "app", 123 | "camelCase" 124 | ], 125 | "component-selector": [ 126 | true, 127 | "element", 128 | "app", 129 | "kebab-case" 130 | ], 131 | "use-input-property-decorator": true, 132 | "use-output-property-decorator": true, 133 | "use-host-property-decorator": true, 134 | "no-input-rename": true, 135 | "no-output-rename": true, 136 | "use-life-cycle-interface": true, 137 | "use-pipe-transform-interface": true, 138 | "component-class-suffix": true, 139 | "directive-class-suffix": true, 140 | "invoke-injectable": true 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /ux-testing/erika.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/ux-testing/erika.jpg -------------------------------------------------------------------------------- /ux-testing/task1_step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/ux-testing/task1_step1.png -------------------------------------------------------------------------------- /ux-testing/task1_step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/ux-testing/task1_step2.png -------------------------------------------------------------------------------- /ux-testing/task2_step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/ux-testing/task2_step1.png -------------------------------------------------------------------------------- /ux-testing/uxTest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monobasic/Angular-Reactive-Demo-Shop/d25467526b2b627132a750775e7e28145a406aad/ux-testing/uxTest.jpg --------------------------------------------------------------------------------