Nákupní košík pomocí HTML5 Web Storage

E-shop-1

Dnešní článek bude věnován implementaci nákupního košíku pomocí HTML5 Web Storage. Kromě toho se podíváme na některé novinky v práci s formuláři v HTML5 a také na jednotkové testování pomocí frameworku Jasmine a jejich spouštění přes Testacular.

Seriál: E-shop pomocí moderních technologií (15 dílů)

  1. Úvodní analýza pro moderní e-shop 4.1.2013
  2. Návrh uživatelské části e-shopu 11.1.2013
  3. Tvorba uživatelské části e-shopu 18.1.2013
  4. Nákupní košík pomocí HTML5 Web Storage 25.1.2013
  5. Tvorba moderního eshopu: kategorie a parametrické hledání 1.2.2013
  6. Tvorba moderního e-shopu: dokončení uživatelské části 8.2.2013
  7. Tvorba moderního e-shopu: plánování administrace 15.2.2013
  8. Tvorba moderního e-shopu: správa objednávek 22.2.2013
  9. Tvorba moderního e-shopu: nahrávání obrázků k produktu 1.3.2013
  10. Tvorba moderního e-shopu: Bower, Yeoman a Gemnasium 15.7.2013
  11. Tvorba moderního e-shopu: HTML5 drag & drop a kategorie 29.7.2013
  12. Tvorba moderního e-shopu: zpracování chyb 12.8.2013
  13. Tvorba moderního e-shopu: Rich-Text Editing a dokončení administrace 26.8.2013
  14. Autentizace v single-page aplikacích 9.9.2013
  15. Autentizace v single-page aplikacích – serverová část 7.10.2013

Jako v minulém díle je i dnes dostupná demoverze aktuálního stavu projektu (od minulého dílu se měnil pouze proces nákupu). Doporučuji si v demoverzi vyzkoušet celý nákup pro lepší představu o tom, jak vše funguje. Také si můžete zdrojové kódy stáhnout příkazem git checkout -f eshop04, popřípadě si kompletní zdrojové kódy prohlédnout v repozitáři na Githubu.

Pokud náhodou slyšíte o Web Storage poprvé, popř. si chcete oživit své znalosti, můžete si nejprve přečíst článek Webdesignérův průvodce po HTML5: WebStorage od M. Malého.

Úložiště nákupního košíku

O práci s košíkem se nám budou starat třída  Basket., která se stará o komunikaci s HTML5 Web Storage (ukládání a získávání dat). Její nejjednodušší implementace může vypadat například takto:

function Basket(window, listener) {
  this._storage = window.localStorage;
  this._listener = listener;
  this._setEventStorage(window);
}

Basket.NS_PRODUCTS = 'products';
Basket.NS_CUSTOMER = 'customer';
Basket.NS_TRANSPORT = 'transport';

//upozornění ostatních oken prohlížeče na změnu v hlavním okně
Basket.prototype.notify = function() {
  this._listener();
};

//přidání produktu do košíku
Basket.prototype.add = function(product) {
  var products = this.getAll();
  products.push(product);
  this._saveProducts(products);
};

//existuje produkt v košíku?
Basket.prototype.exist = function(id, variant) {
  return Boolean(this.get(id, variant));
};

//vrací jeden produkt z košíku
Basket.prototype.get = function(id, variant) {
  var products = this.getAll();
  for (var i = 0; i < products.length; ++i) {
    if (this._equals(products[i], id, variant)) {
      return products[i];
    }
  }
};

//vrací všechny produkty v košíku
Basket.prototype.getAll = function() {
  var products = this._storage.getItem(Basket.NS_PRODUCTS);
  if (typeof products === 'string') {
    products = JSON.parse(products);
  }
  if (products === null) {
    products = [];
  }
  return products;
};

//jsou nějaké produkty v košíku?
Basket.prototype.hasProducts = function() {
  return Object.keys(this.getAll()).length > 0;
};

//jsou vyplněna data zákazníka?
Basket.prototype.hasCustomer = function() {
  var customer = this.getCustomer() || {};
  return Object.keys(customer).length > 0;
};

//aktualizace množství ks daného produktu v košíku
Basket.prototype.updateQuantity = function(quantity, id, variant) {
  var products = this.getAll();
  for (var i = 0; i < products.length; ++i) {
    if (this._equals(products[i], id, variant)) {
      products[i].quantity = quantity;
      this._saveProducts(products);
      break;
    }
  }
};

//odstraní produkt z košíku
Basket.prototype.remove = function(id, variant) {
  var oldProducts = this.getAll();
  var newProducts = [];
  for (var i = 0; i < oldProducts.length; ++i) {
    if (!this._equals(oldProducts[i], id, variant)) {
      newProducts.push(oldProducts[i]);
    }
  }
  this._saveProducts(newProducts);
};

//odstraní všechna data o nákupu z košíku
Basket.prototype.clear = function() {
  this._storage.removeItem(Basket.NS_PRODUCTS);
  this._storage.removeItem(Basket.NS_CUSTOMER);
  this._storage.removeItem(Basket.NS_TRANSPORT);
};

//vrací informace o zákazníkovi
Basket.prototype.getCustomer = function() {
  return JSON.parse(this._storage.getItem(Basket.NS_CUSTOMER));
};

//aktualizuje informace o zákazníkovi
Basket.prototype.updateCustomer = function(data) {
  this._storage.setItem(Basket.NS_CUSTOMER, JSON.stringify(data));
};

//vrací informace o dopravě
Basket.prototype.getTransport = function() {
  return JSON.parse(this._storage.getItem(Basket.NS_TRANSPORT));
};

//aktualizuje vybranou dopravu
Basket.prototype.updateTransport = function(data) {
  this._storage.setItem(Basket.NS_TRANSPORT, JSON.stringify(data));
};

//vrací celkovou cenu všech produktů v košíku
Basket.prototype.priceProducts = function() {
  var products = this.getAll();
  var price = 0;
  for (var id in products) {
    price += products[id].price * products[id].quantity;
  }
  return price;
};

//vrací celkovou cenu objednávky
Basket.prototype.priceTotal = function() {
  return this.priceProducts() + this.getTransport().price;
};

//ukládá produkty do local storage
Basket.prototype._saveProducts = function(products) {
  this._storage.setItem(Basket.NS_PRODUCTS, JSON.stringify(products));
};

//porovnává dva produkty vč. variant
Basket.prototype._equals = function(product, id, variant) {
  return product.id === id && product.variant === variant;
};

//nastavení události storage při změně obsahu localStorage
Basket.prototype._setEventStorage = function(window) {
  var basket = this;
  window.addEventListener('storage', function(){
    basket.notify.call(basket)
  }, false);
};

Pro ukládání informací o nákupu použijeme Local Storage, který uchová data trvale (na rozdíl od Session Storage, který se vyprázdní po zavření okna prohlížeče). To znamená, že i když uživatel třeba nechtěně zavře prohlížeč, o obsah košíku nepřijde.

Protože jsou však data uložena v prohlížeči uživatele a nekomunikuje se se serverem, může se stát, že během nákupu dojde k aktualizaci cen či bude produkt vyřazen. To je možné vyřešit třeba tím, že se v určitý okamžik zeptáme dotazem na server, zda jsou informace o produktech v košíku stále aktuální a v případě, že dojde ke změně, upozorníme na to uživatele. My to budeme řešit až na konci objednávkového procesu, kde budeme při ukládání objednávky kontrolovat, zda jsou data validní a pokud došlo ke změně, upozorníme na to uživatele.

Zmíněný problém půjde také řešit elegantně pomocí socket.io, které budeme také později pro e-shop implementovat pro komunikaci se zákazníkem jako chat. Protože však máme skrze Web Sockets obousměrnou komunikaci mezi klientem i serverem, tak v případě, že správce edituje nějaký produkt, můžeme do prohlížeče uživatelů zaslat zprávu a zkontrolovat, zda se editovaný produkt nenachází u některého v košíku. V takovém případě můžeme hned uživatele upozornit, popř. se s ním spojit a nějak se dohodnout.

Pro uložení dat do Local Storage je potřeba je nejdříve serializovat, tedy převést objekt na řetězec a při jejich načítání zase řetězec převést na objekt (o to se starají metody objektu JSON). HTML5 Web Storage totiž umožňuje ukládat pouze řetězce jako hodnotu klíče. Zajímavější by mohlo být použití některé z HTML5 Web SQL databáze, ty však bohužel zatím nejsou dostatečně v prohlížečích implementovány (Web Storage naopak podporují všechny prohlížeče vč. Internet Exploreru od verze 8).

V našem případě nemáme implementováno žádné kešování, proto se může stát, že budeme často v rámci jedné sekvence komunikace s Web Storage vícekrát deserializovat data. V našem konkrétním příkladě to až tak moc nevadí, ale pokud by to znamenalo výkonnostní problém, mohli bychom problém vyřešit třeba implementací návrhového vzoru (Cache) Proxy. V případě, že by nedošlo k žádné změně v úložišti, vracela by nakešovaná data a při úpravě úložiště by data smazala.

V třídě také registrujeme událost storage. Ta se vyvolá tehdy, pokud máme otevřeno více oken a v jednom z nich provedeme nějakou úpravu úložiště. V takovém případě v ostatních oknech dojde k vyvolání zmíněné události a v tu chvíli pomocí $rootScope.$apply() provedeme aktualizaci dat v šablonách, čímž docílíme toho, že budou oba okna sesynchronizovaná. Ukázku tohoto chování je také možné vidět na html2demos.com.

Zbývá ještě dodat, že každý uložený produkt je unikátní kombinací ID a také názvem varianty. Pokud varianta není uvedena, porovnávají se dvě hodnoty undefined, což bude vyhodnoceno jako true, proto nemusíme vůbec variantu zadávat, pokud produkt žádné varianty nemá.

Testování třídy Basket

Třídu Basket  pokryjeme testy třeba takto:

describe('Basket', function(){

  var basket;

  beforeEach(function(){
    basket = new Basket(window);
    basket.clear();
  });

  it('prida novy produkt s variantou do uloziste', function(){
    basket.add({id: 12345, variant: 'cerny'});
    expect(basket.exist(12345, 'cerny')).toBeTruthy();
    expect(basket.get(12345, 'cerny')).toEqual({id: 12345, variant: 'cerny'});
  });

  it('prida novy produkt bez varianty do uloziste', function(){
    basket.add({id: 12345});
    expect(basket.exist(12345)).toBeTruthy();
    expect(basket.get(12345)).toEqual({id: 12345});
  });

  it('zmeni mnozstvi produktu s variantou v kosiku', function(){
    basket.add({id: 12345, variant: 'cerny'});
    basket.updateQuantity(10, 12345, 'cerny');
    expect(basket.get(12345, 'cerny').quantity).toEqual(10);
  });

  it('zmeni mnozstvi produktu bez varianty v kosiku', function(){
    basket.add({id: 12345});
    basket.updateQuantity(10, 12345);
    expect(basket.get(12345).quantity).toEqual(10);
  });

  it('odstrani produkt bez varianty z kosiku', function(){
    basket.add(12345, {});
    basket.remove(12345);
    expect(basket.get(12345)).toBeUndefined();
  });

  it('odstrani produkt s variantou z kosiku', function(){
    basket.add({id: 12345, variant: 'cerny'});
    basket.add({id: 12345, variant: 'bily'});
    basket.remove(12345, 'cerny');
    expect(basket.get(12345, 'cerny')).toBeUndefined();
    expect(basket.get(12345, 'bily')).toBeDefined();
  });

  it('vrati vsechny produkty v kosiku', function(){
    basket.add({id: 12456, variant: 'cerny'});
    basket.add({id: 12457, variant: 'bily'});
    basket.add({id: 12459, variant: 'modry'});
    expect(basket.getAll()).toEqual([
      {id: 12456, variant: 'cerny'},
      {id: 12457, variant: 'bily'},
      {id: 12459, variant: 'modry'}
    ]);
  });

  it('edituje data uzivatele', function(){
    basket.updateCustomer({name: 'Jakub', surname: 'Mrozek'});
    expect(basket.getCustomer()).toEqual({name: 'Jakub', surname: 'Mrozek'});
  });

  it('edituje informace o doprave', function(){
    basket.updateTransport({name: 'Doprava ABC'});
    expect(basket.getTransport()).toEqual({name: 'Doprava ABC'});
  });

  it('vymaze obsah kosiku', function(){
    basket.add({id: 12456, variant: 'cerny'});
    basket.updateCustomer({name: 'Jakub', surname: 'Mrozek'});
    basket.updateTransport({name: 'Doprava ABC'});
    basket.clear();
    expect(basket.getAll()).toEqual([]);
    expect(basket.getCustomer()).toBeNull();
    expect(basket.getTransport()).toBeNull();
  });

  it('spocita celkovy soucet cen produktu v kosiku', function(){
    basket.getAll = function() {
      return [
        {price: 1000, quantity: 10},
        {price: 500, quantity: 2}
      ];
    };
    expect(basket.priceProducts()).toBe(11000);
  });

  it('spocita celkovou cenu objednavky', function(){
    basket.priceProducts = function() {
      return 11000;
    };
    basket.getTransport = function() {
      return {price: 79};
    };
    expect(basket.priceTotal()).toBe(11079);
  });

});

Pro spuštění použijeme nástroj Testacular, který byl představen ve 12. díle seriálu o Node.js. Zde jsme ho však používali pro end2end testy, pro jednotkové testy vytvoříme zvláštní konfiguraci (soubor  testacular.conf.js):

basePath = './';

files = [
  JASMINE,
  JASMINE_ADAPTER,
  'public/lib/angular/angular.js',
  'public/lib/angular/angular-*.js',
  'test/frontend/lib/angular/angular-mocks.js',
  'public/js/*.js',
  'test/frontend/unit/*.js'
];

autoWatch = true;

browsers = ['Chrome'];

Mimo jiné zde nastavujeme direktivu autoWatch na hodnotu true, což znamená, že Testacular bude sledovat dané soubory a pokud dojde někde k změně, spustí automaticky testy. Nemusíme tedy po každé úpravě testy ručně spouštět.

Testy se spouští zavoláním souboru test.bat či test.sh ze složky scripts, které nastaví cestu k adresáři (soubory jsou převzaty z projektu angular-seed).

Spuštění testů pak vypadá takto:

HTML5 formuláře a data zákazníka

Dále potřebujeme vytvořit formulář, přes který uživatel bude měnit počet ks produktů v košíku, a také formulář pro zákaznické údaje.

Pro počet ks použijeme HTML element input s atributem number, který omezí vkládané hodnoty jen na čísla. Navíc pomocí atributu min řekneme, že minimální hodnota je 1. HTML pak vypadá nějak takto:

<input min="1" type="number" ng-model="product.quantity">

V případě formuláře pro uživatelské údaje použijeme atribut required, díky kterému nebude možné odeslat formulář, dokud nebudou všechny pole vyplněny.

Pro pole pro vložení e-mailu použijeme hodnotu atributu type email. Zajímavé je také pole pro zadání PSČ, kde v atributu pattern říkáme, jaký formát je možné do pole zadat. Pokud uživatel zadá chybná data, neúspěšná validace bude vypadat takto:

Nutno podotknout, že zde uvedený formulář je velmi jednoduchý. V reálném e-shopu bychom chtěli nejspíš po uživateli vyplnit ještě další informace, třeba telefon. Neřešíme také rozdílnost kontaktní, fakturační a dodací adresy, popř. napsání objednávky na firmu. To však znamená pouze přidat další HTML elementy.

Úpravy controllerů

Změny controllerů mohou vypadat takto:

//nákupní košík
function BasketCtrl($scope, $location, basket) {
  $scope.step = 'basket';
  $scope.products = basket.getAll();
  $scope.next = function() {
    $location.path('/zakaznicke-udaje');
  };
}

//údaje o zákazníkovi
function CustomerCtrl($scope, $location, basket, transport) {
  if (!basket.hasProducts()) {
    $location.path('/kosik');
    return;
  }

  $scope.step = 'customer';
  $scope.basket = basket;

  $scope.customer  = basket.getCustomer();
  $scope.transport = basket.getTransport() || {code: 'personal'};
  $scope.transportMethods = transport.methods();

  $scope.next = function() {
    basket.updateCustomer($scope.customer);
    basket.updateTransport(transport.get($scope.transport.code));
    $location.path('/potvrzeni');
  }
}

//potvrzení objednávky
function SummaryCtrl($scope, $location, api, basket) {
  if (!basket.hasCustomer() || !basket.hasProducts()) {
    $location.path('/kosik');
    return;
  }

  $scope.step = 'summary';
  $scope.basket = basket;

  $scope.products   = basket.getAll();
  $scope.customer   = basket.getCustomer();
  $scope.transport  = basket.getTransport();
  $scope.priceTotal = basket.priceTotal();

  $scope.next = function() {
    var data = {
      products: $scope.products,
      customer: $scope.customer,
      transport: $scope.transport
    };

    api.order.create(data, function(info){
      $scope.number = info.number;
      basket.clear();
    });
  }
}

Co dále

Nákupní košík máme zatím hotový. Jeho implementace je odprezentována zákazníkovi, kterému se líbí. Můžeme tedy pokročit dále a v příštím díle se pokusíme dokončit implementaci uživatelské části e-shopu.

Na tvorbě tohoto článku se svými připomínkami podílel také Pavel Lang. Díky!

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 14

Přehled komentářů

petersirka Neviem, neviem ...
vidya Re: Neviem, neviem ...
Jakub Mrozek Re: Neviem, neviem ...
petersirka Re: Neviem, neviem ...
andrejk Re: Nákupní košík pomocí HTML5 Web Storage
Clary Re: Nákupní košík pomocí HTML5 Web Storage
pozortucnak https://github.com/marcuswestin/store.js
https://techi.mojeid.cz/#PL2P791SLx Basket vs. Cart
Jakub Mrozek Re: Basket vs. Cart
https://tomasfejfar.mojeid.cz/#bLGPpVT97A Re: Basket vs. Cart
Shneck Re: Basket vs. Cart
HesseValentino Basket or Cart
Hunaczech Basket vs. Cart vs. local grammar nazis
vaclav.sir Basket = košík, cart = vozík
Zdroj: https://www.zdrojak.cz/?p=3770