feat: implement product detail page with attribute selector and quantity management

This commit is contained in:
2026-07-01 10:36:55 -03:00
parent 41b51f06e7
commit 1a7a6c28e6
6 changed files with 332 additions and 209 deletions

View File

@@ -0,0 +1,36 @@
<div class="attribute-selector">
@for (attribute of attributes(); track attribute.id) {
<div class="attribute-selector__row">
<span class="attribute-selector__label">{{ attribute.nombre }}:</span>
<div class="attribute-selector__options">
@for (option of attribute.options; track option.id) {
@if (isColorOption(option)) {
<button
type="button"
class="attribute-selector__swatch"
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
[style.background-color]="getOptionSwatchColor(option)"
[attr.aria-label]="option.label"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
[title]="option.label"
(click)="selectAttributeOption(attribute, option)"
>
<span class="visually-hidden">{{ option.label }}</span>
</button>
} @else {
<button
type="button"
class="attribute-selector__text-option"
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
(click)="selectAttributeOption(attribute, option)"
>
{{ option.label }}
</button>
}
}
</div>
</div>
}
</div>

View File

@@ -0,0 +1,69 @@
.attribute-selector {
display: flex;
flex-direction: column;
gap: 1.25rem;
&__row {
display: flex;
align-items: center;
gap: 0.875rem;
flex-wrap: wrap;
}
&__label {
font-size: 15px;
font-weight: 700;
line-height: 1.2;
color: #A0A0A0;
min-width: 44px;
}
&__options {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
&__swatch {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid transparent;
padding: 0;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
transform: scale(1.05);
}
&--selected {
border-color: var(--tenant-primary);
box-shadow:
0 0 0 2px rgba(255, 255, 255, 1),
0 0 0 3px var(--tenant-primary);
}
}
&__text-option {
border: 1px solid #dcdcdc;
background: #ffffff;
color: #666666;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
min-width: 34px;
min-height: 34px;
border-radius: 4px;
padding: 0.35rem 0.6rem;
font-size: 16px;
font-weight: 700;
line-height: 1;
&--selected {
background: var(--tenant-primary);
border-color: var(--tenant-primary);
color: #ffffff;
}
}
}

View File

@@ -0,0 +1,214 @@
import {
ChangeDetectionStrategy,
Component,
effect,
input,
output,
signal,
untracked
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ProductAttribute,
ProductAttributeOption,
ProductVariant,
ProductVariantMap
} from '../../../../core/services/catalog/catalog.interface';
@Component({
selector: 'app-product-attribute-selector',
standalone: true,
imports: [CommonModule],
templateUrl: './product-attribute-selector.component.html',
styleUrl: './product-attribute-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductAttributeSelectorComponent {
public attributes = input<ProductAttribute[]>([]);
public variantsMap = input<ProductVariantMap[]>([]);
public defaultVariant = input<ProductVariant | null>(null);
public variantChange = output<ProductVariantMap | null>();
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
constructor() {
effect(() => {
const attributes = this.attributes();
const defaultVariant = this.defaultVariant();
untracked(() => {
this.initializeSelections(attributes, defaultVariant);
});
});
effect(() => {
const selections = this.selectedAttributeOptions();
const variantsMap = this.variantsMap();
const attributes = this.attributes();
untracked(() => {
this.emitMatchingVariant(selections, variantsMap, attributes);
});
});
}
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id;
}
protected selectAttributeOption(
attribute: ProductAttribute,
option: ProductAttributeOption
): void {
this.selectedAttributeOptions.update((current) => ({
...current,
[attribute.id]: option.id
}));
}
protected isColorOption(option: ProductAttributeOption): boolean {
return this.findFirstHexValue(option.metadata) !== null;
}
protected getOptionSwatchColor(option: ProductAttributeOption): string {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
}
private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void {
const selections: Record<number, number> = {};
for (const attribute of attributes) {
const optionId = this.findDefaultOptionId(attribute, defaultVariant);
if (optionId !== null) {
selections[attribute.id] = optionId;
}
}
this.selectedAttributeOptions.set(selections);
}
private findDefaultOptionId(
attribute: ProductAttribute,
variant: ProductVariant | null
): number | null {
if (!variant) {
return null;
}
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
if (!defaultValue) {
return null;
}
const normalizedValue = this.normalizeText(defaultValue);
const matchByValue = attribute.options.find(
(option) => this.normalizeText(option.value) === normalizedValue
);
if (matchByValue) {
return matchByValue.id;
}
const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue
);
return matchByLabel?.id ?? null;
}
private getDefaultVariantAttributeValue(
attribute: ProductAttribute,
variantAttributes: Record<string, string>
): string | null {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
for (const [key, value] of Object.entries(variantAttributes)) {
const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return value;
}
}
return null;
}
private normalizeText(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase();
}
private findFirstHexValue(value: unknown): string | null {
if (typeof value === 'string') {
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
return match ? match[0] : null;
}
if (Array.isArray(value)) {
for (const item of value) {
const found = this.findFirstHexValue(item);
if (found) {
return found;
}
}
return null;
}
if (value && typeof value === 'object') {
for (const item of Object.values(value)) {
const found = this.findFirstHexValue(item);
if (found) {
return found;
}
}
}
return null;
}
private emitMatchingVariant(
selections: Record<number, number>,
variantsMap: ProductVariantMap[],
attributes: ProductAttribute[]
): void {
if (attributes.length === 0 || variantsMap.length === 0) {
this.variantChange.emit(null);
return;
}
const selectedValuesById: Record<number, string> = {};
let allSelected = true;
for (const attr of attributes) {
const selectedOptionId = selections[attr.id];
if (selectedOptionId === undefined) {
allSelected = false;
break;
}
const option = attr.options.find(o => o.id === selectedOptionId);
if (option) {
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
}
}
if (!allSelected) {
this.variantChange.emit(null);
return;
}
const matchingVariant = variantsMap.find(vMap => {
return attributes.every(attr => {
const selectedValue = selectedValuesById[attr.id];
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
if (!vMapValue) return false;
return this.normalizeText(vMapValue) === selectedValue;
});
});
this.variantChange.emit(matchingVariant || null);
}
}

View File

@@ -40,40 +40,12 @@
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--attributes">
@for (attribute of renderableAttributes(); track attribute.id) {
<div class="product-detail__attribute-row">
<span class="product-detail__attribute-label">{{ attribute.nombre }}:</span>
<div class="product-detail__attribute-options">
@for (option of attribute.options; track option.id) {
@if (isColorOption(option)) {
<button
type="button"
class="product-detail__attribute-swatch"
[class.product-detail__attribute-swatch--selected]="hasSelectedOption(attribute, option)"
[style.background-color]="getOptionSwatchColor(option)"
[attr.aria-label]="option.label"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
[title]="option.label"
(click)="selectAttributeOption(attribute, option)"
>
<span class="visually-hidden">{{ option.label }}</span>
</button>
} @else {
<button
type="button"
class="product-detail__attribute-text-option"
[class.product-detail__attribute-text-option--selected]="hasSelectedOption(attribute, option)"
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
(click)="selectAttributeOption(attribute, option)"
>
{{ option.label }}
</button>
}
}
</div>
</div>
}
<app-product-attribute-selector
[attributes]="renderableAttributes()"
[variantsMap]="prod.variants_map"
[defaultVariant]="prod.variant"
(variantChange)="onVariantChange($event)"
/>
</section>
}

View File

@@ -54,21 +54,6 @@
gap: 1.25rem;
}
&__attribute-row {
display: flex;
align-items: center;
gap: 0.875rem;
flex-wrap: wrap;
}
&__attribute-label {
font-size: 15px;
font-weight: 700;
line-height: 1.2;
color: #A0A0A0;
min-width: 44px;
}
&__attribute-options,
&__actions {
display: flex;
@@ -77,29 +62,6 @@
flex-wrap: wrap;
}
&__attribute-swatch {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid transparent;
padding: 0;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
transform: scale(1.05);
}
&--selected {
border-color: var(--tenant-primary);
box-shadow:
0 0 0 2px rgba(255, 255, 255, 1),
0 0 0 3px var(--tenant-primary);
}
}
&__attribute-text-option,
&__quantity-button {
border: 1px solid #dcdcdc;
background: #ffffff;
@@ -107,22 +69,6 @@
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
&__attribute-text-option {
min-width: 34px;
min-height: 34px;
border-radius: 4px;
padding: 0.35rem 0.6rem;
font-size: 16px;
font-weight: 700;
line-height: 1;
&--selected {
background: var(--tenant-primary);
border-color: var(--tenant-primary);
color: #ffffff;
}
}
&__purchase {
display: flex;
align-items: stretch;

View File

@@ -20,15 +20,17 @@ import {
ProductAttribute,
ProductAttributeOption,
ProductDetail,
ProductVariant
ProductVariant,
ProductVariantMap
} from '../../../../core/services/catalog/catalog.interface';
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component';
@Component({
selector: 'app-product-detail-page',
standalone: true,
imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent],
imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent, ProductAttributeSelectorComponent],
templateUrl: './product-detail-page.component.html',
styleUrl: './product-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -62,7 +64,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
});
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
protected readonly quantity = signal(1);
protected readonly descriptionExpanded = signal(false);
protected readonly descriptionMaxHeight = signal(0);
@@ -124,7 +126,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.productSub = this.catalogService.getProducto(id, defaultVariantId).subscribe({
next: (prod) => {
this.product.set(prod);
this.initializeSelections(prod);
this.quantity.set(1);
this.descriptionExpanded.set(false);
this.descriptionHasOverflow.set(false);
@@ -151,22 +152,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return `$${parts.join(',')}`;
}
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id;
}
protected selectAttributeOption(
attribute: ProductAttribute,
option: ProductAttributeOption
): void {
this.selectedAttributeOptions.update((current) => ({
...current,
[attribute.id]: option.id
}));
}
protected isColorOption(option: ProductAttributeOption): boolean {
return this.findFirstHexValue(option.metadata) !== null;
protected onVariantChange(variant: ProductVariantMap | null): void {
this.selectedVariant.set(variant);
}
protected increaseQuantity(): void {
@@ -181,71 +168,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.descriptionExpanded.update((current) => !current);
}
protected getOptionSwatchColor(option: ProductAttributeOption): string {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
}
private initializeSelections(product: ProductDetail): void {
const defaultVariant = product.variant;
const selections: Record<number, number> = {};
for (const attribute of product.attributes) {
const optionId = this.findDefaultOptionId(attribute, defaultVariant);
if (optionId !== null) {
selections[attribute.id] = optionId;
}
}
this.selectedAttributeOptions.set(selections);
}
private findDefaultOptionId(
attribute: ProductAttribute,
variant: ProductVariant | null
): number | null {
if (!variant) {
return null;
}
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.definitions);
if (!defaultValue) {
return null;
}
const normalizedValue = this.normalizeText(defaultValue);
const matchByValue = attribute.options.find(
(option) => this.normalizeText(option.value) === normalizedValue
);
if (matchByValue) {
return matchByValue.id;
}
const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue
);
return matchByLabel?.id ?? null;
}
private getDefaultVariantAttributeValue(
attribute: ProductAttribute,
variantAttributes: Record<string, string>
): string | null {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
for (const [key, value] of Object.entries(variantAttributes)) {
const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return value;
}
}
return null;
}
private parseIntegerParam(value: string | null): number | null {
if (!value) {
return null;
@@ -255,42 +177,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return Number.isInteger(parsed) ? parsed : null;
}
private normalizeText(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase();
}
private findFirstHexValue(value: unknown): string | null {
if (typeof value === 'string') {
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
return match ? match[0] : null;
}
if (Array.isArray(value)) {
for (const item of value) {
const found = this.findFirstHexValue(item);
if (found) {
return found;
}
}
return null;
}
if (value && typeof value === 'object') {
for (const item of Object.values(value)) {
const found = this.findFirstHexValue(item);
if (found) {
return found;
}
}
}
return null;
}
private bindCarouselResizeObserver(): void {
const previewElement = this.getCarouselPreviewElement();