├── src ├── assets │ └── .gitkeep ├── app │ ├── app.component.css │ ├── cart │ │ ├── cart.component.css │ │ ├── cart-routing.module.ts │ │ ├── cart.module.ts │ │ ├── cart.component.ts │ │ ├── cart.component.spec.ts │ │ └── cart.component.html │ ├── footer │ │ ├── footer.component.css │ │ ├── footer.component.ts │ │ ├── footer.component.html │ │ └── footer.component.spec.ts │ ├── cart-table │ │ ├── cart-table.component.css │ │ ├── cart-table.component.ts │ │ ├── cart-table.component.spec.ts │ │ └── cart-table.component.html │ ├── footer-links │ │ ├── footer-links.component.css │ │ ├── footer-links.component.ts │ │ ├── footer-links.component.html │ │ └── footer-links.component.spec.ts │ ├── product-list │ │ ├── product-list.component.css │ │ ├── product-list-routing.module.ts │ │ ├── product-list.module.ts │ │ ├── product-list.component.ts │ │ ├── product-list.component.spec.ts │ │ └── product-list.component.html │ ├── product-detail │ │ ├── product-detail.component.css │ │ ├── product-detail.component.spec.ts │ │ ├── product-detail.component.ts │ │ └── product-detail.component.html │ ├── state │ │ ├── app.state.ts │ │ ├── product-list.selectors.ts │ │ ├── product-list.actions.ts │ │ ├── product-list.reducers.ts │ │ ├── cart.reducers.ts │ │ ├── cart.actions.ts │ │ ├── product-list.effects.ts │ │ └── cart.effects.ts │ ├── app.component.html │ ├── models │ │ └── Product.ts │ ├── app.component.ts │ ├── main-nav │ │ ├── main-nav.component.html │ │ ├── main-nav.component.ts │ │ ├── main-nav.component.spec.ts │ │ └── main-nav.component.css │ ├── services │ │ ├── cart.service.spec.ts │ │ ├── product-list.service.spec.ts │ │ ├── product-list.service.ts │ │ └── cart.service.ts │ ├── app.component.spec.ts │ ├── app-routing.module.ts │ └── app.module.ts ├── favicon.ico ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── index.html ├── main.ts ├── test.ts ├── polyfills.ts └── styles.css ├── netlify.toml ├── .editorconfig ├── tsconfig.app.json ├── tsconfig.spec.json ├── .browserslistrc ├── netlify └── functions │ ├── utils │ ├── postToShopify.js │ ├── addItemToCart.js │ ├── removeItemFromCart.js │ └── createCartWithItem.js │ ├── remove-from-cart.js │ ├── get-product-list.js │ ├── add-to-cart.js │ ├── get-product.js │ └── get-cart.js ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json ├── karma.conf.js └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/cart/cart.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/footer/footer.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/cart-table/cart-table.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/footer-links/footer-links.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/product-list/product-list.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/product-detail/product-detail.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tzmanics/angular-shopify/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/state/app.state.ts: -------------------------------------------------------------------------------- 1 | import { Product } from '../models/Product'; 2 | 3 | export interface AppState { 4 | productList: any[]; 5 | cart: any[]; 6 | } 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "dist/angular-shopify" 3 | command = "ng build --prod" 4 | [[redirects]] 5 | from = "/*" 6 | to = "/index.html" 7 | status = 200 8 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Shoperoni

3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/app/models/Product.ts: -------------------------------------------------------------------------------- 1 | export interface Product { 2 | id: string; 3 | handle: string; 4 | description: string; 5 | title: string; 6 | image: string; 7 | variants: { edges: any[] }; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.css'], 7 | }) 8 | export class AppComponent {} 9 | -------------------------------------------------------------------------------- /src/app/state/product-list.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector, createFeatureSelector } from '@ngrx/store'; 2 | import { AppState } from './app.state'; 3 | import { Product } from '../models/Product'; 4 | 5 | export const selectProduct = createSelector( 6 | (state: AppState) => state.productList, 7 | (productList: Array) => productList 8 | ); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://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 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/app/state/product-list.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | export const loadProductList = createAction( 4 | '[Product List Page] Load Product List' 5 | ); 6 | 7 | export const loadProductListSuccess = createAction( 8 | '[Product List Page | API] Load Product List Success', 9 | props<{ productList: any[] }>() 10 | ); 11 | -------------------------------------------------------------------------------- /src/app/state/product-list.reducers.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | 3 | import { loadProductListSuccess } from './product-list.actions'; 4 | 5 | export const initialState: any[] = []; 6 | 7 | export const productListReducer = createReducer( 8 | initialState, 9 | on(loadProductListSuccess, (_, action) => action.productList) 10 | ); 11 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/app/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer', 5 | templateUrl: './footer.component.html', 6 | styleUrls: ['./footer.component.css'] 7 | }) 8 | export class FooterComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/main-nav/main-nav.component.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/app/main-nav/main-nav.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-main-nav', 5 | templateUrl: './main-nav.component.html', 6 | styleUrls: ['./main-nav.component.css'], 7 | }) 8 | export class MainNavComponent implements OnInit { 9 | cartSize: number = 0; 10 | 11 | constructor() {} 12 | 13 | ngOnInit(): void {} 14 | } 15 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular Shopify 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/footer-links/footer-links.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer-links', 5 | templateUrl: './footer-links.component.html', 6 | styleUrls: ['./footer-links.component.css'] 7 | }) 8 | export class FooterLinksComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/cart/cart-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { CartComponent } from './cart.component'; 4 | 5 | const routes: Routes = [{ path: '', component: CartComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule] 10 | }) 11 | export class CartRoutingModule { } 12 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /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.error(err)); 13 | -------------------------------------------------------------------------------- /src/app/services/cart.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CartService } from './cart.service'; 4 | 5 | describe('CartService', () => { 6 | let service: CartService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(CartService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/product-list/product-list-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { ProductListComponent } from './product-list.component'; 4 | 5 | const routes: Routes = [{ path: '', component: ProductListComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule] 10 | }) 11 | export class ProductListRoutingModule { } 12 | -------------------------------------------------------------------------------- /src/app/product-list/product-list.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { ProductListRoutingModule } from './product-list-routing.module'; 5 | import { ProductListComponent } from './product-list.component'; 6 | 7 | @NgModule({ 8 | declarations: [ProductListComponent], 9 | imports: [CommonModule, ProductListRoutingModule], 10 | }) 11 | export class ProductListModule {} 12 | -------------------------------------------------------------------------------- /src/app/cart/cart.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { CartRoutingModule } from './cart-routing.module'; 5 | import { CartComponent } from './cart.component'; 6 | import { CartTableComponent } from '../cart-table/cart-table.component'; 7 | 8 | @NgModule({ 9 | declarations: [CartComponent, CartTableComponent], 10 | imports: [CommonModule, CartRoutingModule], 11 | }) 12 | export class CartModule {} 13 | -------------------------------------------------------------------------------- /src/app/services/product-list.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ProductListService } from './product-list.service'; 4 | 5 | describe('ProductListService', () => { 6 | let service: ProductListService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ProductListService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/state/cart.reducers.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | 3 | import { addProductSuccess } from './cart.actions'; 4 | import { removeProductSuccess } from './cart.actions'; 5 | import { getCartSuccess } from './cart.actions'; 6 | 7 | export const initialState: any[] = []; 8 | 9 | export const cartReducer = createReducer( 10 | initialState, 11 | on(addProductSuccess, (_, action) => action.cart), 12 | on(removeProductSuccess, (_, action) => action.cart), 13 | on(getCartSuccess, (_, action) => action.cart) 14 | ); 15 | -------------------------------------------------------------------------------- /src/app/footer-links/footer-links.component.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /src/app/cart/cart.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { getCart } from '../state/cart.actions'; 6 | 7 | @Component({ 8 | selector: 'app-cart', 9 | templateUrl: './cart.component.html', 10 | styleUrls: ['./cart.component.css'], 11 | }) 12 | export class CartComponent implements OnInit { 13 | cart$: Observable = this.store.select((state) => state.cart); 14 | constructor(private store: Store<{ cart: any[] }>) {} 15 | 16 | ngOnInit(): void { 17 | this.store.dispatch(getCart()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | imports: [RouterTestingModule], 9 | declarations: [AppComponent], 10 | }).compileComponents(); 11 | }); 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(AppComponent); 15 | const app = fixture.componentInstance; 16 | expect(app).toBeTruthy(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/cart-table/cart-table.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | 4 | import { removeProduct } from '../state/cart.actions'; 5 | 6 | @Component({ 7 | selector: 'app-cart-table', 8 | templateUrl: './cart-table.component.html', 9 | styleUrls: ['./cart-table.component.css'], 10 | }) 11 | export class CartTableComponent implements OnInit { 12 | @Input() cartItems: any[] = []; 13 | 14 | constructor(private store: Store<{ cart: any[] }>) {} 15 | 16 | ngOnInit(): void {} 17 | 18 | removeFromCart(lineId: any) { 19 | this.store.dispatch(removeProduct({ lineId })); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/footer/footer.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | "The interplay of flavors between the cheese, meats and fruits is an 5 | absolute delight." 6 |

7 |

Paul Hotcakes

8 |
9 | 10 |
11 |

12 | This project is 13 | open source on GitHub, hosted with Netlify, built with 15 | Angular and made with 💚 by Tara Z. 16 | Manicsic(@tzmanics) 17 |

18 |
19 |
20 | -------------------------------------------------------------------------------- /src/app/cart/cart.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CartComponent } from './cart.component'; 4 | 5 | describe('CartComponent', () => { 6 | let component: CartComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ CartComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(CartComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/app/footer/footer.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FooterComponent } from './footer.component'; 4 | 5 | describe('FooterComponent', () => { 6 | let component: FooterComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ FooterComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FooterComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/main-nav/main-nav.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { MainNavComponent } from './main-nav.component'; 4 | 5 | describe('MainNavComponent', () => { 6 | let component: MainNavComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ MainNavComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(MainNavComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /src/app/services/product-list.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Product } from '../models/Product'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class ProductListService { 11 | constructor(private http: HttpClient) {} 12 | 13 | getProductList(): Observable { 14 | return this.http.post( 15 | '/.netlify/functions/get-product-list', 16 | {} 17 | ); 18 | } 19 | 20 | getProduct(handle: string): Observable { 21 | const product = this.http.post('/.netlify/functions/get-product', { 22 | itemHandle: handle, 23 | }); 24 | return product; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/cart-table/cart-table.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CartTableComponent } from './cart-table.component'; 4 | 5 | describe('CartTableComponent', () => { 6 | let component: CartTableComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ CartTableComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(CartTableComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/product-list/product-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Product } from '../models/Product'; 6 | import { loadProductList } from '../state/product-list.actions'; 7 | 8 | @Component({ 9 | selector: 'app-product-list', 10 | templateUrl: './product-list.component.html', 11 | styleUrls: ['./product-list.component.css'], 12 | }) 13 | export class ProductListComponent implements OnInit { 14 | productList$: Observable = this.store.select( 15 | (state) => state.productList 16 | ); 17 | 18 | constructor(private store: Store<{ productList: Product[] }>) {} 19 | 20 | ngOnInit(): void { 21 | this.store.dispatch(loadProductList()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/footer-links/footer-links.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FooterLinksComponent } from './footer-links.component'; 4 | 5 | describe('FooterLinksComponent', () => { 6 | let component: FooterLinksComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ FooterLinksComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FooterLinksComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/product-list/product-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ProductListComponent } from './product-list.component'; 4 | 5 | describe('ProductListComponent', () => { 6 | let component: ProductListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ProductListComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ProductListComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/product-detail/product-detail.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ProductDetailComponent } from './product-detail.component'; 4 | 5 | describe('ProductDetailComponent', () => { 6 | let component: ProductDetailComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ProductDetailComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ProductDetailComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/state/cart.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | export const addProduct = createAction( 4 | '[Product Detail Page] Add product', 5 | props<{ productInfo: any }>() 6 | ); 7 | 8 | export const addProductSuccess = createAction( 9 | '[Product Detail Page] Add product success', 10 | props<{ cart: any[] }>() 11 | ); 12 | 13 | export const getCart = createAction('[Shopify API] Get cart'); 14 | 15 | export const getCartSuccess = createAction( 16 | '[Shopify API] Get cart success', 17 | props<{ cart: any[] }>() 18 | ); 19 | 20 | export const removeProduct = createAction( 21 | '[Cart Page] Remove product', 22 | props<{ lineId: any }>() 23 | ); 24 | 25 | export const removeProductSuccess = createAction( 26 | '[Cart Page] Remove product success', 27 | props<{ cart: any[] }>() 28 | ); 29 | -------------------------------------------------------------------------------- /netlify/functions/utils/postToShopify.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | 3 | exports.postToShopify = async ({ query, variables }) => { 4 | try { 5 | const result = await fetch(process.env.SHOPIFY_API_ENDPOINT, { 6 | method: "POST", 7 | headers: { 8 | "Content-Type": "application/json", 9 | "X-Shopify-Storefront-Access-Token": 10 | process.env.SHOPIFY_STOREFRONT_API_TOKEN, 11 | }, 12 | body: JSON.stringify({ query, variables }), 13 | }).then((res) => res.json()); 14 | 15 | if (result.errors) { 16 | console.log({ errors: result.errors }); 17 | } else if (!result || !result.data) { 18 | console.log({ result }); 19 | return "No results found."; 20 | } 21 | 22 | return result.data; 23 | } catch (error) { 24 | console.log(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /.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 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | 16 | # IDEs and editors 17 | /.idea 18 | .project 19 | .classpath 20 | .c9/ 21 | *.launch 22 | .settings/ 23 | *.sublime-workspace 24 | 25 | # IDE - VSCode 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | .history/* 32 | 33 | # misc 34 | /.sass-cache 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | npm-debug.log 39 | yarn-error.log 40 | testem.log 41 | /typings 42 | 43 | # System Files 44 | .DS_Store 45 | Thumbs.db 46 | 47 | # Local Netlify folder 48 | .netlify -------------------------------------------------------------------------------- /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/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context(path: string, deep?: boolean, filter?: RegExp): { 12 | keys(): string[]; 13 | (id: string): T; 14 | }; 15 | }; 16 | 17 | // First, initialize the Angular testing environment. 18 | getTestBed().initTestEnvironment( 19 | BrowserDynamicTestingModule, 20 | platformBrowserDynamicTesting() 21 | ); 22 | // Then we find all the tests. 23 | const context = require.context('./', true, /\.spec\.ts$/); 24 | // And load the modules. 25 | context.keys().map(context); 26 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { ProductDetailComponent } from './product-detail/product-detail.component'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | redirectTo: 'product-list', 10 | pathMatch: 'full', 11 | }, 12 | { 13 | path: 'product-list', 14 | loadChildren: () => 15 | import('./product-list/product-list.module').then( 16 | (m) => m.ProductListModule 17 | ), 18 | }, 19 | { 20 | path: 'product/:handle', 21 | component: ProductDetailComponent, 22 | }, 23 | { path: 'cart', loadChildren: () => import('./cart/cart.module').then(m => m.CartModule) }, 24 | ]; 25 | 26 | @NgModule({ 27 | imports: [RouterModule.forRoot(routes)], 28 | exports: [RouterModule], 29 | }) 30 | export class AppRoutingModule {} 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "noImplicitAny": false, 11 | "noFallthroughCasesInSwitch": true, 12 | "sourceMap": true, 13 | "declaration": false, 14 | "downlevelIteration": true, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "target": "es2017", 19 | "module": "es2020", 20 | "lib": ["es2018", "dom"] 21 | }, 22 | "angularCompilerOptions": { 23 | "enableI18nLegacyMessageIdFormat": false, 24 | "strictInjectionParameters": true, 25 | "strictInputAccessModifiers": true, 26 | "strictTemplates": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/product-list/product-list.component.html: -------------------------------------------------------------------------------- 1 |
2 |
6 |
11 |
12 |
13 | 14 |
15 |
16 |

{{ product.title }}

17 |

18 | {{ product.description?.substring(0, 60) }}... 19 |

20 | 21 |
22 |
23 |
24 |
25 | 26 |

Loading products...

27 |
28 |
29 | -------------------------------------------------------------------------------- /src/app/main-nav/main-nav.component.css: -------------------------------------------------------------------------------- 1 | .app-header { 2 | display: flex; 3 | justify-content: center; 4 | flex-direction: column; 5 | align-items: center; 6 | padding: 40px 40px 0; 7 | } 8 | .main-nav { 9 | display: flex; 10 | width: 80vw; 11 | margin-top: 30px; 12 | justify-content: center; 13 | align-items: center; 14 | border-top: 1px solid #ccc; 15 | border-bottom: 1px solid #ccc; 16 | padding: 8px 0; 17 | } 18 | .main-nav ul { 19 | padding-left: 0; 20 | } 21 | .main-nav-item { 22 | position: relative; 23 | display: inline; 24 | letter-spacing: 0.1em; 25 | text-transform: uppercase; 26 | } 27 | .main-nav-item a { 28 | color: black; 29 | } 30 | .main-nav-item a:hover { 31 | color: #d96528; 32 | } 33 | .cart-size { 34 | position: absolute; 35 | top: -18px; 36 | right: -20px; 37 | width: 25px; 38 | height: 25px; 39 | padding: 6px 10px; 40 | border-radius: 1000px; 41 | background: black; 42 | text-align: center; 43 | color: white; 44 | font-size: 10px; 45 | font-weight: bold; 46 | } 47 | -------------------------------------------------------------------------------- /src/app/state/product-list.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { EMPTY } from 'rxjs'; 4 | import { map, mergeMap, catchError } from 'rxjs/operators'; 5 | 6 | import { ProductListService } from '../services/product-list.service'; 7 | 8 | @Injectable() 9 | export class ProductListEffects { 10 | loadProductList$ = createEffect(() => 11 | this.actions$.pipe( 12 | ofType('[Product List Page] Load Product List'), 13 | mergeMap(() => 14 | this.productListService.getProductList().pipe( 15 | map((productList) => { 16 | return { 17 | type: '[Product List Page | API] Load Product List Success', 18 | productList: productList, 19 | }; 20 | }), 21 | catchError(() => EMPTY) 22 | ) 23 | ) 24 | ) 25 | ); 26 | 27 | constructor( 28 | private actions$: Actions, 29 | private productListService: ProductListService 30 | ) {} 31 | } 32 | -------------------------------------------------------------------------------- /netlify/functions/remove-from-cart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove Item From Cart API Endpoint 3 | * 4 | * * Purpose: Remove a single item from the cart 5 | * @param {string} cartId 6 | * @param {string} lineId - Not the item or variant id 7 | * 8 | * Example: 9 | * ``` 10 | * fetch('/.netlify/functions/remove-from-cart, { 11 | * method: 'POST', 12 | * body: JSON.stringify({ 13 | * cartId: 'S9Qcm9kdWN0VmFyaWFudC8zOTc0NDEyMDEyNzY5NA', 14 | * lineId: 'RIJC3mn0c862e2fc3314ba5971bf22d73d7accb' 15 | * }) 16 | * }) 17 | * ``` 18 | */ 19 | 20 | const { removeItemFromCart } = require("./utils/removeItemFromCart"); 21 | 22 | exports.handler = async (event) => { 23 | const { cartId, lineId } = JSON.parse(event.body); 24 | 25 | try { 26 | console.log("--------------------------------"); 27 | console.log("Removing item from cart..."); 28 | console.log("--------------------------------"); 29 | const shopifyResponse = await removeItemFromCart({ 30 | cartId, 31 | lineId, 32 | }); 33 | 34 | return { 35 | statusCode: 200, 36 | body: JSON.stringify(shopifyResponse.cartLinesRemove.cart), 37 | }; 38 | } catch (error) { 39 | console.log(error); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/app/cart-table/cart-table.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 21 | 24 | 27 | 32 | 35 | 36 | 37 |
ItemPriceQuantityTotalActions
12 | 13 | {{ item.node.merchandise.product.title }} 14 | {{ 15 | item.node.merchandise.title === "Default Title" 16 | ? "" 17 | : " (" + item.node.merchandise.title + ")" 18 | }} 20 | 22 | {{ item.node.merchandise.priceV2.amount | currency }} 23 | 25 | {{ item.node.quantity }} 26 | 28 | {{ 29 | item.node.merchandise.priceV2.amount * item.node.quantity | currency 30 | }} 31 | 33 | 34 |
38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ( ゚ Д゚)ノ[ ($) ] Angular Shopify 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/4ca9ef0e-e6b5-43e3-90ce-c5296ab74bba/deploy-status)](https://app.netlify.com/sites/angular-shopify/deploys) 4 | 5 | 6 | An E-Commerce example built with Angular, Shopify & Netlify Functions! You can learn more about this project [by cheking out this blog post](https://ntl.fyi/3zjDvvk). 7 | 8 | 9 | > [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/tzmanics/angular-shopify&utm_source=blog&utm_medium=ng-shopify-tzm&utm_campaign=devex) 10 | 11 | ## What's the dealio? 12 | 13 | This template is based on a charcuterie dealership but if for some reason, that's not your business, you can customize the products and headers. This template sets up the process to: 14 | 15 | - Grab products from Shopify for the main products page 16 | - Use [NgRx](https://ngrx.io/) to manage the state of the cart & products list 17 | - Loop through the products in a details page that uses the product title as the route 18 | - Add radio buttons for products with multiple variants 19 | - Utilize the Shopify cart to add and remove items and 20 | - Add taxes and shipping to show the user the total amount 21 | 22 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.1.3. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-shopify", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "~12.1.0-", 14 | "@angular/common": "~12.1.0-", 15 | "@angular/compiler": "~12.1.0-", 16 | "@angular/core": "~12.1.0-", 17 | "@angular/forms": "~12.1.0-", 18 | "@angular/platform-browser": "~12.1.0-", 19 | "@angular/platform-browser-dynamic": "~12.1.0-", 20 | "@angular/router": "~12.1.0-", 21 | "@ngrx/effects": "^12.3.0", 22 | "@ngrx/store": "^12.3.0", 23 | "@ngrx/store-devtools": "^12.3.0", 24 | "node-fetch": "^2.6.1", 25 | "rxjs": "~6.6.0", 26 | "tslib": "^2.2.0", 27 | "zone.js": "~0.11.4" 28 | }, 29 | "devDependencies": { 30 | "@angular-devkit/build-angular": "~12.1.3", 31 | "@angular/cli": "~12.1.3", 32 | "@angular/compiler-cli": "~12.1.0-", 33 | "@types/jasmine": "~3.8.0", 34 | "@types/node": "^12.11.1", 35 | "jasmine-core": "~3.8.0", 36 | "karma": "~6.3.0", 37 | "karma-chrome-launcher": "~3.1.0", 38 | "karma-coverage": "~2.0.3", 39 | "karma-jasmine": "~4.0.0", 40 | "karma-jasmine-html-reporter": "~1.7.0", 41 | "typescript": "~4.3.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/services/cart.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class CartService { 9 | cartId: string = window.localStorage.getItem('ngShopifyCartId') || ''; 10 | items: string[] = []; 11 | 12 | constructor(private http: HttpClient) {} 13 | 14 | addToCart(productInfo: { 15 | cartId: string; 16 | itemId: string; 17 | quantity: number; 18 | }): Observable { 19 | const addToCartResponse = this.http.post( 20 | '/.netlify/functions/add-to-cart', 21 | { 22 | cartId: productInfo.cartId, 23 | itemId: productInfo.itemId, 24 | quantity: productInfo.quantity, 25 | } 26 | ); 27 | return addToCartResponse; 28 | } 29 | 30 | removeFromCart(action: any) { 31 | const shopifyRemoveCartResponse = this.http.post( 32 | '/.netlify/functions/remove-from-cart', 33 | { 34 | cartId: this.cartId, 35 | lineId: action.lineId, 36 | } 37 | ); 38 | return shopifyRemoveCartResponse; 39 | } 40 | 41 | getCart() { 42 | const shopifyGetCartResponse = this.http.post( 43 | '/.netlify/functions/get-cart', 44 | { 45 | cartId: this.cartId, 46 | } 47 | ); 48 | return shopifyGetCartResponse; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/cart/cart.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Your Cart

4 |
5 |
6 | 7 |
8 |
9 |
10 |

11 | Subtotal: 12 |

13 |

Shipping:

14 |

Tax:

15 |

Total:

16 |
17 |
18 |

19 | {{ 20 | cart.estimatedCost.subtotalAmount.amount | currency 21 | }} 22 |

23 |

Free Shipping

24 |

{{ cart.estimatedCost.totalTaxAmount.amount | currency }}

25 |

{{ cart.estimatedCost.totalAmount.amount | currency }}

26 |
27 |
28 |
29 |
30 |
31 | 32 |

Your cart is empty, fill it up!

33 | Back to Products 34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /src/app/product-detail/product-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute } from '@angular/router'; 3 | import { Store } from '@ngrx/store'; 4 | 5 | import { Product } from '../models/Product'; 6 | import { ProductListService } from '../services/product-list.service'; 7 | import { addProduct } from '../state/cart.actions'; 8 | 9 | @Component({ 10 | selector: 'app-product-detail', 11 | templateUrl: './product-detail.component.html', 12 | styleUrls: ['./product-detail.component.css'], 13 | }) 14 | export class ProductDetailComponent implements OnInit { 15 | product!: Product; 16 | selectedProductId!: string; 17 | selectedQuantity: number = 1; 18 | variants: any[] = [{ node: { priceV2: { amount: 0 } } }]; 19 | 20 | constructor( 21 | private route: ActivatedRoute, 22 | private productsService: ProductListService, 23 | private store: Store<{ cart: any[] }> 24 | ) {} 25 | 26 | ngOnInit(): void { 27 | this.getProduct(); 28 | } 29 | 30 | getProduct(): void { 31 | const handle = this.route.snapshot.paramMap.get('handle') || ''; 32 | this.productsService.getProduct(handle).subscribe((product) => { 33 | this.product = product; 34 | this.selectedProductId = this.product.variants.edges[0].node.id; 35 | this.variants = product.variants.edges; 36 | }); 37 | } 38 | 39 | addToCart(productInfo: any) { 40 | productInfo.cartId = window.localStorage.getItem('ngShopifyCartId') || ''; 41 | this.store.dispatch(addProduct(productInfo)); 42 | alert('Successfully added to cart 👍'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, './coverage/angular-shopify'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | port: 9876, 37 | colors: true, 38 | logLevel: config.LOG_INFO, 39 | autoWatch: true, 40 | browsers: ['Chrome'], 41 | singleRun: false, 42 | restartOnFileChange: true 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { EffectsModule } from '@ngrx/effects'; 4 | import { StoreModule } from '@ngrx/store'; 5 | import { HttpClientModule } from '@angular/common/http'; 6 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 7 | import { FormsModule } from '@angular/forms'; 8 | 9 | import { ProductListEffects } from './state/product-list.effects'; 10 | import { productListReducer } from './state/product-list.reducers'; 11 | import { CartEffects } from './state/cart.effects'; 12 | import { cartReducer } from './state/cart.reducers'; 13 | 14 | import { AppRoutingModule } from './app-routing.module'; 15 | import { AppComponent } from './app.component'; 16 | import { MainNavComponent } from './main-nav/main-nav.component'; 17 | import { FooterComponent } from './footer/footer.component'; 18 | import { FooterLinksComponent } from './footer-links/footer-links.component'; 19 | import { ProductDetailComponent } from './product-detail/product-detail.component'; 20 | 21 | @NgModule({ 22 | declarations: [ 23 | AppComponent, 24 | MainNavComponent, 25 | FooterComponent, 26 | FooterLinksComponent, 27 | ProductDetailComponent, 28 | ], 29 | imports: [ 30 | BrowserModule, 31 | AppRoutingModule, 32 | HttpClientModule, 33 | FormsModule, 34 | EffectsModule.forRoot([ProductListEffects, CartEffects]), 35 | StoreModule.forRoot({ productList: productListReducer, cart: cartReducer }), 36 | StoreDevtoolsModule.instrument({ 37 | maxAge: 25, 38 | autoPause: true, 39 | }), 40 | ], 41 | providers: [], 42 | bootstrap: [AppComponent], 43 | }) 44 | export class AppModule {} 45 | -------------------------------------------------------------------------------- /src/app/state/cart.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { EMPTY } from 'rxjs'; 4 | import { map, mergeMap, catchError } from 'rxjs/operators'; 5 | 6 | import { CartService } from '../services/cart.service'; 7 | 8 | @Injectable() 9 | export class CartEffects { 10 | addProduct$ = createEffect(() => 11 | this.actions$.pipe( 12 | ofType('[Product Detail Page] Add product'), 13 | mergeMap((action) => 14 | this.cartService.addToCart(action).pipe( 15 | map((cart) => { 16 | window.localStorage.setItem('ngShopifyCartId', cart.id); 17 | return { 18 | type: '[Product Detail Page] Add product success', 19 | cart: cart, 20 | }; 21 | }), 22 | catchError(() => EMPTY) 23 | ) 24 | ) 25 | ) 26 | ); 27 | 28 | removeProduct$ = createEffect(() => 29 | this.actions$.pipe( 30 | ofType('[Cart Page] Remove product'), 31 | mergeMap((action) => 32 | this.cartService.removeFromCart(action).pipe( 33 | map((cart) => { 34 | return { 35 | type: '[Cart Page] Remove product success', 36 | cart: cart, 37 | }; 38 | }), 39 | catchError(() => EMPTY) 40 | ) 41 | ) 42 | ) 43 | ); 44 | 45 | getCart$ = createEffect(() => 46 | this.actions$.pipe( 47 | ofType('[Shopify API] Get cart'), 48 | mergeMap(() => 49 | this.cartService.getCart().pipe( 50 | map((cart) => { 51 | return { 52 | type: '[Shopify API] Get cart success', 53 | cart: cart.cart, 54 | }; 55 | }), 56 | catchError(() => EMPTY) 57 | ) 58 | ) 59 | ) 60 | ); 61 | constructor(private actions$: Actions, private cartService: CartService) {} 62 | } 63 | -------------------------------------------------------------------------------- /netlify/functions/get-product-list.js: -------------------------------------------------------------------------------- 1 | const { postToShopify } = require("./utils/postToShopify"); 2 | 3 | exports.handler = async () => { 4 | try { 5 | const shopifyResponse = await postToShopify({ 6 | query: ` 7 | query getProductList { 8 | products(sortKey: TITLE, first: 100) { 9 | edges { 10 | node { 11 | id 12 | handle 13 | description 14 | title 15 | totalInventory 16 | variants(first: 5) { 17 | edges { 18 | node { 19 | id 20 | title 21 | quantityAvailable 22 | priceV2 { 23 | amount 24 | currencyCode 25 | } 26 | } 27 | } 28 | } 29 | priceRange { 30 | maxVariantPrice { 31 | amount 32 | currencyCode 33 | } 34 | minVariantPrice { 35 | amount 36 | currencyCode 37 | } 38 | } 39 | images(first: 1) { 40 | edges { 41 | node { 42 | src 43 | altText 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | `, 52 | }).then((response) => { 53 | return response.products.edges.map((edge) => { 54 | const product = edge.node; 55 | const image = product.images.edges[0].node; 56 | product.image = image.src; 57 | return product; 58 | }); 59 | }); 60 | 61 | return { 62 | statusCode: 200, 63 | body: JSON.stringify(shopifyResponse), 64 | }; 65 | } catch (error) { 66 | console.log(error); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /netlify/functions/utils/addItemToCart.js: -------------------------------------------------------------------------------- 1 | const { postToShopify } = require("./postToShopify"); 2 | 3 | exports.addItemToCart = async ({ cartId, itemId, quantity }) => { 4 | try { 5 | const shopifyResponse = postToShopify({ 6 | query: ` 7 | mutation addItemToCart($cartId: ID!, $lines: [CartLineInput!]!) { 8 | cartLinesAdd(cartId: $cartId, lines: $lines) { 9 | cart { 10 | id 11 | lines(first: 10) { 12 | edges { 13 | node { 14 | id 15 | quantity 16 | merchandise { 17 | ... on ProductVariant { 18 | id 19 | title 20 | priceV2 { 21 | amount 22 | currencyCode 23 | } 24 | product { 25 | title 26 | handle 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | estimatedCost { 34 | totalAmount { 35 | amount 36 | currencyCode 37 | } 38 | subtotalAmount { 39 | amount 40 | currencyCode 41 | } 42 | totalTaxAmount { 43 | amount 44 | currencyCode 45 | } 46 | totalDutyAmount { 47 | amount 48 | currencyCode 49 | } 50 | } 51 | } 52 | } 53 | } 54 | `, 55 | variables: { 56 | cartId, 57 | lines: [ 58 | { 59 | merchandiseId: itemId, 60 | quantity, 61 | }, 62 | ], 63 | }, 64 | }); 65 | 66 | return shopifyResponse; 67 | } catch (error) { 68 | console.log(error); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /netlify/functions/utils/removeItemFromCart.js: -------------------------------------------------------------------------------- 1 | const { postToShopify } = require("./postToShopify"); 2 | 3 | /** 4 | * @param {string} cartId - Target cart to update 5 | * @param lineId - Line id that the item belongs to 6 | */ 7 | exports.removeItemFromCart = async ({ cartId, lineId }) => { 8 | try { 9 | const shopifyResponse = await postToShopify({ 10 | query: ` 11 | mutation removeItemFromCart($cartId: ID!, $lineIds: [ID!]!) { 12 | cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { 13 | cart { 14 | id 15 | lines(first: 10) { 16 | edges { 17 | node { 18 | id 19 | quantity 20 | merchandise { 21 | ... on ProductVariant { 22 | id 23 | title 24 | priceV2 { 25 | amount 26 | currencyCode 27 | } 28 | product { 29 | title 30 | handle 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | estimatedCost { 38 | totalAmount { 39 | amount 40 | currencyCode 41 | } 42 | subtotalAmount { 43 | amount 44 | currencyCode 45 | } 46 | totalTaxAmount { 47 | amount 48 | currencyCode 49 | } 50 | totalDutyAmount { 51 | amount 52 | currencyCode 53 | } 54 | } 55 | } 56 | } 57 | } 58 | `, 59 | variables: { 60 | cartId, 61 | lineIds: [lineId], 62 | }, 63 | }); 64 | 65 | return shopifyResponse; 66 | } catch (error) { 67 | console.log(error); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /netlify/functions/utils/createCartWithItem.js: -------------------------------------------------------------------------------- 1 | const { postToShopify } = require("./postToShopify"); 2 | 3 | // Creates a cart with a single item 4 | exports.createCartWithItem = async ({ itemId, quantity }) => { 5 | try { 6 | const response = await postToShopify({ 7 | query: ` 8 | mutation createCart($cartInput: CartInput) { 9 | cartCreate(input: $cartInput) { 10 | cart { 11 | id 12 | createdAt 13 | updatedAt 14 | lines(first:10) { 15 | edges { 16 | node { 17 | id 18 | quantity 19 | merchandise { 20 | ... on ProductVariant { 21 | id 22 | title 23 | priceV2 { 24 | amount 25 | currencyCode 26 | } 27 | product { 28 | id 29 | title 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | estimatedCost { 37 | totalAmount { 38 | amount 39 | currencyCode 40 | } 41 | subtotalAmount { 42 | amount 43 | currencyCode 44 | } 45 | totalTaxAmount { 46 | amount 47 | currencyCode 48 | } 49 | totalDutyAmount { 50 | amount 51 | currencyCode 52 | } 53 | } 54 | } 55 | } 56 | } 57 | `, 58 | variables: { 59 | cartInput: { 60 | lines: [ 61 | { 62 | quantity, 63 | merchandiseId: itemId, 64 | }, 65 | ], 66 | }, 67 | }, 68 | }); 69 | 70 | return response; 71 | } catch (error) { 72 | console.log(error); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/app/product-detail/product-detail.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
7 |

{{ product?.title }}

8 |

9 | {{ product?.description }} 10 |

11 |
12 |
16 |
17 | 24 | 29 |
30 |
31 | 32 |
33 | {{ variants![0].node.priceV2.amount | currency }} 34 | 35 | (Only 36 | {{ variants![0].node.quantityAvailable }} left) 37 | 38 |
39 |
40 |
41 | 48 | 49 | 59 |
60 |
61 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /netlify/functions/add-to-cart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add to Cart API Endpoint 3 | * 4 | * * Purpose: Add a single item to the cart 5 | * @param {string} cartId (Optional) 6 | * @param {string} itemId - Usually it's the product variant id 7 | * @param {number} quantity - Minimum 1 8 | * 9 | * @returns {object} cart that contains lines of items inside 10 | * See './utils/createCartWithItem' for the data structure 11 | * 12 | * Examples: 13 | * 14 | * If a cart does not exist yet, 15 | * ``` 16 | * fetch('/.netlify/functions/add-to-cart', { 17 | * method: 'POST', 18 | * body: JSON.stringify({ 19 | * cardId: '', // cardId can also be omitted if desired 20 | * itemId: 'Z2lkOi8vc2hvcGlmFyaWFudC8zOTc0NDEyMDEyNzY5NA==', 21 | * quantity: 4 22 | * }) 23 | * }) 24 | * ``` 25 | * 26 | * Add item to an existing cart 27 | * ``` 28 | * fetch('/.netlify/functions/add-to-cart', { 29 | * method: 'POST', 30 | * body: JSON.stringify({ 31 | * cartId: 'S9Qcm9kdWN0VmFyaWFudC8zOTc0NDEyMDEyNzY5NA', 32 | * itemId: 'Z2lkOi8vc2hvcGlmFyaWFudC8zOTc0NDEyMDEyNzY5NA==', 33 | * quantity: 4 34 | * }) 35 | * }) 36 | * ``` 37 | */ 38 | 39 | const { createCartWithItem } = require("./utils/createCartWithItem"); 40 | const { addItemToCart } = require("./utils/addItemToCart"); 41 | 42 | exports.handler = async (event) => { 43 | const { cartId, itemId, quantity } = JSON.parse(event.body); 44 | 45 | if (cartId) { 46 | console.log("--------------------------------"); 47 | console.log("Adding item to existing cart..."); 48 | console.log("--------------------------------"); 49 | 50 | const shopifyResponse = await addItemToCart({ 51 | cartId, 52 | itemId, 53 | quantity, 54 | }); 55 | 56 | return { 57 | statusCode: 200, 58 | body: JSON.stringify(shopifyResponse.cartLinesAdd.cart), 59 | }; 60 | } else { 61 | console.log("--------------------------------"); 62 | console.log("Creating new cart with item..."); 63 | console.log("--------------------------------"); 64 | const createCartResponse = await createCartWithItem({ 65 | itemId, 66 | quantity, 67 | }); 68 | 69 | return { 70 | statusCode: 200, 71 | body: JSON.stringify(createCartResponse.cartCreate.cart), 72 | }; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /netlify/functions/get-product.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get Product API Endpoint 3 | * 4 | * * Purpose: Retrieve data on a specific product 5 | * @param {string} itemHandle - kebab-cased-product-name 6 | * 7 | * Example: 8 | * ``` 9 | * fetch('/.netlify/functions/get-product', { 10 | * method: 'POST', 11 | * body: JSON.stringify({ itemHandle: 'my-product' }) 12 | * }) 13 | * ``` 14 | */ 15 | 16 | const { postToShopify } = require("./utils/postToShopify"); 17 | 18 | exports.handler = async (event) => { 19 | const { itemHandle } = JSON.parse(event.body); 20 | 21 | try { 22 | console.log("--------------------------------"); 23 | console.log("Retrieving product details..."); 24 | console.log("--------------------------------"); 25 | const shopifyResponse = await postToShopify({ 26 | query: ` 27 | query getProduct($handle: String!) { 28 | productByHandle(handle: $handle) { 29 | id 30 | handle 31 | description 32 | title 33 | totalInventory 34 | variants(first: 5) { 35 | edges { 36 | node { 37 | id 38 | title 39 | quantityAvailable 40 | priceV2 { 41 | amount 42 | currencyCode 43 | } 44 | } 45 | } 46 | } 47 | priceRange { 48 | maxVariantPrice { 49 | amount 50 | currencyCode 51 | } 52 | minVariantPrice { 53 | amount 54 | currencyCode 55 | } 56 | } 57 | images(first: 1) { 58 | edges { 59 | node { 60 | src 61 | altText 62 | } 63 | } 64 | } 65 | } 66 | } 67 | `, 68 | variables: { 69 | handle: itemHandle, 70 | }, 71 | }); 72 | 73 | return { 74 | statusCode: 200, 75 | body: JSON.stringify({ 76 | ...shopifyResponse.productByHandle, 77 | image: shopifyResponse.productByHandle.images.edges[0].node.src, 78 | }), 79 | }; 80 | } catch (error) { 81 | console.log(error); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /netlify/functions/get-cart.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API Endpoint 3 | * 4 | * * Purpose: Get items from an existing cart 5 | * @param {string} cartId 6 | * 7 | * Example: 8 | *``` 9 | * fetch('/.netlify/functions/get-cart', { 10 | * method: 'POST', 11 | * body: JSON.stringify({ cartId: '12345' }) 12 | * }) 13 | * ``` 14 | * 15 | * ! POST method is intentional for future enhancement 16 | * 17 | * TODO: Add enhancement for pagination 18 | */ 19 | 20 | const { postToShopify } = require("./utils/postToShopify"); 21 | 22 | exports.handler = async (event) => { 23 | const { cartId } = JSON.parse(event.body); 24 | 25 | try { 26 | console.log("--------------------------------"); 27 | console.log("Retrieving existing cart..."); 28 | console.log("--------------------------------"); 29 | const shopifyResponse = await postToShopify({ 30 | query: ` 31 | query getCart($cartId: ID!) { 32 | cart(id: $cartId) { 33 | id 34 | lines(first: 10) { 35 | edges { 36 | node { 37 | id 38 | quantity 39 | merchandise { 40 | ... on ProductVariant { 41 | id 42 | title 43 | priceV2 { 44 | amount 45 | currencyCode 46 | } 47 | product { 48 | title 49 | handle 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | estimatedCost { 57 | totalAmount { 58 | amount 59 | currencyCode 60 | } 61 | subtotalAmount { 62 | amount 63 | currencyCode 64 | } 65 | totalTaxAmount { 66 | amount 67 | currencyCode 68 | } 69 | totalDutyAmount { 70 | amount 71 | currencyCode 72 | } 73 | } 74 | } 75 | } 76 | `, 77 | variables: { 78 | cartId, 79 | }, 80 | }); 81 | 82 | return { 83 | statusCode: 200, 84 | body: JSON.stringify(shopifyResponse), 85 | }; 86 | } catch (error) { 87 | console.log(error); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /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/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * IE11 requires the following for NgClass support on SVG elements 23 | */ 24 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 25 | 26 | /** 27 | * Web Animations `@angular/platform-browser/animations` 28 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 29 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 30 | */ 31 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 32 | 33 | /** 34 | * By default, zone.js will patch all possible macroTask and DomEvents 35 | * user can disable parts of macroTask/DomEvents patch by setting following flags 36 | * because those flags need to be set before `zone.js` being loaded, and webpack 37 | * will put import in the top of bundle, so user need to create a separate file 38 | * in this directory (for example: zone-flags.ts), and put the following flags 39 | * into that file, and then add the following code before importing zone.js. 40 | * import './zone-flags'; 41 | * 42 | * The flags allowed in zone-flags.ts are listed here. 43 | * 44 | * The following flags will work for all browsers. 45 | * 46 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 47 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 48 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 49 | * 50 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 51 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 52 | * 53 | * (window as any).__Zone_enable_cross_context_check = true; 54 | * 55 | */ 56 | 57 | /*************************************************************************************************** 58 | * Zone JS is required by default for Angular itself. 59 | */ 60 | import 'zone.js'; // Included with Angular CLI. 61 | 62 | 63 | /*************************************************************************************************** 64 | * APPLICATION IMPORTS 65 | */ 66 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "cli": { 4 | "analytics": "edea4108-ad74-4fe5-85af-e27f855f27c9" 5 | }, 6 | "version": 1, 7 | "newProjectRoot": "projects", 8 | "projects": { 9 | "angular-shopify": { 10 | "projectType": "application", 11 | "schematics": { 12 | "@schematics/angular:application": { 13 | "strict": true 14 | } 15 | }, 16 | "root": "", 17 | "sourceRoot": "src", 18 | "prefix": "app", 19 | "architect": { 20 | "build": { 21 | "builder": "@angular-devkit/build-angular:browser", 22 | "options": { 23 | "outputPath": "dist/angular-shopify", 24 | "index": "src/index.html", 25 | "main": "src/main.ts", 26 | "polyfills": "src/polyfills.ts", 27 | "tsConfig": "tsconfig.app.json", 28 | "assets": [ 29 | "src/favicon.ico", 30 | "src/assets" 31 | ], 32 | "styles": [ 33 | "src/styles.css" 34 | ], 35 | "scripts": [] 36 | }, 37 | "configurations": { 38 | "production": { 39 | "budgets": [ 40 | { 41 | "type": "initial", 42 | "maximumWarning": "500kb", 43 | "maximumError": "1mb" 44 | }, 45 | { 46 | "type": "anyComponentStyle", 47 | "maximumWarning": "2kb", 48 | "maximumError": "4kb" 49 | } 50 | ], 51 | "fileReplacements": [ 52 | { 53 | "replace": "src/environments/environment.ts", 54 | "with": "src/environments/environment.prod.ts" 55 | } 56 | ], 57 | "outputHashing": "all" 58 | }, 59 | "development": { 60 | "buildOptimizer": false, 61 | "optimization": false, 62 | "vendorChunk": true, 63 | "extractLicenses": false, 64 | "sourceMap": true, 65 | "namedChunks": true 66 | } 67 | }, 68 | "defaultConfiguration": "production" 69 | }, 70 | "serve": { 71 | "builder": "@angular-devkit/build-angular:dev-server", 72 | "configurations": { 73 | "production": { 74 | "browserTarget": "angular-shopify:build:production" 75 | }, 76 | "development": { 77 | "browserTarget": "angular-shopify:build:development" 78 | } 79 | }, 80 | "defaultConfiguration": "development" 81 | }, 82 | "extract-i18n": { 83 | "builder": "@angular-devkit/build-angular:extract-i18n", 84 | "options": { 85 | "browserTarget": "angular-shopify:build" 86 | } 87 | }, 88 | "test": { 89 | "builder": "@angular-devkit/build-angular:karma", 90 | "options": { 91 | "main": "src/test.ts", 92 | "polyfills": "src/polyfills.ts", 93 | "tsConfig": "tsconfig.spec.json", 94 | "karmaConfig": "karma.conf.js", 95 | "assets": [ 96 | "src/favicon.ico", 97 | "src/assets" 98 | ], 99 | "styles": [ 100 | "src/styles.css" 101 | ], 102 | "scripts": [] 103 | } 104 | } 105 | } 106 | } 107 | }, 108 | "defaultProject": "angular-shopify" 109 | } 110 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the result of the compiled CSS from the various components. 3 | * It's meant to serve as a single file that can be imported into any site 4 | * and as long as the markup is identical, the design should be applied correctly. 5 | */ 6 | *, 7 | :after, 8 | :before { 9 | box-sizing: border-box; 10 | margin: 0; 11 | } 12 | body, 13 | html { 14 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 15 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 16 | font-size: 16px; 17 | word-spacing: 1px; 18 | -ms-text-size-adjust: 100%; 19 | -webkit-text-size-adjust: 100%; 20 | -moz-osx-font-smoothing: grayscale; 21 | -webkit-font-smoothing: antialiased; 22 | box-sizing: border-box; 23 | } 24 | body { 25 | border: 10px solid #ccc; 26 | min-height: 100vh; 27 | line-height: 1.4; 28 | } 29 | h1, 30 | h2, 31 | h3 { 32 | font-family: Domine, "PT Serif", -apple-system, BlinkMacSystemFont, Segoe UI, 33 | Roboto, Helvetica Neue, Arial, sans-serif; 34 | font-weight: 400; 35 | } 36 | h1 { 37 | font-size: 2.5rem; 38 | } 39 | p { 40 | margin: 20px 0; 41 | } 42 | a, 43 | a:active, 44 | a:visited { 45 | color: #d96528; 46 | text-decoration: none; 47 | transition: all 0.3s ease; 48 | } 49 | button { 50 | border: 1px solid #ccc; 51 | background: #fff; 52 | padding: 10px 14px; 53 | cursor: pointer; 54 | color: #000; 55 | font-weight: 700; 56 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 57 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 58 | transition: all 0.3s ease; 59 | } 60 | button:hover { 61 | background: #000; 62 | border: 1px solid #000; 63 | color: #fff; 64 | } 65 | hr { 66 | border-top: 1px solid #eee; 67 | margin: 30px 0; 68 | } 69 | input { 70 | font-family: Nanum Gothic, Montserrat, -apple-system, BlinkMacSystemFont, 71 | Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; 72 | font-size: 16px; 73 | padding: 5px 10px; 74 | } 75 | .app-header { 76 | flex-direction: column; 77 | padding: 40px 40px 0; 78 | } 79 | .app-header, 80 | .main-nav { 81 | display: flex; 82 | justify-content: center; 83 | align-items: center; 84 | } 85 | .main-nav { 86 | width: 80vw; 87 | margin-top: 30px; 88 | border-top: 1px solid #ccc; 89 | border-bottom: 1px solid #ccc; 90 | padding: 8px 0; 91 | } 92 | .main-nav ul { 93 | padding-left: 0; 94 | } 95 | .main-nav-item { 96 | position: relative; 97 | display: inline; 98 | padding: 0 3px; 99 | font-size: 0.6rem; 100 | letter-spacing: 0.1em; 101 | text-transform: uppercase; 102 | } 103 | @media screen and (min-width: 414px) { 104 | .main-nav-item { 105 | padding: 0 8px; 106 | border-left: 1px solid #ddd; 107 | border-right: 1px solid #ddd; 108 | font-size: 0.7rem; 109 | } 110 | } 111 | @media screen and (min-width: 640px) { 112 | .main-nav-item { 113 | padding: 0 10px; 114 | font-size: 0.8rem; 115 | } 116 | } 117 | .main-nav-item a { 118 | color: #000; 119 | } 120 | .main-nav-item a:hover { 121 | color: #d96528; 122 | } 123 | .cart-size { 124 | position: absolute; 125 | top: -18px; 126 | right: -20px; 127 | width: 25px; 128 | height: 25px; 129 | padding: 6px 10px; 130 | border-radius: 1000px; 131 | background: #000; 132 | text-align: center; 133 | color: #fff; 134 | font-size: 10px; 135 | font-weight: 700; 136 | } 137 | @media screen and (min-width: 768px) { 138 | .cart-size { 139 | right: -18px; 140 | } 141 | } 142 | .testimonial { 143 | width: 100%; 144 | height: 280px; 145 | background: url("https://res.cloudinary.com/dzkoxrsdj/image/upload/v1626068306/testimonial-bg_kb5gf4.jpg") 146 | 50% no-repeat; 147 | background-size: cover; 148 | display: flex; 149 | justify-content: center; 150 | align-items: center; 151 | flex-direction: column; 152 | color: #fff; 153 | } 154 | .testimonial h2 { 155 | padding: 0 30px; 156 | text-align: center; 157 | } 158 | .project-credit { 159 | width: 100%; 160 | padding: 10px 30px; 161 | background: #000; 162 | color: #fff; 163 | text-align: center; 164 | } 165 | .project-credit a, 166 | .project-credit a:active, 167 | .project-credit a:visited { 168 | color: #2af; 169 | font-weight: 700; 170 | } 171 | .app-footer-links { 172 | width: 80%; 173 | padding: 40px 0; 174 | margin-left: 10%; 175 | display: grid; 176 | grid-template-columns: 1fr 1fr; 177 | grid-template-rows: 1fr 1fr; 178 | grid-row-gap: 30px; 179 | } 180 | @media screen and (min-width: 1024px) { 181 | .app-footer-links { 182 | grid-template-columns: 1fr 1fr 2fr; 183 | grid-template-rows: 1fr; 184 | grid-row-gap: 0; 185 | } 186 | } 187 | .app-footer-links ul { 188 | list-style: none; 189 | padding-left: 0; 190 | } 191 | .newsletter { 192 | width: 100%; 193 | grid-column: 1 / span 2; 194 | } 195 | @media screen and (min-width: 1024px) { 196 | .newsletter { 197 | grid-column: 3; 198 | } 199 | } 200 | .newsletter-title { 201 | margin-bottom: 1rem; 202 | } 203 | .newsletter-input { 204 | width: 100%; 205 | padding: 10px; 206 | } 207 | .cart-page { 208 | width: 80vw; 209 | margin: 0 auto; 210 | } 211 | .cart-page-button.is-dark { 212 | background: #222; 213 | color: #f8f8f8; 214 | padding: 10px 14px; 215 | display: inline-block; 216 | } 217 | .cart-page-content { 218 | margin: 2rem 0 3rem; 219 | text-align: center; 220 | } 221 | .cart-page-message { 222 | margin-bottom: 1.5rem; 223 | } 224 | .cart-table { 225 | width: 100%; 226 | margin-top: 20px; 227 | margin-bottom: 30px; 228 | } 229 | .cart-table-cell { 230 | padding: 8px 0; 231 | border-bottom: 1px solid #ccc; 232 | } 233 | .cart-table-heading { 234 | padding: 10px 0; 235 | border-bottom: 1px solid #ccc; 236 | } 237 | .cart-table-row { 238 | text-align: center; 239 | } 240 | .cart-total { 241 | display: grid; 242 | grid-template-columns: repeat(5, 1fr); 243 | } 244 | .cart-total-content { 245 | grid-column: 1 / span 5; 246 | display: grid; 247 | grid-template-columns: repeat(2, 1fr); 248 | } 249 | @media screen and (min-width: 1024px) { 250 | .cart-total-content { 251 | grid-column: 4 / span 2; 252 | } 253 | } 254 | .cart-total-column p { 255 | padding: 10px; 256 | margin: 0; 257 | text-align: right; 258 | } 259 | .cart-total-column p:last-child { 260 | font-weight: 700; 261 | background: #f2eee2; 262 | } 263 | .product-page { 264 | margin: 60px 0; 265 | } 266 | .product-page-content { 267 | width: 80%; 268 | margin: 30px auto 0; 269 | } 270 | @media screen and (min-width: 1024px) { 271 | .product-page-content { 272 | display: grid; 273 | justify-content: space-between; 274 | justify-items: center; 275 | align-items: center; 276 | grid-template-columns: 1fr 1fr; 277 | grid-column-gap: 30px; 278 | } 279 | } 280 | .product-page-image { 281 | width: 100%; 282 | margin-bottom: 30px; 283 | } 284 | @media screen and (min-width: 1024px) { 285 | .product-page-image { 286 | width: 100%; 287 | margin-bottom: 0; 288 | } 289 | } 290 | .product-page-price { 291 | color: #d96528; 292 | font-size: 1.2rem; 293 | margin: 5px 0; 294 | font-weight: 400; 295 | font-family: Domine, "PT Serif", -apple-system, BlinkMacSystemFont, Segoe UI, 296 | Roboto, Helvetica Neue, Arial, sans-serif; 297 | } 298 | .product-page-price-list, 299 | .product-page-price.is-solo { 300 | margin-bottom: 30px; 301 | } 302 | .product-page-quantity-input { 303 | width: 70px; 304 | } 305 | .product-page-quantity-row { 306 | display: flex; 307 | } 308 | .home-page { 309 | margin: 30px 0 45px; 310 | } 311 | .product-grid { 312 | max-width: 60vw; 313 | margin: 0 auto; 314 | display: grid; 315 | grid-template-columns: 1fr; 316 | grid-template-rows: 1fr; 317 | grid-column-gap: 30px; 318 | grid-row-gap: 30px; 319 | } 320 | @media screen and (min-width: 640px) { 321 | .product-grid { 322 | grid-template-columns: repeat(2, 1fr); 323 | } 324 | } 325 | @media screen and (min-width: 1024px) { 326 | .product-grid { 327 | grid-template-columns: repeat(3, 1fr); 328 | } 329 | } 330 | @media screen and (min-width: 1280px) { 331 | .product-grid { 332 | grid-template-columns: repeat(4, 1fr); 333 | } 334 | } 335 | .product-card { 336 | display: flex; 337 | justify-content: space-between; 338 | align-items: center; 339 | flex-direction: column; 340 | } 341 | .product-card-description { 342 | margin-top: 0; 343 | margin-bottom: 1rem; 344 | overflow: hidden; 345 | width: 100%; 346 | display: -webkit-box; 347 | -webkit-box-orient: vertical; 348 | -webkit-line-clamp: 2; 349 | } 350 | .product-card-frame { 351 | height: 120px; 352 | margin-bottom: 0.5rem; 353 | display: flex; 354 | align-content: center; 355 | align-items: center; 356 | border-radius: 10px; 357 | overflow: hidden; 358 | } 359 | .product-card-frame img { 360 | width: 100%; 361 | border-radius: 10px; 362 | -o-object-fit: cover; 363 | object-fit: cover; 364 | height: 100%; 365 | } 366 | .product-card-text { 367 | margin: 0.5rem 0; 368 | } 369 | .product-card-title { 370 | margin: 0.5rem 0; 371 | text-align: center; 372 | font-weight: 700; 373 | } 374 | { 375 | "mode":"full","isactive": false; 376 | } 377 | --------------------------------------------------------------------------------