feat: implement category dropdown and category items page with routing and styling
This commit is contained in:
@@ -23,7 +23,8 @@ const tenant: Tenant = {
|
|||||||
header_bg_color: '#313131',
|
header_bg_color: '#313131',
|
||||||
footer_bg_color: '#313131',
|
footer_bg_color: '#313131',
|
||||||
header_logo: 'https://example.com/header.png',
|
header_logo: 'https://example.com/header.png',
|
||||||
footer_logo: 'https://example.com/footer.png'
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: []
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTenantServiceStub(
|
function createTenantServiceStub(
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const tenant: Tenant = {
|
|||||||
header_bg_color: '#313131',
|
header_bg_color: '#313131',
|
||||||
footer_bg_color: '#313131',
|
footer_bg_color: '#313131',
|
||||||
header_logo: 'https://example.com/header.png',
|
header_logo: 'https://example.com/header.png',
|
||||||
footer_logo: 'https://example.com/footer.png'
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: []
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
|
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@if (open() && categories().length) {
|
||||||
|
<section class="dropdown-menu show p-2 category-dropdown" role="menu" aria-label="Categorías">
|
||||||
|
@for (category of categories(); track category.id) {
|
||||||
|
<div class="dropdown-submenu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
|
||||||
|
[class.active]="activeCategory()?.id === category.id"
|
||||||
|
role="menuitem"
|
||||||
|
[attr.aria-expanded]="category.subcategories.length ? activeCategory()?.id === category.id : null"
|
||||||
|
(click)="selectCategory(category)"
|
||||||
|
>
|
||||||
|
<span>{{ category.nombre }}</span>
|
||||||
|
@if (category.subcategories.length) {
|
||||||
|
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (activeCategory()?.id === category.id && subcategories().length) {
|
||||||
|
<div class="dropdown-menu show p-2 category-dropdown__submenu">
|
||||||
|
@for (subcategory of subcategories(); track subcategory.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropdown-item d-flex align-items-center justify-content-between gap-3"
|
||||||
|
role="menuitem"
|
||||||
|
(click)="categorySelect.emit(subcategory)"
|
||||||
|
>
|
||||||
|
<span>{{ subcategory.nombre }}</span>
|
||||||
|
@if (subcategory.subcategories.length) {
|
||||||
|
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.category-dropdown {
|
||||||
|
min-width: 240px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 0 25px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-dropdown .dropdown-item,
|
||||||
|
.category-dropdown .dropdown-item:active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2rem;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #8a8a8a;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-dropdown .dropdown-item:hover,
|
||||||
|
.category-dropdown .dropdown-item:focus,
|
||||||
|
.category-dropdown .dropdown-item.active {
|
||||||
|
color: var(--tenant-primary, var(--bs-primary));
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-submenu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-dropdown__submenu {
|
||||||
|
top: -0.5rem;
|
||||||
|
left: calc(100% - 0.1rem);
|
||||||
|
min-width: 240px;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 0 25px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.category-dropdown__submenu {
|
||||||
|
position: static;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { Category } from '../../../../services/tenant.interface';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-category-dropdown',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './category-dropdown.component.html',
|
||||||
|
styleUrl: './category-dropdown.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class CategoryDropdownComponent {
|
||||||
|
readonly categories = input<readonly Category[]>([]);
|
||||||
|
readonly open = input(false);
|
||||||
|
readonly categorySelect = output<Category>();
|
||||||
|
|
||||||
|
protected readonly activeCategory = signal<Category | null>(null);
|
||||||
|
protected readonly subcategories = computed(() => this.activeCategory()?.subcategories ?? []);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const categories = this.categories();
|
||||||
|
const activeCategory = this.activeCategory();
|
||||||
|
|
||||||
|
if (!this.open()) {
|
||||||
|
this.activeCategory.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeCategory && !categories.some((category) => category.id === activeCategory.id)) {
|
||||||
|
this.activeCategory.set(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectCategory(category: Category): void {
|
||||||
|
if (category.subcategories.length) {
|
||||||
|
this.activeCategory.update((activeCategory) =>
|
||||||
|
activeCategory?.id === category.id ? null : category,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.categorySelect.emit(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,16 +99,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="store-layout__categories">
|
||||||
<div class="container-xl px-3 px-md-4">
|
<div class="container-xl px-3 px-md-4">
|
||||||
<button
|
<div class="store-layout__category-menu">
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
type="button"
|
||||||
aria-label="Categorias"
|
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
||||||
>
|
aria-label="Categorias"
|
||||||
<span>Categorias</span>
|
aria-controls="store-category-dropdown"
|
||||||
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
[attr.aria-expanded]="isCategoryDropdownOpen()"
|
||||||
</button>
|
(click)="toggleCategoryDropdown()"
|
||||||
|
>
|
||||||
|
<span>Categorias</span>
|
||||||
|
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<app-category-dropdown
|
||||||
|
id="store-category-dropdown"
|
||||||
|
[categories]="categories()"
|
||||||
|
[open]="isCategoryDropdownOpen()"
|
||||||
|
(categorySelect)="onCategorySelect($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -111,6 +111,19 @@
|
|||||||
color: #8a8a8a;
|
color: #8a8a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-layout__category-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__category-menu app-category-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1050;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes store-header-logo-skeleton {
|
@keyframes store-header-logo-skeleton {
|
||||||
from {
|
from {
|
||||||
background-position: 200% 0;
|
background-position: 200% 0;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
|
|||||||
import { Menu } from '../../../services/tenant.interface';
|
import { Menu } from '../../../services/tenant.interface';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||||
|
import { Category } from '../../../services/tenant.interface';
|
||||||
|
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-header',
|
selector: 'app-store-header',
|
||||||
@@ -17,6 +19,7 @@ import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
|||||||
RouterLink,
|
RouterLink,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
UserDropdownComponent,
|
UserDropdownComponent,
|
||||||
|
CategoryDropdownComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './store-header.component.html',
|
templateUrl: './store-header.component.html',
|
||||||
styleUrl: './store-header.component.scss',
|
styleUrl: './store-header.component.scss',
|
||||||
@@ -30,32 +33,37 @@ export class StoreHeaderComponent {
|
|||||||
readonly isAuthenticated = input<boolean>(false);
|
readonly isAuthenticated = input<boolean>(false);
|
||||||
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||||
readonly ticketsMenu = input<Menu | null>(null);
|
readonly ticketsMenu = input<Menu | null>(null);
|
||||||
|
readonly categories = input<readonly Category[]>([]);
|
||||||
readonly cartClick = output<void>();
|
readonly cartClick = output<void>();
|
||||||
readonly ticketsClick = output<void>();
|
readonly ticketsClick = output<void>();
|
||||||
readonly loginClick = output<void>();
|
readonly loginClick = output<void>();
|
||||||
readonly logoutClick = output<void>();
|
readonly logoutClick = output<void>();
|
||||||
readonly searchSubmit = output<string>();
|
readonly searchSubmit = output<string>();
|
||||||
|
readonly categorySelect = output<Category>();
|
||||||
|
|
||||||
protected readonly isUserDropdownOpen = signal(false);
|
protected readonly isUserDropdownOpen = signal(false);
|
||||||
|
protected readonly isCategoryDropdownOpen = signal(false);
|
||||||
protected readonly minSearchLength = 3;
|
protected readonly minSearchLength = 3;
|
||||||
protected readonly showSearchError = signal(false);
|
protected readonly showSearchError = signal(false);
|
||||||
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
protected onDocumentClick(event: MouseEvent): void {
|
protected onDocumentClick(event: MouseEvent): void {
|
||||||
if (!this.isUserDropdownOpen()) {
|
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
||||||
this.isUserDropdownOpen.set(false);
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
protected onEscapeKey(): void {
|
protected onEscapeKey(): void {
|
||||||
this.isUserDropdownOpen.set(false);
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected toggleUserDropdown(): void {
|
protected toggleUserDropdown(): void {
|
||||||
@@ -94,4 +102,13 @@ export class StoreHeaderComponent {
|
|||||||
protected clearSearchError(): void {
|
protected clearSearchError(): void {
|
||||||
this.showSearchError.set(false);
|
this.showSearchError.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected toggleCategoryDropdown(): void {
|
||||||
|
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCategorySelect(category: Category): void {
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.categorySelect.emit(category);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
[isAuthenticated]="isAuthenticated()"
|
[isAuthenticated]="isAuthenticated()"
|
||||||
[accountNavigationMenus]="accountNavigationMenus()"
|
[accountNavigationMenus]="accountNavigationMenus()"
|
||||||
[ticketsMenu]="ticketsMenu() ?? null"
|
[ticketsMenu]="ticketsMenu() ?? null"
|
||||||
|
[categories]="tenant()?.categories ?? []"
|
||||||
(cartClick)="isCartOpen.set(!isCartOpen())"
|
(cartClick)="isCartOpen.set(!isCartOpen())"
|
||||||
(ticketsClick)="onTicketsClick()"
|
(ticketsClick)="onTicketsClick()"
|
||||||
(loginClick)="onLoginClick()"
|
(loginClick)="onLoginClick()"
|
||||||
(logoutClick)="onLogoutClick()"
|
(logoutClick)="onLogoutClick()"
|
||||||
(searchSubmit)="onSearch($event)"
|
(searchSubmit)="onSearch($event)"
|
||||||
|
(categorySelect)="onCategorySelect($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if (isCartOpen()) {
|
@if (isCartOpen()) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const tenant: Tenant = {
|
|||||||
footer_bg_color: '#313131',
|
footer_bg_color: '#313131',
|
||||||
header_logo: 'https://example.com/header.png',
|
header_logo: 'https://example.com/header.png',
|
||||||
footer_logo: 'https://example.com/footer.png',
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [],
|
||||||
menues: [
|
menues: [
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AuthService } from '../../services/auth/auth.service';
|
|||||||
import { findMenu } from '../../services/menu.utils';
|
import { findMenu } from '../../services/menu.utils';
|
||||||
import { CheckoutService } from '../../services/checkout.service';
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
import { ToastService } from '../../services/toast.service';
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
import { Category } from '../../services/tenant.interface';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-layout',
|
selector: 'app-store-layout',
|
||||||
@@ -125,6 +126,12 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onCategorySelect(category: Category): void {
|
||||||
|
void this.router.navigate(['/categoria', category.id], {
|
||||||
|
queryParams: { page: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected onTicketsClick(): void {
|
protected onTicketsClick(): void {
|
||||||
const route = this.ticketsMenu()?.route;
|
const route = this.ticketsMenu()?.route;
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ export interface CatalogFeaturedItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CatalogFeaturedItems =
|
export type CatalogFeaturedItems =
|
||||||
ApiPaginatedResponse<CatalogFeaturedItem[]> | CatalogFeaturedItem[];
|
| ApiPaginatedResponse<CatalogFeaturedItem[]>
|
||||||
|
| CatalogFeaturedItem[];
|
||||||
|
|
||||||
export interface CatalogFeaturedGroup {
|
export interface CatalogFeaturedGroup {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -98,3 +99,15 @@ export interface CatalogFeaturedGroup {
|
|||||||
group_order: number;
|
group_order: number;
|
||||||
items: CatalogFeaturedItems;
|
items: CatalogFeaturedItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogCategory {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
categoria_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryItemsResponse extends ApiPaginatedResponse<CatalogFeaturedItem[]> {
|
||||||
|
category: CatalogCategory;
|
||||||
|
layout: CatalogProductLayout;
|
||||||
|
group_layout: CatalogGroupLayout;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CatalogFeaturedItem,
|
CatalogFeaturedItem,
|
||||||
CatalogFeaturedItems,
|
CatalogFeaturedItems,
|
||||||
CatalogItemDetail,
|
CatalogItemDetail,
|
||||||
|
CategoryItemsResponse,
|
||||||
Product,
|
Product,
|
||||||
} from './catalog.interface';
|
} from './catalog.interface';
|
||||||
|
|
||||||
@@ -50,6 +51,15 @@ export class CatalogService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCategoryItems(
|
||||||
|
categoryId: number,
|
||||||
|
params?: ApiPaginationQueryParams,
|
||||||
|
): Observable<CategoryItemsResponse> {
|
||||||
|
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getFeaturedGroupItems(
|
getFeaturedGroupItems(
|
||||||
featuredGroupId: number,
|
featuredGroupId: number,
|
||||||
params?: ApiPaginationQueryParams,
|
params?: ApiPaginationQueryParams,
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export interface SocialMedia {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
subcategories: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Tenant {
|
export interface Tenant {
|
||||||
id: number;
|
id: number;
|
||||||
codigo: string;
|
codigo: string;
|
||||||
@@ -64,6 +70,7 @@ export interface Tenant {
|
|||||||
main_carousel_images?: string[];
|
main_carousel_images?: string[];
|
||||||
social_media?: SocialMedia[];
|
social_media?: SocialMedia[];
|
||||||
menues?: Menu[];
|
menues?: Menu[];
|
||||||
|
categories: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TenantBootstrapResponse = ApiResponse<Tenant>;
|
export type TenantBootstrapResponse = ApiResponse<Tenant>;
|
||||||
|
|||||||
@@ -20,7 +20,20 @@ const tenant: Tenant = {
|
|||||||
header_bg_color: '#313131',
|
header_bg_color: '#313131',
|
||||||
footer_bg_color: '#313131',
|
footer_bg_color: '#313131',
|
||||||
header_logo: 'https://example.com/header.png',
|
header_logo: 'https://example.com/header.png',
|
||||||
footer_logo: 'https://example.com/footer.png'
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Remeras',
|
||||||
|
subcategories: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nombre: 'Manga corta',
|
||||||
|
subcategories: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const tenantResponse: TenantBootstrapResponse = {
|
const tenantResponse: TenantBootstrapResponse = {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const tenant: Tenant = {
|
|||||||
footer_bg_color: '#313131',
|
footer_bg_color: '#313131',
|
||||||
header_logo: 'https://example.com/header.png',
|
header_logo: 'https://example.com/header.png',
|
||||||
footer_logo: 'https://example.com/footer.png',
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTenantServiceStub(currentTenant: Tenant | null) {
|
function createTenantServiceStub(currentTenant: Tenant | null) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('AccountSidebar', () => {
|
|||||||
footer_bg_color: '#ffffff',
|
footer_bg_color: '#ffffff',
|
||||||
header_logo: '',
|
header_logo: '',
|
||||||
footer_logo: '',
|
footer_logo: '',
|
||||||
|
categories: [],
|
||||||
menues: [
|
menues: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<section class="category-items" aria-labelledby="category-items-title">
|
||||||
|
@if (error()) {
|
||||||
|
<p class="alert text-center mb-0 category-items__alert-error">{{ error() }}</p>
|
||||||
|
} @else if (loading()) {
|
||||||
|
<p class="alert alert-light border text-center mb-0">Cargando productos...</p>
|
||||||
|
} @else if (results(); as categoryResults) {
|
||||||
|
<header class="category-items__header mb-5">
|
||||||
|
<h1 id="category-items-title" class="category-items__title mb-0">
|
||||||
|
{{ categoryResults.category.nombre }}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!categoryResults.data.length) {
|
||||||
|
<p class="alert alert-light border text-center mb-0">
|
||||||
|
No hay productos disponibles en esta categoría.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<app-product-list
|
||||||
|
[layout]="categoryResults.layout"
|
||||||
|
[groupLayout]="paginatedLayout"
|
||||||
|
[items]="categoryResults"
|
||||||
|
[loading]="loading()"
|
||||||
|
(buy)="onBuyProduct($event)"
|
||||||
|
(addToCart)="onAddToCart($event)"
|
||||||
|
(pageChange)="onPageChange($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.category-items__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 28px;
|
||||||
|
background-color: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-items__title {
|
||||||
|
color: #313131;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-items__alert-error {
|
||||||
|
color: var(--tenant-danger-color, var(--bs-danger));
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
|
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component';
|
||||||
|
import { CategoryItemsPageComponent } from './category-items-page.component';
|
||||||
|
|
||||||
|
describe('CategoryItemsPageComponent', () => {
|
||||||
|
const getCategoryItems = vi.fn();
|
||||||
|
const tenant = signal({ codigo: 'shop' } as Tenant);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
getCategoryItems.mockReset();
|
||||||
|
getCategoryItems.mockReturnValue(
|
||||||
|
of({
|
||||||
|
category: {
|
||||||
|
id: 7,
|
||||||
|
nombre: 'Remeras',
|
||||||
|
categoria_id: null,
|
||||||
|
},
|
||||||
|
layout: 'row',
|
||||||
|
group_layout: 'simple_vertical',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Remera clásica',
|
||||||
|
descripcion: 'Algodón',
|
||||||
|
precio: '100.00',
|
||||||
|
variants: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: {
|
||||||
|
first: '/categories/7?page=1',
|
||||||
|
last: '/categories/7?page=2',
|
||||||
|
prev: null,
|
||||||
|
next: '/categories/7?page=2',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
current_page: 1,
|
||||||
|
from: 1,
|
||||||
|
last_page: 2,
|
||||||
|
links: [],
|
||||||
|
path: '/categories/7',
|
||||||
|
per_page: 12,
|
||||||
|
to: 1,
|
||||||
|
total: 13,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CategoryItemsPageComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
paramMap: of(convertToParamMap({ id: '7' })),
|
||||||
|
queryParamMap: of(convertToParamMap({ page: '1' })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CatalogService,
|
||||||
|
useValue: { getCategoryItems },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { tenant: tenant.asReadonly() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CartService,
|
||||||
|
useValue: { addItem: vi.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: {
|
||||||
|
success: vi.fn(),
|
||||||
|
danger: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the category and renders its products with the configured product layout', () => {
|
||||||
|
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getCategoryItems).toHaveBeenCalledWith(7, { page: 1 });
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Remeras');
|
||||||
|
|
||||||
|
const productList = fixture.debugElement.query(By.directive(ProductListComponent))
|
||||||
|
.componentInstance as ProductListComponent;
|
||||||
|
|
||||||
|
expect(productList.layout()).toBe('row');
|
||||||
|
expect(productList.groupLayout()).toBe('paginated');
|
||||||
|
expect(Array.isArray(productList.items())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not request the API when the category id is invalid', () => {
|
||||||
|
TestBed.overrideProvider(ActivatedRoute, {
|
||||||
|
useValue: {
|
||||||
|
paramMap: of(convertToParamMap({ id: 'invalid' })),
|
||||||
|
queryParamMap: of(convertToParamMap({ page: '1' })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getCategoryItems).not.toHaveBeenCalled();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||||
|
'La categoría solicitada no es válida.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
Injector,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
|
import { CheckoutService } from '../../../../core/services/checkout.service';
|
||||||
|
import {
|
||||||
|
CatalogGroupLayout,
|
||||||
|
CategoryItemsResponse,
|
||||||
|
} from '../../../../core/services/catalog/catalog.interface';
|
||||||
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import {
|
||||||
|
ProductListBuyEvent,
|
||||||
|
ProductListCartEvent,
|
||||||
|
ProductListComponent,
|
||||||
|
} from '../../../../shared/components/product-list/product-list.component';
|
||||||
|
|
||||||
|
interface CategoryRouteState {
|
||||||
|
categoryId: number;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-category-items-page',
|
||||||
|
imports: [ProductListComponent],
|
||||||
|
templateUrl: './category-items-page.component.html',
|
||||||
|
styleUrl: './category-items-page.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class CategoryItemsPageComponent {
|
||||||
|
private readonly cartService = inject(CartService);
|
||||||
|
private readonly catalogService = inject(CatalogService);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
|
|
||||||
|
protected readonly results = signal<CategoryItemsResponse | null>(null);
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly error = signal<string | null>(null);
|
||||||
|
protected readonly creatingDirectPurchase = signal(false);
|
||||||
|
protected readonly paginatedLayout: CatalogGroupLayout = 'paginated';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
combineLatest([this.route.paramMap, this.route.queryParamMap])
|
||||||
|
.pipe(
|
||||||
|
map(
|
||||||
|
([params, queryParams]): CategoryRouteState => ({
|
||||||
|
categoryId: this.parsePositiveInteger(params.get('id')),
|
||||||
|
page: this.parsePositiveInteger(queryParams.get('page'), 1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
distinctUntilChanged(
|
||||||
|
(previous, current) =>
|
||||||
|
previous.categoryId === current.categoryId && previous.page === current.page,
|
||||||
|
),
|
||||||
|
tap(() => {
|
||||||
|
this.results.set(null);
|
||||||
|
this.error.set(null);
|
||||||
|
this.loading.set(true);
|
||||||
|
}),
|
||||||
|
switchMap(({ categoryId, page }) => {
|
||||||
|
if (categoryId === 0) {
|
||||||
|
this.error.set('La categoría solicitada no es válida.');
|
||||||
|
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.catalogService.getCategoryItems(categoryId, { page }).pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.error.set('No pudimos cargar los productos de esta categoría.');
|
||||||
|
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((results) => {
|
||||||
|
this.results.set(results);
|
||||||
|
this.loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onPageChange(page: number): void {
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { page },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onBuyProduct(event: ProductListBuyEvent): Promise<void> {
|
||||||
|
if (!event.directPurchase) {
|
||||||
|
await this.router.navigate(['/producto', event.product.id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.creatingDirectPurchase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.injector.get(AuthService).user()) {
|
||||||
|
await this.router.navigate(['/login'], {
|
||||||
|
queryParams: { returnUrl: `/producto/${event.product.id}` },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = this.tenantService.tenant();
|
||||||
|
if (!tenant) {
|
||||||
|
this.toastService.danger('No se pudo identificar la tienda.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creatingDirectPurchase.set(true);
|
||||||
|
try {
|
||||||
|
const purchase = await this.injector.get(CheckoutService).startCheckout(tenant.codigo, {
|
||||||
|
direct_item: {
|
||||||
|
catalog_item_id: event.product.id,
|
||||||
|
variant_id: event.variant ?? null,
|
||||||
|
cantidad: event.quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create direct purchase:', error);
|
||||||
|
this.toastService.danger('No se pudo iniciar la compra directa.');
|
||||||
|
} finally {
|
||||||
|
this.creatingDirectPurchase.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onAddToCart(event: ProductListCartEvent): void {
|
||||||
|
this.cartService.addItem(event.product.id, event.variant ?? null, event.quantity).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.toastService.success(response.message || 'Producto agregado al carrito');
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) => {
|
||||||
|
const message = error.error?.message || 'No se pudo agregar el producto al carrito.';
|
||||||
|
this.toastService.danger(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePositiveInteger(value: string | null, fallback = 0): number {
|
||||||
|
const parsed = Number(value ?? fallback);
|
||||||
|
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ describe('HelpSidebar', () => {
|
|||||||
footer_bg_color: '#ffffff',
|
footer_bg_color: '#ffffff',
|
||||||
header_logo: '',
|
header_logo: '',
|
||||||
footer_logo: '',
|
footer_logo: '',
|
||||||
|
categories: [],
|
||||||
menues: [
|
menues: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const tenant: Tenant = {
|
|||||||
footer_bg_color: '#202020',
|
footer_bg_color: '#202020',
|
||||||
header_logo: '',
|
header_logo: '',
|
||||||
footer_logo: '',
|
footer_logo: '',
|
||||||
|
categories: [],
|
||||||
menues: [
|
menues: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const tenant: Tenant = {
|
|||||||
footer_bg_color: '#202020',
|
footer_bg_color: '#202020',
|
||||||
header_logo: '',
|
header_logo: '',
|
||||||
footer_logo: '',
|
footer_logo: '',
|
||||||
|
categories: [],
|
||||||
menues: [
|
menues: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ function createTenant(mainCarouselImages?: string[]): Tenant {
|
|||||||
footer_bg_color: '#ffffff',
|
footer_bg_color: '#ffffff',
|
||||||
header_logo: '/header.png',
|
header_logo: '/header.png',
|
||||||
footer_logo: '/footer.png',
|
footer_logo: '/footer.png',
|
||||||
|
categories: [],
|
||||||
main_carousel_images: mainCarouselImages,
|
main_carousel_images: mainCarouselImages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent),
|
import('./pages/search-page/search-page.component').then((m) => m.SearchPageComponent),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'categoria/:id',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/category-items-page/category-items-page.component').then(
|
||||||
|
(m) => m.CategoryItemsPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'producto/:id',
|
path: 'producto/:id',
|
||||||
canActivate: [hasMenuGuard('product.detail')],
|
canActivate: [hasMenuGuard('product.detail')],
|
||||||
|
|||||||
Reference in New Issue
Block a user