Přejít k navigační liště

Zdroják » Různé » Nákupní košík pomocí HTML5 Web Storage

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

Články Různé

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.

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!

Komentáře

Subscribe
Upozornit na
guest
14 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
petersirka

Nehnevajte sa na mňa, ale ten obchod ide veľmi zle. Kým ho načítalo prešli asi 2 sekundy a na začiatku prázdny obsah a tie templatetovacie značky nie sú moc pekné pre užívateľa. Nevravím ako to celé skáče hore dole, keď preklikávam menu. Toto riešenie internetového obchodu v JavaScripte je samovražda.

PS: keď sa zmení cena tovaru v DB, zmení sa aj v nákupnom košíku?

vidya

detaily. pri uvodnom nacitavani dam spinner a co sa tyka toho skakania, je sposobene loadovanim obrazkov, staci nastavit tomu img pevnu vysku.
„Toto riešenie internetového obchodu v JavaScripte je samovražda.“ ale no :-)

petersirka

Neviem, celé je to trošku nezvyk. V každom prípade si počkám na ďalšie články a ukážky. Možno tam uvidím nejakú benefitu oproti klasickým riešeniam.

Ja som v node.js urobil ukážku klasického obchodu, tiež je to hostované na free variante, ale ide to bez problémov (je tam trošku cítiť odozvu).

http://nodejs-eshop.eu01.aws.af.cm

andrejk

praca s objednavkou je prijemna, navrat na predchadzajuce checkout stavy funguje, super.
pouzity bootstrap design je fajn.

k teme:
ako poor man’s kosik ok, ale v reale pouzitelne len obmedzene.
lokalne ulozenie kosika by malo byt backupovane aj na serveri, pretoze v tomto stave mi to neumozni pracovat s objednavkou na roznych zariadeniach (work pc / home pc).

Clary

Chápu to tak, že implementace je pouze ukázkou možností webstorage. My ho v práci například používáme pro ukládání stavu aplikace – když si uživatel v systému poskládá okýnka, tak jak mu vyhovuje v práci, aby se mu po reloadu zase nerozutíkala.

pozortucnak

Nebylo by vhodnější například použít knihovnu store.js?

https://techi.mojeid.cz/#PL2P791SLx

Jako tvůj bývalý kolega apeluji na to, aby si košíky v e-shopu přestal pojmenovávat jako basket (proutěný košík nebo basketbalový koš), ale cart (nákupní košík) – někteří lidé tím dodnes trpí :)

https://tomasfejfar.mojeid.cz/#bLGPpVT97A

Basket je správně, pokud píšeš v UK english – tj. např. ColoUr. Což předpokládám nepíšeš.

Shneck

Co to tu řešíte za kraviny?? Neni to jedno??

HesseValentino

Já jsem pro Cart. Basket je taky dobře. Ale mám za to ze Cart je populárnější.

Hunaczech

A neni to jedno ? :-) Basket nebo Cart … hlavne, ze vznikl takovy clanek a patri za nej dik autorovi. Me se libil :)

vaclav.sir

Basket je košík, cart je vozík (snad ve všech současných angličtinách). V českých e-shopech se víc ujaly nákupní košíky, ovšem s ikonkou nákupního vozíku… To je pak těžké si vybrat.

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.