Repository: jlozoya/angular-shop
Branch: master
Commit: 4d681f897003
Files: 57
Total size: 97.0 KB
Directory structure:
gitextract_mrrg3t_n/
├── .editorconfig
├── .gitignore
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ └── tasks.json
├── README.md
├── angular.json
├── package.json
├── public/
│ └── assets/
│ ├── .gitkeep
│ ├── .npmignore
│ └── data.json
├── src/
│ ├── app/
│ │ ├── app.component.html
│ │ ├── app.component.scss
│ │ ├── app.component.ts
│ │ ├── app.config.ts
│ │ ├── app.html
│ │ ├── app.module.ts
│ │ ├── app.routes.ts
│ │ ├── app.spec.ts
│ │ ├── app.ts
│ │ ├── cart/
│ │ │ ├── cart.component.html
│ │ │ ├── cart.component.scss
│ │ │ └── cart.component.ts
│ │ ├── cart.service.ts
│ │ ├── data.service.ts
│ │ ├── filters/
│ │ │ ├── filters.component.html
│ │ │ ├── filters.component.scss
│ │ │ └── filters.component.ts
│ │ ├── index.ts
│ │ ├── mock-data.ts
│ │ ├── product-thumbnail/
│ │ │ ├── product-thumbnail.component.html
│ │ │ ├── product-thumbnail.component.scss
│ │ │ └── product-thumbnail.component.ts
│ │ ├── search-bar/
│ │ │ ├── search-bar.component.html
│ │ │ ├── search-bar.component.scss
│ │ │ └── search-bar.component.ts
│ │ ├── shared/
│ │ │ ├── _colors.scss
│ │ │ ├── _grid.scss
│ │ │ ├── _mixins.scss
│ │ │ ├── category.model.ts
│ │ │ ├── index.ts
│ │ │ └── product.model.ts
│ │ ├── showcase/
│ │ │ ├── showcase.component.html
│ │ │ ├── showcase.component.scss
│ │ │ └── showcase.component.ts
│ │ ├── sort-filters/
│ │ │ ├── sort-filters.component.html
│ │ │ ├── sort-filters.component.scss
│ │ │ └── sort-filters.component.ts
│ │ └── url-form/
│ │ ├── url-form.component.html
│ │ ├── url-form.component.scss
│ │ └── url-form.component.ts
│ ├── index.html
│ ├── main.ts
│ └── styles.scss
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
================================================
FILE: .gitignore
================================================
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
================================================
FILE: .vscode/extensions.json
================================================
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
================================================
FILE: .vscode/launch.json
================================================
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
================================================
FILE: .vscode/tasks.json
================================================
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}
================================================
FILE: README.md
================================================
## ✨ Características
- Ordenar productos por nombre, precio (ascendente y descendente).
- Búsqueda instantánea por nombre del producto.
- Filtrar productos por categorías, rango de precios, disponibilidad y más filtros personalizados.
- Agregar productos al carrito de compras.
- Ver detalles y administrar el carrito de compras.
- Cargar tus propios datos a través de la aplicación:
- Verás un botón rojo con un ícono de **enlace**, haz clic en él y pega la URL.
- El archivo **JSON** debe seguir un formato específico ([ejemplo aquí](http://carlosroso.com/angular2-shop-json/)).
- ⚠️ **Importante:** asegúrate de configurar correctamente el encabezado `Access-Control-Allow-Methods` en tu respuesta HTTP JSON.
- **UI atractiva** con animaciones que mejoran la experiencia de usuario (UX).
---
## 🚀 Instalación
Clona el repositorio:
```bash
git clone https://github.com/jlozoya/angular-shop
```
Instala las dependencias dentro de la carpeta del proyecto:
```bash
cd angular-shop
npm install
```
Asegúrate de tener instalada la última versión de Angular CLI:
```bash
npm install -g @angular/cli
```
Inicia la aplicación en modo desarrollo:
```bash
ng serve
```
Abre tu navegador en [http://localhost:4200](http://localhost:4200).
---
### 🧪 Pruebas unitarias
```bash
ng test
```
Ejecuta las pruebas unitarias con [Karma](https://karma-runner.github.io).
---
## 📌 Notas
- Este proyecto es ideal como **proyecto de aprendizaje** y permite explorar conceptos clave de Angular como:
- Componentes
- Data binding
- Formularios
- Servicios
- Rutas
- Pipes personalizados
================================================
FILE: angular.json
================================================
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"angular-shop2": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "angular-shop2:build:production"
},
"development": {
"buildTarget": "angular-shop2:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
}
}
}
}
}
}
================================================
FILE: package.json
================================================
{
"name": "angular-shop2",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.2",
"@angular/cli": "^20.3.2",
"@angular/compiler-cli": "^20.3.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
}
}
================================================
FILE: public/assets/.gitkeep
================================================
================================================
FILE: public/assets/.npmignore
================================================
================================================
FILE: public/assets/data.json
================================================
{
"categories": [
{
"categori_id": 1,
"name": "drinks"
},
{
"categori_id": 2,
"name": "lunch"
},
{
"categori_id": 3,
"name": "food"
},
{
"categori_id": 4,
"name": "sea"
}
],
"products": [
{
"id": 1,
"name": "Lorem",
"price": "60.000",
"available": true,
"best_seller": true,
"categories": [
1,
4
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 2,
"name": "ipsum",
"price": "20.000",
"available": false,
"best_seller": false,
"categories": [
4
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 3,
"name": "dolor",
"price": "10.000",
"available": true,
"best_seller": true,
"categories": [
4
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 4,
"name": "sit",
"price": "35.000",
"available": false,
"best_seller": false,
"categories": [
1,
2
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 5,
"name": "amet",
"price": "12.000",
"available": true,
"best_seller": true,
"categories": [
1,
4
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 6,
"name": "consectetur",
"price": "120.000",
"available": true,
"best_seller": false,
"categories": [
1,
4
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 7,
"name": "adipiscing",
"price": "50.000",
"available": false,
"best_seller": false,
"categories": [
1,
3
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 8,
"name": "elit",
"price": "2000",
"available": true,
"best_seller": false,
"categories": [
1,
3
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 9,
"name": "Maecenas",
"price": "150.000",
"available": true,
"best_seller": true,
"categories": [
2,
4
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
},
{
"id": 10,
"name": "eu",
"price": "200.000",
"available": false,
"best_seller": true,
"categories": [
2,
3
],
"img": "https://placehold.co/200x100",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu."
}
]
}
================================================
FILE: src/app/app.component.html
================================================
================================================
FILE: src/app/app.component.scss
================================================
@use "./shared/colors" as *;
@use "./shared/mixins" as *;
.main-container{
padding-top: 100px;
}
.filters-wrapper{
position: relative;
z-index: 90000;
}
sort-filters{
position: relative;
z-index: 20;
}
cart{
position: absolute;
top: -23px;
right: -57px;
z-index: 99999999;
}
search-bar{
position: relative;
top: -8px;
}
showcase{
position: relative;
top: -39px;
z-index: 10
}
.sort-filters-wrapper{
position: relative;
}
url-form{
position: absolute;
top: -55px;
}
/** Media queries **/
@media only screen
and (min-device-width: 320px)
and (max-device-width: 480px) {
url-form{
position: fixed;
bottom: 20px;
left: 10px;
}
filters{
position: fixed;
top: 44px;
right: -100%;
width: 100%;
height: 100%;
}
.sort-filters-wrapper{
position: fixed;
top: 44px;
left: 0;
width: 100%;
box-shadow: 0px 3px 16px rgba(0,0,0,0.3);
padding-top: 10px;
background: $primary-color;
z-index: 200;
}
.main-container{
padding-top: 34px;
}
search-bar{
position: fixed;
top: 0;
width: 100%;
left: 0;
z-index: 9000;
}
}
================================================
FILE: src/app/app.component.ts
================================================
import { Component, OnInit } from '@angular/core';
import { Product } from './shared/product.model';
import { DataService } from './data.service';
import { CartService } from './cart.service';
import { ViewChild } from '@angular/core';
import { FiltersComponent } from './filters/filters.component';
import { SearchBarComponent } from './search-bar/search-bar.component';
import { ShowcaseComponent } from './showcase/showcase.component';
import { CartComponent } from './cart/cart.component';
import { SortFiltersComponent } from './sort-filters/sort-filters.component';
import { UrlFormComponent } from './url-form/url-form.component';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
imports: [
SearchBarComponent,
FiltersComponent,
ShowcaseComponent,
CartComponent,
SortFiltersComponent,
UrlFormComponent,
],
providers: [DataService, CartService]
})
export class AppComponent implements OnInit {
products: Product[] = [];
mainFilter: any;
currentSorting: string = '';
@ViewChild('filtersComponent')
filtersComponent!: FiltersComponent;
@ViewChild('searchComponent')
searchComponent!: SearchBarComponent;
sortFilters: any[] = [
{ name: 'Nombre (A to Z)', value: 'name' },
{ name: 'Precio (low to high)', value: 'priceAsc' },
{ name: 'Precio (high to low)', value: 'priceDes' }
];
customFilters: any[] = [
{ name: 'Todo', value: 'all', checked: true },
{ name: 'Disponible', value: 'available', checked: false },
{ name: 'No disponible', value: 'unavailable', checked: false },
{ name: 'Mejor vendido', value: 'bestseller', checked: false }
];
priceFilters: any[] = [
{ name: 'Todo', value: 'all', checked: true },
{ name: 'Precio > 30.000', value: 'more_30000', checked: false },
{ name: 'Precio < 10.000', value: 'less_10000', checked: false }
];
originalData: any = [];
constructor(private dataService: DataService, private cartService: CartService) { }
ngOnInit() {
this.dataService.getData().then(data => {
this.originalData = data;
this.mainFilter = {
search: '',
categories: this.originalData.categories.slice(0),
customFilter: this.customFilters[0],
priceFilter: this.priceFilters[0]
};
// Make a deep copy of the original data to keep it immutable
this.products = this.originalData.products.slice(0);
this.sortProducts('name');
});
}
onURLChange(url: string) {
this.dataService.getRemoteData(url).subscribe((data: any) => {
this.originalData = data;
this.mainFilter = {
search: '',
categories: this.originalData.categories.slice(0),
customFilter: this.customFilters[0],
priceFilter: this.priceFilters[0]
};
// Make a deep copy of the original data to keep it immutable
this.products = this.originalData.products.slice(0);
this.sortProducts('name');
this.filtersComponent.reset(this.customFilters, this.priceFilters);
this.searchComponent.reset();
this.cartService.flushCart();
});
}
onSearchChange(search: any) {
this.mainFilter.search = search.search;
this.updateProducts({
type: 'search',
change: search.change
});
}
onFilterChange(data: any) {
if (data.type === 'category') {
if (data.isChecked) {
this.mainFilter.categories.push(data.filter);
} else {
this.mainFilter.categories = this.mainFilter.categories.filter(
(category: any) => {
return category.categori_id !== data.filter.categori_id;
});
}
} else if (data.type === 'custom') {
this.mainFilter.customFilter = data.filter;
} else if (data.type === 'price') {
this.mainFilter.priceFilter = data.filter;
}
this.updateProducts({
type: data.type,
change: data.change
});
}
updateProducts(filter: any) {
let productsSource = this.originalData.products;
const prevProducts = this.products;
let filterAllData = true;
if ((filter.type === 'search' && filter.change === 1) || (filter.type === 'category' && filter.change === -1)) {
productsSource = this.products;
filterAllData = false;
}
// console.log('filtering ' + productsSource.length + ' products')
this.products = productsSource.filter((product: any) => {
// Filter by search
if (filterAllData || filter.type === 'search') {
if (!product.name.match(new RegExp(this.mainFilter.search, 'i'))) {
return false;
}
}
// Filter by categories
if (filterAllData || filter.type === 'category') {
let passCategoryFilter = false;
product.categories.forEach((product_category: any) => {
if (!passCategoryFilter) {
passCategoryFilter = this.mainFilter.categories.reduce((found: any, category: any) => {
return found || product_category === category.categori_id;
}, false);
}
});
if (!passCategoryFilter) {
return false;
}
}
// Filter by custom filters
if (filterAllData || filter.type === 'custom') {
let passCustomFilter = false;
const customFilter = this.mainFilter.customFilter.value;
if (customFilter === 'all') {
passCustomFilter = true;
} else if (customFilter === 'available' && product.available) {
passCustomFilter = true;
} else if (customFilter === 'unavailable' && !product.available) {
passCustomFilter = true;
} else if (customFilter === 'bestseller' && product.best_seller) {
passCustomFilter = true;
}
if (!passCustomFilter) {
return false;
}
}
// Filter by price filters
if (filterAllData || filter.type === 'price') {
let passPriceFilter = false;
const customFilter = this.mainFilter.priceFilter.value;
const productPrice = parseFloat(product.price.replace(/\./g, '').replace(',', '.'));
if (customFilter === 'all') {
passPriceFilter = true;
} else if (customFilter === 'more_30000' && productPrice > 30000) {
passPriceFilter = true;
} else if (customFilter === 'less_10000' && productPrice < 10000) {
passPriceFilter = true;
}
if (!passPriceFilter) {
return false;
}
}
return true;
});
// If the number of products increased after the filter has been applied then sort again
// If the number of products remained equal, there's a high chance that the items have been reordered.
if (prevProducts.length <= this.products.length && this.products.length > 1) {
this.sortProducts(this.currentSorting);
}
// These two types of filters usually add new data to the products showcase so a sort is necessary
if (filter.type === 'custom' || filter.type === 'price') {
this.sortProducts(this.currentSorting);
}
}
sortProducts(criteria: any) {
// console.log('sorting ' + this.products.length + ' products')
this.products.sort((a, b) => {
const priceComparison = parseFloat(a.price.replace(/\./g, '')
.replace(',', '.')) - parseFloat(b.price.replace(/\./g, '').replace(',', '.'));
if (criteria === 'priceDes') {
return -priceComparison;
} else if (criteria === 'priceAsc') {
return priceComparison;
} else if (criteria === 'name') {
const nameA = a.name.toLowerCase(), nameB = b.name.toLowerCase();
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
} else {
// Keep the same order in case of any unexpected sort criteria
return -1;
}
});
this.currentSorting = criteria;
}
}
================================================
FILE: src/app/app.config.ts
================================================
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes)
]
};
================================================
FILE: src/app/app.html
================================================
Hello, {{ title() }}
Congratulations! Your app is running. 🎉
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
{{ item.title }}
}
================================================
FILE: src/app/app.module.ts
================================================
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { SearchBarComponent } from './search-bar/search-bar.component';
import { FiltersComponent } from './filters/filters.component';
import { ShowcaseComponent } from './showcase/showcase.component';
import { CartComponent } from './cart/cart.component';
import { ProductThumbnailComponent } from './product-thumbnail/product-thumbnail.component';
import { SortFiltersComponent } from './sort-filters/sort-filters.component';
import { DataService } from './data.service';
import { CartService } from './cart.service';
import { UrlFormComponent } from './url-form/url-form.component';
@NgModule({
providers: [
CartService
],
})
export class AppModule { }
================================================
FILE: src/app/app.routes.ts
================================================
import { Routes } from '@angular/router';
export const routes: Routes = [];
================================================
FILE: src/app/app.spec.ts
================================================
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, angular-shop2');
});
});
================================================
FILE: src/app/app.ts
================================================
import { Component, OnInit } from '@angular/core';
import { Product } from './shared/product.model';
import { DataService } from './data.service';
import { CartService } from './cart.service';
import { ViewChild } from '@angular/core';
import { FiltersComponent } from './filters/filters.component';
import { SearchBarComponent } from './search-bar/search-bar.component';
import { ShowcaseComponent } from './showcase/showcase.component';
import { CartComponent } from './cart/cart.component';
import { SortFiltersComponent } from './sort-filters/sort-filters.component';
import { UrlFormComponent } from './url-form/url-form.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
imports: [
SearchBarComponent,
FiltersComponent,
ShowcaseComponent,
CartComponent,
SortFiltersComponent,
UrlFormComponent,
],
providers: [DataService, CartService]
})
export class App implements OnInit {
products: Product[] = [];
mainFilter: any;
currentSorting: string = '';
@ViewChild('filtersComponent')
filtersComponent!: FiltersComponent;
@ViewChild('searchComponent')
searchComponent!: SearchBarComponent;
sortFilters: any[] = [
{ name: 'Nombre (A to Z)', value: 'name' },
{ name: 'Precio (low to high)', value: 'priceAsc' },
{ name: 'Precio (high to low)', value: 'priceDes' }
];
customFilters: any[] = [
{ name: 'Todo', value: 'all', checked: true },
{ name: 'Disponible', value: 'available', checked: false },
{ name: 'No disponible', value: 'unavailable', checked: false },
{ name: 'Mejor vendido', value: 'bestseller', checked: false }
];
priceFilters: any[] = [
{ name: 'Todo', value: 'all', checked: true },
{ name: 'Precio > 30.000', value: 'more_30000', checked: false },
{ name: 'Precio < 10.000', value: 'less_10000', checked: false }
];
originalData: any = [];
constructor(private dataService: DataService, private cartService: CartService) { }
ngOnInit() {
this.dataService.getData().then(data => {
this.originalData = data;
this.mainFilter = {
search: '',
categories: this.originalData.categories.slice(0),
customFilter: this.customFilters[0],
priceFilter: this.priceFilters[0]
};
// Make a deep copy of the original data to keep it immutable
this.products = this.originalData.products.slice(0);
this.sortProducts('name');
});
}
onURLChange(url: string) {
this.dataService.getRemoteData(url).subscribe((data: any) => {
this.originalData = data;
this.mainFilter = {
search: '',
categories: this.originalData.categories.slice(0),
customFilter: this.customFilters[0],
priceFilter: this.priceFilters[0]
};
// Make a deep copy of the original data to keep it immutable
this.products = this.originalData.products.slice(0);
this.sortProducts('name');
this.filtersComponent.reset(this.customFilters, this.priceFilters);
this.searchComponent.reset();
this.cartService.flushCart();
});
}
onSearchChange(search: any) {
this.mainFilter.search = search.search;
this.updateProducts({
type: 'search',
change: search.change
});
}
onFilterChange(data: any) {
if (data.type === 'category') {
if (data.isChecked) {
this.mainFilter.categories.push(data.filter);
} else {
this.mainFilter.categories = this.mainFilter.categories.filter(
(category: any) => {
return category.categori_id !== data.filter.categori_id;
});
}
} else if (data.type === 'custom') {
this.mainFilter.customFilter = data.filter;
} else if (data.type === 'price') {
this.mainFilter.priceFilter = data.filter;
}
this.updateProducts({
type: data.type,
change: data.change
});
}
updateProducts(filter: any) {
let productsSource = this.originalData.products;
const prevProducts = this.products;
let filterAllData = true;
if ((filter.type === 'search' && filter.change === 1) || (filter.type === 'category' && filter.change === -1)) {
productsSource = this.products;
filterAllData = false;
}
// console.log('filtering ' + productsSource.length + ' products')
this.products = productsSource.filter((product: any) => {
// Filter by search
if (filterAllData || filter.type === 'search') {
if (!product.name.match(new RegExp(this.mainFilter.search, 'i'))) {
return false;
}
}
// Filter by categories
if (filterAllData || filter.type === 'category') {
let passCategoryFilter = false;
product.categories.forEach((product_category: any) => {
if (!passCategoryFilter) {
passCategoryFilter = this.mainFilter.categories.reduce((found: any, category: any) => {
return found || product_category === category.categori_id;
}, false);
}
});
if (!passCategoryFilter) {
return false;
}
}
// Filter by custom filters
if (filterAllData || filter.type === 'custom') {
let passCustomFilter = false;
const customFilter = this.mainFilter.customFilter.value;
if (customFilter === 'all') {
passCustomFilter = true;
} else if (customFilter === 'available' && product.available) {
passCustomFilter = true;
} else if (customFilter === 'unavailable' && !product.available) {
passCustomFilter = true;
} else if (customFilter === 'bestseller' && product.best_seller) {
passCustomFilter = true;
}
if (!passCustomFilter) {
return false;
}
}
// Filter by price filters
if (filterAllData || filter.type === 'price') {
let passPriceFilter = false;
const customFilter = this.mainFilter.priceFilter.value;
const productPrice = parseFloat(product.price.replace(/\./g, '').replace(',', '.'));
if (customFilter === 'all') {
passPriceFilter = true;
} else if (customFilter === 'more_30000' && productPrice > 30000) {
passPriceFilter = true;
} else if (customFilter === 'less_10000' && productPrice < 10000) {
passPriceFilter = true;
}
if (!passPriceFilter) {
return false;
}
}
return true;
});
// If the number of products increased after the filter has been applied then sort again
// If the number of products remained equal, there's a high chance that the items have been reordered.
if (prevProducts.length <= this.products.length && this.products.length > 1) {
this.sortProducts(this.currentSorting);
}
// These two types of filters usually add new data to the products showcase so a sort is necessary
if (filter.type === 'custom' || filter.type === 'price') {
this.sortProducts(this.currentSorting);
}
}
sortProducts(criteria: any) {
// console.log('sorting ' + this.products.length + ' products')
this.products.sort((a, b) => {
const priceComparison = parseFloat(a.price.replace(/\./g, '')
.replace(',', '.')) - parseFloat(b.price.replace(/\./g, '').replace(',', '.'));
if (criteria === 'priceDes') {
return -priceComparison;
} else if (criteria === 'priceAsc') {
return priceComparison;
} else if (criteria === 'name') {
const nameA = a.name.toLowerCase(), nameB = b.name.toLowerCase();
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
} else {
// Keep the same order in case of any unexpected sort criteria
return -1;
}
});
this.currentSorting = criteria;
}
}
================================================
FILE: src/app/cart/cart.component.html
================================================
Esta es tu lista de compras:
@for (item of products; track item.id) {
{{item.quantity}} x {{item.product.name}}
{{item.quantity * item.product.parsedPrice}}
}
@if (expanded) {
}
================================================
FILE: src/app/cart/cart.component.scss
================================================
@use "../shared/colors" as *;
@use "../shared/mixins" as *;
.preview {
position: relative;
.fill {
position: absolute;
top: 0;
left: 0;
}
}
.close-btn {
position: absolute;
top: 6px;
right: 5px;
background: none;
border: none;
color: white;
font-size: 0.7em;
font-weight: 600;
text-decoration: underline;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
.pay-btn {
display: block;
margin-left: auto;
margin-right: auto;
border-radius: 44px;
height: 44px;
padding: 0 25px;
border: none;
background-color: #EF364C;
box-shadow: 0 2px 19px rgba(0, 0, 0, 0.32);
color: white;
font-weight: 600;
margin-top: 20px;
}
.expanded-info {
opacity: 0;
z-index: -1;
position: absolute;
top: 0;
left: 0;
right: 0;
color: white;
width: 80%;
margin: 0 auto;
.product, h3, .pay-btn{
opacity: 0;
transform: translateY(10px);
-webkit-transform: translateY(10px);
@include transition-fade(0.5s);
}
.price {
text-align: right;
}
&.shown {
opacity: 1;
z-index: 3;
.product, h3, .pay-btn {
transform: translateY(0);
-webkit-transform: translateY(0);
}
h3 {
-webkit-transition-delay: 0.2s;
transition-delay: 0.2s;
opacity: 0.43;
}
.product {
-webkit-transition-delay: 0.4s;
transition-delay: 0.4s;
opacity: 1;
}
.pay-btn {
-webkit-transition-delay: 0.6s;
transition-delay: 0.6s;
opacity: 1;
}
}
h3 {
font-weight: 400;
font-size: 16px;
margin-top: 30px;
}
.product {
position: relative;
width: 95%;
p {
margin: 0;
}
&:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.delete-btn {
position: absolute;
right: -28px;
top: 14px;
background: none;
border: none;
opacity: 0.6;
color: white;
font-weight: 600;
font-size: 0.8em;
&:hover {
opacity: 0.8;
}
}
}
}
.preview.expanded {
.fill {
width: 460px;
border-radius: 3px;
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.49);
&.animate-plop {
-webkit-transform: scale(1.02);
transform: scale(1.02);
}
}
}
.preview:not(.expanded) .fill:hover {
box-shadow: 0 2px 13px rgba(49, 46, 82, 0.65);
}
.preview {
.fill {
height: 40px;
width: 40px;
box-shadow: 0 2px 13px rgba(93, 78, 240, 0.55);
background: $primary-color;
border-radius: 50px;
z-index: 4;
@include transition-fade-circ(0.35s);
&.not-shown {
-webkit-transform: scale(0);
transform: scale(0);
}
&.shown {
-webkit-transform: scale(1.3);
transform: scale(1.3);
}
&.animate-plop {
-webkit-transform: scale(1.35);
transform: scale(1.35);
}
}
.circle {
height: 40px;
width: 40px;
border: none;
padding: 0;
border-radius: 50px;
position: relative;
z-index: 5;
background: none;
@include transition-fade-circ(0.2s);
img {
position: relative;
top: 3px;
left: -1px;
}
&.not-shown {
-webkit-transform: scale(0);
transform: scale(0);
}
}
.indicator {
background-color: #EF364C;
height: 14px;
width: 14px;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
color: white;
position: absolute;
top: -3px;
font-size: 0.6em;
right: 0;
font-weight: 700;
text-align: center;
span {
position: relative;
top: 1px;
}
}
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 0;
opacity: 0;
@include transition-fade-circ(0.4s);
background-color: rgba(0, 0, 0, 0.7);
}
/** Media queries **/
@media only screen
and (min-device-width: 320px)
and (max-device-width: 480px) {
.overlay.shown {
height: 100%;
opacity: 1;
z-index: 9;
}
.pay-btn {
margin-top: 35px;
}
.close-btn {
z-index: 90;
}
.description, .price {
p {
font-size: 1em;
}
}
.expanded-info {
width: 90%;
h3 {
font-size: 0.9em;
}
.product .delete-btn {
right: -21px;
top: 10px;
}
}
.preview {
position: fixed;
bottom: 3%;
right: 5%;
width: 13%;
@include transition-fade-circ(0.3s);
.fill {
box-shadow: 0 2px 13px rgba(93, 78, 240, 0.75);
}
&.expanded {
width: 90%;
margin: 0 auto;
bottom: 75%;
z-index: 800;
.fill {
width: 100%;
}
}
}
}
================================================
FILE: src/app/cart/cart.component.ts
================================================
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';
import { CartService } from '../cart.service';
import { CommonModule } from '@angular/common';
const OFFSET_HEIGHT = 170;
const PRODUCT_HEIGHT = 48;
@Component({
selector: 'app-cart',
templateUrl: './cart.component.html',
styleUrls: ['./cart.component.scss'],
imports: [CommonModule]
})
export class CartComponent implements OnInit {
products: any[] = [];
numProducts = 0;
animatePlop = false;
animatePopout = false;
expanded = false;
expandedHeight: string = '';
cartTotal = 0;
inherit: string = '';
changeDetectorRef: ChangeDetectorRef;
constructor(private cartService: CartService, changeDetectorRef: ChangeDetectorRef) {
this.changeDetectorRef = changeDetectorRef;
}
ngOnInit() {
this.expandedHeight = '0';
this.cartService.productAdded$.subscribe((data: any) => {
this.products = data.products;
this.cartTotal = data.cartTotal;
this.numProducts = data.products.reduce((acc: any, product: any) => {
acc += product.quantity;
return acc;
}, 0);
// Make a plop animation
if (this.numProducts > 1) {
this.animatePlop = true;
setTimeout(() => {
this.animatePlop = false;
}, 160);
} else if (this.numProducts === 1) {
this.animatePopout = true;
setTimeout(() => {
this.animatePopout = false;
}, 300);
}
this.expandedHeight = (this.products.length * PRODUCT_HEIGHT + OFFSET_HEIGHT) + 'px';
if (!this.products.length) {
this.expanded = false;
}
this.changeDetectorRef.detectChanges();
});
}
deleteProduct(product: any) {
this.cartService.deleteProductFromCart(product);
}
onCartClick() {
this.expanded = !this.expanded;
}
}
================================================
FILE: src/app/cart.service.ts
================================================
import { Injectable } from '@angular/core';
import { Product } from './shared/product.model';
import { Subject } from 'rxjs';
@Injectable()
export class CartService {
products: any[] = [];
cartTotal = 0;
private productAddedSource = new Subject();
productAdded$ = this.productAddedSource.asObservable();
constructor() { }
addProductToCart(product: any) {
let exists = false;
const parsedPrice = parseFloat(product.price.replace(/\./g, '').replace(',', '.'));
this.cartTotal += parsedPrice;
// Search this product on the cart and increment the quantity
this.products = this.products.map(_product => {
if (_product.product.id === product.id) {
_product.quantity++;
exists = true;
}
return _product;
});
// Add a new product to the cart if it's a new product
if (!exists) {
product.parsedPrice = parsedPrice;
this.products.push({
product: product,
quantity: 1
});
}
this.productAddedSource.next({ products: this.products, cartTotal: this.cartTotal });
}
deleteProductFromCart(product: any) {
this.products = this.products.filter(_product => {
if (_product.product.id === product.id) {
this.cartTotal -= _product.product.parsedPrice * _product.quantity;
return false;
}
return true;
});
this.productAddedSource.next({ products: this.products, cartTotal: this.cartTotal });
}
flushCart() {
this.products = [];
this.cartTotal = 0;
this.productAddedSource.next({ products: this.products, cartTotal: this.cartTotal });
}
}
================================================
FILE: src/app/data.service.ts
================================================
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { DATA } from './mock-data';
@Injectable({ providedIn: 'root' })
export class DataService {
constructor(private http: HttpClient) {}
/** Local mock data — use Observable for consistency */
getData(): Promise {
return Promise.resolve(DATA);
}
/** Remote data — HttpClient already parses JSON */
getRemoteData(url: string): Observable {
return this.http.get(url).pipe(
catchError(this.handleError)
);
}
private handleError(error: HttpErrorResponse) {
const message =
error.error?.message ||
(typeof error.error === 'string' && error.error) ||
`${error.status} - ${error.statusText || 'Server error'}`;
console.error('HTTP error:', error);
return throwError(() => new Error(message));
}
}
================================================
FILE: src/app/filters/filters.component.html
================================================
Filtrar por categoría
@if (showFilters) {
}
Filtrar por precio
@if (showFilters) {
}
Filtros personalizados
@if (showFilters) {
}
================================================
FILE: src/app/filters/filters.component.scss
================================================
@use "../shared/colors" as *;
@use "../shared/mixins" as *;
.filters{
border: 1px solid #333333;
padding: 20px;
width: 100%;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
background-color: white;
border: none;
border-radius: 4px;
padding-top: 1px;
position: relative;
}
.filter{
width: 100%;
background: #999999;
border-radius: 3px;
margin-bottom: 10px;
height: 35px;
}
.fake-checkbox{
position: relative;
}
.filter-wrapper{
margin-bottom: 8px;
label{
cursor: pointer;
&:hover .label{
}
}
input[type=checkbox], input[type=radio]{
display: none;
&:checked + .square, &:checked + .circle{
opacity: 1;
}
&:checked + .square .fill, &:checked + .circle .fill{
opacity: 1;
}
&:checked ~ .label{
opacity: 1;
}
}
.square, .circle, .fill{
display: inline-block;
}
.square, .circle{
height: 16px;
width: 16px;
border: 1px solid $primary-color;
position: relative;
opacity: 0.4;
.fill{
height: 10px;
width: 10px;
background-color: $primary-color;
@include transition-fade-circ(0.25s);
position: absolute;
opacity: 0;
}
}
.square{
border-radius: 3px;
.fill{
border-radius: 2px;
}
}
.circle{
border-radius: 50%;
.fill{
border-radius: 50%;
}
}
.fill{
top: 3px;
left: 3px;
}
.label{
text-transform: capitalize;
position: relative;
top: -4px;
margin-left: 7px;
opacity: 0.6;
@include transition-fade(0.4s);
}
}
h5{
text-transform: uppercase;
color: #bababa;
font-size: 0.8em;
font-weight: 600;
&:after{
content: "";
display: block;
width: 100%;
height: 1px;
margin-top: 3px;
background-color: #e8e8e8;
}
}
.toggle-btn{
display: none;
}
.close-side-btn{
display: none;
background: none;
border: none;
}
/** Media queries **/
@media only screen
and (min-device-width: 320px)
and (max-device-width: 480px) {
.close-side-btn{
display: block;
position: absolute;
color: #aaaaaa;
font-size: 1.2em;
font-weight: 300;
right: 18px;
top: 6px;
opacity: 0.7;
}
.toggle-btn{
display: inline-block;
position: absolute;
left: -69px;
top: 11px;
background: white;
border-radius: 3px;
color: #5D4EF0;
padding: 4px 11px;
border: none;
font-size: 0.8em;
font-weight: 600;
box-shadow: 0 2px 15px rgba(0,0,0,0.4);
}
.filters{
width: initial;
position: relative;
box-shadow: none;
border-radius: 0;
height: 100%;
@include transition-fade-circ(0.35s);
&.side-shown{
-webkit-transform: translateX(-100%);
transform: translateX(-100%);
}
}
}
================================================
FILE: src/app/filters/filters.component.ts
================================================
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Category } from '../shared/category.model';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-filters',
templateUrl: './filters.component.html',
styleUrls: ['./filters.component.scss'],
imports: [CommonModule]
})
export class FiltersComponent implements OnInit {
@Input()
categories: Category[] = [];
@Input()
customFilters: any[] = [];
@Input()
priceFilters: any[] = [];
@Input()
filter: any = {};
@Output()
filterChange = new EventEmitter();
showFilters = true;
sideShown = false;
constructor() { }
ngOnInit() {
}
reset(customFilters: any, priceFilters: any) {
this.customFilters = customFilters;
this.priceFilters = priceFilters;
this.showFilters = false;
setTimeout(() => {
this.showFilters = true;
});
}
onInputChange($event: any, filter: any, type: any) {
const change = $event.target.checked ? 1 : -1;
this.filterChange.emit({
type: type,
filter: filter,
isChecked: $event.target.checked,
change: change
});
}
}
================================================
FILE: src/app/index.ts
================================================
export * from './app.component';
export * from './app.module';
================================================
FILE: src/app/mock-data.ts
================================================
export const DATA: any = {
'categories': [
{
'categori_id': 1,
'name': 'bebidas'
},
{
'categori_id': 2,
'name': 'almuerzo'
},
{
'categori_id': 3,
'name': 'comida'
},
{
'categori_id': 4,
'name': 'mar'
}
],
'products': [
{
'id': 1,
'name': 'Lorem',
'price': '60.000',
'available': true,
'best_seller': true,
'categories': [
1,
4
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 2,
'name': 'ipsum',
'price': '20.000',
'available': false,
'best_seller': false,
'categories': [
4
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 3,
'name': 'dolor',
'price': '10.000',
'available': true,
'best_seller': true,
'categories': [
4
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 4,
'name': 'sit',
'price': '35.000',
'available': false,
'best_seller': false,
'categories': [
1,
2
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 5,
'name': 'amet',
'price': '12.000',
'available': true,
'best_seller': true,
'categories': [
1,
4
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 6,
'name': 'consectetur',
'price': '120.000',
'available': true,
'best_seller': false,
'categories': [
1,
4
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 7,
'name': 'adipiscing',
'price': '50.000',
'available': false,
'best_seller': false,
'categories': [
1,
3
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 8,
'name': 'elit',
'price': '2000',
'available': true,
'best_seller': false,
'categories': [
1,
3
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 9,
'name': 'Maecenas',
'price': '150.000',
'available': true,
'best_seller': true,
'categories': [
2,
4
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
},
{
'id': 10,
'name': 'eu',
'price': '200.000',
'available': false,
'best_seller': true,
'categories': [
2,
3
],
'img': 'https://placehold.co/200x100',
'description': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas eu.'
}
]
};
================================================
FILE: src/app/product-thumbnail/product-thumbnail.component.html
================================================
{{product.name}}
${{product.price}}
@if (product.available) {
}
@if (!product.available) {
}
{{product.name}}
$ {{product.price}}
{{product.description}}
@if (product.best_seller) {
Bestseller
}
================================================
FILE: src/app/product-thumbnail/product-thumbnail.component.scss
================================================
@use "../shared/colors" as *;
@use "../shared/mixins" as *;
:host {
display: block;
height: 210px;
}
.wrapper{
border-radius: 5px;
box-shadow: 0 5px 5px;
position: relative;
text-align: center;
display: block;
background-color: white;
box-shadow: 0 6px 17px rgba(0,0,0,0.07);
}
.add-cart-wrapper, .view-details-wrapper{
margin-top: 0;
margin-bottom: 0;
}
.view-details-wrapper{
position: relative;
&:after{
content: "";
width: 1px;
display: block;
position: absolute;
height: 28px;
background-color: #5D4EF0;
right: -3px;
top: -4px;
opacity: 0.2;
}
}
.hide-detail-btn{
position: absolute;
bottom: 0;
left: 0;
right: 0;
text-align: center;
width: 100%;
z-index: 20;
background: none;
border: none;
color: white;
opacity: 0.6;
font-size: 0.7em;
font-weight: 600;
cursor: pointer;
background: rgba(255, 255, 255, 0.1);
@include transition-fade-circ(0.5s);
-webkit-transform: translateY(15px);
transform: translateY(15px);
-webkit-transition-delay: 1.2s;
transition-delay: 1.2s;
&:hover{
background: rgba(255, 255, 255, 0.2);
}
}
.img-wrapper{
height: 85px;
position: relative;
overflow: hidden;
}
::-webkit-scrollbar {
display: none;
}
.details{
padding-bottom: 7px;
hr{
width: 90%;
height: 0;
border-top: 1px solid $primary-color;
}
button{
background: none;
border: none;
cursor: pointer;
}
.view:hover, .cart:hover{
opacity: 0.8;
}
.view{
text-transform: uppercase;
color: $primary-color;
font-size: 0.85em;
font-weight: 500;
position: relative;
top: -1px;
left: -2px;
}
.cart{
position: relative;
top: 2px;
}
.not-available{
hr{
border-top: 1px solid #999999;
}
p{
margin: 0;
margin-top: -7px;
position: relative;
top: 2px;
text-transform: uppercase;
font-size: 0.85em;
font-weight: 500;
padding-top: 5px;
padding-bottom: 3px;
}
}
}
.img-placeholder, .img{
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 5px 5px 0 0;
}
.img-placeholder{
z-index: 3;
height: 100%;
background: #dddddd;
}
.detail-view{
position: absolute;
z-index: 30;
border-radius: 5px;
overflow: hidden;
top: 0;
height: 100%;
width: 100%;
pointer-events: none;
&.active{
pointer-events: all;
.hide-detail-btn{
-webkit-transform: translateY(0);
transform: translateY(0);
}
.info-wrapper{
opacity: 1;
}
.bg{
-webkit-transform: scale(232);
transform: scale(232);
}
.d-holder{
opacity: 1;
-webkit-transform: translate(0);
transform: translate(0);
@include transition-fade(0.5s);
}
.d-title{
-webkit-transition-delay: 0.8;
transition-delay: 0.4s;
}
.d-price{
-webkit-transition-delay: 0.8;
transition-delay: 0.5s;
}
.d-description{
-webkit-transition-delay: 0.8;
transition-delay: 0.6s;
}
}
.d-holder{
opacity: 0;
-webkit-transform: translateY(10px);
transform: translateY(10px);
@include transition-fade(0.1s);
}
.d-title{
font-size: 1.3em;
font-weight: 600;
margin-bottom: 0;
margin-top: 5px;
}
.d-price{
margin-top: -7px;
font-size: 0.9em;
}
.d-description{
font-size: 0.9em;
line-height: 1.4em;
}
.info-wrapper{
position: relative;
z-index: 30;
color: white;
text-align: left;
padding-left: 14px;
padding-right: 14px;
height: 90%;
overflow: scroll;
}
.bg{
position: absolute;
bottom: -9px;
left: 43px;
height: 3px;
width: 3px;
border-radius: 50%;
z-index: 20;
background: $primary-color;
@include transition-fade-circ(0.4s);
}
}
.info{
position: relative;
}
.unavailable{
opacity: 0.3;
}
.img{
z-index: 5;
height: auto;
background-color: #eeeeee;
}
.title{
font-size: 1em;
margin-top: 18px;
font-weight: 600;
margin-bottom: 3px;
}
.price{
margin-bottom: 10px;
color: #999999;
font-size: 18px;
font-weight: 300;
margin-top: 0;
}
.bestseller-badge{
position: absolute;
top: -10px;
border-radius: 10px;
background-color: #EF364C;
color: white;
font-size: 0.7em;
left: 0;
right: 0;
margin: 0 auto;
width: 70%;
z-index: 40;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.1em;
overflow: hidden;
height: 20px;
@include transition-fade-circ(0.35s);
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.48);
.txt, .star{
@include transition-fade(0.35s);
}
.txt{
position: relative;
top: 4px;
}
.star{
position: absolute;
top: 2px;
&.left{
left: 8px;
}
&.right{
right: 14px;
}
}
&.in-detailed{
box-shadow: 0 2px 7px rgba(0, 0, 0, 0);
width: 20px;
height: 20px;
padding: 0;
top: 9px;
right: -80%;
background-color: $primary-color;
.right, .txt{
opacity: 0;
}
.left{
left: 5px;
top: 2px;
}
}
}
.sad-face{
border-radius: 50%;
background-color: #aaaaaa;
height: 90px;
width: 90px;
}
.category-name{
display: inline-block;
margin-right: 10px;
}
.star{
&:before{
content: "\2605";
position: absolute;
color: white;
}
}
/** Media queries **/
@media only screen
and (min-device-width: 320px)
and (max-device-width: 480px) {
.detail-view.active .bg{
-webkit-transform: translateY(0);
transform: translateY(0);
}
.hide-detail-btn{
height: 32px;
-webkit-transform: translateY(32px);
transform: translateY(32px);
}
.wrapper{
margin-bottom: -22px;
}
.img-wrapper{
height: 123px;
}
.detail-view{
.bg{
width: 100%;
height: 100%;
border-radius: 0;
left: 0;
bottom: 0;
-webkit-transform: translateY(100%);
transform: translateY(100%);
}
.info-wrapper{
padding: 10px 25px;
height: 78%;
}
}
}
================================================
FILE: src/app/product-thumbnail/product-thumbnail.component.ts
================================================
import { Component, OnInit, Input } from '@angular/core';
import { Product } from '../shared/product.model';
import { CartService } from '../cart.service';
@Component({
selector: 'app-product-thumbnail',
templateUrl: './product-thumbnail.component.html',
styleUrls: ['./product-thumbnail.component.scss']
})
export class ProductThumbnailComponent implements OnInit {
@Input() product: Product = {
id: 0,
name: '',
price: '',
available: false,
best_seller: false,
categories: [0],
img: '',
description: '',
};
detailViewActive: boolean = false;
constructor(private cartService: CartService) {
}
ngOnInit() {
this.detailViewActive = false;
}
onProductClick() {
this.detailViewActive = !this.detailViewActive;
}
onAddToCart() {
this.cartService.addProductToCart(this.product);
}
}
================================================
FILE: src/app/search-bar/search-bar.component.html
================================================
================================================
FILE: src/app/search-bar/search-bar.component.scss
================================================
@use "../shared/colors" as *;
@use "../shared/mixins" as *;
@-webkit-keyframes plop-glass{
50%{
-webkit-transform: scale(1.35);
transform: scale(1.35);
}
100%{
-webkit-transform: scale(1.2);
transform: scale(1.2);
}
}
@keyframes plop-glass{
50%{
-webkit-transform: scale(1.35);
transform: scale(1.35);
}
100%{
-webkit-transform: scale(1.2);
transform: scale(1.2);
}
}
.wrapper{
position: relative;
display: block;
margin-left: auto;
}
.search-bar{
width: 100%;
display: block;
margin: 0 auto;
height: 35px;
border-radius: 20px;
border: none;
padding-left: 20px;
box-shadow: 0 3px 14px rgba(25, 25, 25, 0.05);
font-weight: 500;
position: relative;
left: -33px;
color: #999999;
@include transition-fade-circ(0.4s);
&:focus{
box-shadow: 0 4px 11px rgba(93, 78, 240, 0.09);
font-weight: 500;
color: $primary-color;
width: 120%;
-webkit-transform: translateX(-15%);
transform: translateX(-15%);
& + svg path{
fill: $primary-color;
}
& + svg{
-webkit-transform: scale(1.2);
transform: scale(1.2);
}
}
}
svg.animate-plop{
-webkit-animation: plop-glass 0.1s ease forwards;
animation: plop-glass 0.1s ease forwards;
}
::-webkit-input-placeholder {
color: #cccccc;
}
:-moz-placeholder { /* Firefox 18- */
color: #cccccc;
}
::-moz-placeholder { /* Firefox 19+ */
color: #cccccc
}
:-ms-input-placeholder {
color: #cccccc;
}
svg{
position: absolute;
right: 24px;
top: 3px;
height: 31px;
width: 15px;
@include transition-fade(0.3s);
path{
fill: #333333;
}
}
/** Media queries **/
@media only screen
and (min-device-width: 320px)
and (max-device-width: 480px) {
.search-bar{
left: 0;
border-radius: 0;
height: 43px;
&:focus{
width: 100%;
-webkit-transform: translateX(0);
transform: translateX(0);
}
}
svg{
top: 8px;
}
}
================================================
FILE: src/app/search-bar/search-bar.component.ts
================================================
import { Component, OnInit, EventEmitter, Output } from '@angular/core';
@Component({
selector: 'app-search-bar',
standalone: true,
templateUrl: './search-bar.component.html',
styleUrls: ['./search-bar.component.scss']
})
export class SearchBarComponent implements OnInit {
previousSearch: string = '';
animatePlop = false;
showSearch = true;
@Output()
searchChange = new EventEmitter();
constructor() { }
ngOnInit() {
this.previousSearch = '';
}
/*
This event will emit an object indicating the new search term, and:
-1 if the search term length has descreased
1 if the search term length has increased
0 if the search term remained equal
*/
onSearchKeyup(search: string) {
let change = 0;
if (search.length > this.previousSearch.length) {
change = 1;
} else if (search.length < this.previousSearch.length) {
change = -1;
}
this.previousSearch = search;
if (change !== 0) {
this.searchChange.emit({search, change});
}
}
// Perform a plop animation on the search icon. This animation is executed on keydown just for visual reasons
plop() {
this.animatePlop = true;
setTimeout(() => {
this.animatePlop = false;
}, 110);
}
reset() {
this.showSearch = false;
setTimeout(() => {
this.showSearch = true;
});
}
}
================================================
FILE: src/app/shared/_colors.scss
================================================
$primary-color: #5D4EF0;
$text-color: #444444;
$secondary-color: #EF364C;
================================================
FILE: src/app/shared/_grid.scss
================================================
// SIMPLE GRID - SASS/SCSS (deprecation-safe)
// 1) Load Sass math for division
@use "sass:math";
// 2) (Recommended) Load Google Fonts in HTML instead of Sass:
//
// colors
$dark-grey: #333447;
$dark-gray: #333447; // alias
// universal
html,
body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
left: 0;
top: 0;
}
// typography
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.375rem; }
h4 { font-size: 1.125rem; }
h5 { font-size: 1rem; }
h6 { font-size: 0.875rem; }
p { font-size: 1.125rem; line-height: 1.8; }
// utility
.left { text-align: left; }
.right { text-align: right; }
.center { text-align: center; margin-left: auto; margin-right: auto; }
.justify{ text-align: justify; }
.hidden-sm { display: none; }
// grid
$width: 96%;
$gutter: 4%;
$breakpoint-small: 33.75em; // 540px
$breakpoint-med: 45em; // 720px
$breakpoint-large: 60em; // 960px
.container {
width: 90%;
margin-left: auto;
margin-right: auto;
@media only screen and (min-width: $breakpoint-small) {
width: 80%;
}
@media only screen and (min-width: $breakpoint-large) {
width: 75%;
max-width: 60rem;
}
}
.row {
position: relative;
width: 100%;
}
.row [class^="col"] {
float: left;
margin: 0.5rem 2%;
min-height: 0.125rem;
}
.row::after {
content: "";
display: table;
clear: both;
}
.col-1,
.col-2,
.col-3,
.col-4,
.col-5,
.col-6,
.col-7,
.col-8,
.col-9,
.col-10,
.col-11,
.col-12 {
width: $width;
}
// Helper to compute column width without deprecated `/`
@function col-width($n) {
// (($width / (12 / $n)) - ($gutter * (12 - $n) / 12))
@return math.div($width, math.div(12, $n)) - math.div($gutter * (12 - $n), 12);
}
/* Small (default) cols */
.col-1-sm { width: col-width(1); }
.col-2-sm { width: col-width(2); }
.col-3-sm { width: col-width(3); }
.col-4-sm { width: col-width(4); }
.col-5-sm { width: col-width(5); }
.col-6-sm { width: col-width(6); }
.col-7-sm { width: col-width(7); }
.col-8-sm { width: col-width(8); }
.col-9-sm { width: col-width(9); }
.col-10-sm { width: col-width(10); }
.col-11-sm { width: col-width(11); }
.col-12-sm { width: $width; }
/* Medium+ cols */
@media only screen and (min-width: $breakpoint-med) {
.col-1 { width: col-width(1); }
.col-2 { width: col-width(2); }
.col-3 { width: col-width(3); }
.col-4 { width: col-width(4); }
.col-5 { width: col-width(5); }
.col-6 { width: col-width(6); }
.col-7 { width: col-width(7); }
.col-8 { width: col-width(8); }
.col-9 { width: col-width(9); }
.col-10 { width: col-width(10); }
.col-11 { width: col-width(11); }
.col-12 { width: $width; }
.hidden-sm { display: block; }
}
================================================
FILE: src/app/shared/_mixins.scss
================================================
@mixin transition-fade($time) {
-webkit-transition: all $time ease;
transition: all $time ease;
}
@mixin transition-fade-circ($time) {
-webkit-transition: all $time cubic-bezier(0.785, 0.135, 0.15, 0.86);
transition: all $time cubic-bezier(0.785, 0.135, 0.15, 0.86);
}
================================================
FILE: src/app/shared/category.model.ts
================================================
export class Category {
categori_id: number = 0;
name: string = '';
}
================================================
FILE: src/app/shared/index.ts
================================================
================================================
FILE: src/app/shared/product.model.ts
================================================
export class Product {
id: number = 0;
name: string = '';
price: string = '';
available: boolean = false;
best_seller: boolean = false;
categories: number[] = [0];
img: string = '';
description: string = '';
}
================================================
FILE: src/app/showcase/showcase.component.html
================================================
@for (product of products; track product.id) {
}
================================================
FILE: src/app/showcase/showcase.component.scss
================================================
.row .product-thumbnail{
margin-bottom: 2rem;
}
================================================
FILE: src/app/showcase/showcase.component.ts
================================================
import { Component, OnInit, Input } from '@angular/core';
import { Product } from '../shared/product.model';
import { CartService } from '../cart.service';
import { ProductThumbnailComponent } from '../product-thumbnail/product-thumbnail.component';
@Component({
selector: 'app-showcase',
templateUrl: './showcase.component.html',
styleUrls: ['./showcase.component.scss'],
imports: [
ProductThumbnailComponent,
]
})
export class ShowcaseComponent implements OnInit {
@Input() products: Product[] = [];
constructor(private cartService: CartService) {
}
ngOnInit() {
}
}
================================================
FILE: src/app/sort-filters/sort-filters.component.html
================================================
================================================
FILE: src/app/sort-filters/sort-filters.component.scss
================================================
@use "../shared/colors" as *;
@use "../shared/mixins" as *;
.wrapper{
display: block;
height: 35px;
background: none;
font-size: 14px;
padding-left: 11px;
position: relative;
top: 4px;
label{
opacity: 0.8;
}
select{
color: $primary-color;
background: none;
border: none;
font-size: 13px;
margin-left: 2px;
font-weight: 500;
cursor: pointer;
width: 130px;
}
.triangle{
height: 0;
width: 0;
border-top: 4px solid $primary-color;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
display: inline-block;
position: relative;
top: -2px;
right: 14px;
pointer-events: none;
opacity: 0.3;
}
}
/** Media queries **/
@media only screen
and (min-device-width: 320px)
and (max-device-width: 480px) {
.wrapper{
label{
color: white;
}
select{
color: white;
}
.triangle{
border-top: 4px solid white;
}
}
}
================================================
FILE: src/app/sort-filters/sort-filters.component.ts
================================================
import { CommonModule } from '@angular/common';
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-sort-filters',
templateUrl: './sort-filters.component.html',
styleUrls: ['./sort-filters.component.scss'],
imports: [
CommonModule
],
})
export class SortFiltersComponent implements OnInit {
@Input()
filter: any = {};
@Input()
filters: any[] = [];
@Output()
sortChange = new EventEmitter();
constructor() { }
ngOnInit() {
}
onSelectChange($event: any) {
this.sortChange.emit($event.target.value);
}
}
================================================
FILE: src/app/url-form/url-form.component.html
================================================
================================================
FILE: src/app/url-form/url-form.component.scss
================================================
@use "../shared/colors" as *;
@use "../shared/mixins" as *;
.wrapper{
margin-bottom: 20px;
position: relative;
}
.url-btn{
background: $secondary-color;
border: none;
padding: 0;
height: 36px;
width: 36px;
position: absolute;
top: 0;
left: 0;
z-index: 100;
svg{
height: 9px;
}
}
.fill{
width: 0px;
height: 36px;
width: 36px;
border-radius: 3px;
background-color: $secondary-color;
box-shadow: 0 2px 13px rgba(0, 0, 0, 0.3);
position: relative;
overflow: hidden;
@include transition-fade-circ(0.2s);
&.expanded{
width: 400px;
.url-input{
width: 80%;
color: white;
margin-left: 34px;
}
}
}
.url-input{
width: 0px;
position: absolute;
background: none;
border: none;
border-radius: 0;
margin-left: 0;
margin-left: 44px;
border-bottom: 1px solid rgba(255, 255, 255, 0.27);
background: rgba(255,255,255,0.1);
position: relative;
top: 7px;
padding-left: 4px;
@include transition-fade(0.5s);
&:focus{
border-bottom: 1px solid rgba(255, 255, 255, 1);
}
}
.send-btn{
width: 0;
position: absolute;
height: 36px;
width: 36px;
z-index: 50;
top:0;
border: none;
background: none;
svg{
height: 14px;
position: relative;
top: 3px;
}
}
::-webkit-input-placeholder {
color: rgba(255,255,255,0.4);
}
:-moz-placeholder { /* Firefox 18- */
color: rgba(255,255,255,0.4);
}
::-moz-placeholder { /* Firefox 19+ */
color: rgba(255,255,255,0.4)
}
:-ms-input-placeholder {
color: rgba(255,255,255,0.4);
}
/** Media queries **/
@media only screen
and (min-device-width: 320px)
and (max-device-width: 480px) {
.wrapper{
margin-bottom: 20px;
position: absolute;
top: 152px;
}
.fill{
&.expanded{
width: 250px;
.url-input{
width: 168px;
top: 2px;
}
.send-btn{
padding: 0;
}
}
}
}
================================================
FILE: src/app/url-form/url-form.component.ts
================================================
import { Component, OnInit, EventEmitter, Output } from '@angular/core';
@Component({
selector: 'app-url-form',
templateUrl: './url-form.component.html',
styleUrls: ['./url-form.component.scss']
})
export class UrlFormComponent implements OnInit {
@Output()
urlChange = new EventEmitter();
expanded = false;
constructor() { }
ngOnInit() {
}
onSend(url: string) {
this.expanded = false;
this.urlChange.emit(url);
}
}
================================================
FILE: src/index.html
================================================
AngularShop2
================================================
FILE: src/main.ts
================================================
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
================================================
FILE: src/styles.scss
================================================
/* 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 */
@use "./app/shared/grid" as *;
@use "./app/shared/colors" as *;
$font-family: 'Roboto', Helvetica, sans-serif;
html, body {
font-family: $font-family;
color: #444444;
-webkit-font-smoothing: antialiased;
-webkit-overflow-scrolling: touch;
height: 100%;
margin: 0;
font-size: 16px;
font-weight: 400;
background-color: #f2f2f2;
}
input, textarea, button {
font-family: inherit;
font-size: 1em;
}
input:not([type=checkbox]):not([type=radio]), textarea {
-webkit-appearance: none !important;
}
* {
outline: none;
}
select::-ms-expand {
display: none;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
text-indent: -1px;
text-overflow: '';
}
button {
cursor: pointer;
}
================================================
FILE: tsconfig.app.json
================================================
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
================================================
FILE: tsconfig.json
================================================
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
================================================
FILE: tsconfig.spec.json
================================================
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}