Compare commits

...

10 Commits

Author SHA1 Message Date
1df6964859 feat(auth): refactor authentication to use cookies for token management and add CookieService 2026-07-03 14:16:22 -03:00
3971970975 feat(checkout): implement authentication guard for checkout route and add bank account service 2026-07-03 14:07:57 -03:00
0a2d999c00 feat(checkout): adjust button styles and layout for improved usability 2026-07-03 12:03:57 -03:00
37d94159aa feat(checkout): update styles for title and labels in payment step for improved readability 2026-07-03 11:52:11 -03:00
361596bbbc feat(checkout): update styles for payment card and text elements for improved readability 2026-07-03 11:44:29 -03:00
97a359d65c feat(store-layout): add checkout redirection on buy button click and update button functionality 2026-07-03 11:33:37 -03:00
811f127deb feat: implement checkout data and payment steps as standalone components
- Add `CheckoutDataStepComponent` for handling user data input with reactive forms.
- Create `CheckoutPaymentStepComponent` for managing payment method selection and display.
- Refactor `CheckoutPageComponent` to utilize the new components, improving modularity and readability.
- Introduce SCSS styles for both new components, ensuring consistent design with existing styles.
- Update HTML structure in `checkout-page.component.html` to integrate the new components.
- Define models for checkout forms and payment methods in `checkout-page.models.ts`.
2026-07-03 11:31:09 -03:00
bfbbfb9c09 feat(checkout): enhance checkout page with payment methods and form validation 2026-07-03 11:27:53 -03:00
41bb5e7c9e feat(search): update search component width for better responsiveness 2026-07-03 11:07:45 -03:00
be041f6e88 feat(search): update search input styles for improved focus visibility 2026-07-03 10:57:59 -03:00
23 changed files with 999 additions and 285 deletions

View File

@@ -182,4 +182,16 @@ describe('app routes', () => {
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
it('redirects unauthenticated users from /checkout to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
expect(router.url).toBe('/login');
});
it('allows authenticated users to access /checkout', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true));
expect(router.url).toBe('/checkout');
});
});

View File

@@ -22,7 +22,6 @@
id="store-search-input"
type="search"
class="form-control store-layout__search-input border-end-0 rounded-start"
placeholder="Buscar productos"
/>
<button
type="button"

View File

@@ -41,7 +41,7 @@
}
.store-layout__search {
width: min(100%, 24rem);
width: min(100%, 260px);
min-width: 0;
}
@@ -56,8 +56,9 @@
}
.store-layout__search-input:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem color-mix(in srgb, white 70%, var(--tenant-primary));
border-color: #cccccc;
box-shadow: none;
outline: none;
}
.store-layout__search-button {

View File

@@ -18,8 +18,8 @@
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
<app-button variant="secondary" class="flex-grow-1">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1">Comprar</app-button>
<app-button variant="secondary" class="flex-grow-1" (click)="isCartOpen.set(false)">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1" (click)="onCheckoutClick()">Comprar</app-button>
</app-cart>
</div>
}

View File

@@ -146,4 +146,26 @@ describe('StoreLayoutComponent', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('redirects to /checkout when cart buy button is clicked', () => {
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const buyButton = Array.from(compiled.querySelectorAll('app-button button'))
.find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement | undefined;
expect(buyButton).toBeDefined();
buyButton!.click();
fixture.detectChanges();
expect(router.navigate).toHaveBeenCalledWith(['/checkout']);
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
});
});

View File

@@ -89,6 +89,11 @@ export class StoreLayoutComponent implements OnInit {
}
}
protected onCheckoutClick(): void {
this.isCartOpen.set(false);
void this.router.navigate(['/checkout']);
}
protected readonly footerSections: StoreFooterSection[] = [
{

View File

@@ -1,20 +1,37 @@
import { PLATFORM_ID } from '@angular/core';
import { PLATFORM_ID, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service';
describe('AuthService', () => {
let cookieStore: Record<string, string> = {};
beforeEach(() => {
window.localStorage.clear();
cookieStore = {};
TestBed.resetTestingModule();
});
function createCookieServiceStub() {
return {
get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => { cookieStore[name] = value; },
delete: (name: string) => { delete cookieStore[name]; }
};
}
it('stores token and user on successful login', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@@ -40,16 +57,22 @@ describe('AuthService', () => {
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(service.isAuthenticated()).toBe(true);
expect(window.localStorage.getItem('shopit.auth.token')).toBe('plain-text-token');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify();
});
it('hydrates token from localStorage and loads the current user during bootstrap', async () => {
window.localStorage.setItem('shopit.auth.token', 'persisted-token');
it('hydrates token from cookies and loads the current user during bootstrap', async () => {
cookieStore['shopit.auth.token'] = 'persisted-token';
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@@ -73,10 +96,16 @@ describe('AuthService', () => {
});
it('clears the session when bootstrap receives 401 from /me', async () => {
window.localStorage.setItem('shopit.auth.token', 'expired-token');
cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@@ -91,14 +120,20 @@ describe('AuthService', () => {
expect(service.user()).toBeNull();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(window.localStorage.getItem('shopit.auth.token')).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});
it('registers without creating a session', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@@ -135,7 +170,13 @@ describe('AuthService', () => {
it('clears local session even when logout request fails', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@@ -163,26 +204,8 @@ describe('AuthService', () => {
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(window.localStorage.getItem('shopit.auth.token')).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});
it('does not access localStorage on the server', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: PLATFORM_ID, useValue: 'server' }
]
});
const service = TestBed.inject(AuthService);
service.hydrateSession();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});
});

View File

@@ -1,6 +1,6 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { environment } from '../../../../environments/environment';
@@ -11,8 +11,10 @@ import {
RegisterPayload,
RegisterResponse
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
const AUTH_TOKEN_STORAGE_KEY = 'shopit.auth.token';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@Injectable({
providedIn: 'root'
@@ -20,6 +22,8 @@ const AUTH_TOKEN_STORAGE_KEY = 'shopit.auth.token';
export class AuthService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
@@ -60,8 +64,18 @@ export class AuthService {
return;
}
const transferredUser = this.transferState.get(AUTH_USER_SSR_STATE_KEY, null);
if (transferredUser) {
this.transferState.remove(AUTH_USER_SSR_STATE_KEY);
this.userState.set(transferredUser);
return;
}
try {
await firstValueFrom(this.loadCurrentUser());
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
@@ -79,34 +93,20 @@ export class AuthService {
}
hydrateSession(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
const token = this.cookieService.get(AUTH_TOKEN_COOKIE_KEY);
this.tokenState.set(token);
}
clearSession(): void {
this.userState.set(null);
this.tokenState.set(null);
if (!isPlatformBrowser(this.platformId)) {
return;
}
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
this.cookieService.delete(AUTH_TOKEN_COOKIE_KEY);
}
private applyAuthenticatedState(token: string, user: AuthUser): void {
this.tokenState.set(token);
this.userState.set(user);
if (!isPlatformBrowser(this.platformId)) {
return;
}
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
}
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {

View File

@@ -0,0 +1,23 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { BankAccount } from './tenant.interface';
@Injectable({
providedIn: 'root'
})
export class CheckoutService {
private readonly http = inject(HttpClient);
async getSelectedBankAccount(tenantCode: string): Promise<BankAccount> {
const response = await firstValueFrom(
this.http.get<{ data: BankAccount }>(`${environment.url}tenants/${tenantCode}/bank-accounts/selected`)
);
if (!response?.data) {
throw new Error('No se encontraron los datos de la cuenta bancaria seleccionada.');
}
return response.data;
}
}

View File

@@ -0,0 +1,59 @@
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID, REQUEST } from '@angular/core';
import { DOCUMENT } from '@angular/common';
@Injectable({
providedIn: 'root'
})
export class CookieService {
private readonly platformId = inject(PLATFORM_ID);
private readonly document = inject(DOCUMENT);
private readonly request = inject(REQUEST, { optional: true });
get(name: string): string | null {
if (isPlatformBrowser(this.platformId)) {
return this.getCookieFromString(name, this.document.cookie);
}
if (this.request) {
const cookieHeader = this.request.headers.get('cookie') || '';
return this.getCookieFromString(name, cookieHeader);
}
return null;
}
set(name: string, value: string, days = 7): void {
if (isPlatformBrowser(this.platformId)) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
}
}
delete(name: string): void {
if (isPlatformBrowser(this.platformId)) {
this.document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
}
}
private getCookieFromString(name: string, cookieString: string): string | null {
if (!cookieString) {
return null;
}
const nameEQ = `${name}=`;
const ca = cookieString.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length);
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length);
}
}
return null;
}
}

View File

@@ -1,5 +1,14 @@
import { ApiResponse } from './api-response.interface';
export interface BankAccount {
id: number;
tenant_code: string;
titular: string;
entidad: string;
alias: string;
cvu: string;
}
export interface Tenant {
id: number;
codigo: string;
@@ -13,6 +22,8 @@ export interface Tenant {
footer_bg_color: string;
header_logo: string;
footer_logo: string;
selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null;
}
export type TenantBootstrapResponse = ApiResponse<Tenant>;

View File

@@ -0,0 +1,95 @@
<form
class="checkout-form"
novalidate
[formGroup]="form()"
(ngSubmit)="submit()"
>
<div class="checkout-form__fields">
<div class="form-field">
<label class="form-field__label" for="checkout-nombre">
Nombre y Apellido <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-nombre"
type="text"
placeholder="Descripcion"
[value]="form().controls.nombre.value"
[invalid]="showControlError('nombre')"
(valueChange)="updateField('nombre', $event)"
/>
@if (getControlError('nombre'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<div class="form-field">
<label class="form-field__label" for="checkout-email">
Email <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-email"
type="email"
placeholder="Descripcion"
[value]="form().controls.email.value"
[invalid]="showControlError('email')"
(valueChange)="updateField('email', $event)"
/>
@if (getControlError('email'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<div class="form-field">
<label class="form-field__label" for="checkout-dni">
DNI <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-dni"
type="text"
placeholder="Descripcion"
[value]="form().controls.dni.value"
[invalid]="showControlError('dni')"
(valueChange)="updateField('dni', $event)"
/>
@if (getControlError('dni'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<div class="form-field">
<label class="form-field__label" for="checkout-telefono">
Telefono <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-telefono"
type="text"
placeholder="Descripcion"
[value]="form().controls.telefono.value"
[invalid]="showControlError('telefono')"
(valueChange)="updateField('telefono', $event)"
/>
@if (getControlError('telefono'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
</div>
<div class="checkout-form__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
(click)="cancelStep.emit()"
>
Cancelar
</app-button>
<app-button
type="submit"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
>
Continuar
</app-button>
</div>
</form>

View File

@@ -0,0 +1,48 @@
.checkout-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
&__fields {
display: grid;
gap: 1.25rem;
}
&__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.5rem;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
}
&__action-btn {
display: block;
width: 100%;
}
}
.form-field {
display: grid;
gap: 0.35rem;
&__label {
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary, #6c757d);
letter-spacing: 0;
}
&__required {
color: var(--bs-danger, #dc3545);
margin-left: 2px;
}
&__error {
font-size: 0.75rem;
color: var(--bs-danger, #dc3545);
}
}

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { CheckoutForm } from './checkout-page.models';
@Component({
selector: 'app-checkout-data-step',
standalone: true,
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './checkout-data-step.component.html',
styleUrl: './checkout-data-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutDataStepComponent {
readonly form = input.required<CheckoutForm>();
readonly cancelStep = output<void>();
readonly continueStep = output<void>();
protected updateField(field: keyof CheckoutForm['controls'], value: string | number): void {
this.form().controls[field].setValue(String(value));
}
protected showControlError(controlName: keyof CheckoutForm['controls']): boolean {
const control = this.form().controls[controlName];
return control.invalid && control.touched;
}
protected getControlError(controlName: keyof CheckoutForm['controls']): string | null {
const control = this.form().controls[controlName];
if (!this.showControlError(controlName)) return null;
if (control.hasError('required')) return 'Este campo es obligatorio.';
if (control.hasError('email')) return 'Ingresa un email valido.';
return 'El valor ingresado no es valido.';
}
protected submit(): void {
const form = this.form();
if (form.invalid) {
form.markAllAsTouched();
return;
}
this.continueStep.emit();
}
}

View File

@@ -1,137 +1,37 @@
<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">
<app-stepper #stepper>
<app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step
[form]="form"
(cancelStep)="onCancel()"
(continueStep)="onStep1Continue()"
/>
</app-step>
<!-- Left: Stepper with steps -->
<div class="checkout-page__stepper-col">
<app-stepper #stepper>
<app-step label="Pago">
<app-checkout-payment-step
[paymentMethods]="paymentMethods"
[selectedPaymentMethod]="selectedPaymentMethod()"
[copiedTransferField]="copiedTransferField()"
[transferAccount]="transferAccount()"
(paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()"
/>
</app-step>
</app-stepper>
</div>
<!-- Step 1: Datos -->
<app-step label="Datos" [isValid]="isStep1Valid()">
<form
class="checkout-form"
novalidate
[formGroup]="form"
(ngSubmit)="onContinue()"
>
<div class="checkout-form__fields">
<!-- Nombre y Apellido -->
<div class="form-field">
<label class="form-field__label" for="checkout-nombre">
Nombre y Apellido <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-nombre"
type="text"
placeholder="Descripción"
[value]="form.controls.nombre.value"
[invalid]="showControlError('nombre')"
(valueChange)="updateField('nombre', $event)"
/>
@if (getControlError('nombre'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<!-- Email -->
<div class="form-field">
<label class="form-field__label" for="checkout-email">
Email <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-email"
type="email"
placeholder="Descripción"
[value]="form.controls.email.value"
[invalid]="showControlError('email')"
(valueChange)="updateField('email', $event)"
/>
@if (getControlError('email'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<!-- DNI -->
<div class="form-field">
<label class="form-field__label" for="checkout-dni">
DNI <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-dni"
type="text"
placeholder="Descripción"
[value]="form.controls.dni.value"
[invalid]="showControlError('dni')"
(valueChange)="updateField('dni', $event)"
/>
@if (getControlError('dni'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
<!-- Teléfono -->
<div class="form-field">
<label class="form-field__label" for="checkout-telefono">
Teléfono <span class="form-field__required" aria-hidden="true">*</span>
</label>
<app-input
id="checkout-telefono"
type="text"
placeholder="Descripción"
[value]="form.controls.telefono.value"
[invalid]="showControlError('telefono')"
(valueChange)="updateField('telefono', $event)"
/>
@if (getControlError('telefono'); as errorMessage) {
<small class="form-field__error">{{ errorMessage }}</small>
}
</div>
</div>
<!-- Actions -->
<div class="checkout-form__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
(click)="onCancel()"
>
Cancelar
</app-button>
<app-button
type="submit"
hostClass="checkout-form__action-btn"
buttonClass="w-100"
>
Continuar
</app-button>
</div>
</form>
</app-step>
<!-- Step 2: Pago (placeholder) -->
<app-step label="Pago">
<div class="checkout-payment-placeholder">
<p>Próximamente: sección de pago.</p>
</div>
</app-step>
</app-stepper>
<div class="checkout-page__cart-col">
<app-cart
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
backgroundColor="transparent"
/>
</div>
</div>
<!-- Right: Cart detail -->
<div class="checkout-page__cart-col">
<app-cart
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
backgroundColor="transparent"
/>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
.checkout-page {
display: grid;
grid-template-columns: 1fr 420px;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 2rem;
align-items: start;
padding: 2rem 0;
@@ -9,13 +9,12 @@
grid-template-columns: 1fr;
}
// ── Left column ──────────────────────────────────────────────────
&__stepper-col {
min-width: 0;
border-radius: 4px;
min-height: 420px;
}
// ── Right column ─────────────────────────────────────────────────
&__cart-col {
border: 1px solid #ececec;
border-radius: 4px;
@@ -26,61 +25,3 @@
flex-direction: column;
}
}
// ── Form ────────────────────────────────────────────────────────────
.checkout-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
&__fields {
display: grid;
gap: 1.25rem;
}
&__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.5rem;
}
&__action-btn {
display: block;
width: 100%;
}
}
// ── Field label & error ──────────────────────────────────────────────
.form-field {
display: grid;
gap: 0.35rem;
&__label {
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary, #6c757d);
text-transform: none;
letter-spacing: 0;
}
&__required {
color: var(--bs-danger, #dc3545);
margin-left: 2px;
}
&__error {
font-size: 0.75rem;
color: var(--bs-danger, #dc3545);
}
}
// ── Payment placeholder ──────────────────────────────────────────────
.checkout-payment-placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: var(--bs-secondary, #6c757d);
font-size: 0.9rem;
}

View File

@@ -1,25 +1,29 @@
import { ChangeDetectionStrategy, Component, computed, inject, ViewChild } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ChangeDetectionStrategy, Component, computed, inject, signal, ViewChild } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { startWith } from 'rxjs';
import { CartService } from '../../../../core/services/cart/cart.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartItem } from '../../../../core/services/cart/cart.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { CheckoutDataStepComponent } from './checkout-data-step.component';
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
@Component({
selector: 'app-checkout-page',
standalone: true,
imports: [
ReactiveFormsModule,
CartComponent,
StepperComponent,
StepComponent,
InputComponent,
ButtonComponent
CheckoutDataStepComponent,
CheckoutPaymentStepComponent
],
templateUrl: './checkout-page.component.html',
styleUrl: './checkout-page.component.scss',
@@ -29,10 +33,12 @@ export class CheckoutPageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly cartService = inject(CartService);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService);
@ViewChild(StepperComponent) stepper!: StepperComponent;
protected readonly form = this.formBuilder.nonNullable.group({
protected readonly form: CheckoutForm = this.formBuilder.nonNullable.group({
nombre: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]],
dni: ['', [Validators.required]],
@@ -54,7 +60,45 @@ export class CheckoutPageComponent {
return cart.items.map((item) => this.mapCartItemToMock(item));
});
protected readonly isStep1Valid = computed(() => this.form.valid);
protected readonly isStep1Valid = signal(this.form.valid);
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' },
{ id: 'transferencia', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' }
];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
protected readonly transferAccount = signal<TransferAccount>({
titular: 'Nombre y Apellido',
entidad: 'TelePagos',
cvu: '0000000000000000000000',
alias: 'telepagos.ar'
});
constructor() {
this.form.statusChanges
.pipe(startWith(this.form.status))
.subscribe(() => this.isStep1Valid.set(this.form.valid));
void this.loadSelectedBankAccount();
}
private async loadSelectedBankAccount(): Promise<void> {
const tenant = this.tenantService.tenant();
if (!tenant) return;
try {
const data = await this.checkoutService.getSelectedBankAccount(tenant.codigo);
this.transferAccount.set({
titular: data.titular,
entidad: data.entidad,
cvu: data.cvu,
alias: data.alias
});
} catch (error) {
console.error('Failed to load selected bank account:', error);
}
}
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
@@ -86,32 +130,32 @@ export class CheckoutPageComponent {
};
}
protected updateField(field: keyof typeof this.form.controls, value: string | number): void {
this.form.controls[field].setValue(String(value));
}
protected showControlError(controlName: keyof typeof this.form.controls): boolean {
const control = this.form.controls[controlName];
return control.invalid && control.touched;
}
protected getControlError(controlName: keyof typeof this.form.controls): string | null {
const control = this.form.controls[controlName];
if (!this.showControlError(controlName)) return null;
if (control.hasError('required')) return 'Este campo es obligatorio.';
if (control.hasError('email')) return 'Ingresá un email válido.';
return 'El valor ingresado no es válido.';
}
protected onContinue(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
protected onStep1Continue(): void {
this.stepper.next();
}
protected onCancel(): void {
void this.router.navigate(['/']);
}
protected selectPaymentMethod(method: PaymentMethod): void {
this.selectedPaymentMethod.set(method);
}
protected async copyTransferValue(field: TransferField, value: string): Promise<void> {
if (!globalThis.navigator?.clipboard?.writeText) return;
try {
await globalThis.navigator.clipboard.writeText(value);
this.copiedTransferField.set(field);
setTimeout(() => {
if (this.copiedTransferField() === field) {
this.copiedTransferField.set(null);
}
}, 1800);
} catch {
this.copiedTransferField.set(null);
}
}
}

View File

@@ -0,0 +1,23 @@
import { FormControl, FormGroup } from '@angular/forms';
export type PaymentMethod = 'qr' | 'transferencia' | 'telepagos';
export type TransferField = 'cvu' | 'alias';
export interface PaymentMethodOption {
id: PaymentMethod;
label: string;
}
export interface TransferAccount {
titular: string;
entidad: string;
cvu: string;
alias: string;
}
export type CheckoutForm = FormGroup<{
nombre: FormControl<string>;
email: FormControl<string>;
dni: FormControl<string>;
telefono: FormControl<string>;
}>;

View File

@@ -0,0 +1,140 @@
<div class="checkout-payment">
<div class="checkout-payment__content">
<section class="payment-methods" aria-labelledby="payment-methods-title">
<h2 id="payment-methods-title" class="payment-methods__title">Selecciona el metodo de pago</h2>
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
@for (method of paymentMethods(); track method.id) {
<label
class="payment-method"
[class.is-selected]="selectedPaymentMethod() === method.id"
>
<input
class="payment-method__radio"
type="radio"
name="payment-method"
[value]="method.id"
[checked]="selectedPaymentMethod() === method.id"
(change)="selectPaymentMethod(method.id)"
/>
<span class="payment-method__label">
@if (method.id === 'telepagos') {
<span class="telepagos-logo" aria-label="TelePagos">
<span class="telepagos-logo__tele">tele</span><span class="telepagos-logo__pagos">pagos</span>
</span>
} @else {
{{ method.label }}
}
</span>
<i class="fa-solid fa-angle-right payment-method__chevron" aria-hidden="true"></i>
</label>
}
</div>
</section>
<section class="payment-panel" aria-live="polite">
@if (selectedPaymentMethod() === 'qr') {
<div class="payment-panel__card">
<h3 class="payment-panel__title">Ingresa a tu billetera y escanea el siguiente QR</h3>
<div class="payment-panel__divider"></div>
<div class="qr-code" aria-label="QR de pago">
<span class="qr-code__finder qr-code__finder--tl"></span>
<span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span>
</div>
</div>
} @else if (selectedPaymentMethod() === 'transferencia') {
<div class="payment-panel__card">
<h3 class="payment-panel__title">Transferi a la siguiente cuenta desde cualquier billetera</h3>
<div class="payment-panel__divider"></div>
<div class="payment-panel__account">
<p class="payment-panel__eyebrow">DATOS DE CUENTA:</p>
<div class="payment-detail">
<span class="payment-detail__label">Titular:</span>
<strong class="payment-detail__value">{{ transferAccount().titular }}</strong>
</div>
<div class="payment-detail">
<span class="payment-detail__label">Entidad:</span>
<strong class="payment-detail__value">{{ transferAccount().entidad }}</strong>
</div>
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">CVU:</span>
<strong class="payment-detail__value">{{ transferAccount().cvu }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar CVU"
(click)="requestCopy('cvu', transferAccount().cvu)"
/>
</div>
@if (copiedTransferField() === 'cvu') {
<small class="payment-detail__feedback">CVU copiado</small>
}
<div class="payment-detail payment-detail--copy">
<span class="payment-detail__label">Alias:</span>
<strong class="payment-detail__value">{{ transferAccount().alias }}</strong>
<app-icon-button
variant="copy"
ariaLabel="Copiar alias"
(click)="requestCopy('alias', transferAccount().alias)"
/>
</div>
@if (copiedTransferField() === 'alias') {
<small class="payment-detail__feedback">Alias copiado</small>
}
</div>
</div>
} @else {
<div class="payment-panel__card">
<h3 class="payment-panel__title">Descarga la app de TelePagos para finalizar la compra</h3>
<div class="payment-panel__divider"></div>
<div class="store-badges" aria-label="Tiendas disponibles">
<div class="store-badge">
<i class="fa-brands fa-google-play" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Disponible en</small>
<strong>Google Play</strong>
</span>
</div>
<div class="store-badge">
<i class="fa-brands fa-apple" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Descargalo en</small>
<strong>App Store</strong>
</span>
</div>
</div>
</div>
}
</section>
</div>
<div class="checkout-payment__actions">
<app-button
type="button"
variant="cancel"
hostClass="checkout-payment__action-btn"
(click)="cancelStep.emit()"
>
Cancelar
</app-button>
<app-button
type="button"
hostClass="checkout-payment__action-btn"
>
Finalizar compra
</app-button>
</div>
</div>

View File

@@ -0,0 +1,286 @@
.checkout-payment {
display: grid;
gap: 1rem;
&__content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 330px);
gap: 2rem;
align-items: start;
@media (max-width: 760px) {
grid-template-columns: 1fr;
}
}
&__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 2rem;
@media (max-width: 540px) {
grid-template-columns: 1fr;
}
}
&__action-btn {
display: block;
}
}
.payment-methods {
min-width: 0;
&__title {
margin: 0 0 1.75rem;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
}
&__list {
display: grid;
gap: 1.1rem;
}
}
.payment-method {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.85rem;
width: 100%;
max-width: 300px;
justify-self: start;
padding: 0.35rem 0;
color: #666666;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #4f4f4f;
}
&.is-selected {
color: var(--tenant-primary, #6376f3);
}
&__radio {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--tenant-primary, #6376f3);
cursor: pointer;
}
&__label {
min-width: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
}
&__chevron {
color: #9f9f9f;
font-size: 0.95rem;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
&.is-selected &__chevron,
&:hover &__chevron {
opacity: 1;
transform: translateX(0);
}
}
.telepagos-logo {
display: inline-block;
font-size: 1.45rem;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
background: linear-gradient(90deg, #0a69d8 0 78%, #f6a11a 78% 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.payment-panel {
min-width: 0;
&__card {
width: fit-content;
max-width: 100%;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
&__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
&__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
&__account {
width: 100%;
display: grid;
gap: 0.75rem;
color: #7a7a7a;
font-size: 12px;
}
&__eyebrow {
margin: 0;
color: #838383;
font-size: 12px;
font-weight: 325;
letter-spacing: 0.06em;
}
}
.payment-detail {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
gap: 0.35rem;
align-items: center;
font-size: 12px;
&--copy {
grid-template-columns: auto auto auto;
}
&__label {
color: #8a8a8a;
font-weight: 325;
}
&__value {
color: #5e5e5e;
font-weight: 500;
}
&__feedback {
display: block;
margin-top: -0.25rem;
color: var(--tenant-primary, #6376f3);
font-size: 0.78rem;
font-weight: 700;
}
}
.qr-code {
position: relative;
width: min(170px, 100%);
aspect-ratio: 1;
border: 8px solid #ffffff;
background-color: #ffffff;
background-image:
linear-gradient(
90deg,
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
),
linear-gradient(
rgba(17, 17, 17, 0.95) 0 14%,
transparent 14% 28%,
rgba(17, 17, 17, 0.95) 28% 42%,
transparent 42% 56%,
rgba(17, 17, 17, 0.95) 56% 70%,
transparent 70% 84%,
rgba(17, 17, 17, 0.95) 84% 100%
);
background-size: 18px 18px;
background-position: 0 0, 9px 9px;
}
.qr-code__finder {
position: absolute;
width: 38px;
height: 38px;
border: 5px solid #111111;
background: #ffffff;
box-shadow: inset 0 0 0 8px #ffffff, inset 0 0 0 14px #111111;
&--tl {
top: 10px;
left: 10px;
}
&--tr {
top: 10px;
right: 10px;
}
&--bl {
bottom: 10px;
left: 10px;
}
}
.store-badges {
width: 100%;
max-width: 210px;
display: grid;
gap: 0.8rem;
}
.store-badge {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.95rem;
border-radius: 0.85rem;
background: #111111;
color: #ffffff;
box-shadow: 0 10px 24px rgba(17, 17, 17, 0.16);
i {
font-size: 1.35rem;
}
&__text {
display: grid;
text-align: left;
line-height: 1.1;
small {
font-size: 0.62rem;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.8;
}
strong {
font-size: 1rem;
font-weight: 700;
}
}
}

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
@Component({
selector: 'app-checkout-payment-step',
standalone: true,
imports: [ButtonComponent, IconButtonComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentStepComponent {
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
readonly selectedPaymentMethod = input.required<PaymentMethod>();
readonly copiedTransferField = input<TransferField | null>(null);
readonly transferAccount = input.required<TransferAccount>();
readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly cancelStep = output<void>();
protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method);
}
protected requestCopy(field: TransferField, value: string): void {
this.copyTransferValue.emit({ field, value });
}
}

View File

@@ -2,7 +2,7 @@ import { Routes } from '@angular/router';
import { AuthLayoutComponent } from '../../core/layout/auth-layout/auth-layout.component';
import { StoreLayoutComponent } from '../../core/layout/store-layout/store-layout.component';
import { guestOnlyGuard } from '../../core/services/auth/auth.guards';
import { authGuard, guestOnlyGuard } from '../../core/services/auth/auth.guards';
import { LoginPageComponent } from './pages/login-page/login-page.component';
import { RegisterPageComponent } from './pages/register-page/register-page.component';
import { StoreHomePageComponent } from './pages/store-home-page/store-home-page.component';
@@ -47,6 +47,7 @@ export const routes: Routes = [
},
{
path: 'checkout',
canActivate: [authGuard],
loadComponent: () =>
import('./pages/checkout-page/checkout-page.component').then(
(m) => m.CheckoutPageComponent

View File

@@ -17,7 +17,9 @@
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
flex: 0 1 190px;
width: 100%;
max-width: 190px;
position: relative;
}