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) {
@for (filter of categories; track $index) {
}
}
Filtrar por precio
@if (showFilters) {
@for (filter of priceFilters; track $index) {
}
}
Filtros personalizados
@if (showFilters) {
@for (filter of customFilters; track $index) {
}
}
================================================ 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) {

No disponible

}

{{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 ================================================
@if (showSearch) { }
================================================ 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" ] }