BETTERZON-25: Finishing prototype of UC

This commit is contained in:
Patrick Müller 2020-12-09 20:35:08 +01:00
parent 6338060b78
commit 31423c630a
18 changed files with 315 additions and 51 deletions

View File

@ -123,16 +123,16 @@ export const findByType = async (product: string, type: string): Promise<Prices>
'PARTITION BY p.vendor_id ' + 'PARTITION BY p.vendor_id ' +
'ORDER BY p.timestamp DESC) AS rk ' + 'ORDER BY p.timestamp DESC) AS rk ' +
'FROM prices p ' + 'FROM prices p ' +
'WHERE product_id = ?) ' + 'WHERE product_id = ? AND vendor_id != 1) ' +
'SELECT s.* ' + 'SELECT s.* ' +
'FROM summary s ' + 'FROM summary s ' +
'WHERE s.rk = 1 '), product); 'WHERE s.rk = 1 '), product);
} else if (type === 'lowest') { } else if (type === 'lowest') {
// Used to get the lowest prices for this product over a period of time // Used to get the lowest prices for this product over a period of time
rows = await conn.query('SELECT price_id, product_id, vendor_id, MIN(price_in_cents) as price_in_cents, timestamp FROM prices WHERE product_id = ? GROUP BY DAY(timestamp) ORDER BY timestamp', product); rows = await conn.query('SELECT price_id, product_id, vendor_id, MIN(price_in_cents) as price_in_cents, timestamp FROM prices WHERE product_id = ? AND vendor_id != 1 GROUP BY DAY(timestamp) ORDER BY timestamp', product);
} else { } else {
// If no type is given, return all prices for this product // If no type is given, return all prices for this product
rows = await conn.query('SELECT price_id, product_id, vendor_id, price_in_cents, timestamp FROM prices WHERE product_id = ?', product); rows = await conn.query('SELECT price_id, product_id, vendor_id, price_in_cents, timestamp FROM prices WHERE product_id = ? AND vendor_id != 1', product);
} }
for (let row in rows) { for (let row in rows) {

View File

@ -5,7 +5,7 @@
<img src="assets/images/Betterzon.svg" alt="Betterzon Logo" width="50px" (click)="clickedLogo()"> <img src="assets/images/Betterzon.svg" alt="Betterzon Logo" width="50px" (click)="clickedLogo()">
</div> </div>
<div class="searchBox"> <div class="searchBox">
<input type="text" [(ngModel)]="searchInput" placeholder="Search" (keyup.enter)="startedSearch()"> <input *ngIf="showSearch===true" type="text" [(ngModel)]="searchInput" placeholder="Search" (keyup.enter)="startedSearch()">
</div> </div>
<div class="profileIcon"> <div class="profileIcon">
Profile Profile

View File

@ -1,4 +1,4 @@
import {Component, OnInit} from '@angular/core'; import {Component, Input, OnInit} from '@angular/core';
import {Router} from '@angular/router'; import {Router} from '@angular/router';
@Component({ @Component({
@ -8,6 +8,7 @@ import {Router} from '@angular/router';
}) })
export class HeaderComponent implements OnInit { export class HeaderComponent implements OnInit {
searchInput: string; searchInput: string;
@Input() showSearch: boolean;
constructor( constructor(
private router: Router private router: Router
@ -15,6 +16,9 @@ export class HeaderComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
if (!this.showSearch) {
this.showSearch = false;
}
} }
clickedLogo(): void { clickedLogo(): void {

View File

@ -1,7 +1,25 @@
.priceList { .priceList {
max-width: 50%; margin: 2em;
margin: auto; position: relative;
margin: auto; }
align-content: center;
text-align: center; .priceList table {
position: relative;
margin: auto;
font-family: Arial, Helvetica, sans-serif;
border-collapse: collapse;
width: 80%;
}
.priceList table td, .priceList table th {
border: 1px solid #ddd;
padding: 8px;
}
.priceList table tr:nth-child(even) {
background-color: #f2f2f2;
}
.priceList table tr:hover {
background-color: #ddd;
} }

View File

@ -1,3 +1,14 @@
<div class="priceList"> <div class="priceList">
PriceList <table>
<tr>
<th>Vendor</th>
<th>Current price</th>
<th>Visit</th>
</tr>
<tr *ngFor="let price of prices">
<td>{{vendorMap[price.vendor_id]?.name}}</td>
<td>{{price.price_in_cents / 100}}€</td>
<td><a href="https://www.amazon.com">Visit Shop</a></td>
</tr>
</table>
</div> </div>

View File

@ -1,4 +1,7 @@
import { Component, OnInit } from '@angular/core'; import {Component, Input, OnInit} from '@angular/core';
import {Price} from '../../models/price';
import {Vendor} from '../../models/vendor';
import {ApiService} from '../../services/api.service';
@Component({ @Component({
selector: 'app-newest-prices-list', selector: 'app-newest-prices-list',
@ -6,10 +9,37 @@ import { Component, OnInit } from '@angular/core';
styleUrls: ['./newest-prices-list.component.css'] styleUrls: ['./newest-prices-list.component.css']
}) })
export class NewestPricesListComponent implements OnInit { export class NewestPricesListComponent implements OnInit {
@Input() productId: number;
prices: Price[] = [];
vendors: Vendor[] = [];
vendorMap = {};
constructor() { } constructor(
private apiService: ApiService
) {
}
ngOnInit(): void { ngOnInit(): void {
this.getVendors();
this.getPrices();
}
getPrices(): void {
// Lowest prices
this.apiService.getCurrentPricePerVendor(this.productId).subscribe(
prices => {
this.prices = prices;
});
}
getVendors(): void {
this.apiService.getVendors().subscribe(vendors => {
this.vendors = vendors;
this.vendors.forEach(vendor => {
this.vendorMap[vendor.vendor_id] = vendor;
});
});
} }
} }

View File

@ -57,14 +57,27 @@
border-radius: 5px; border-radius: 5px;
padding: .25em; padding: .25em;
margin: auto; margin: auto;
font-size: 1.5em;
} }
/* Best price div */ /* Best price container div */
.bestPrice { .bestPriceContainer {
grid-area: bestPrice; grid-area: bestPrice;
}
/* best price div */
.bestPrice {
border-style: solid; border-style: solid;
border-color: dimgrey; border-color: dimgrey;
border-radius: 5px; border-radius: 5px;
padding: .25em; padding: .25em;
margin: auto; margin: auto;
text-align: center;
font-size: 1.5em;
}
/* amazon price div */
.amazonPrice {
margin-top: .5em;
text-align: center;
} }

View File

@ -3,7 +3,7 @@
<img class="productImage" src="https://www.mueller-patrick.tech/betterzon/images/{{product.image_guid}}.jpg"/> <img class="productImage" src="https://www.mueller-patrick.tech/betterzon/images/{{product.image_guid}}.jpg"/>
</div> </div>
<div class="productTitle"> <div class="productTitle">
<b>{{product.name}}</b> <b>{{product?.name}}</b>
</div> </div>
<div class="priceChart"> <div class="priceChart">
<div style="text-align:center"> <div style="text-align:center">
@ -17,13 +17,20 @@
</div> </div>
<div class="productDescription"> <div class="productDescription">
<div> <div>
{{product.short_description}} {{product?.short_description}}
</div> </div>
</div> </div>
<div class="priceAlarm"> <div class="priceAlarm">
Set Price Alarm Set Price Alarm
</div> </div>
<div class="bestPriceContainer">
<div class="bestPrice"> <div class="bestPrice">
5€ Best price: {{currentlyLowestPrice?.price_in_cents / 100}}€ at
vendor {{vendorMap[currentlyLowestPrice.vendor_id]?.name}}!
</div>
<div class="amazonPrice">
Amazon-price: {{currentAmazonPrice?.price_in_cents / 100}}€ (<span
*ngIf="getAmazonPriceDifference()>0">+</span>{{getAmazonPriceDifference()}}%)
</div>
</div> </div>
</div> </div>

View File

@ -8,6 +8,8 @@ import {
ApexXAxis, ApexXAxis,
ApexTitleSubtitle, ApexStroke ApexTitleSubtitle, ApexStroke
} from 'ng-apexcharts'; } from 'ng-apexcharts';
import {Price} from '../../models/price';
import {Vendor} from '../../models/vendor';
export type ChartOptions = { export type ChartOptions = {
series: ApexAxisChartSeries; series: ApexAxisChartSeries;
@ -25,6 +27,11 @@ export type ChartOptions = {
export class ProductDetailsComponent implements OnInit { export class ProductDetailsComponent implements OnInit {
@Input() productId: number; @Input() productId: number;
product: Product; product: Product;
lowestPrices: Price[];
currentlyLowestPrice: Price;
currentAmazonPrice: Price;
vendors: Vendor[] = [];
vendorMap = {};
@ViewChild('chart') chart: ChartComponent; @ViewChild('chart') chart: ChartComponent;
public chartOptions: ChartOptions; public chartOptions: ChartOptions;
@ -35,19 +42,53 @@ export class ProductDetailsComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.getProduct(); this.getProduct();
this.getChartData(); this.getVendors();
this.getPrices();
} }
getProduct(): void { getProduct(): void {
this.apiService.getProduct(this.productId).subscribe(product => this.product = product); this.apiService.getProduct(this.productId).subscribe(product => this.product = product);
} }
getPrices(): void {
// Lowest prices
this.apiService.getLowestPrices(this.productId).subscribe(
prices => {
this.lowestPrices = prices;
this.currentlyLowestPrice = prices[prices.length - 1];
// Update charts
this.getChartData();
});
// Amazon price
this.apiService.getAmazonPrice(this.productId).subscribe(price => {
this.currentAmazonPrice = price[0];
});
}
getVendors(): void {
this.apiService.getVendors().subscribe(vendors => {
this.vendors = vendors;
this.vendors.forEach(vendor => {
this.vendorMap[vendor.vendor_id] = vendor;
});
});
}
getChartData(): void { getChartData(): void {
const prices = [];
const categs = [];
this.lowestPrices?.forEach(price => {
prices.push(price.price_in_cents / 100);
categs.push(new Date(price.timestamp).toDateString());
});
this.chartOptions = { this.chartOptions = {
series: [ series: [
{ {
name: 'Lowest Price', name: 'Lowest Price',
data: [1061.20, 1060, 1070, 1040, 1061.20, 1061, 1100, 1070, 1061.20] data: prices
} }
], ],
chart: { chart: {
@ -58,7 +99,7 @@ export class ProductDetailsComponent implements OnInit {
text: 'Lowest price' text: 'Lowest price'
}, },
xaxis: { xaxis: {
categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'] categories: categs
}, },
stroke: { stroke: {
curve: 'stepline' curve: 'stepline'
@ -66,4 +107,16 @@ export class ProductDetailsComponent implements OnInit {
}; };
} }
getAmazonPriceDifference(): number {
const amazonPrice = this.currentAmazonPrice?.price_in_cents;
const lowestPrice = this.currentlyLowestPrice?.price_in_cents;
const percentage = amazonPrice / lowestPrice;
if (percentage < 1) {
return -Math.round(percentage);
} else {
return +Math.round(percentage);
}
}
} }

View File

@ -0,0 +1,10 @@
export interface Vendor {
vendor_id: number;
name: string;
streetname: string;
zip_code: string;
city: string;
country_code: string;
phone: string;
website: string;
}

View File

@ -3,3 +3,48 @@
margin-top: .5em; margin-top: .5em;
margin-bottom: .5em; margin-bottom: .5em;
} }
#productListsContainer {
display: grid;
grid-template-areas:
'search search'
'popularSearches bestDeals';
grid-template-columns: 50% 50%;
}
#searchContainer {
position: relative;
grid-area: search;
height: 10em;
}
#searchContainer input {
position: relative;
font-size: 1.5em;
padding: .25em;
display: block;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
margin: auto;
-ms-transform: translateY(50%);
transform: translateY(2.5em);
}
#popularSearchesList {
grid-area: popularSearches;
padding: .5em;
}
#popularSearchesList h2 {
text-align: center;
}
#bestDealsList {
grid-area: bestDeals;
padding: .5em;
}
#bestDealsList h2 {
text-align: center;
}

View File

@ -1,5 +1,18 @@
<app-header></app-header> <app-header [showSearch]="false"></app-header>
<div id="mainComponents"> <div id="mainComponents">
<app-product-list numberOfProducts="20" [showProductPicture]="true"></app-product-list> <div id="searchContainer">
<input type="text" [(ngModel)]="searchInput" placeholder="Search" (keyup.enter)="startedSearch()">
</div>
<div id="productListsContainer">
<div id="popularSearchesList">
<h2>Popular Searches</h2>
<app-product-list numberOfProducts="3" [showProductPicture]="true"
type="popularSearches"></app-product-list>
</div>
<div id="bestDealsList">
<h2>Best Deals</h2>
<app-product-list numberOfProducts="3" [showProductPicture]="true" type="bestDeals"></app-product-list>
</div>
</div>
</div> </div>
<app-footer></app-footer> <app-footer></app-footer>

View File

@ -1,4 +1,5 @@
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
@Component({ @Component({
selector: 'app-landingpage', selector: 'app-landingpage',
@ -6,10 +7,23 @@ import { Component, OnInit } from '@angular/core';
styleUrls: ['./landingpage.component.css'] styleUrls: ['./landingpage.component.css']
}) })
export class LandingpageComponent implements OnInit { export class LandingpageComponent implements OnInit {
searchInput: string;
constructor() { } constructor(
private router: Router
) {
}
ngOnInit(): void { ngOnInit(): void {
} }
startedSearch(): void {
this.redirectTo('/search', {queryParams: {q: this.searchInput}});
}
redirectTo(uri: string, queryParams: object): void {
this.router.navigateByUrl('/', {skipLocationChange: true}).then(() =>
this.router.navigate([uri], queryParams));
}
} }

View File

@ -1,6 +1,6 @@
<app-header></app-header> <app-header [showSearch]="true"></app-header>
<div id="mainComponents"> <div id="mainComponents">
<app-product-details [productId]="productId"></app-product-details> <app-product-details [productId]="productId"></app-product-details>
<app-newest-prices-list></app-newest-prices-list> <app-newest-prices-list [productId]="productId"></app-newest-prices-list>
</div> </div>
<app-footer></app-footer> <app-footer></app-footer>

View File

@ -1,4 +1,4 @@
<app-header></app-header> <app-header [showSearch]="true"></app-header>
<div id="mainComponents"> <div id="mainComponents">
<app-product-list numberOfProducts="20" [showProductPicture]="true" searchQuery="{{searchTerm}}" <app-product-list numberOfProducts="20" [showProductPicture]="true" searchQuery="{{searchTerm}}"
type="search"></app-product-list> type="search"></app-product-list>

View File

@ -1,9 +1,10 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http'; import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
import process from 'process'; import process from 'process';
import {Product} from '../models/product'; import {Product} from '../models/product';
import {Price} from '../models/price'; import {Price} from '../models/price';
import {Observable, of} from 'rxjs'; import {Observable, of} from 'rxjs';
import {Vendor} from '../models/vendor';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -46,10 +47,55 @@ export class ApiService {
getPrices(): Observable<Price[]> { getPrices(): Observable<Price[]> {
try { try {
const prices = this.http.get<Price[]>((this.apiUrl + '/prices')); const prices = this.http.get<Price[]>((this.apiUrl + '/prices'));
console.log(prices);
return prices; return prices;
} catch (exception) { } catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`); process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
} }
} }
getLowestPrices(productId): Observable<Price[]> {
try {
let params = new HttpParams();
params = params.append('product', productId);
params = params.append('type', 'lowest');
const prices = this.http.get<Price[]>((this.apiUrl + '/prices'), {params});
return prices;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
getAmazonPrice(productId): Observable<Price> {
try {
let params = new HttpParams();
params = params.append('product', productId);
params = params.append('vendor', '1');
params = params.append('type', 'newest');
const price = this.http.get<Price>((this.apiUrl + '/prices'), {params});
return price;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
getCurrentPricePerVendor(productId): Observable<Price[]> {
try {
let params = new HttpParams();
params = params.append('product', productId);
params = params.append('type', 'newest');
const prices = this.http.get<Price[]>((this.apiUrl + '/prices'), {params});
return prices;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
getVendors(): Observable<Vendor[]> {
try {
const vendors = this.http.get<Vendor[]>((this.apiUrl + '/vendors'));
return vendors;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
} }