feat: implement product detail page with attribute selector and quantity management
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user