Jednostavna implementacija e-trgovine s proljećem

1. Pregled naše aplikacije za e-trgovinu

U ovom uputstvu implementirat ćemo jednostavnu aplikaciju za e-trgovinu. Razvit ćemo API koristeći Spring Boot i klijentsku aplikaciju koja će trošiti API koristeći Angular.

U osnovi, korisnik će moći dodati / ukloniti proizvode s popisa proizvoda u / iz košarice i poslati narudžbu.

2. Backend dio

Za razvoj API-ja koristit ćemo najnoviju verziju Spring Boot-a. Također koristimo JPA i H2 bazu podataka za postojanost stvari.

Da biste saznali više o Spring Boot-u,mogli biste pogledati našu seriju članaka Spring Boot i ako želite kako biste se upoznali s izradom REST API-ja, pogledajte drugu seriju.

2.1. Ovisnosti Mavena

Pripremimo svoj projekt i uvezimo potrebne ovisnosti u naš pom.xml.

Trebat će nam neke osnovne ovisnosti Spring Boota:

 org.springframework.boot spring-boot-starter-data-jpa 2.2.2.RELEASE org.springframework.boot spring-boot-starter-web 2.2.2.RELEASE 

Zatim, baza podataka H2:

 com.h2database h2 1.4.197 vrijeme izvođenja 

I na kraju - Jackson knjižnica:

 com.fasterxml.jackson.datatype jackson-datatype-jsr310 2.9.6 

Koristili smo Spring Initializr za brzo postavljanje projekta s potrebnim ovisnostima.

2.2. Postavljanje baze podataka

Iako bismo H2 bazu podataka u memoriji mogli upotrijebiti izravno iz Spring Boxa, ipak ćemo izvršiti neke prilagodbe prije nego što započnemo s razvojem našeg API-ja.

Dobro omogućiti H2 konzolu u našem primjena.svojstva datoteka tako da zapravo možemo provjeriti stanje naše baze podataka i vidjeti ide li sve kako smo očekivali.

Također, moglo bi biti korisno prijaviti SQL upite na konzolu tijekom razvoja:

spring.datasource.name = ecommercedb spring.jpa.show-sql = true # H2 postavke spring.h2.console.enabled = true spring.h2.console.path = / h2-console

Nakon dodavanja ovih postavki, moći ćemo pristupiti bazi podataka na // localhost: 8080 / h2-konzola koristeći jdbc: h2: mem: ecommercedb kao JDBC URL i korisnik sa bez lozinke.

2.3. Struktura projekta

Projekt će biti organiziran u nekoliko standardnih paketa, a Angular aplikacija stavljena u frontend mapu:

├───pom.xml ├───src ├─── glavni │ ├───frontend │ ├───java │ │ └───com │ │ └───baeldung │ │ └───ecommerce │ │ │ EcommerceApplication.java │ │ ├───controller │ │ ├───dto │ │ ├───exception │ │ ├───model │ │ ├───repozitorij │ │ └───service │ │ │ └───resures │ │ application.properties │ ├───static │ └─── predlošci └───test └───java └───com └───baeldung └───ecommerce EcommerceApplicationIntegrationTest. Java

Trebali bismo imati na umu da su sva sučelja u paketu spremišta jednostavna i proširuju CrudRepository Spring Data, pa ćemo ih izostaviti da ih ovdje prikažemo.

2.4. Rukovanje iznimkama

Trebat će nam rukovatelj iznimkama za naš API kako bismo se pravilno bavili eventualnim iznimkama.

Više detalja o temi možete pronaći u našim člancima o rukovanju pogreškama za REST s proljetnim i prilagođenim rukovanjem porukama o pogreškama za REST API.

Ovdje se usredotočujemo na ConstraintViolationException i naš običaj ResourceNotFoundException:

@RestControllerAdvice javna klasa ApiExceptionHandler {@SuppressWarnings ("rawtypes") @ExceptionHandler (ConstraintViolationException.class) javna ručka ResponseEntity (ConstraintViolationException e) {ErrorResponse greške (new ErrorResponse); za (kršenje ConstraintViolation: e.getConstraintViolations ()) {ErrorItem error = new ErrorItem (); error.setCode (kršenje.getMessageTemplate ()); error.setMessage (kršenje.getMessage ()); error.addError (pogreška); } vratiti novi ResponseEntity (pogreške, HttpStatus.BAD_REQUEST); } @SuppressWarnings ("rawtypes") @ExceptionHandler (ResourceNotFoundException.class) javni ResponseEntity handle (ResourceNotFoundException e) {ErrorItem error = new ErrorItem (); error.setMessage (e.getMessage ()); vrati novi ResponseEntity (pogreška, HttpStatus.NOT_FOUND); }}

2.5. Proizvodi

Ako vam je potrebno više znanja o postojanosti u proljeće, u seriji Spring Persistence postoji mnogo korisnih članaka.

Naša aplikacija će podržati samo čitanje proizvoda iz baze podataka, tako da prvo moramo dodati neke.

Stvorimo jednostavan Proizvod razred:

@Entity javna klasa Product {@Id @GeneratedValue (strategy = GenerationType.IDENTITY) private Long id; @NotNull (message = "Potreban je naziv proizvoda.") @ Basic (neobvezno = netačno) naziv privatnog niza; privatno Dvostruka cijena; private String pictureUrl; // svi argumenti contructor // standardni getteri i postavljači}

Iako korisnik neće imati priliku dodavati proizvode putem aplikacije, podržati ćemo spremanje proizvoda u bazu podataka kako bismo unaprijed popunili popis proizvoda.

Jednostavna usluga bit će nam dovoljna za naše potrebe:

@Service @Transactional javna klasa ProductServiceImpl implementira ProductService {// ubrizgavanje konstruktora productRepository @Override public Iterable getAllProducts () {return productRepository.findAll (); } @Override public Product getProduct (long id) {return productRepository .findById (id) .orElseThrow (() -> new ResourceNotFoundException ("Proizvod nije pronađen")); } @Override public Product save (Product product) {return productRepository.save (product); }}

Jednostavni kontroler obrađivat će zahtjeve za preuzimanje popisa proizvoda:

@RestController @RequestMapping ("/ api / products") javna klasa ProductController {// productService konstruktor injekcija @GetMapping (value = {"", "/"}) public @NotNull Iterable getProducts () {return productService.getAllProducts (); }}

Sve što trebamo sada da bismo korisniku iznijeli popis proizvoda - zapravo je staviti neke proizvode u bazu podataka. Stoga ćemo iskoristiti CommandLineRunner razred za izradu a Grah u našoj glavnoj klasi primjene.

Na ovaj ćemo način umetnuti proizvode u bazu podataka tijekom pokretanja aplikacije:

@Bean CommandLineRunner pokretač (ProductService productService) {return args -> {productService.save (...); // više proizvoda}

Ako sada pokrenemo našu aplikaciju, mogli bismo dohvatiti popis proizvoda putem // localhost: 8080 / api / products. Također, ako odemo na // localhost: 8080 / h2-konzola i prijavite se, vidjet ćemo da postoji tablica s imenom PROIZVOD s proizvodima koje smo upravo dodali.

2.6. Narudžbe

Na strani API-ja moramo omogućiti POST zahtjeve za spremanje naloga koje će krajnji korisnik izvršiti.

Stvorimo prvo model:

@Entity @Table (name = "naloga") narudžba javne klase {@Id @GeneratedValue (strategy = GenerationType.IDENTITY) private Long id; @JsonFormat (pattern = "dd / MM / yyyy") private LocalDate dateCreate; status privatnog niza; @JsonManagedReference @OneToMany (mappedBy = "pk.order") @Valid private list orderProducts = new ArrayList (); @Transient public Double getTotalOrderPrice () {dvostruka suma = 0D; Popis orderProducts = getOrderProducts (); za (OrderProduct op: orderProducts) {sum + = op.getTotalPrice (); } povratna suma; } @Transient public int getNumberOfProducts () {return this.orderProducts.size (); } // standardni getteri i postavljači}

Ovdje bismo trebali primijetiti nekoliko stvari. Svakako je jedna od najvažnijih stvari koja se ističe ne zaboravite promijeniti zadani naziv naše tablice. Budući da smo imenovali razred Narudžba, prema zadanim postavkama imenovana tablica NARUDŽBA treba stvoriti. Ali budući da je to rezervirana SQL riječ, dodali smo @Tablica (name = “narudžbe”) kako bi se izbjegli sukobi.

Nadalje, imamo dvije @Prijelazno metode koje će vratiti ukupan iznos za tu narudžbu i broj proizvoda u njoj. Oba predstavljaju izračunate podatke, pa ih nema potrebe pohranjivati ​​u bazu podataka.

Napokon, imamo a @OneToMany relacija koja predstavlja detalje narudžbe. Za to nam treba još jedna klasa entiteta:

@Entity javna klasa OrderProduct {@EmbeddedId @JsonIgnore private OrderProductPK pk; @Kolona (nullable = false) private Integer količina; // zadani konstruktor public OrderProduct (Narudžba narudžbe, Proizvod proizvoda, Cjelobrojna količina) {pk = new OrderProductPK (); pk.setOrder (narudžba); pk.setProduct (proizvod); this.quantity = količina; } @Transient public Product getProduct () {return this.pk.getProduct (); } @Transient public Double getTotalPrice () {return getProduct (). GetPrice () * getQuantity (); } // standardni getteri i postavljači // hashcode () i equals () metode}

Imamo složeni primarni ključovdje:

@Embeddable javna klasa OrderProductPK implementira serizibilnu narudžbu narudžbe {@JsonBackReference @ManyToOne (izborno = false, fetch = FetchType.LAZY) @JoinColumn (name = "order_id"); @ManyToOne (neobavezno = false, fetch = FetchType.LAZY) @JoinColumn (name = "product_id") privatni proizvod proizvoda; // standardni getteri i postavljači // hashcode () i equals () metode}

Ti razredi nisu ništa previše složeni, ali to bismo trebali primijetiti u NaručiProizvod razred koji smo stavili @JsonIgnore na primarnom ključu. To je zato što ne želimo serializirati Narudžba dio primarnog ključa jer bi bio suvišan.

Trebamo samo Proizvod da se prikaže korisniku, pa zato imamo prijelazni getProduct () metoda.

Sljedeća što nam treba je jednostavna implementacija usluge:

@Service @Transactional javna klasa OrderServiceImpl implementira OrderService {// ubrizgavanje konstruktora orderRepository @Override public Iterable getAllOrders () {return this.orderRepository.findAll (); } @Override javni nalog za kreiranje (nalog za narudžbu) {order.setDateCreated (LocalDate.now ()); vrati this.orderRepository.save (narudžba); } @Override javno void ažuriranje (narudžba narudžbe) {this.orderRepository.save (narudžba); }}

I kontroler mapiran na / api / narudžbe rukovati Narudžba zahtjevi.

Najvažnije je stvoriti() metoda:

@PostMapping javni ResponseEntity create (@RequestBody OrderForm obrazac) {List formDtos = form.getProductOrders (); validateProductsExistence (formDtos); // stvaranje logike narudžbe // popunjavanje narudžbe proizvodima order.setOrderProducts (orderProducts); this.orderService.update (narudžba); Niz uri = ServletUriComponentsBuilder .fromCurrentServletMapping () .path ("/ naloga / {id}") .buildAndExpand (order.getId ()) .toString (); HttpHeaders zaglavlja = novi HttpHeaders (); headers.add ("Lokacija", uri); vrati novi ResponseEntity (poredak, zaglavlja, HttpStatus.CREATED); }

Kao prvo, prihvaćamo popis proizvoda s pripadajućim količinama. Nakon toga, provjeravamo postoje li svi proizvodi u bazi podataka i zatim stvorite i spremite novu narudžbu. Zadržavamo referencu na novostvoreni objekt kako bismo mu mogli dodati detalje narudžbe.

Konačno, kreiramo zaglavlje "Lokacija".

Detaljna implementacija nalazi se u spremištu - veza do nje spomenuta je na kraju ovog članka.

3. Prednji dio

Sad kad imamo izgrađenu aplikaciju Spring Boot, vrijeme je za pomicanje kutni dio projekta. Da bismo to učinili, prvo ćemo morati instalirati Node.js s NPM-om, a nakon toga i Angular CLI, sučelje naredbenog retka za Angular.

Stvarno je lako instalirati oba, kao što smo mogli vidjeti u službenoj dokumentaciji.

3.1. Postavljanje kutnog projekta

Kao što smo spomenuli, koristit ćemo Kutni CLI stvoriti našu aplikaciju. Kako bi stvari bile jednostavne i sve na jednom mjestu, našu ćemo aplikaciju Angular zadržati unutar / src / main / frontend mapu.

Da bismo ga stvorili, moramo otvoriti terminal (ili naredbeni redak) u / src / glavni mapa i pokrenite:

ng novo sučelje

To će stvoriti sve datoteke i mape koje su nam potrebne za našu Angular aplikaciju. U spisu pakage.json, možemo provjeriti koje su verzije naših ovisnosti instalirane. Ovaj se vodič temelji na Angular v6.0.3, ali starije verzije trebaju obaviti posao, barem verzije 4.3 i novije (HttpClient koje ovdje koristimo uvedeno je u Angular 4.3).

To bismo trebali primijetiti pokrenut ćemo sve naše naredbe iz / frontend mapu osim ako nije drugačije navedeno.

Ova je postavka dovoljna za pokretanje aplikacije Angular izvođenjem ng poslužiti naredba. Po defaultu se izvršava // localhost: 4200 i ako sada odemo tamo, vidjet ćemo učitanu osnovnu Angular aplikaciju.

3.2. Dodavanje Bootstrapa

Prije nego što nastavimo s izradom vlastitih komponenata, prvo dodajmo Bootstrap našem projektu kako bismo učinili da naše stranice izgledaju lijepo.

Potrebno nam je samo nekoliko stvari da bismo to postigli. Prvo, trebamopokrenite naredbu da ga instalirate:

npm install --save bootstrap

i zatim reći Angulalu da ga zapravo koristi. Za to moramo otvoriti datoteku src / main / frontend / angular.json i dodati node_modules / bootstrap / dist / css / bootstrap.min.css pod, ispod "Stilovi" imovine. I to je to.

3.3. Komponente i modeli

Prije nego što započnemo s izradom komponenata za našu aplikaciju, provjerimo kako će naša aplikacija zapravo izgledati:

Sada ćemo stvoriti osnovnu komponentu, nazvanu e-trgovina:

ng g c e-trgovina

To će stvoriti našu komponentu unutar / frontend / src / app mapu. Da bismo ga učitali pri pokretanju aplikacije, mi ćemouključite gau app.component.html:

Zatim ćemo stvoriti druge komponente unutar ove osnovne komponente:

ng g c / e-trgovina / proizvodi ng g c / e-trgovina / narudžbe ng g c / e-trgovina / shopping-cart

Svakako, mogli smo ručno stvoriti sve te mape i datoteke ako je to poželjno, ali u tom bismo slučaju morali ne zaboravite te komponente registrirati u našem AppModule.

Trebat će nam i neki modeli za lako rukovanje našim podacima:

klasa izvoza Product {id: number; ime: niz; cijena: broj; pictureUrl: niz; // konstruktor svih argumenata}
klasa izvoza ProductOrder {proizvod: Proizvod; količina: broj; // konstruktor svih argumenata}
klasa izvoza ProductOrders {productOrders: ProductOrder [] = []; }

Posljednji spomenuti model odgovara našem Obrazac za narudžbu na pozadini.

3.4. Osnovna komponenta

Na vrhu našeg e-trgovina komponentu, stavit ćemo navigacijsku traku s vezom Početna s desne strane:

 Baeldung e-trgovina 
  • Početna (trenutno)

Odavde ćemo učitati i ostale komponente:

Trebali bismo imati na umu da, kako bismo vidjeli sadržaj naših komponenata, budući da koristimo navbar klase, moramo dodati nekoliko CSS-a u app.component.css:

.container {padding-top: 65px; }

Provjerimo .ts datoteku prije nego što komentiramo najvažnije dijelove:

@Component ({selector: 'app-ecommerce', templateUrl: './ecommerce.component.html', styleUrls: ['./ecommerce.component.css']}) klasa izvoza EcommerceComponent implementira OnInit {private collapsed = true; orderFinished = false; @ViewChild ('productsC') productsC: ProductsComponent; @ViewChild ('shoppingCartC') shoppingCartC: ShoppingCartComponent; @ViewChild ('nalogaC') nalogaC: OrdersComponent; toggleCollapsed (): void {this.collapsed =! this.collapsed; } finishOrder (orderFinished: boolean) {this.orderFinished = orderFinished; } reset () {this.orderFinished = false; this.productsC.reset (); this.shoppingCartC.reset (); this.ordersC.paid = false; }}

Kao što vidimo, klikom na Dom veza će resetirati podređene komponente. Moramo roditelju pristupiti metodama i polju unutar podređenih komponenata, pa zato zadržavamo reference na djecu i koristimo one unutar resetirati () metoda.

3.5. Usluga

Da bi se komponente braće i sestara za međusobnu komunikacijui za preuzimanje / slanje podataka s / na naš API, trebat ćemo stvoriti uslugu:

@Injectable () klasa izvoza EcommerceService {private productsUrl = "/ api / products"; privatne narudžbeUrl = "/ api / naloga"; privatni productOrder: ProductOrder; privatne narudžbe: ProductOrders = novi ProductOrders (); private productOrderSubject = new Subject (); privatne narudžbeSubject = novi Subject (); private totalSubject = novi Predmet (); privatno ukupno: broj; ProductOrderChanged = this.productOrderSubject.asObservable (); OrdersChanged = this.ordersSubject.asObservable (); TotalChanged = this.totalSubject.asObservable (); konstruktor (privatni http: HttpClient) {} getAllProducts () {return this.http.get (this.productsUrl); } saveOrder (order: ProductOrders) {return this.http.post (this.ordersUrl, order); } // getteri i postavljači za dijeljena polja}

Kao što smo mogli primijetiti, ovdje su relativno jednostavne stvari. Izrađujemo GET i POST zahtjeve za komunikaciju s API-jem. Također, podatke koje trebamo dijeliti između komponenata činimo vidljivima kako bismo ih kasnije mogli pretplatiti.

Ipak, moramo istaknuti jednu stvar u vezi s komunikacijom s API-jem. Ako sada pokrenemo aplikaciju, primili bismo 404 i ne bismo dobili podatke. Razlog tome je taj što će, budući da koristimo relativne URL-ove, Angular prema zadanim postavkama pokušati uputiti poziv // localhost: 4200 / api / products i naša pozadinska aplikacija radi lokalnihost: 8080.

Mogli bismo teško kodirati URL-ove lokalnihost: 8080, naravno, ali to nije nešto što želimo raditi. Umjesto toga, kada radimo s različitim domenama, trebali bismo stvoriti datoteku s imenom proxy-conf.json u našem / frontend mapu:

{"/ api": {"target": "// localhost: 8080", "secure": false}}

A onda trebamo otvorena paket.json i promjena skripte.početi imovine upariti:

"skripte": {... "start": "ng serve --proxy-config proxy-conf.json", ...}

A sada bismo samo trebali imajte na umu da započnete aplikaciju s npm start umjesto toga ng poslužiti.

3.6. Proizvodi

U našem ProductsComponent, ubrizgat ćemo uslugu koju smo napravili ranije i učitati popis proizvoda iz API-ja i pretvoriti ga u popis ProductOrders budući da svakom proizvodu želimo dodati polje količine:

klasa izvoza ProductsComponent implementira OnInit {productOrders: ProductOrder [] = []; proizvodi: Proizvod [] = []; selectedProductOrder: ProductOrder; privatni shoppingCartOrders: ProductOrders; pod: Pretplata; productSelected: boolean = false; konstruktor (privatna ecommerceService: EcommerceService) {} ngOnInit () {this.productOrders = []; this.loadProducts (); this.loadOrders (); } loadProducts () {this.ecommerceService.getAllProducts () .subscribe ((products: any []) => {this.products = products; this.products.forEach (product => {this.productOrders.push (new ProductOrder ( proizvod, 0));})}, (pogreška) => console.log (pogreška)); } loadOrders () {this.sub = this.ecommerceService.OrdersChanged.subscribe (() => {this.shoppingCartOrders = this.ecommerceService.ProductOrders;}); }}

Također trebamo mogućnost dodavanja proizvoda u košaricu ili uklanjanja iz njega:

addToCart (narudžba: ProductOrder) {this.ecommerceService.SelectedProductOrder = narudžba; this.selectedProductOrder = this.ecommerceService.SelectedProductOrder; this.productSelected = true; } removeFromCart (productOrder: ProductOrder) {let index = this.getProductIndex (productOrder.product); if (indeks> -1) {this.shoppingCartOrders.productOrders.splice (this.getProductIndex (productOrder.product), 1); } this.ecommerceService.ProductOrders = this.shoppingCartOrders; this.shoppingCartOrders = this.ecommerceService.ProductOrders; this.productSelected = false; }

Napokon ćemo stvoriti resetirati() metoda koju smo spomenuli u odjeljku 3.4:

reset () {this.productOrders = []; this.loadProducts (); this.ecommerceService.ProductOrders.productOrders = []; this.loadOrders (); this.productSelected = false; }

Prelistavat ćemo popis proizvoda u našoj HTML datoteci i prikazati ga korisniku:

{{order.product.name}}

$ {{order.product.price}}

3.8. Narudžbe

Stvari ćemo održavati najjednostavnijima što možemo i u OrdersComponent simulirajte plaćanje postavljanjem svojstva na true i spremanjem narudžbe u bazu podataka. Možemo provjeriti spremaju li se narudžbe putem h2-konzola ili udaranjem // localhost: 8080 / api / orders.

Trebamo EcommerceService i ovdje kako bismo preuzeli popis proizvoda iz košarice i ukupan iznos za našu narudžbu:

klasa izvoza OrdersComponent implementira OnInit {naloga: ProductOrders; ukupni broj; plaćeno: boolean; pod: Pretplata; konstruktor (privatna ecommerceService: EcommerceService) {this.orders = this.ecommerceService.ProductOrders; } ngOnInit () {this.paid = false; this.sub = this.ecommerceService.OrdersChanged.subscribe (() => {this.orders = this.ecommerceService.ProductOrders;}); this.loadTotal (); } pay () {this.paid = true; this.ecommerceService.saveOrder (this.orders) .subscribe (); }}

I na kraju, korisniku moramo prikazati informacije:

NARUDŽBA

  • {{order.product.name}} - $ {{order.product.price}} x {{order.quantity}} kom.

Ukupan iznos: {{ukupno}}

Platiti Svaka čast! Uspješno ste izvršili narudžbu.

4. Spajanje Spring Boot i kutnih aplikacija

Završili smo razvoj obje naše aplikacije i vjerojatno je lakše razvijati ih odvojeno, kao što smo to i učinili. Ali, u proizvodnji bi bilo puno prikladnije imati jednu aplikaciju, pa sada spojimo te dvije.

Ono što ovdje želimo učiniti je izraditi aplikaciju Angular koja poziva Webpack da grupira svu imovinu i ugura je u / resources / static direktorija aplikacije Spring Boot. Na taj način možemo samo pokrenuti Spring Boot aplikaciju i testirati našu aplikaciju te sve to spakirati i implementirati kao jednu aplikaciju.

Da bismo to omogućili, moramo otvorena 'paket.json'Opet dodajte neke nove skripte nakon skripte.izgraditi:

"postbuild": "npm run deploy", "predeploy": "rimraf ../resources/static/ && mkdirp ../resources/static", "deploy": "copyfiles -f dist / ** ../resources/ statički",

Koristimo neke pakete koje nismo instalirali, pa ih instalirajmo:

npm install --save-dev rimraf npm install --save-dev mkdirp npm install --save-dev copyfiles

The rimraf naredba će pogledati direktorij i napraviti novi direktorij (zapravo ga očistiti) kopije datoteka kopira datoteke iz mape za distribuciju (gdje Angular smješta sve) u našu statički mapu.

Sad samo trebamo trčanje npm trčanje graditi naredba i ovo bi trebalo pokrenuti sve te naredbe, a krajnji izlaz bit će naša zapakirana aplikacija u statičkoj mapi.

Zatim pokrećemo našu aplikaciju Spring Boot na priključku 8080, tamo joj pristupamo i koristimo aplikaciju Angular.

5. Zaključak

U ovom smo članku stvorili jednostavnu aplikaciju za e-trgovinu. Stvorili smo API na pozadini pomoću Spring Boota, a zatim smo ga potrošili u našoj frontend aplikaciji izrađenoj u Angular-u. Pokazali smo kako napraviti komponente koje su nam potrebne, natjerati ih da međusobno komuniciraju i dohvatiti / poslati podatke iz / u API.

Na kraju smo pokazali kako obje aplikacije spojiti u jednu, zapakiranu web-aplikaciju unutar statičke mape.

Kao i uvijek, cjelovit projekt koji smo opisali u ovom članku možete pronaći u projektu GitHub.


$config[zx-auto] not found$config[zx-overlay] not found