Merge pull request #15 from Mueller-Patrick/BETTERZON-42

Prototype for Midterm UC Demo
This commit is contained in:
Patrick 2020-12-09 21:39:38 +01:00 committed by GitHub
commit 29aa02e07d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1104 additions and 125 deletions

View File

@ -23,7 +23,20 @@ export const pricesRouter = express.Router();
pricesRouter.get('/', async (req: Request, res: Response) => { pricesRouter.get('/', async (req: Request, res: Response) => {
try { try {
const prices: Prices = await PriceService.findAll(); let prices: Prices = [];
const product = req.query.product;
const vendor = req.query.vendor;
const type = req.query.type;
if (product) {
if (vendor) {
prices = await PriceService.findByVendor(<string> product, <string> vendor, <string> type);
} else {
prices = await PriceService.findByType(<string> product, <string> type);
}
} else {
prices = await PriceService.findAll();
}
res.status(200).send(prices); res.status(200).send(prices);
} catch (e) { } catch (e) {
@ -50,26 +63,6 @@ pricesRouter.get('/:id', async (req: Request, res: Response) => {
} }
}); });
// GET items/:name
pricesRouter.get('/products/:id', async (req: Request, res: Response) => {
const id: number = parseInt(req.params.id, 10);
if (!id) {
res.status(400).send('Missing parameters.');
return;
}
try {
const prices: Prices = await PriceService.findByProduct(id);
res.status(200).send(prices);
} catch (e) {
res.status(404).send(e.message);
}
});
// POST items/ // POST items/
// pricesRouter.post('/', async (req: Request, res: Response) => { // pricesRouter.post('/', async (req: Request, res: Response) => {

View File

@ -106,6 +106,86 @@ export const findByProduct = async (product: number): Promise<Prices> => {
return priceRows; return priceRows;
}; };
export const findByType = async (product: string, type: string): Promise<Prices> => {
let conn;
let priceRows = [];
try {
conn = await pool.getConnection();
let rows = [];
if (type === 'newest') {
// Used to get the newest price for this product per vendor
rows = await conn.query(('WITH summary AS ( ' +
'SELECT p.product_id, ' +
'p.vendor_id, ' +
'p.price_in_cents, ' +
'p.timestamp, ' +
'ROW_NUMBER() OVER( ' +
'PARTITION BY p.vendor_id ' +
'ORDER BY p.timestamp DESC) AS rk ' +
'FROM prices p ' +
'WHERE product_id = ? AND vendor_id != 1) ' +
'SELECT s.* ' +
'FROM summary s ' +
'WHERE s.rk = 1 '), product);
} else if (type === 'lowest') {
// 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 = ? AND vendor_id != 1 GROUP BY DAY(timestamp) ORDER BY timestamp', product);
} else {
// 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 = ? AND vendor_id != 1', product);
}
for (let row in rows) {
if (row !== 'meta') {
priceRows.push(rows[row]);
}
}
} catch (err) {
throw err;
} finally {
if (conn) {
conn.end();
}
}
return priceRows;
};
export const findByVendor = async (product: string, vendor: string, type: string): Promise<Prices> => {
let conn;
let priceRows = [];
try {
conn = await pool.getConnection();
let rows = [];
if (type === 'newest') {
// Used to get the newest price for this product and vendor
rows = await conn.query('SELECT price_id, product_id, vendor_id, price_in_cents, timestamp FROM prices WHERE product_id = ? AND vendor_id = ? ORDER BY timestamp DESC LIMIT 1', [product, vendor]);
} else if (type === 'lowest') {
// Used to get the lowest prices for this product and vendor in all 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 = ? AND vendor_id = ? LIMIT 1', [product, vendor]);
} else {
// If no type is given, return all prices for this product and vendor
rows = await conn.query('SELECT price_id, product_id, vendor_id, price_in_cents, timestamp FROM prices WHERE product_id = ? AND vendor_id = ?', [product, vendor]);
}
for (let row in rows) {
if (row !== 'meta') {
priceRows.push(rows[row]);
}
}
} catch (err) {
throw err;
} finally {
if (conn) {
conn.end();
}
}
return priceRows;
};
// export const create = async (newItem: Product): Promise<void> => { // export const create = async (newItem: Product): Promise<void> => {
// let conn; // let conn;
// try { // try {

View File

@ -2260,6 +2260,19 @@
"picomatch": "^2.0.4" "picomatch": "^2.0.4"
} }
}, },
"apexcharts": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.22.3.tgz",
"integrity": "sha512-ZRZWmAmSdyc+tFhHMZ10ZxbvSbomWe46izpi8yQj5cKLxuujw2XeXVQ0jxnPl9yE5Q7W2hAbDWStaouBN4mSuw==",
"requires": {
"svg.draggable.js": "^2.2.2",
"svg.easing.js": "^2.0.0",
"svg.filter.js": "^2.0.2",
"svg.pathmorphing.js": "^0.1.3",
"svg.resize.js": "^1.4.3",
"svg.select.js": "^3.0.1"
}
},
"app-root-path": { "app-root-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.0.0.tgz",
@ -7844,6 +7857,21 @@
"resolved": "https://registry.npmjs.org/ng/-/ng-0.0.0.tgz", "resolved": "https://registry.npmjs.org/ng/-/ng-0.0.0.tgz",
"integrity": "sha512-HwR40IBJa1ZU+CIGyuy7vSCN3xFYlSRfw5eIwwKOdOMNNNIl8KhT6PXKmHuFEFYpfrbOMaCYtr4QOJ3gkkubcg==" "integrity": "sha512-HwR40IBJa1ZU+CIGyuy7vSCN3xFYlSRfw5eIwwKOdOMNNNIl8KhT6PXKmHuFEFYpfrbOMaCYtr4QOJ3gkkubcg=="
}, },
"ng-apexcharts": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/ng-apexcharts/-/ng-apexcharts-1.5.6.tgz",
"integrity": "sha512-78vmZvrT9iqfZXE00+T8NTvR+EHV0wo4qqf0Zfu1/2KiwazCU9S5EROcmgqMQ1eCO7Sz4GiR19rLTMdtWL/WmQ==",
"requires": {
"tslib": "^1.10.0"
},
"dependencies": {
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
}
}
},
"nice-try": { "nice-try": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@ -11950,6 +11978,70 @@
"has-flag": "^3.0.0" "has-flag": "^3.0.0"
} }
}, },
"svg.draggable.js": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
"integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
"requires": {
"svg.js": "^2.0.1"
}
},
"svg.easing.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
"integrity": "sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI=",
"requires": {
"svg.js": ">=2.3.x"
}
},
"svg.filter.js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
"integrity": "sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM=",
"requires": {
"svg.js": "^2.2.5"
}
},
"svg.js": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
"integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA=="
},
"svg.pathmorphing.js": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
"integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
"requires": {
"svg.js": "^2.4.0"
}
},
"svg.resize.js": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
"integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
"requires": {
"svg.js": "^2.6.5",
"svg.select.js": "^2.1.2"
},
"dependencies": {
"svg.select.js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
"requires": {
"svg.js": "^2.2.5"
}
}
}
},
"svg.select.js": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
"integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
"requires": {
"svg.js": "^2.6.5"
}
},
"svgo": { "svgo": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz",

View File

@ -20,7 +20,9 @@
"@angular/platform-browser": "^10.2.3", "@angular/platform-browser": "^10.2.3",
"@angular/platform-browser-dynamic": "^10.2.3", "@angular/platform-browser-dynamic": "^10.2.3",
"@angular/router": "^10.2.3", "@angular/router": "^10.2.3",
"apexcharts": "^3.22.3",
"ng": "0.0.0", "ng": "0.0.0",
"ng-apexcharts": "^1.5.6",
"rxjs": "~6.6.0", "rxjs": "~6.6.0",
"tslib": "^2.0.3", "tslib": "^2.0.3",
"zone.js": "~0.10.2" "zone.js": "~0.10.2"

View File

@ -1,27 +0,0 @@
import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import process from 'process';
import {Product} from './models/product';
import {Observable, of} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ApiService {
apiUrl = 'https://backend.betterzon.xyz';
constructor(
private http: HttpClient
) {
}
getProducts(): Observable<Product[]> {
try {
const prods = this.http.get<Product[]>((this.apiUrl + '/products'));
console.log(prods);
return prods;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
}

View File

@ -4,10 +4,17 @@ import {NgModule} from '@angular/core';
import {AppComponent} from './app.component'; import {AppComponent} from './app.component';
import {AppRouting} from './app.routing'; import {AppRouting} from './app.routing';
import {ProductListComponent} from './product-list/product-list.component'; import {ProductListComponent} from './components/product-list/product-list.component';
import { LandingpageComponent } from './landingpage/landingpage.component'; import {LandingpageComponent} from './pages/landingpage/landingpage.component';
import { ProductDetailPageComponent } from './product-detail-page/product-detail-page.component'; import {ProductDetailPageComponent} from './pages/product-detail-page/product-detail-page.component';
import { FooterComponent } from './footer/footer.component'; import {FooterComponent} from './components/footer/footer.component';
import {ProductDetailsComponent} from './components/product-details/product-details.component';
import {NgApexchartsModule} from 'ng-apexcharts';
import {ProductSearchPageComponent} from './pages/product-search-page/product-search-page.component';
import {HeaderComponent} from './components/header/header.component';
import {NewestPricesListComponent} from './components/newest-prices-list/newest-prices-list.component';
import {FormsModule} from '@angular/forms';
import {PageNotFoundPageComponent} from './pages/page-not-found-page/page-not-found-page.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -15,12 +22,19 @@ import { FooterComponent } from './footer/footer.component';
ProductListComponent, ProductListComponent,
LandingpageComponent, LandingpageComponent,
ProductDetailPageComponent, ProductDetailPageComponent,
FooterComponent FooterComponent,
ProductDetailsComponent,
ProductSearchPageComponent,
HeaderComponent,
NewestPricesListComponent,
PageNotFoundPageComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
AppRouting, AppRouting,
HttpClientModule HttpClientModule,
NgApexchartsModule,
FormsModule
], ],
providers: [], providers: [],
bootstrap: [AppComponent] bootstrap: [AppComponent]

View File

@ -2,13 +2,17 @@ import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {RouterModule, Routes} from '@angular/router'; import {RouterModule, Routes} from '@angular/router';
import {AppComponent} from './app.component'; import {AppComponent} from './app.component';
import {ProductListComponent} from './product-list/product-list.component'; import {ProductListComponent} from './components/product-list/product-list.component';
import {LandingpageComponent} from './landingpage/landingpage.component'; import {LandingpageComponent} from './pages/landingpage/landingpage.component';
import {ProductDetailPageComponent} from './product-detail-page/product-detail-page.component'; import {ProductDetailPageComponent} from './pages/product-detail-page/product-detail-page.component';
import {ProductSearchPageComponent} from './pages/product-search-page/product-search-page.component';
import {PageNotFoundPageComponent} from './pages/page-not-found-page/page-not-found-page.component';
const routes: Routes = [ const routes: Routes = [
{path: '', component: LandingpageComponent}, {path: '', component: LandingpageComponent},
{path: 'product/:id', component: ProductDetailPageComponent} {path: 'search', component: ProductSearchPageComponent},
{path: 'product/:id', component: ProductDetailPageComponent},
{path: '**', component: PageNotFoundPageComponent}
]; ];
@NgModule({ @NgModule({

View File

@ -0,0 +1,42 @@
.header {
width: auto;
background-color: dimgrey;
color: white;
text-align: center;
padding: .25em;
}
#headerContent {
display: flex;
}
.logo {
position: relative;
margin-left: 50%;
}
.searchBox {
position: relative;
margin: auto;
margin-left: 25%;
}
.searchBox input {
width: 100%;
padding: .25em;
display: inline-block;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.profileIcon {
position: relative;
margin: auto;
margin-left: 10%;
}
.icon-3d {
padding: 10px;
color: #fff;
}

View File

@ -0,0 +1,14 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<header class="header">
<div id="headerContent" class='wrap'>
<div class="logo">
<img src="assets/images/Betterzon.svg" alt="Betterzon Logo" width="50px" (click)="clickedLogo()">
</div>
<div class="searchBox">
<input *ngIf="showSearch===true" type="text" [(ngModel)]="searchInput" placeholder="Search" (keyup.enter)="startedSearch()">
</div>
<div class="profileIcon">
Profile
</div>
</div>
</header>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeaderComponent } from './header.component';
describe('HeaderComponent', () => {
let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ HeaderComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(HeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,37 @@
import {Component, Input, OnInit} from '@angular/core';
import {Router} from '@angular/router';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.css']
})
export class HeaderComponent implements OnInit {
searchInput: string;
@Input() showSearch: boolean;
constructor(
private router: Router
) {
}
ngOnInit(): void {
if (!this.showSearch) {
this.showSearch = false;
}
}
clickedLogo(): void {
this.router.navigate([('/')]);
}
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

@ -0,0 +1,25 @@
.priceList {
margin: 2em;
position: relative;
}
.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

@ -0,0 +1,14 @@
<div class="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>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NewestPricesListComponent } from './newest-prices-list.component';
describe('NewestPricesListComponent', () => {
let component: NewestPricesListComponent;
let fixture: ComponentFixture<NewestPricesListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ NewestPricesListComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(NewestPricesListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,45 @@
import {Component, Input, OnInit} from '@angular/core';
import {Price} from '../../models/price';
import {Vendor} from '../../models/vendor';
import {ApiService} from '../../services/api.service';
@Component({
selector: 'app-newest-prices-list',
templateUrl: './newest-prices-list.component.html',
styleUrls: ['./newest-prices-list.component.css']
})
export class NewestPricesListComponent implements OnInit {
@Input() productId: number;
prices: Price[] = [];
vendors: Vendor[] = [];
vendorMap = {};
constructor(
private apiService: ApiService
) {
}
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

@ -0,0 +1,83 @@
/* Div that contains each product */
.productItem {
padding: .25em;
margin: auto;
margin-bottom: .5em;
display: grid;
grid-template-columns: 20% 25% 25% 30%;
grid-template-areas:
'image title title priceChart'
'image description description priceChart'
'image priceAlarm bestPrice blank';
}
/* Image div */
.productImageContainer {
grid-area: image;
position: relative;
}
/* Product Image */
.productImage {
max-width: 300px;
max-height: 300px;
display: block;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* Title div */
.productTitle {
grid-area: title;
font-size: 2em;
font-family: sans-serif;
}
/* Price div */
.priceChart {
grid-area: priceChart;
text-align: center;
margin: auto;
}
/* Description div */
.productDescription {
grid-area: description;
}
/* Price alarm div */
.priceAlarm {
grid-area: priceAlarm;
border-style: solid;
border-color: dimgrey;
border-radius: 5px;
padding: .25em;
margin: auto;
font-size: 1.5em;
}
/* Best price container div */
.bestPriceContainer {
grid-area: bestPrice;
}
/* best price div */
.bestPrice {
border-style: solid;
border-color: dimgrey;
border-radius: 5px;
padding: .25em;
margin: auto;
text-align: center;
font-size: 1.5em;
}
/* amazon price div */
.amazonPrice {
margin-top: .5em;
text-align: center;
}

View File

@ -0,0 +1,36 @@
<div class="productItem">
<div class="productImageContainer">
<img class="productImage" src="https://www.mueller-patrick.tech/betterzon/images/{{product.image_guid}}.jpg"/>
</div>
<div class="productTitle">
<b>{{product?.name}}</b>
</div>
<div class="priceChart">
<div style="text-align:center">
<apx-chart
[series]="chartOptions.series"
[chart]="chartOptions.chart"
[xaxis]="chartOptions.xaxis"
[title]="chartOptions.title"
></apx-chart>
</div>
</div>
<div class="productDescription">
<div>
{{product?.short_description}}
</div>
</div>
<div class="priceAlarm">
Set Price Alarm
</div>
<div class="bestPriceContainer">
<div class="bestPrice">
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>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductDetailsComponent } from './product-details.component';
describe('ProductDetailsComponent', () => {
let component: ProductDetailsComponent;
let fixture: ComponentFixture<ProductDetailsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ProductDetailsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ProductDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,118 @@
import {Component, Input, OnInit, ViewChild} from '@angular/core';
import {Product} from '../../models/product';
import {ApiService} from '../../services/api.service';
import {
ChartComponent,
ApexAxisChartSeries,
ApexChart,
ApexXAxis,
ApexTitleSubtitle, ApexStroke
} from 'ng-apexcharts';
import {Price} from '../../models/price';
import {Vendor} from '../../models/vendor';
export type ChartOptions = {
series: ApexAxisChartSeries;
chart: ApexChart;
xaxis: ApexXAxis;
title: ApexTitleSubtitle;
stroke: ApexStroke;
};
@Component({
selector: 'app-product-details',
templateUrl: './product-details.component.html',
styleUrls: ['./product-details.component.css']
})
export class ProductDetailsComponent implements OnInit {
@Input() productId: number;
product: Product;
lowestPrices: Price[];
currentlyLowestPrice: Price;
currentAmazonPrice: Price;
vendors: Vendor[] = [];
vendorMap = {};
@ViewChild('chart') chart: ChartComponent;
public chartOptions: ChartOptions;
constructor(
private apiService: ApiService
) {
}
ngOnInit(): void {
this.getProduct();
this.getVendors();
this.getPrices();
}
getProduct(): void {
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 {
const prices = [];
const categs = [];
this.lowestPrices?.forEach(price => {
prices.push(price.price_in_cents / 100);
categs.push(new Date(price.timestamp).toDateString());
});
this.chartOptions = {
series: [
{
name: 'Lowest Price',
data: prices
}
],
chart: {
height: 350,
type: 'area'
},
title: {
text: 'Lowest price'
},
xaxis: {
categories: categs
},
stroke: {
curve: 'stepline'
}
};
}
getAmazonPriceDifference(): number {
const amazonPrice = this.currentAmazonPrice?.price_in_cents;
const lowestPrice = this.currentlyLowestPrice?.price_in_cents;
const percentage = ((amazonPrice / lowestPrice) - 1) * 100;
return Math.round(percentage);
}
}

View File

@ -24,7 +24,7 @@
.productImage { .productImage {
max-width: 50px; max-width: 50px;
max-height: 50px; max-height: 50px;
display:block; display: block;
margin: auto; margin: auto;
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -1,4 +1,6 @@
<meta charset="UTF-8"> <div *ngIf="products.length==0">
No Products found!
</div>
<div class="productItem" *ngFor="let product of products" (click)="clickedProduct(product)"> <div class="productItem" *ngFor="let product of products" (click)="clickedProduct(product)">
<div class="productImageContainer" *ngIf="showProductPicture===true"> <div class="productImageContainer" *ngIf="showProductPicture===true">
<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"/>

View File

@ -0,0 +1,67 @@
import {Component, Input, OnInit} from '@angular/core';
import {ApiService} from '../../services/api.service';
import {Product} from '../../models/product';
import {ActivatedRoute, Router} from '@angular/router';
@Component({
selector: 'app-product-list',
templateUrl: './product-list.component.html',
styleUrls: ['./product-list.component.css']
})
export class ProductListComponent implements OnInit {
products: Product[] = [];
@Input() numberOfProducts: number;
@Input() showProductPicture: boolean;
@Input() searchQuery: string;
@Input() type: string;
constructor(
private apiService: ApiService,
private router: Router,
private route: ActivatedRoute
) {
}
ngOnInit(): void {
this.loadParams();
}
loadParams(): void {
if (!this.numberOfProducts) {
this.numberOfProducts = 10;
}
if (!this.showProductPicture) {
this.showProductPicture = false;
}
if (!this.searchQuery) {
this.searchQuery = '';
}
if (!this.type) {
this.type = '';
}
switch (this.type) {
case 'search': {
this.getSearchedProducts();
break;
}
default: {
this.getProducts();
break;
}
}
}
getProducts(): void {
this.apiService.getProducts().subscribe(products => this.products = products);
}
getSearchedProducts(): void {
this.apiService.getProductsByQuery(this.searchQuery).subscribe(products => this.products = products);
}
clickedProduct(product: Product): void {
this.router.navigate([('/product/' + product.product_id)]);
}
}

View File

@ -1,2 +0,0 @@
<app-product-list numberOfProducts="20" [showProductPicture]="true"></app-product-list>
<app-footer></app-footer>

View File

@ -1,15 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-landingpage',
templateUrl: './landingpage.component.html',
styleUrls: ['./landingpage.component.css']
})
export class LandingpageComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,7 @@
export interface Price {
price_id: number;
product_id: number;
vendor_id: number;
price_in_cents: number;
timestamp: Date;
}

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

@ -0,0 +1,50 @@
#mainComponents {
margin: 5em;
margin-top: .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

@ -0,0 +1,18 @@
<app-header [showSearch]="false"></app-header>
<div id="mainComponents">
<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>
<app-footer></app-footer>

View File

@ -0,0 +1,29 @@
import {Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
@Component({
selector: 'app-landingpage',
templateUrl: './landingpage.component.html',
styleUrls: ['./landingpage.component.css']
})
export class LandingpageComponent implements OnInit {
searchInput: string;
constructor(
private router: Router
) {
}
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

@ -0,0 +1,2 @@
<h1>404</h1>
<p>Page not found!</p>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PageNotFoundPageComponent } from './page-not-found-page.component';
describe('PageNotFoundPageComponent', () => {
let component: PageNotFoundPageComponent;
let fixture: ComponentFixture<PageNotFoundPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PageNotFoundPageComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PageNotFoundPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-page-not-found-page',
templateUrl: './page-not-found-page.component.html',
styleUrls: ['./page-not-found-page.component.css']
})
export class PageNotFoundPageComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,3 @@
#mainComponents {
padding: 5em;
}

View File

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

View File

@ -0,0 +1,5 @@
#mainComponents {
margin: 5em;
margin-top: .5em;
margin-bottom: .5em;
}

View File

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

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductSearchPageComponent } from './product-search-page.component';
describe('ProductSearchPageComponent', () => {
let component: ProductSearchPageComponent;
let fixture: ComponentFixture<ProductSearchPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ProductSearchPageComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ProductSearchPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,21 @@
import {Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
@Component({
selector: 'app-product-search-page',
templateUrl: './product-search-page.component.html',
styleUrls: ['./product-search-page.component.css']
})
export class ProductSearchPageComponent implements OnInit {
searchTerm: string;
constructor(
private route: ActivatedRoute
) {
}
ngOnInit(): void {
this.searchTerm = this.route.snapshot.queryParamMap.get('q');
}
}

View File

@ -1,2 +0,0 @@
<p>product-detail-page works! Product: {{productId}}</p>
<app-footer></app-footer>

View File

@ -1,46 +0,0 @@
import {Component, Input, OnInit} from '@angular/core';
import {ApiService} from '../api.service';
import {Product} from '../models/product';
import {Router} from '@angular/router';
@Component({
selector: 'app-product-list',
templateUrl: './product-list.component.html',
styleUrls: ['./product-list.component.css']
})
export class ProductListComponent implements OnInit {
products: Product[];
@Input() numberOfProducts: number;
@Input() showProductPicture: boolean;
type: string;
constructor(
private apiService: ApiService,
private router: Router
) {
}
ngOnInit(): void {
this.getProducts();
console.log(this.showProductPicture);
if (!this.numberOfProducts) {
this.numberOfProducts = 10;
}
if (!this.showProductPicture) {
this.showProductPicture = false;
}
this.type = 'PLP';
console.log(this.showProductPicture);
}
getProducts(): void {
this.apiService.getProducts().subscribe(products => this.products = products);
}
clickedProduct(product: Product): void {
this.router.navigate([('/product/' + product.product_id)]);
}
}

View File

@ -0,0 +1,101 @@
import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
import process from 'process';
import {Product} from '../models/product';
import {Price} from '../models/price';
import {Observable, of} from 'rxjs';
import {Vendor} from '../models/vendor';
@Injectable({
providedIn: 'root'
})
export class ApiService {
apiUrl = 'https://backend.betterzon.xyz';
constructor(
private http: HttpClient
) {
}
getProduct(id): Observable<Product> {
try {
const prod = this.http.get<Product>((this.apiUrl + '/products/' + id));
return prod;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
getProductsByQuery(query): Observable<Product[]> {
try {
const prods = this.http.get<Product[]>((this.apiUrl + '/products/search/' + query));
return prods;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
getProducts(): Observable<Product[]> {
try {
const prods = this.http.get<Product[]>((this.apiUrl + '/products'));
return prods;
} catch (exception) {
process.stderr.write(`ERROR received from ${this.apiUrl}: ${exception}\n`);
}
}
getPrices(): Observable<Price[]> {
try {
const prices = this.http.get<Price[]>((this.apiUrl + '/prices'));
return prices;
} catch (exception) {
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`);
}
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1 +1,5 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
body {
margin: 0;
font-family: sans-serif;
}