bug-fixes
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
<aside class="catalog-filters card">
|
||||
<div class="filters-head">
|
||||
<h3>Filters</h3>
|
||||
<button type="button" (click)="resetFilters.emit()">Reset</button>
|
||||
<h3>{{ 'catalog.filtersTitle' | translate }}</h3>
|
||||
</div>
|
||||
|
||||
@for (filter of definitions; track filter.id) {
|
||||
@@ -32,12 +31,12 @@
|
||||
type="number"
|
||||
[value]="state.ranges[filter.id]?.min ?? ''"
|
||||
(input)="updateRange(filter.id, 'min', $any($event.target).value)"
|
||||
[placeholder]="'Min ' + (filter.min ?? '')" />
|
||||
[placeholder]="'catalog.minValue' | translate:{ value: filter.min ?? '' }" />
|
||||
<input
|
||||
type="number"
|
||||
[value]="state.ranges[filter.id]?.max ?? ''"
|
||||
(input)="updateRange(filter.id, 'max', $any($event.target).value)"
|
||||
[placeholder]="'Max ' + (filter.max ?? '')" />
|
||||
[placeholder]="'catalog.maxValue' | translate:{ value: filter.max ?? '' }" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -47,7 +46,7 @@
|
||||
type="checkbox"
|
||||
[checked]="state.toggles[filter.id]"
|
||||
(change)="updateToggle(filter.id, $any($event.target).checked)" />
|
||||
<span>Enabled</span>
|
||||
<span>{{ 'catalog.enabled' | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
.filters-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filters-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -64,3 +56,13 @@
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.catalog-filters {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FilterDefinition } from '../../../../../core/products/models/catalog-experience.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
export interface CatalogFilterStateValue {
|
||||
values: Record<string, string[]>;
|
||||
@@ -11,7 +12,7 @@ export interface CatalogFilterStateValue {
|
||||
@Component({
|
||||
selector: 'app-catalog-filters-panel',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, TranslatePipe],
|
||||
templateUrl: './filters-panel.component.html',
|
||||
styleUrls: ['./filters-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -21,7 +22,6 @@ export class CatalogFiltersPanelComponent {
|
||||
@Input() state: CatalogFilterStateValue = { values: {}, ranges: {}, toggles: {} };
|
||||
|
||||
@Output() stateChange = new EventEmitter<CatalogFilterStateValue>();
|
||||
@Output() resetFilters = new EventEmitter<void>();
|
||||
|
||||
isSelected(filterId: string, optionValue: string): boolean {
|
||||
return (this.state.values[filterId] ?? []).includes(optionValue);
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
<div class="catalog-layout-switcher card">
|
||||
@for (layout of available; track layout) {
|
||||
@for (layout of available; track $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="layout-btn"
|
||||
[class.active]="layout === active"
|
||||
[attr.aria-label]="labels[layout] | translate"
|
||||
(click)="activeChange.emit(layout)">
|
||||
{{ labels[layout] }}
|
||||
@switch (layout) {
|
||||
@case ('grid') {
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="3" width="8" height="8" rx="1"></rect>
|
||||
<rect x="13" y="3" width="8" height="8" rx="1"></rect>
|
||||
<rect x="3" y="13" width="8" height="8" rx="1"></rect>
|
||||
<rect x="13" y="13" width="8" height="8" rx="1"></rect>
|
||||
</svg>
|
||||
}
|
||||
@case ('large-grid') {
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="3" width="18" height="8" rx="1"></rect>
|
||||
<rect x="3" y="13" width="8" height="8" rx="1"></rect>
|
||||
<rect x="13" y="13" width="8" height="8" rx="1"></rect>
|
||||
</svg>
|
||||
}
|
||||
@case ('compact-grid') {
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="3" width="5" height="5" rx="1"></rect>
|
||||
<rect x="10" y="3" width="5" height="5" rx="1"></rect>
|
||||
<rect x="17" y="3" width="4" height="5" rx="1"></rect>
|
||||
<rect x="3" y="10" width="5" height="5" rx="1"></rect>
|
||||
<rect x="10" y="10" width="5" height="5" rx="1"></rect>
|
||||
<rect x="17" y="10" width="4" height="5" rx="1"></rect>
|
||||
<rect x="3" y="17" width="5" height="4" rx="1"></rect>
|
||||
<rect x="10" y="17" width="5" height="4" rx="1"></rect>
|
||||
<rect x="17" y="17" width="4" height="4" rx="1"></rect>
|
||||
</svg>
|
||||
}
|
||||
@default {
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="4" width="18" height="4" rx="1"></rect>
|
||||
<rect x="3" y="10" width="18" height="4" rx="1"></rect>
|
||||
<rect x="3" y="16" width="18" height="4" rx="1"></rect>
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
|
||||
<span>{{ labels[layout] | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -6,17 +6,46 @@
|
||||
}
|
||||
|
||||
.layout-btn {
|
||||
min-height: 34px;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.layout-btn.active {
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, white);
|
||||
}
|
||||
|
||||
.layout-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.catalog-layout-switcher {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.layout-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog-layout-switcher',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './layout-switcher.component.html',
|
||||
styleUrls: ['./layout-switcher.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -15,9 +17,9 @@ export class CatalogLayoutSwitcherComponent {
|
||||
@Output() activeChange = new EventEmitter<CatalogLayoutMode>();
|
||||
|
||||
readonly labels: Record<CatalogLayoutMode, string> = {
|
||||
grid: 'Grid',
|
||||
'large-grid': 'Large Grid',
|
||||
'compact-grid': 'Compact Grid',
|
||||
list: 'List'
|
||||
grid: 'catalog.layoutGrid',
|
||||
'large-grid': 'catalog.layoutLargeGrid',
|
||||
'compact-grid': 'catalog.layoutCompactGrid',
|
||||
list: 'catalog.layoutList'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="queryChange.emit($event)"
|
||||
name="query"
|
||||
placeholder="Search products, brands, categories"
|
||||
[placeholder]="'catalog.searchPlaceholder' | translate"
|
||||
autocomplete="off" />
|
||||
<button type="submit" [disabled]="loading">Search</button>
|
||||
<button type="submit" [disabled]="loading">{{ 'catalog.searchSubmit' | translate }}</button>
|
||||
</form>
|
||||
|
||||
@if (suggestions.length > 0) {
|
||||
<div class="suggestions">
|
||||
<strong>Suggestions</strong>
|
||||
<strong>{{ 'catalog.suggestionsTitle' | translate }}</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of suggestions; track item) {
|
||||
@for (item of suggestions; track $index) {
|
||||
<button type="button" class="chip" (click)="selectSuggestion(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
@@ -23,9 +23,9 @@
|
||||
|
||||
@if (recentSearches.length > 0) {
|
||||
<div class="recent">
|
||||
<strong>Recent searches</strong>
|
||||
<strong>{{ 'catalog.recentSearchesTitle' | translate }}</strong>
|
||||
<div class="chip-list">
|
||||
@for (item of recentSearches; track item) {
|
||||
@for (item of recentSearches; track $index) {
|
||||
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
@@ -35,11 +35,11 @@
|
||||
@if (searchHistory.length > 0) {
|
||||
<div class="history">
|
||||
<div class="history-head">
|
||||
<strong>Search history</strong>
|
||||
<button type="button" (click)="historyCleared.emit()">Clear</button>
|
||||
<strong>{{ 'catalog.searchHistoryTitle' | translate }}</strong>
|
||||
<button type="button" (click)="historyCleared.emit()">{{ 'catalog.clearHistory' | translate }}</button>
|
||||
</div>
|
||||
<div class="chip-list">
|
||||
@for (item of searchHistory; track item) {
|
||||
@for (item of searchHistory; track $index) {
|
||||
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
|
||||
}
|
||||
</div>
|
||||
@@ -47,6 +47,6 @@
|
||||
}
|
||||
|
||||
@if (noResults && !loading) {
|
||||
<p class="no-results">No results found for this query. Try broader keywords.</p>
|
||||
<p class="no-results">{{ 'catalog.searchNoResultsHint' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.search-form input:focus-visible {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.search-form button {
|
||||
@@ -26,6 +33,13 @@
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
.search-form button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.chip-list {
|
||||
@@ -42,6 +56,13 @@
|
||||
color: var(--text-secondary);
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, white);
|
||||
}
|
||||
|
||||
.history-head {
|
||||
@@ -56,6 +77,7 @@
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
@@ -67,4 +89,8 @@
|
||||
.search-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog-search-box',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, TranslatePipe],
|
||||
templateUrl: './search-box.component.html',
|
||||
styleUrls: ['./search-box.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="catalog-search-results section">
|
||||
<div class="results-head">
|
||||
<strong>{{ summary }}</strong>
|
||||
<span>{{ total }} items</span>
|
||||
<span>{{ 'catalog.itemsCount' | translate:{ count: total } }}</span>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
@@ -29,16 +29,16 @@
|
||||
(shareRequested)="shareRequested.emit($event)" />
|
||||
} @else {
|
||||
<div class="empty-state card">
|
||||
<h3>No results found</h3>
|
||||
<p>Try changing filters, sorting, or search keywords.</p>
|
||||
<h3>{{ 'catalog.emptyResultsTitle' | translate }}</h3>
|
||||
<p>{{ 'catalog.emptyResultsDescription' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && totalPages > 1) {
|
||||
<div class="results-pager">
|
||||
<button type="button" (click)="previous()" [disabled]="page <= 1">Previous</button>
|
||||
<span>Page {{ page }} / {{ totalPages }}</span>
|
||||
<button type="button" (click)="next()" [disabled]="page >= totalPages">Next</button>
|
||||
<button type="button" (click)="previous()" [disabled]="page <= 1">{{ 'catalog.previousPage' | translate }}</button>
|
||||
<span>{{ 'catalog.pageOf' | translate:{ page: page, total: totalPages } }}</span>
|
||||
<button type="button" (click)="next()" [disabled]="page >= totalPages">{{ 'catalog.nextPage' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -64,6 +64,25 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.results-pager button:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.results-pager {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.results-pager span {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
|
||||
@@ -2,11 +2,12 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
|
||||
import { CatalogProductGridComponent } from '../product-grid/product-grid.component';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog-search-results',
|
||||
standalone: true,
|
||||
imports: [CatalogProductGridComponent],
|
||||
imports: [CatalogProductGridComponent, TranslatePipe],
|
||||
templateUrl: './search-results.component.html',
|
||||
styleUrls: ['./search-results.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<label class="catalog-sorting card">
|
||||
<span>Sort by</span>
|
||||
<span>{{ 'catalog.sortBy' | translate }}</span>
|
||||
<select [ngModel]="selected" (ngModelChange)="selectedChange.emit($event)">
|
||||
@for (option of options; track option.id) {
|
||||
@if (option.enabled !== false) {
|
||||
|
||||
@@ -20,3 +20,15 @@
|
||||
padding: 0 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.catalog-sorting {
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.catalog-sorting select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SortDefinition } from '../../../../../core/products/models/catalog-experience.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog-sorting-control',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, TranslatePipe],
|
||||
templateUrl: './sorting-control.component.html',
|
||||
styleUrls: ['./sorting-control.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
@for (saved of savedSearches(); track saved.id) {
|
||||
<div class="catalog-saved-chip">
|
||||
<button type="button" (click)="useSavedSearch(saved.id)">{{ saved.name }}</button>
|
||||
<button type="button" class="catalog-saved-chip-remove" (click)="removeSavedSearch(saved.id)">×</button>
|
||||
<button type="button" class="catalog-saved-chip-remove" [attr.aria-label]="'catalog.removeSavedSearch' | translate" (click)="removeSavedSearch(saved.id)">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
@if (loading()) {
|
||||
<section class="catalog-loading" [attr.aria-label]="'catalog.loading' | translate">
|
||||
@for (slot of skeletonSlots; track slot) {
|
||||
@for (slot of skeletonSlots; track $index) {
|
||||
<div class="catalog-skeleton-card">
|
||||
<div class="catalog-skeleton-image"></div>
|
||||
<div class="catalog-skeleton-line catalog-skeleton-title"></div>
|
||||
@@ -61,13 +61,13 @@
|
||||
<section class="catalog-section">
|
||||
<div class="catalog-section-heading">
|
||||
<h1>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h1>
|
||||
<p>{{ 'catalog.categoryHint' | translate }} • {{ categories().length }} entries</p>
|
||||
<p>{{ 'catalog.categoryHint' | translate }} • {{ 'catalog.entriesCount' | translate:{ count: categories().length } }}</p>
|
||||
</div>
|
||||
|
||||
@if (catalogConfig().showCategoryBanner) {
|
||||
<div class="catalog-category-banner card">
|
||||
<h3>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h3>
|
||||
<p>Category banner placeholder for backend-driven media and description.</p>
|
||||
<p>{{ 'catalog.categoryBannerPlaceholder' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -98,16 +98,19 @@
|
||||
<app-catalog-filters-panel
|
||||
[definitions]="filterDefinitions()"
|
||||
[state]="filterState()"
|
||||
(stateChange)="onFilterStateChange($event)"
|
||||
(resetFilters)="resetFilters()" />
|
||||
(stateChange)="onFilterStateChange($event)" />
|
||||
</aside>
|
||||
|
||||
<div class="catalog-results-panel">
|
||||
<div class="catalog-tools-row">
|
||||
<app-catalog-sorting-control
|
||||
[options]="sortDefinitions()"
|
||||
[selected]="state().sort"
|
||||
(selectedChange)="changeSort($event)" />
|
||||
<div class="catalog-sort-reset-group">
|
||||
<app-catalog-sorting-control
|
||||
[options]="sortDefinitions()"
|
||||
[selected]="state().sort"
|
||||
(selectedChange)="changeSort($event)" />
|
||||
|
||||
<button type="button" class="catalog-reset-btn" (click)="resetFilters()">{{ 'catalog.resetFilters' | translate }}</button>
|
||||
</div>
|
||||
|
||||
@if (userExperienceConfig().savedSearches.enabled) {
|
||||
<button type="button" class="catalog-save-search-btn" (click)="saveCurrentSearch()">{{ 'ux.saveSearch' | translate }}</button>
|
||||
@@ -122,7 +125,7 @@
|
||||
@if (catalogConfig().navigationMode !== 'default') {
|
||||
<div class="catalog-navigation-placeholder card">
|
||||
<strong>{{ catalogConfig().navigationMode }}</strong>
|
||||
<p>Navigation layout placeholder prepared for backend/bootstrap-driven rendering.</p>
|
||||
<p>{{ 'catalog.navigationPlaceholder' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,31 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.catalog-sort-reset-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.catalog-reset-btn {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.catalog-reset-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 7%, white);
|
||||
}
|
||||
|
||||
.catalog-save-search-btn {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -110,6 +135,13 @@
|
||||
padding: 0 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.catalog-save-search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, white);
|
||||
}
|
||||
|
||||
.catalog-category-banner {
|
||||
@@ -272,14 +304,40 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.catalog-header {
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.catalog-products-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.catalog-left-panel {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.catalog-tools-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.catalog-sort-reset-group,
|
||||
.catalog-save-search-btn,
|
||||
.catalog-layout-switcher {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.catalog-sort-reset-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-reset-btn,
|
||||
.catalog-save-search-btn {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.catalog-root-link {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
import { PrefetchService } from '../../../../services/prefetch.service';
|
||||
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
|
||||
import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
|
||||
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component';
|
||||
@@ -61,6 +62,7 @@ export class CatalogContainerComponent {
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly shareService = inject(ProductShareService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly catalogConfig = signal(this.resolveCatalogConfig());
|
||||
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||
@@ -89,13 +91,13 @@ export class CatalogContainerComponent {
|
||||
|
||||
readonly sortDefinitions = computed<SortDefinition[]>(() => {
|
||||
const labels: Record<string, string> = {
|
||||
relevance: 'Relevance',
|
||||
latest: 'Newest',
|
||||
price_asc: 'Price Low -> High',
|
||||
price_desc: 'Price High -> Low',
|
||||
rating: 'Highest Rated',
|
||||
popular: 'Most Popular',
|
||||
discount: 'Discount'
|
||||
relevance: this.translate.t('catalog.sortRelevance'),
|
||||
latest: this.translate.t('catalog.sortLatest'),
|
||||
price_asc: this.translate.t('catalog.sortPriceAsc'),
|
||||
price_desc: this.translate.t('catalog.sortPriceDesc'),
|
||||
rating: this.translate.t('catalog.sortRating'),
|
||||
popular: this.translate.t('catalog.sortPopular'),
|
||||
discount: this.translate.t('catalog.sortDiscount')
|
||||
};
|
||||
|
||||
return this.catalogConfig().availableSorts.map((id: string) => ({ id, label: labels[id] ?? id, enabled: true }));
|
||||
@@ -267,24 +269,27 @@ export class CatalogContainerComponent {
|
||||
|
||||
onFavoriteToggled(product: Product): void {
|
||||
const result = this.uxFacade.toggleWishlist(product);
|
||||
this.notifications.show(result.added ? 'Added to wishlist' : 'Removed from wishlist', result.added ? 'success' : 'info');
|
||||
this.notifications.show(
|
||||
result.added ? this.translate.t('ux.wishlistAdded') : this.translate.t('ux.wishlistRemoved'),
|
||||
result.added ? 'success' : 'info'
|
||||
);
|
||||
}
|
||||
|
||||
onCompareToggled(product: Product): void {
|
||||
if (this.uxFacade.isInCompare(product.itemID)) {
|
||||
this.uxFacade.removeFromCompare(product.itemID);
|
||||
this.notifications.show('Removed from compare', 'info');
|
||||
this.notifications.show(this.translate.t('ux.compareRemoved'), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxItems = this.userExperienceConfig().compare.maxItems;
|
||||
const result = this.uxFacade.addToCompare(product, maxItems);
|
||||
if (result.reason === 'limit') {
|
||||
this.notifications.show(`Compare limit reached (${maxItems})`, 'warning');
|
||||
this.notifications.show(this.translate.t('ux.compareLimitReached', { maxItems }), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifications.show('Added to compare', 'success');
|
||||
this.notifications.show(this.translate.t('ux.compareAdded'), 'success');
|
||||
}
|
||||
|
||||
async onShareRequested(product: Product): Promise<void> {
|
||||
@@ -300,16 +305,16 @@ export class CatalogContainerComponent {
|
||||
|
||||
const result = await this.shareService.shareProduct(product, productUrl);
|
||||
if (result === 'native') {
|
||||
this.notifications.show('Product shared', 'success');
|
||||
this.notifications.show(this.translate.t('ux.productShared'), 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === 'copied') {
|
||||
this.notifications.show('Product link copied', 'success');
|
||||
this.notifications.show(this.translate.t('ux.productLinkCopied'), 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifications.show('Sharing is not supported on this device', 'warning');
|
||||
this.notifications.show(this.translate.t('ux.sharingUnsupported'), 'warning');
|
||||
}
|
||||
|
||||
saveCurrentSearch(): void {
|
||||
@@ -319,7 +324,7 @@ export class CatalogContainerComponent {
|
||||
|
||||
const query = this.state().search.trim();
|
||||
if (!query.length) {
|
||||
this.notifications.show('Enter a search query before saving', 'warning');
|
||||
this.notifications.show(this.translate.t('ux.enterQueryBeforeSave'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ export class CatalogContainerComponent {
|
||||
filters: this.filterState()
|
||||
}, this.userExperienceConfig().savedSearches.maxItems);
|
||||
|
||||
this.notifications.show(`Saved search: ${saved.name}`, 'success');
|
||||
this.notifications.show(this.translate.t('ux.savedSearchNamed', { name: saved.name }), 'success');
|
||||
}
|
||||
|
||||
useSavedSearch(id: string): void {
|
||||
@@ -483,7 +488,7 @@ export class CatalogContainerComponent {
|
||||
const definitions: FilterDefinition[] = [
|
||||
{
|
||||
id: 'price',
|
||||
label: 'Price',
|
||||
label: this.translate.t('catalog.filterPrice'),
|
||||
type: 'range',
|
||||
min: priceValues.length ? Math.min(...priceValues) : 0,
|
||||
max: priceValues.length ? Math.max(...priceValues) : 0,
|
||||
@@ -491,72 +496,72 @@ export class CatalogContainerComponent {
|
||||
},
|
||||
{
|
||||
id: 'availability',
|
||||
label: 'Availability',
|
||||
label: this.translate.t('catalog.filterAvailability'),
|
||||
type: 'multi-select',
|
||||
options: [
|
||||
{ id: 'in-stock', label: 'In stock', value: 'in-stock' },
|
||||
{ id: 'low-stock', label: 'Low stock', value: 'low-stock' },
|
||||
{ id: 'out-of-stock', label: 'Out of stock', value: 'out-of-stock' }
|
||||
{ id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock' },
|
||||
{ id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock' },
|
||||
{ id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock' }
|
||||
],
|
||||
enabled: enabled.has('availability')
|
||||
},
|
||||
{
|
||||
id: 'rating',
|
||||
label: 'Rating',
|
||||
label: this.translate.t('catalog.filterRating'),
|
||||
type: 'multi-select',
|
||||
options: ratings.map(value => ({ id: `rating-${value}`, label: `${value} stars`, value: String(value) })),
|
||||
options: ratings.map(value => ({ id: `rating-${value}`, label: this.translate.t('catalog.filterStars', { count: value }), value: String(value) })),
|
||||
enabled: enabled.has('rating')
|
||||
},
|
||||
{
|
||||
id: 'brand',
|
||||
label: 'Brand',
|
||||
label: this.translate.t('catalog.filterBrand'),
|
||||
type: 'multi-select',
|
||||
options: brands.map(value => ({ id: `brand-${value}`, label: value, value })),
|
||||
enabled: enabled.has('brand')
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
label: 'Category',
|
||||
label: this.translate.t('catalog.filterCategory'),
|
||||
type: 'multi-select',
|
||||
options: categories.map(value => ({ id: `cat-${value}`, label: `Category ${value}`, value })),
|
||||
options: categories.map(value => ({ id: `cat-${value}`, label: this.translate.t('catalog.filterCategoryValue', { value }), value })),
|
||||
enabled: enabled.has('category')
|
||||
},
|
||||
{
|
||||
id: 'subcategory',
|
||||
label: 'Subcategory',
|
||||
label: this.translate.t('catalog.filterSubcategory'),
|
||||
type: 'multi-select',
|
||||
options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })),
|
||||
enabled: enabled.has('subcategory')
|
||||
},
|
||||
{
|
||||
id: 'discount',
|
||||
label: 'Discount',
|
||||
label: this.translate.t('catalog.filterDiscount'),
|
||||
type: 'toggle',
|
||||
enabled: enabled.has('discount')
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
label: 'New',
|
||||
label: this.translate.t('catalog.filterNew'),
|
||||
type: 'toggle',
|
||||
enabled: enabled.has('new')
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
label: 'Color',
|
||||
label: this.translate.t('catalog.filterColor'),
|
||||
type: 'multi-select',
|
||||
options: colors.map(value => ({ id: `color-${value}`, label: value, value })),
|
||||
enabled: enabled.has('color')
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
label: 'Size',
|
||||
label: this.translate.t('catalog.filterSize'),
|
||||
type: 'multi-select',
|
||||
options: sizes.map(value => ({ id: `size-${value}`, label: value, value })),
|
||||
enabled: enabled.has('size')
|
||||
},
|
||||
{
|
||||
id: 'attributes',
|
||||
label: 'Attributes',
|
||||
label: this.translate.t('catalog.filterAttributes'),
|
||||
type: 'multi-select',
|
||||
options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })),
|
||||
enabled: enabled.has('attributes')
|
||||
@@ -579,7 +584,7 @@ export class CatalogContainerComponent {
|
||||
page: 1
|
||||
}
|
||||
}));
|
||||
this.searchSummary.set('No results');
|
||||
this.searchSummary.set(this.translate.t('catalog.noResults'));
|
||||
this.searchResult.set(null);
|
||||
this.searchSuggestions.set([]);
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<section class="product-gallery" aria-label="Product media">
|
||||
<section class="product-gallery" [attr.aria-label]="'productDetails.mediaAria' | translate">
|
||||
<div class="product-gallery-main">
|
||||
@if (media[selectedIndex]?.video) {
|
||||
<video [src]="media[selectedIndex].url" controls></video>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: box-shadow 0.24s ease, border-color 0.24s ease;
|
||||
|
||||
img,
|
||||
video {
|
||||
@@ -36,12 +37,18 @@
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&.active {
|
||||
border-color: #497671;
|
||||
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.18);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #497671;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -59,3 +66,10 @@
|
||||
color: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.product-gallery-thumbs {
|
||||
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { getMainImage } from '../../../../../utils/item.utils';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-gallery',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './product-gallery.component.html',
|
||||
styleUrls: ['./product-gallery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
@if (product.badges?.length) {
|
||||
<div class="product-badges">
|
||||
@for (badge of product.badges; track badge) {
|
||||
@for (badge of product.badges; track $index) {
|
||||
<span class="product-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -99,8 +99,25 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #3d635f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(73, 118, 113, 0.25);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.product-information {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-price strong {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section class="product-specifications card section">
|
||||
<h3>Specifications</h3>
|
||||
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
|
||||
|
||||
@if (descriptionFields.length > 0) {
|
||||
<dl>
|
||||
@@ -11,6 +11,6 @@
|
||||
}
|
||||
</dl>
|
||||
} @else {
|
||||
<p>No specifications provided yet.</p>
|
||||
<p>{{ 'productDetails.specificationsEmpty' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-specifications',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './product-specifications.component.html',
|
||||
styleUrls: ['./product-specifications.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<section class="product-warranty card section">
|
||||
<h3>Warranty and returns</h3>
|
||||
<h3>{{ 'productDetails.warrantyTitle' | translate }}</h3>
|
||||
<ul>
|
||||
<li>Warranty terms are provided by the seller and local law.</li>
|
||||
<li>Return eligibility depends on item condition and category.</li>
|
||||
<li>Detailed return instructions are available after purchase.</li>
|
||||
<li>{{ 'productDetails.warrantyItem1' | translate }}</li>
|
||||
<li>{{ 'productDetails.warrantyItem2' | translate }}</li>
|
||||
<li>{{ 'productDetails.warrantyItem3' | translate }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-warranty',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './product-warranty.component.html',
|
||||
styleUrls: ['./product-warranty.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
|
||||
<div class="variant-options">
|
||||
@for (colour of colours; track colour) {
|
||||
@for (colour of colours; track $index) {
|
||||
<button type="button" class="colour-swatch" [class.active]="selectedColour === colour" [style.background-color]="colour" [attr.aria-label]="colour" (click)="colourSelected.emit(colour)"></button>
|
||||
}
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="variant-group">
|
||||
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
|
||||
<div class="variant-options">
|
||||
@for (size of sizes; track size) {
|
||||
@for (size of sizes; track $index) {
|
||||
<button type="button" class="size-chip" [class.active]="selectedSize === size" (click)="sizeSelected.emit(size)">{{ size }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 42px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.product-details-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1fr);
|
||||
gap: 42px;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 1fr);
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
.product-engagement {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-tab-content {
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
.product-engagement-stacked {
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-details-loading,
|
||||
@@ -91,12 +91,28 @@
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.product-details-page {
|
||||
padding: 16px;
|
||||
gap: 32px;
|
||||
padding: 18px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.product-details-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.product-details-main {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.product-details-page {
|
||||
padding: 14px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-details-message {
|
||||
min-height: 320px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConfigService } from '../../../../core/config/config.service';
|
||||
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
||||
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
|
||||
import { CartService } from '../../../../services';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
@@ -56,6 +57,7 @@ export class ProductDetailsContainerComponent {
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
||||
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||
@@ -422,17 +424,17 @@ export class ProductDetailsContainerComponent {
|
||||
private getTabLabel(key: ProductTabKey): string {
|
||||
switch (key) {
|
||||
case 'description':
|
||||
return 'Description';
|
||||
return this.translate.t('itemDetail.description');
|
||||
case 'specifications':
|
||||
return 'Specifications';
|
||||
return this.translate.t('itemDetail.specifications');
|
||||
case 'reviews':
|
||||
return 'Reviews';
|
||||
return this.translate.t('itemDetail.reviews');
|
||||
case 'questions':
|
||||
return 'Questions';
|
||||
return this.translate.t('itemDetail.qna');
|
||||
case 'delivery':
|
||||
return 'Delivery';
|
||||
return this.translate.t('cart.deliveryLabel');
|
||||
case 'warranty':
|
||||
return 'Warranty';
|
||||
return this.translate.t('footer.guarantee');
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@if (tabs.length > 0) {
|
||||
<nav class="product-tabs" aria-label="Product tabs">
|
||||
<nav class="product-tabs" [attr.aria-label]="'productDetails.tabsAria' | translate">
|
||||
@for (tab of tabs; track tab.key) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
|
||||
export type ProductTabKey = 'description' | 'specifications' | 'reviews' | 'questions' | 'delivery' | 'warranty';
|
||||
|
||||
@@ -12,6 +13,7 @@ export interface ProductTabItem {
|
||||
@Component({
|
||||
selector: 'app-product-tabs',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './product-tabs.component.html',
|
||||
styleUrls: ['./product-tabs.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
<div class="answer">
|
||||
<div class="answer-meta">
|
||||
<strong>{{ answer.author }}</strong>
|
||||
<span class="seller-pill" [class.visible]="answer.isOfficialSeller">Official seller</span>
|
||||
<span class="seller-pill" [class.visible]="answer.isOfficialSeller">{{ 'productDetails.officialSeller' | translate }}</span>
|
||||
@if (answer.isAccepted) {
|
||||
<span class="accepted-pill">Accepted answer</span>
|
||||
<span class="accepted-pill">{{ 'productDetails.acceptedAnswer' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
<p>{{ answer.text }}</p>
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
<footer>
|
||||
<button type="button" disabled>Like ({{ question.likes }})</button>
|
||||
<button type="button" disabled>Dislike ({{ question.dislikes }})</button>
|
||||
<button type="button" disabled>{{ 'productDetails.like' | translate }} ({{ question.likes }})</button>
|
||||
<button type="button" disabled>{{ 'productDetails.dislike' | translate }} ({{ question.dislikes }})</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { Question } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-question-card',
|
||||
standalone: true,
|
||||
imports: [DatePipe],
|
||||
imports: [DatePipe, TranslatePipe],
|
||||
templateUrl: './question-card.component.html',
|
||||
styleUrls: ['./question-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<form class="question-form card" (ngSubmit)="onSubmit()">
|
||||
<h3>Ask a question</h3>
|
||||
<h3>{{ 'productDetails.questionFormTitle' | translate }}</h3>
|
||||
|
||||
<textarea
|
||||
id="question-text"
|
||||
name="text"
|
||||
rows="4"
|
||||
[(ngModel)]="text"
|
||||
placeholder="Ask about size, delivery, compatibility or usage"></textarea>
|
||||
[placeholder]="'productDetails.questionFormPlaceholder' | translate"></textarea>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
|
||||
<span>Ask anonymously</span>
|
||||
<span>{{ 'productDetails.questionFormAnonymous' | translate }}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" [disabled]="submitting || !text.trim()">
|
||||
@if (submitting) { Submitting... } @else { Submit question }
|
||||
@if (submitting) { {{ 'productDetails.submitting' | translate }} } @else { {{ 'productDetails.submitQuestion' | translate }} }
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-question-form',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, TranslatePipe],
|
||||
templateUrl: './question-form.component.html',
|
||||
styleUrls: ['./question-form.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<section class="question-list section">
|
||||
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
|
||||
@if (askFormVisible()) { Hide question form } @else { Ask question }
|
||||
@if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} }
|
||||
</button>
|
||||
|
||||
@if (askFormVisible()) {
|
||||
@@ -19,14 +19,14 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="empty">No questions yet.</p>
|
||||
<p class="empty">{{ 'productDetails.questionsEmpty' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (result && totalPages > 1) {
|
||||
<div class="pager">
|
||||
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button>
|
||||
<span>Page {{ result.page }} / {{ totalPages }}</span>
|
||||
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button>
|
||||
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
|
||||
<span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
|
||||
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
color: var(--text-primary);
|
||||
padding: 0 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ask-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.question-items {
|
||||
@@ -37,6 +44,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
@@ -55,3 +63,19 @@
|
||||
@keyframes shimmer {
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ask-btn {
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pager span {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||
import { EngagementListResult, Question, SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
import { QuestionCardComponent } from '../question-card/question-card.component';
|
||||
import { QuestionFormComponent } from '../question-form/question-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-question-list',
|
||||
standalone: true,
|
||||
imports: [QuestionCardComponent, QuestionFormComponent],
|
||||
imports: [QuestionCardComponent, QuestionFormComponent, TranslatePipe],
|
||||
templateUrl: './question-list.component.html',
|
||||
styleUrls: ['./question-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="headline">
|
||||
<strong class="average">{{ summary.average | number:'1.1-1' }}</strong>
|
||||
<app-stars [rating]="summary.average" size="lg" />
|
||||
<span class="total">{{ summary.totalReviews }} reviews</span>
|
||||
<span class="total">{{ 'productDetails.reviewsCountLabel' | translate:{ count: summary.totalReviews } }}</span>
|
||||
</div>
|
||||
|
||||
<div class="distribution">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { DecimalPipe, PercentPipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
import { StarsComponent } from '../stars/stars.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rating-summary',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, PercentPipe, StarsComponent],
|
||||
imports: [DecimalPipe, PercentPipe, StarsComponent, TranslatePipe],
|
||||
templateUrl: './rating-summary.component.html',
|
||||
styleUrls: ['./rating-summary.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="review-header">
|
||||
<app-stars [rating]="review.rating" size="sm" />
|
||||
<strong class="author">{{ review.author }}</strong>
|
||||
<span class="pill">Verified purchase</span>
|
||||
<span class="pill">{{ 'productDetails.verifiedPurchase' | translate }}</span>
|
||||
<time>{{ review.createdAt | date:'mediumDate' }}</time>
|
||||
</header>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<p>{{ review.text }}</p>
|
||||
|
||||
<footer class="review-actions">
|
||||
<button type="button" disabled>Like ({{ review.likes }})</button>
|
||||
<button type="button" disabled>Dislike ({{ review.dislikes }})</button>
|
||||
<button type="button" disabled>{{ 'productDetails.like' | translate }} ({{ review.likes }})</button>
|
||||
<button type="button" disabled>{{ 'productDetails.dislike' | translate }} ({{ review.dislikes }})</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { Review } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
import { StarsComponent } from '../stars/stars.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-review-card',
|
||||
standalone: true,
|
||||
imports: [DatePipe, StarsComponent],
|
||||
imports: [DatePipe, StarsComponent, TranslatePipe],
|
||||
templateUrl: './review-card.component.html',
|
||||
styleUrls: ['./review-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<form class="review-form card" (ngSubmit)="onSubmit()">
|
||||
<h3>Leave a review</h3>
|
||||
<h3>{{ 'productDetails.reviewFormTitle' | translate }}</h3>
|
||||
|
||||
<label>Rating</label>
|
||||
<label>{{ 'productDetails.reviewRatingLabel' | translate }}</label>
|
||||
<app-star-selector [(rating)]="rating" />
|
||||
|
||||
<label for="review-title">Title</label>
|
||||
<input id="review-title" name="title" [(ngModel)]="title" placeholder="Short headline" maxlength="120" />
|
||||
<label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label>
|
||||
<input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" />
|
||||
|
||||
<label for="review-text">Review</label>
|
||||
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" placeholder="Describe your experience"></textarea>
|
||||
<label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label>
|
||||
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate"></textarea>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
|
||||
<span>Submit anonymously</span>
|
||||
<span>{{ 'productDetails.reviewAnonymous' | translate }}</span>
|
||||
</label>
|
||||
|
||||
<p class="upload-placeholder">Photo upload is planned for a future sprint.</p>
|
||||
<p class="upload-placeholder">{{ 'productDetails.reviewUploadPlaceholder' | translate }}</p>
|
||||
|
||||
<button type="submit" [disabled]="submitting || !rating || !text.trim()">
|
||||
@if (submitting) { Submitting... } @else { Submit review }
|
||||
@if (submitting) { {{ 'productDetails.submitting' | translate }} } @else { {{ 'productDetails.submitReview' | translate }} }
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
import { StarSelectorComponent } from '../star-selector/star-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-review-form',
|
||||
standalone: true,
|
||||
imports: [FormsModule, StarSelectorComponent],
|
||||
imports: [FormsModule, StarSelectorComponent, TranslatePipe],
|
||||
templateUrl: './review-form.component.html',
|
||||
styleUrls: ['./review-form.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="empty">No reviews yet.</p>
|
||||
<p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p>
|
||||
}
|
||||
|
||||
@if (result && totalPages > 1) {
|
||||
<div class="pager">
|
||||
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button>
|
||||
<span>Page {{ result.page }} / {{ totalPages }}</span>
|
||||
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button>
|
||||
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
|
||||
<span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
|
||||
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
background: var(--bg-primary);
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pager button:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
@@ -45,3 +51,14 @@
|
||||
@keyframes shimmer {
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pager {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pager span {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { EngagementListResult, Review, SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
import { RatingSummaryComponent } from '../rating-summary/rating-summary.component';
|
||||
import { ReviewCardComponent } from '../review-card/review-card.component';
|
||||
import { ReviewFormComponent } from '../review-form/review-form.component';
|
||||
@@ -8,7 +9,7 @@ import { ReviewFormComponent } from '../review-form/review-form.component';
|
||||
@Component({
|
||||
selector: 'app-review-list',
|
||||
standalone: true,
|
||||
imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent],
|
||||
imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent, TranslatePipe],
|
||||
templateUrl: './review-list.component.html',
|
||||
styleUrls: ['./review-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="star-selector" role="radiogroup" aria-label="Select rating">
|
||||
<div class="star-selector" role="radiogroup" [attr.aria-label]="'productDetails.selectRatingAria' | translate">
|
||||
@for (star of stars; track star) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-star-selector',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './star-selector.component.html',
|
||||
styleUrls: ['./star-selector.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<span class="stars" [class.sm]="size === 'sm'" [class.lg]="size === 'lg'" aria-label="Rating stars">
|
||||
<span class="stars" [class.sm]="size === 'sm'" [class.lg]="size === 'lg'" [attr.aria-label]="'productDetails.ratingStarsAria' | translate">
|
||||
@for (star of stars; track star) {
|
||||
<span class="star" [class.filled]="star <= rating">★</span>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stars',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './stars.component.html',
|
||||
styleUrls: ['./stars.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
Reference in New Issue
Block a user