12 Commits

Author SHA1 Message Date
3579abdecb refactor(user-dropdown): remove registration functionality and simplify user dropdown structure 2026-07-08 03:03:56 +00:00
f6ac1b8727 feat(user-dropdown): implement user dropdown component with authentication actions and integrate with store header 2026-07-08 02:52:46 +00:00
eb73398397 refactor(qrcode): replace angularx-qrcode with custom QRCodeComponent and update dependencies 2026-07-08 02:21:17 +00:00
1af478d65e refactor(button): update click handling and aria attributes for better accessibility 2026-07-07 17:04:07 -03:00
7e8a638d01 refactor(checkout): update button sizes for consistent layout across checkout steps 2026-07-07 17:00:26 -03:00
639004c8c6 refactor(checkout): update layout and styles for checkout actions and payment panel 2026-07-07 16:57:40 -03:00
17ef39e9a8 refactor(checkout): remove border and padding from cart column for cleaner layout 2026-07-07 16:44:07 -03:00
fff18e05c0 refactor(cart): remove CartCopiaComponent and related references from test page 2026-07-07 16:42:27 -03:00
c9d0e5ad01 refactor(cart): remove cart showcase component and related functionality 2026-07-07 16:40:04 -03:00
4885d39284 feat(cart): enhance cart item layout and styles for improved design consistency 2026-07-07 16:37:06 -03:00
9d42de9fa8 feat(cart-copia): enhance cart design with improved button styles and layout 2026-07-07 16:06:10 -03:00
bdb7f5e907 feat(cart-copia): implement cart showcase component for isolated cart redesign 2026-07-07 16:03:22 -03:00
30 changed files with 434 additions and 86 deletions

27
package-lock.json generated
View File

@@ -17,9 +17,9 @@
"@angular/router": "^22.0.0", "@angular/router": "^22.0.0",
"@angular/ssr": "^22.0.4", "@angular/ssr": "^22.0.4",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"angularx-qrcode": "^21.0.5",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"express": "^5.1.0", "express": "^5.1.0",
"qrcode": "1.5.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@@ -3217,6 +3217,17 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2", "version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
@@ -4091,20 +4102,6 @@
"node": ">= 14.0.0" "node": ">= 14.0.0"
} }
}, },
"node_modules/angularx-qrcode": {
"version": "21.0.5",
"resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-21.0.5.tgz",
"integrity": "sha512-9Kv/Mi5tKzAkBL2xhYZyPaJEoq+b24mIpUAntp16kIBiozVnpGI6DbigTPJiSDkV7EpYHdjBJNGj8igFAHEp6g==",
"license": "MIT",
"dependencies": {
"qrcode": "1.5.4",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0"
}
},
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",

View File

@@ -21,9 +21,9 @@
"@angular/router": "^22.0.0", "@angular/router": "^22.0.0",
"@angular/ssr": "^22.0.4", "@angular/ssr": "^22.0.4",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^7.2.0",
"angularx-qrcode": "^21.0.5",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"express": "^5.1.0", "express": "^5.1.0",
"qrcode": "1.5.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },

View File

@@ -40,12 +40,24 @@
title="Carrito" title="Carrito"
(click)="cartClick.emit()" (click)="cartClick.emit()"
/> />
<app-icon-button <div class="store-layout__user-menu">
variant="user" <app-icon-button
ariaLabel="Mi cuenta" variant="user"
title="Mi cuenta" ariaLabel="Mi cuenta"
(click)="userClick.emit()" ariaControls="store-user-dropdown"
/> [ariaExpanded]="isUserDropdownOpen()"
title="Mi cuenta"
(clicked)="toggleUserDropdown()"
/>
@if (isUserDropdownOpen()) {
<app-user-dropdown
id="store-user-dropdown"
[user]="user()"
(logoutClick)="onLogoutClick()"
/>
}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -83,6 +83,11 @@
color: #8a8a8a; color: #8a8a8a;
} }
.store-layout__user-menu {
position: relative;
display: inline-flex;
}
.store-layout__category-trigger { .store-layout__category-trigger {
font-size: 0.8rem; font-size: 0.8rem;
letter-spacing: 0.02em; letter-spacing: 0.02em;

View File

@@ -1,17 +1,62 @@
import { Component, input, output } from '@angular/core'; import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AuthUser } from '../../../services/auth/auth.interfaces';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
@Component({ @Component({
selector: 'app-store-header', selector: 'app-store-header',
imports: [CartIconComponent, IconButtonComponent, RouterLink], imports: [CartIconComponent, IconButtonComponent, RouterLink, UserDropdownComponent],
templateUrl: './store-header.component.html', templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss' styleUrl: './store-header.component.scss'
}) })
export class StoreHeaderComponent { export class StoreHeaderComponent {
private readonly elementRef = inject(ElementRef<HTMLElement>);
readonly logoUrl = input<string | null>(null); readonly logoUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0); readonly cartQuantity = input<number>(0);
readonly user = input<AuthUser | null>(null);
readonly isAuthenticated = input<boolean>(false);
readonly cartClick = output<void>(); readonly cartClick = output<void>();
readonly userClick = output<void>(); readonly loginClick = output<void>();
readonly logoutClick = output<void>();
protected readonly isUserDropdownOpen = signal(false);
@HostListener('document:click', ['$event'])
protected onDocumentClick(event: MouseEvent): void {
if (!this.isUserDropdownOpen()) {
return;
}
const target = event.target;
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
this.isUserDropdownOpen.set(false);
}
}
@HostListener('document:keydown.escape')
protected onEscapeKey(): void {
this.isUserDropdownOpen.set(false);
}
protected toggleUserDropdown(): void {
if (!this.isAuthenticated()) {
this.loginClick.emit();
return;
}
this.isUserDropdownOpen.update((isOpen) => !isOpen);
}
protected onLoginClick(): void {
this.isUserDropdownOpen.set(false);
this.loginClick.emit();
}
protected onLogoutClick(): void {
this.isUserDropdownOpen.set(false);
this.logoutClick.emit();
}
} }

View File

@@ -0,0 +1,23 @@
<section class="user-dropdown" role="menu" aria-label="Menu de usuario">
<div class="user-dropdown__profile">
<span class="user-dropdown__name">{{ displayName() }}</span>
</div>
<div class="user-dropdown__divider"></div>
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-account">
Mi cuenta
</button>
<button type="button" class="user-dropdown__item" role="menuitem" data-testid="user-dropdown-purchases">
Mis compras
</button>
<button
type="button"
class="user-dropdown__item"
role="menuitem"
data-testid="user-dropdown-logout"
(click)="logoutClick.emit()"
>
Cerrar sesion
</button>
</section>

View File

@@ -0,0 +1,74 @@
:host {
position: absolute;
top: calc(100% + 0.75rem);
right: 0;
z-index: 1060;
display: block;
width: min(18rem, calc(100vw - 2rem));
}
.user-dropdown {
display: grid;
background: #ffffff;
border: 1px solid rgba(32, 32, 32, 0.1);
border-radius: 8px;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.16);
padding: 0.75rem;
animation: user-dropdown-enter 0.16s ease-out;
}
.user-dropdown__profile {
padding: 0.25rem;
}
.user-dropdown__name {
color: #a0a0a0;
font-size: 13px;
font-weight: 325;
line-height: 1.2;
}
.user-dropdown__divider {
height: 1px;
margin: 0.6rem 0;
background: rgba(32, 32, 32, 0.08);
}
.user-dropdown__item {
width: 100%;
border: 0;
border-radius: 6px;
padding: 0.65rem 0.6rem;
color: #8a8a8a;
font-size: 14px;
font-weight: 700;
text-align: left;
background: transparent;
cursor: pointer;
transition: color 0.15s ease;
}
.user-dropdown__item:active,
.user-dropdown__item:hover,
.user-dropdown__item:focus-visible {
color: var(--tenant-primary);
outline: none;
}
@keyframes user-dropdown-enter {
from {
opacity: 0;
transform: translateY(-0.35rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 575.98px) {
:host {
right: -0.5rem;
}
}

View File

@@ -0,0 +1,14 @@
import { Component, computed, input, output } from '@angular/core';
import { AuthUser } from '../../../../services/auth/auth.interfaces';
@Component({
selector: 'app-user-dropdown',
templateUrl: './user-dropdown.component.html',
styleUrl: './user-dropdown.component.scss'
})
export class UserDropdownComponent {
readonly user = input<AuthUser | null>(null);
readonly logoutClick = output<void>();
protected readonly displayName = computed(() => this.user()?.nombre_apellido || 'Mi cuenta');
}

View File

@@ -2,8 +2,11 @@
<app-store-header <app-store-header
[cartQuantity]="cartQuantity()" [cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null" [logoUrl]="tenant()?.header_logo ?? null"
[user]="user()"
[isAuthenticated]="isAuthenticated()"
(cartClick)="isCartOpen.set(!isCartOpen())" (cartClick)="isCartOpen.set(!isCartOpen())"
(userClick)="onUserClick()" (loginClick)="onLoginClick()"
(logoutClick)="onLogoutClick()"
/> />
@if (isCartOpen()) { @if (isCartOpen()) {

View File

@@ -24,7 +24,6 @@
top: 76px; top: 76px;
right: calc((100% - 1320px) / 2 + 1.5rem); right: calc((100% - 1320px) / 2 + 1.5rem);
height: 520px;
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
z-index: 1050; z-index: 1050;
background-color: #ffffff; background-color: #ffffff;

View File

@@ -10,6 +10,7 @@ import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { ToastService } from '../../services/toast.service'; import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth/auth.service'; import { AuthService } from '../../services/auth/auth.service';
import { AuthUser } from '../../services/auth/auth.interfaces';
import { StoreLayoutComponent } from './store-layout.component'; import { StoreLayoutComponent } from './store-layout.component';
const tenant: Tenant = { const tenant: Tenant = {
@@ -30,6 +31,8 @@ const tenant: Tenant = {
describe('StoreLayoutComponent', () => { describe('StoreLayoutComponent', () => {
beforeEach(async () => { beforeEach(async () => {
const tenantState = signal(tenant); const tenantState = signal(tenant);
const authUserState = signal<AuthUser | null>(null);
const isAuthenticatedState = signal(false);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [StoreLayoutComponent], imports: [StoreLayoutComponent],
@@ -66,7 +69,9 @@ describe('StoreLayoutComponent', () => {
{ {
provide: AuthService, provide: AuthService,
useValue: { useValue: {
isAuthenticated: signal(false) user: authUserState,
isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0))
} }
} }
] ]
@@ -112,7 +117,6 @@ describe('StoreLayoutComponent', () => {
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');
// Set isAuthenticated to false
(authService.isAuthenticated as any).set(false); (authService.isAuthenticated as any).set(false);
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
@@ -124,16 +128,21 @@ describe('StoreLayoutComponent', () => {
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(compiled.querySelector('app-user-dropdown')).toBeNull();
expect(router.navigate).toHaveBeenCalledWith(['/login']); expect(router.navigate).toHaveBeenCalledWith(['/login']);
}); });
it('does not redirect to /login when user icon is clicked and user is logged in', () => { it('opens authenticated user dropdown without redirecting on icon click', () => {
const authService = TestBed.inject(AuthService); const authService = TestBed.inject(AuthService);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');
// Set isAuthenticated to true
(authService.isAuthenticated as any).set(true); (authService.isAuthenticated as any).set(true);
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
});
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -144,9 +153,40 @@ describe('StoreLayoutComponent', () => {
userButton.click(); userButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
expect(compiled.textContent).toContain('Ada Lovelace');
expect(router.navigate).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled();
}); });
it('logs out from the authenticated user dropdown', () => {
const authService = TestBed.inject(AuthService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
(authService.isAuthenticated as any).set(true);
(authService.user as any).set({
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const userButton = compiled.querySelector('app-icon-button[variant="user"] button') as HTMLButtonElement;
userButton.click();
fixture.detectChanges();
const logoutButton = compiled.querySelector('[data-testid="user-dropdown-logout"]') as HTMLButtonElement;
expect(logoutButton).not.toBeNull();
logoutButton.click();
fixture.detectChanges();
expect(authService.logout).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
});
it('redirects to /checkout when cart buy button is clicked', () => { it('redirects to /checkout when cart buy button is clicked', () => {
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate'); vi.spyOn(router, 'navigate');

View File

@@ -70,6 +70,8 @@ export class StoreLayoutComponent implements OnInit {
protected readonly currentYear = new Date().getFullYear(); protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant; protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;
protected readonly isAuthenticated = this.authService.isAuthenticated;
protected readonly cartQuantity = computed(() => { protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart(); const cart = this.cartService.cart();
@@ -83,10 +85,15 @@ export class StoreLayoutComponent implements OnInit {
}); });
} }
protected onUserClick(): void { protected onLoginClick(): void {
if (!this.authService.isAuthenticated()) { void this.router.navigate(['/login']);
this.router.navigate(['/login']); }
}
protected onLogoutClick(): void {
this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']),
error: (err) => console.error('Error logging out', err)
});
} }
protected onCheckoutClick(): void { protected onCheckoutClick(): void {

View File

@@ -454,6 +454,7 @@
</div> </div>
} }
</div> </div>
</div> </div>
</section> </section>
@@ -652,3 +653,4 @@
</div> </div>
</app-store-section> </app-store-section>
</main> </main>

View File

@@ -113,8 +113,8 @@ h1 {
.cart-showcase__viewport { .cart-showcase__viewport {
width: 100%; width: 100%;
max-width: 370px; max-width: 504px;
height: 360px; height: 640px;
} }
.cart-showcase__header { .cart-showcase__header {

View File

@@ -257,3 +257,5 @@ export class ReutilizablesTestPageComponent {
}); });
} }
} }

View File

@@ -9,19 +9,18 @@
} }
&__actions { &__actions {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; justify-content: flex-end;
flex-wrap: wrap;
gap: 1rem; gap: 1rem;
margin-top: 0.5rem; margin-top: 0.5rem;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
} }
&__action-btn { &__action-btn {
display: block; display: block;
width: 100%; width: 100%;
flex: 1 1 290px;
max-width: 290px;
} }
} }
@@ -46,3 +45,4 @@
color: var(--bs-danger, #dc3545); color: var(--bs-danger, #dc3545);
} }
} }

View File

@@ -1,6 +1,6 @@
<div class="container-xl px-3 px-md-4 py-4"> <div class="container-xl px-3 px-md-4 py-4">
<div class="checkout-page"> <div class="checkout-page">
<div class="checkout-page__stepper-col"> <div class="checkout-page__stepper-col ">
<app-stepper #stepper> <app-stepper #stepper>
<app-step label="Datos" [isValid]="isStep1Valid()"> <app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step <app-checkout-data-step

View File

@@ -1,7 +1,7 @@
.checkout-page { .checkout-page {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 420px; grid-template-columns: minmax(0, 1fr) 420px;
gap: 2rem; gap: 4rem;
align-items: start; align-items: start;
padding: 2rem 0; padding: 2rem 0;
@@ -16,9 +16,7 @@
} }
&__cart-col { &__cart-col {
border: 1px solid #ececec;
border-radius: 4px;
padding: 1.25rem 1.5rem;
max-height: 80vh; max-height: 80vh;
overflow: hidden; overflow: hidden;
display: flex; display: flex;

View File

@@ -34,7 +34,7 @@
</div> </div>
</section> </section>
<section class="payment-panel" aria-live="polite"> <section class="payment-panel d-flex justify-content-end" aria-live="polite">
@if (isGeneratingIntent()) { @if (isGeneratingIntent()) {
<div class="payment-panel__card" style="text-align: center; padding: 2rem;"> <div class="payment-panel__card" style="text-align: center; padding: 2rem;">
<h3 class="payment-panel__title">Cargando información de pago...</h3> <h3 class="payment-panel__title">Cargando información de pago...</h3>
@@ -133,7 +133,8 @@
type="button" type="button"
variant="cancel" variant="cancel"
hostClass="checkout-payment__action-btn" hostClass="checkout-payment__action-btn"
(click)="cancelStep.emit()" buttonClass="w-100"
[disabled]="true"
> >
Cancelar Cancelar
</app-button> </app-button>
@@ -141,6 +142,7 @@
<app-button <app-button
type="button" type="button"
hostClass="checkout-payment__action-btn" hostClass="checkout-payment__action-btn"
buttonClass="w-100"
[disabled]="isGeneratingIntent()" [disabled]="isGeneratingIntent()"
(click)="complete.emit()" (click)="complete.emit()"
> >
@@ -148,3 +150,4 @@
</app-button> </app-button>
</div> </div>
</div> </div>

View File

@@ -14,17 +14,17 @@
} }
&__actions { &__actions {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; justify-content: flex-end;
flex-wrap: wrap;
gap: 1rem; gap: 1rem;
margin-top: 2rem; margin-top: 2rem;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
} }
&__action-btn { &__action-btn {
width: 100%;
flex: 1 1 290px;
max-width: 290px;
display: block; display: block;
} }
} }
@@ -110,10 +110,9 @@
.payment-panel { .payment-panel {
min-width: 0; min-width: 0;
&__card { &__card {
width: fit-content; width: 100%;
max-width: 100%; max-width: 290px;
min-height: 269px; min-height: 269px;
padding: 1.5rem 1.75rem; padding: 1.5rem 1.75rem;
border-radius: 5px; border-radius: 5px;
@@ -304,3 +303,4 @@
} }
} }
} }

View File

@@ -1,9 +1,8 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { QRCodeComponent } from '../../../../shared/components/qrcode/qrcode.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models'; import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
@Component({ @Component({

View File

@@ -17,7 +17,9 @@ export type ButtonVariant =
styleUrl: './button.component.scss', styleUrl: './button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
'[class]': 'hostClass()' '[class]': 'hostClasses()',
'[attr.aria-disabled]': 'disabled()',
'(click)': 'onHostClick($event)'
} }
}) })
export class ButtonComponent { export class ButtonComponent {
@@ -26,6 +28,9 @@ export class ButtonComponent {
readonly disabled = input(false); readonly disabled = input(false);
readonly hostClass = input<string>(''); readonly hostClass = input<string>('');
readonly buttonClass = input<string>(''); readonly buttonClass = input<string>('');
protected readonly hostClasses = computed(() =>
[this.hostClass(), this.disabled() ? 'app-button--disabled' : ''].filter(Boolean).join(' ')
);
protected readonly buttonClasses = computed(() => { protected readonly buttonClasses = computed(() => {
const variants: Record<ButtonVariant, string> = { const variants: Record<ButtonVariant, string> = {
@@ -40,4 +45,14 @@ export class ButtonComponent {
return ['btn', variants[this.variant()], this.buttonClass()]; return ['btn', variants[this.variant()], this.buttonClass()];
}); });
protected onHostClick(event: Event): void {
if (!this.disabled()) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
} }

View File

@@ -1,4 +1,4 @@
<article class="w-100 rounded-0 cart-item"> <article class="w-100 p-3 rounded-0 cart-item">
<div class="position-relative overflow-hidden cart-item-media"> <div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) { @if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span> <span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
@@ -17,10 +17,10 @@
<div class="text-nowrap cart-item-prices"> <div class="text-nowrap cart-item-prices">
@if (formattedOriginalPrice(); as originalPrice) { @if (formattedOriginalPrice(); as originalPrice) {
<span class="text-decoration-line-through fw-light cart-item-original-price">{{ originalPrice }}</span> <span class="text-decoration-line-through mb-1 fw-light cart-item-original-price">{{ originalPrice }}</span>
} }
<span class="fw-normal cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span> <span class="fw-bold cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span>
</div> </div>
</div> </div>

View File

@@ -1,28 +1,43 @@
:host { :host {
display: block; display: block;
--item-media-width: 64px; --item-media-size: 90px;
--item-media-height: 72px; --item-divider-inset: 1rem;
} }
@media (max-width: 420px) { :host(:first-child) .cart-item,
:host { :host .cart-item {
--item-media-width: 56px; position: relative;
--item-media-height: 64px; }
}
:host(:first-child) .cart-item::before,
:host .cart-item::after {
content: "";
position: absolute;
right: var(--item-divider-inset);
left: var(--item-divider-inset);
height: 1px;
background-color: #ffffff;
}
:host(:first-child) .cart-item::before {
top: 0;
}
:host .cart-item::after {
bottom: 0;
} }
.cart-item { .cart-item {
display: grid; display: grid;
grid-template-columns: var(--item-media-width) minmax(0, 1fr); grid-template-columns: var(--item-media-size) minmax(0, 1fr);
gap: 0.75rem; gap: 0.75rem;
padding: 10px 12px;
background-color: #f5f5f5; background-color: #f5f5f5;
} }
.cart-item-media { .cart-item-media {
width: var(--item-media-width); width: var(--item-media-size);
height: var(--item-media-height); aspect-ratio: 1 / 1;
border-radius: 6px; border-radius: 5px 0 0 5px;
background-color: #ffffff; background-color: #ffffff;
border: 1px solid #eaeaea; border: 1px solid #eaeaea;
overflow: hidden; overflow: hidden;
@@ -30,14 +45,14 @@
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
} }
} }
.cart-item-discount-badge { .cart-item-discount-badge {
padding: 2px 5px; padding: 2px 5px;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
background-color: #6173ff; background-color: var(--bs-primary);
color: #ffffff; color: #ffffff;
font-size: 8px; font-size: 8px;
font-weight: 500; font-weight: 500;
@@ -45,6 +60,8 @@
} }
.cart-item-placeholder { .cart-item-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #d2d2d2, #ececec); background: linear-gradient(135deg, #d2d2d2, #ececec);
} }
@@ -100,9 +117,6 @@
gap: 0.125rem; gap: 0.125rem;
} }
.cart-item-remove-btn { .cart-item-remove-btn {
margin-left: 0.35rem; margin-left: 0.35rem;
} }

View File

@@ -1,5 +1,5 @@
<section class="d-flex flex-column h-100 overflow-hidden text-secondary" [style.background-color]="backgroundColor()"> <section class="d-flex flex-column h-100 overflow-hidden text-secondary" [style.background-color]="backgroundColor()">
<header class="d-flex align-items-center justify-content-between bg-transparent cart-header"> <header class="d-flex align-items-center p-3 justify-content-between bg-transparent cart-header">
<h2 class="m-0 text-uppercase fw-bold cart-title">{{ title() }}</h2> <h2 class="m-0 text-uppercase fw-bold cart-title">{{ title() }}</h2>
@if (showClose()) { @if (showClose()) {
@@ -9,7 +9,7 @@
} }
</header> </header>
<div class="d-grid gap-2 flex-grow-1 overflow-y-auto cart-items-container"> <div class="d-grid w-100 flex-grow-1 overflow-y-auto cart-items-container">
@for (item of items(); track item.productVariantId || item.product + item.discountedPrice; let idx = $index) { @for (item of items(); track item.productVariantId || item.product + item.discountedPrice; let idx = $index) {
<app-cart-item <app-cart-item
[imageUrl]="item.imageUrl" [imageUrl]="item.imageUrl"
@@ -48,3 +48,4 @@
</section> </section>

View File

@@ -23,10 +23,16 @@
.cart-items-container { .cart-items-container {
min-height: 0; min-height: 0;
padding: 0 0 0 24px; gap: 0;
scrollbar-color: #d5d5d5 transparent; scrollbar-color: #d5d5d5 transparent;
scrollbar-width: thin; scrollbar-width: thin;
app-cart-item {
display: block;
}
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
} }

View File

@@ -3,6 +3,9 @@
[disabled]="disabled()" [disabled]="disabled()"
[ngClass]="buttonClasses()" [ngClass]="buttonClasses()"
[attr.aria-label]="ariaLabel() || variant()" [attr.aria-label]="ariaLabel() || variant()"
[attr.aria-expanded]="ariaExpanded()"
[attr.aria-controls]="ariaControls()"
(click)="clicked.emit($event)"
> >
<i [ngClass]="iconClass()"></i> <i [ngClass]="iconClass()"></i>
</button> </button>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
export type IconButtonVariant = export type IconButtonVariant =
@@ -21,6 +21,9 @@ export class IconButtonComponent {
readonly variant = input.required<IconButtonVariant>(); readonly variant = input.required<IconButtonVariant>();
readonly disabled = input(false); readonly disabled = input(false);
readonly ariaLabel = input<string>(''); readonly ariaLabel = input<string>('');
readonly ariaExpanded = input<boolean | null>(null);
readonly ariaControls = input<string | null>(null);
readonly clicked = output<MouseEvent>();
protected readonly iconClass = computed(() => { protected readonly iconClass = computed(() => {
const classes: Record<IconButtonVariant, string> = { const classes: Record<IconButtonVariant, string> = {

View File

@@ -0,0 +1,68 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnChanges,
ViewChild
} from '@angular/core';
import * as QRCode from 'qrcode';
type QRCodeErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
@Component({
selector: 'qrcode',
standalone: true,
template: '<canvas #canvas [attr.aria-label]="ariaLabel" role="img"></canvas>',
styles: [
`
:host {
display: inline-block;
line-height: 0;
}
canvas {
display: block;
height: auto;
max-width: 100%;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QRCodeComponent implements AfterViewInit, OnChanges {
@Input({ required: true }) qrdata = '';
@Input() width = 200;
@Input() errorCorrectionLevel: QRCodeErrorCorrectionLevel = 'M';
@Input() ariaLabel = 'Codigo QR';
@ViewChild('canvas', { static: true }) private readonly canvas!: ElementRef<HTMLCanvasElement>;
private viewReady = false;
ngAfterViewInit(): void {
this.viewReady = true;
this.render();
}
ngOnChanges(): void {
this.render();
}
private render(): void {
if (!this.viewReady || !this.qrdata) {
return;
}
void QRCode.toCanvas(this.canvas.nativeElement, this.qrdata, {
errorCorrectionLevel: this.errorCorrectionLevel,
margin: 2,
width: this.width
}).catch(() => {
const context = this.canvas.nativeElement.getContext('2d');
context?.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height);
});
}
}

15
src/types/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
declare module 'qrcode' {
export type QRCodeErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
export interface QRCodeToCanvasOptions {
errorCorrectionLevel?: QRCodeErrorCorrectionLevel;
margin?: number;
width?: number;
}
export function toCanvas(
canvasElement: HTMLCanvasElement,
text: string,
options?: QRCodeToCanvasOptions
): Promise<void>;
}