bug-fixes

This commit is contained in:
sdarbinyan
2026-07-09 01:40:22 +04:00
parent 92e1bdaff8
commit 55216817b2
59 changed files with 885 additions and 158 deletions

View File

@@ -1,11 +1,8 @@
{ {
"/api": { "/api": {
"target": "https://api.dexarmarket.ru:445", "target": "https://novo.market",
"secure": false, "secure": false,
"changeOrigin": true, "changeOrigin": true,
"logLevel": "debug", "logLevel": "debug"
"pathRewrite": {
"^/api": ""
}
} }
} }

View File

@@ -28,7 +28,7 @@
@if (item.badges && item.badges.length > 0) { @if (item.badges && item.badges.length > 0) {
<div class="product-badges-overlay"> <div class="product-badges-overlay">
@for (badge of item.badges; track badge) { @for (badge of item.badges; track $index) {
<span class="product-badge" [class]="getBadgeClass(badge)">{{ badge }}</span> <span class="product-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
} }
</div> </div>

View File

@@ -1,7 +1,6 @@
<aside class="catalog-filters card"> <aside class="catalog-filters card">
<div class="filters-head"> <div class="filters-head">
<h3>Filters</h3> <h3>{{ 'catalog.filtersTitle' | translate }}</h3>
<button type="button" (click)="resetFilters.emit()">Reset</button>
</div> </div>
@for (filter of definitions; track filter.id) { @for (filter of definitions; track filter.id) {
@@ -32,12 +31,12 @@
type="number" type="number"
[value]="state.ranges[filter.id]?.min ?? ''" [value]="state.ranges[filter.id]?.min ?? ''"
(input)="updateRange(filter.id, 'min', $any($event.target).value)" (input)="updateRange(filter.id, 'min', $any($event.target).value)"
[placeholder]="'Min ' + (filter.min ?? '')" /> [placeholder]="'catalog.minValue' | translate:{ value: filter.min ?? '' }" />
<input <input
type="number" type="number"
[value]="state.ranges[filter.id]?.max ?? ''" [value]="state.ranges[filter.id]?.max ?? ''"
(input)="updateRange(filter.id, 'max', $any($event.target).value)" (input)="updateRange(filter.id, 'max', $any($event.target).value)"
[placeholder]="'Max ' + (filter.max ?? '')" /> [placeholder]="'catalog.maxValue' | translate:{ value: filter.max ?? '' }" />
</div> </div>
} }
@@ -47,7 +46,7 @@
type="checkbox" type="checkbox"
[checked]="state.toggles[filter.id]" [checked]="state.toggles[filter.id]"
(change)="updateToggle(filter.id, $any($event.target).checked)" /> (change)="updateToggle(filter.id, $any($event.target).checked)" />
<span>Enabled</span> <span>{{ 'catalog.enabled' | translate }}</span>
</label> </label>
} }
</section> </section>

View File

@@ -8,7 +8,7 @@
.filters-head { .filters-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 8px; gap: 8px;
} }
@@ -17,14 +17,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
.filters-head button {
border: 0;
background: transparent;
color: var(--primary-color);
font-weight: 700;
cursor: pointer;
}
.filter-group { .filter-group {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -64,3 +56,13 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 0 8px; padding: 0 8px;
} }
@media (max-width: 1024px) {
.catalog-filters {
gap: 12px;
}
.range-inputs {
grid-template-columns: 1fr;
}
}

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FilterDefinition } from '../../../../../core/products/models/catalog-experience.model'; import { FilterDefinition } from '../../../../../core/products/models/catalog-experience.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
export interface CatalogFilterStateValue { export interface CatalogFilterStateValue {
values: Record<string, string[]>; values: Record<string, string[]>;
@@ -11,7 +12,7 @@ export interface CatalogFilterStateValue {
@Component({ @Component({
selector: 'app-catalog-filters-panel', selector: 'app-catalog-filters-panel',
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule, TranslatePipe],
templateUrl: './filters-panel.component.html', templateUrl: './filters-panel.component.html',
styleUrls: ['./filters-panel.component.scss'], styleUrls: ['./filters-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -21,7 +22,6 @@ export class CatalogFiltersPanelComponent {
@Input() state: CatalogFilterStateValue = { values: {}, ranges: {}, toggles: {} }; @Input() state: CatalogFilterStateValue = { values: {}, ranges: {}, toggles: {} };
@Output() stateChange = new EventEmitter<CatalogFilterStateValue>(); @Output() stateChange = new EventEmitter<CatalogFilterStateValue>();
@Output() resetFilters = new EventEmitter<void>();
isSelected(filterId: string, optionValue: string): boolean { isSelected(filterId: string, optionValue: string): boolean {
return (this.state.values[filterId] ?? []).includes(optionValue); return (this.state.values[filterId] ?? []).includes(optionValue);

View File

@@ -1,11 +1,50 @@
<div class="catalog-layout-switcher card"> <div class="catalog-layout-switcher card">
@for (layout of available; track layout) { @for (layout of available; track $index) {
<button <button
type="button" type="button"
class="layout-btn" class="layout-btn"
[class.active]="layout === active" [class.active]="layout === active"
[attr.aria-label]="labels[layout] | translate"
(click)="activeChange.emit(layout)"> (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> </button>
} }
</div> </div>

View File

@@ -6,17 +6,46 @@
} }
.layout-btn { .layout-btn {
min-height: 34px; min-height: 36px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
font-weight: 700; font-weight: 700;
padding: 0 10px; padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer; 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 { .layout-btn.active {
border-color: var(--primary-color); border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 10%, white); 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;
}
}

View File

@@ -1,9 +1,11 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model'; import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-catalog-layout-switcher', selector: 'app-catalog-layout-switcher',
standalone: true, standalone: true,
imports: [TranslatePipe],
templateUrl: './layout-switcher.component.html', templateUrl: './layout-switcher.component.html',
styleUrls: ['./layout-switcher.component.scss'], styleUrls: ['./layout-switcher.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -15,9 +17,9 @@ export class CatalogLayoutSwitcherComponent {
@Output() activeChange = new EventEmitter<CatalogLayoutMode>(); @Output() activeChange = new EventEmitter<CatalogLayoutMode>();
readonly labels: Record<CatalogLayoutMode, string> = { readonly labels: Record<CatalogLayoutMode, string> = {
grid: 'Grid', grid: 'catalog.layoutGrid',
'large-grid': 'Large Grid', 'large-grid': 'catalog.layoutLargeGrid',
'compact-grid': 'Compact Grid', 'compact-grid': 'catalog.layoutCompactGrid',
list: 'List' list: 'catalog.layoutList'
}; };
} }

View File

@@ -5,16 +5,16 @@
[ngModel]="query" [ngModel]="query"
(ngModelChange)="queryChange.emit($event)" (ngModelChange)="queryChange.emit($event)"
name="query" name="query"
placeholder="Search products, brands, categories" [placeholder]="'catalog.searchPlaceholder' | translate"
autocomplete="off" /> autocomplete="off" />
<button type="submit" [disabled]="loading">Search</button> <button type="submit" [disabled]="loading">{{ 'catalog.searchSubmit' | translate }}</button>
</form> </form>
@if (suggestions.length > 0) { @if (suggestions.length > 0) {
<div class="suggestions"> <div class="suggestions">
<strong>Suggestions</strong> <strong>{{ 'catalog.suggestionsTitle' | translate }}</strong>
<div class="chip-list"> <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> <button type="button" class="chip" (click)="selectSuggestion(item)">{{ item }}</button>
} }
</div> </div>
@@ -23,9 +23,9 @@
@if (recentSearches.length > 0) { @if (recentSearches.length > 0) {
<div class="recent"> <div class="recent">
<strong>Recent searches</strong> <strong>{{ 'catalog.recentSearchesTitle' | translate }}</strong>
<div class="chip-list"> <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> <button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
} }
</div> </div>
@@ -35,11 +35,11 @@
@if (searchHistory.length > 0) { @if (searchHistory.length > 0) {
<div class="history"> <div class="history">
<div class="history-head"> <div class="history-head">
<strong>Search history</strong> <strong>{{ 'catalog.searchHistoryTitle' | translate }}</strong>
<button type="button" (click)="historyCleared.emit()">Clear</button> <button type="button" (click)="historyCleared.emit()">{{ 'catalog.clearHistory' | translate }}</button>
</div> </div>
<div class="chip-list"> <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> <button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button>
} }
</div> </div>
@@ -47,6 +47,6 @@
} }
@if (noResults && !loading) { @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> </section>

View File

@@ -16,6 +16,13 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 0 12px; padding: 0 12px;
font: inherit; 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 { .search-form button {
@@ -26,6 +33,13 @@
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
padding: 0 14px; 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 { .chip-list {
@@ -42,6 +56,13 @@
color: var(--text-secondary); color: var(--text-secondary);
padding: 0 10px; padding: 0 10px;
cursor: pointer; 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 { .history-head {
@@ -56,6 +77,7 @@
background: transparent; background: transparent;
color: var(--primary-color); color: var(--primary-color);
cursor: pointer; cursor: pointer;
font-weight: 700;
} }
.no-results { .no-results {
@@ -67,4 +89,8 @@
.search-form { .search-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.search-form button {
width: 100%;
}
} }

View File

@@ -1,10 +1,11 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-catalog-search-box', selector: 'app-catalog-search-box',
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule, TranslatePipe],
templateUrl: './search-box.component.html', templateUrl: './search-box.component.html',
styleUrls: ['./search-box.component.scss'], styleUrls: ['./search-box.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,7 +1,7 @@
<section class="catalog-search-results section"> <section class="catalog-search-results section">
<div class="results-head"> <div class="results-head">
<strong>{{ summary }}</strong> <strong>{{ summary }}</strong>
<span>{{ total }} items</span> <span>{{ 'catalog.itemsCount' | translate:{ count: total } }}</span>
</div> </div>
@if (loading) { @if (loading) {
@@ -29,16 +29,16 @@
(shareRequested)="shareRequested.emit($event)" /> (shareRequested)="shareRequested.emit($event)" />
} @else { } @else {
<div class="empty-state card"> <div class="empty-state card">
<h3>No results found</h3> <h3>{{ 'catalog.emptyResultsTitle' | translate }}</h3>
<p>Try changing filters, sorting, or search keywords.</p> <p>{{ 'catalog.emptyResultsDescription' | translate }}</p>
</div> </div>
} }
@if (!loading && totalPages > 1) { @if (!loading && totalPages > 1) {
<div class="results-pager"> <div class="results-pager">
<button type="button" (click)="previous()" [disabled]="page <= 1">Previous</button> <button type="button" (click)="previous()" [disabled]="page <= 1">{{ 'catalog.previousPage' | translate }}</button>
<span>Page {{ page }} / {{ totalPages }}</span> <span>{{ 'catalog.pageOf' | translate:{ page: page, total: totalPages } }}</span>
<button type="button" (click)="next()" [disabled]="page >= totalPages">Next</button> <button type="button" (click)="next()" [disabled]="page >= totalPages">{{ 'catalog.nextPage' | translate }}</button>
</div> </div>
} }
</section> </section>

View File

@@ -64,6 +64,25 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-primary); background: var(--bg-primary);
padding: 0 12px; 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 { @keyframes shimmer {

View File

@@ -2,11 +2,12 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
import { Product } from '../../../../../core/products/models/product-domain.model'; import { Product } from '../../../../../core/products/models/product-domain.model';
import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model'; import { CatalogLayoutMode } from '../../../../../core/products/models/catalog-experience.model';
import { CatalogProductGridComponent } from '../product-grid/product-grid.component'; import { CatalogProductGridComponent } from '../product-grid/product-grid.component';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-catalog-search-results', selector: 'app-catalog-search-results',
standalone: true, standalone: true,
imports: [CatalogProductGridComponent], imports: [CatalogProductGridComponent, TranslatePipe],
templateUrl: './search-results.component.html', templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'], styleUrls: ['./search-results.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,5 +1,5 @@
<label class="catalog-sorting card"> <label class="catalog-sorting card">
<span>Sort by</span> <span>{{ 'catalog.sortBy' | translate }}</span>
<select [ngModel]="selected" (ngModelChange)="selectedChange.emit($event)"> <select [ngModel]="selected" (ngModelChange)="selectedChange.emit($event)">
@for (option of options; track option.id) { @for (option of options; track option.id) {
@if (option.enabled !== false) { @if (option.enabled !== false) {

View File

@@ -20,3 +20,15 @@
padding: 0 8px; padding: 0 8px;
font: inherit; font: inherit;
} }
@media (max-width: 640px) {
.catalog-sorting {
width: 100%;
grid-template-columns: 1fr;
gap: 6px;
}
.catalog-sorting select {
width: 100%;
}
}

View File

@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { SortDefinition } from '../../../../../core/products/models/catalog-experience.model'; import { SortDefinition } from '../../../../../core/products/models/catalog-experience.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-catalog-sorting-control', selector: 'app-catalog-sorting-control',
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule, TranslatePipe],
templateUrl: './sorting-control.component.html', templateUrl: './sorting-control.component.html',
styleUrls: ['./sorting-control.component.scss'], styleUrls: ['./sorting-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -30,7 +30,7 @@
@for (saved of savedSearches(); track saved.id) { @for (saved of savedSearches(); track saved.id) {
<div class="catalog-saved-chip"> <div class="catalog-saved-chip">
<button type="button" (click)="useSavedSearch(saved.id)">{{ saved.name }}</button> <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>
} }
</div> </div>
@@ -39,7 +39,7 @@
@if (loading()) { @if (loading()) {
<section class="catalog-loading" [attr.aria-label]="'catalog.loading' | translate"> <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-card">
<div class="catalog-skeleton-image"></div> <div class="catalog-skeleton-image"></div>
<div class="catalog-skeleton-line catalog-skeleton-title"></div> <div class="catalog-skeleton-line catalog-skeleton-title"></div>
@@ -61,13 +61,13 @@
<section class="catalog-section"> <section class="catalog-section">
<div class="catalog-section-heading"> <div class="catalog-section-heading">
<h1>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h1> <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> </div>
@if (catalogConfig().showCategoryBanner) { @if (catalogConfig().showCategoryBanner) {
<div class="catalog-category-banner card"> <div class="catalog-category-banner card">
<h3>{{ state().category?.title || ('catalog.allCategories' | translate) }}</h3> <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> </div>
} }
@@ -98,17 +98,20 @@
<app-catalog-filters-panel <app-catalog-filters-panel
[definitions]="filterDefinitions()" [definitions]="filterDefinitions()"
[state]="filterState()" [state]="filterState()"
(stateChange)="onFilterStateChange($event)" (stateChange)="onFilterStateChange($event)" />
(resetFilters)="resetFilters()" />
</aside> </aside>
<div class="catalog-results-panel"> <div class="catalog-results-panel">
<div class="catalog-tools-row"> <div class="catalog-tools-row">
<div class="catalog-sort-reset-group">
<app-catalog-sorting-control <app-catalog-sorting-control
[options]="sortDefinitions()" [options]="sortDefinitions()"
[selected]="state().sort" [selected]="state().sort"
(selectedChange)="changeSort($event)" /> (selectedChange)="changeSort($event)" />
<button type="button" class="catalog-reset-btn" (click)="resetFilters()">{{ 'catalog.resetFilters' | translate }}</button>
</div>
@if (userExperienceConfig().savedSearches.enabled) { @if (userExperienceConfig().savedSearches.enabled) {
<button type="button" class="catalog-save-search-btn" (click)="saveCurrentSearch()">{{ 'ux.saveSearch' | translate }}</button> <button type="button" class="catalog-save-search-btn" (click)="saveCurrentSearch()">{{ 'ux.saveSearch' | translate }}</button>
} }
@@ -122,7 +125,7 @@
@if (catalogConfig().navigationMode !== 'default') { @if (catalogConfig().navigationMode !== 'default') {
<div class="catalog-navigation-placeholder card"> <div class="catalog-navigation-placeholder card">
<strong>{{ catalogConfig().navigationMode }}</strong> <strong>{{ catalogConfig().navigationMode }}</strong>
<p>Navigation layout placeholder prepared for backend/bootstrap-driven rendering.</p> <p>{{ 'catalog.navigationPlaceholder' | translate }}</p>
</div> </div>
} }

View File

@@ -101,6 +101,31 @@
justify-content: space-between; 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 { .catalog-save-search-btn {
min-height: 36px; min-height: 36px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -110,6 +135,13 @@
padding: 0 12px; padding: 0 12px;
font-weight: 700; font-weight: 700;
cursor: pointer; 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 { .catalog-category-banner {
@@ -272,14 +304,40 @@
padding: 16px; padding: 16px;
} }
.catalog-header {
gap: 12px;
margin-bottom: 18px;
}
.catalog-products-section { .catalog-products-section {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 14px;
} }
.catalog-left-panel { .catalog-left-panel {
position: static; 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 { .catalog-root-link {
font-size: 1.5rem; font-size: 1.5rem;
} }

View File

@@ -15,6 +15,7 @@ import { LanguageService } from '../../../../services/language.service';
import { PrefetchService } from '../../../../services/prefetch.service'; import { PrefetchService } from '../../../../services/prefetch.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../../../i18n/translate.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 { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component'; import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.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 uxFacade = inject(UserExperienceFacade);
private readonly shareService = inject(ProductShareService); private readonly shareService = inject(ProductShareService);
private readonly notifications = inject(UserNotificationService); private readonly notifications = inject(UserNotificationService);
private readonly translate = inject(TranslateService);
readonly catalogConfig = signal(this.resolveCatalogConfig()); readonly catalogConfig = signal(this.resolveCatalogConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
@@ -89,13 +91,13 @@ export class CatalogContainerComponent {
readonly sortDefinitions = computed<SortDefinition[]>(() => { readonly sortDefinitions = computed<SortDefinition[]>(() => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
relevance: 'Relevance', relevance: this.translate.t('catalog.sortRelevance'),
latest: 'Newest', latest: this.translate.t('catalog.sortLatest'),
price_asc: 'Price Low -> High', price_asc: this.translate.t('catalog.sortPriceAsc'),
price_desc: 'Price High -> Low', price_desc: this.translate.t('catalog.sortPriceDesc'),
rating: 'Highest Rated', rating: this.translate.t('catalog.sortRating'),
popular: 'Most Popular', popular: this.translate.t('catalog.sortPopular'),
discount: 'Discount' discount: this.translate.t('catalog.sortDiscount')
}; };
return this.catalogConfig().availableSorts.map((id: string) => ({ id, label: labels[id] ?? id, enabled: true })); 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 { onFavoriteToggled(product: Product): void {
const result = this.uxFacade.toggleWishlist(product); 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 { onCompareToggled(product: Product): void {
if (this.uxFacade.isInCompare(product.itemID)) { if (this.uxFacade.isInCompare(product.itemID)) {
this.uxFacade.removeFromCompare(product.itemID); this.uxFacade.removeFromCompare(product.itemID);
this.notifications.show('Removed from compare', 'info'); this.notifications.show(this.translate.t('ux.compareRemoved'), 'info');
return; return;
} }
const maxItems = this.userExperienceConfig().compare.maxItems; const maxItems = this.userExperienceConfig().compare.maxItems;
const result = this.uxFacade.addToCompare(product, maxItems); const result = this.uxFacade.addToCompare(product, maxItems);
if (result.reason === 'limit') { if (result.reason === 'limit') {
this.notifications.show(`Compare limit reached (${maxItems})`, 'warning'); this.notifications.show(this.translate.t('ux.compareLimitReached', { maxItems }), 'warning');
return; return;
} }
this.notifications.show('Added to compare', 'success'); this.notifications.show(this.translate.t('ux.compareAdded'), 'success');
} }
async onShareRequested(product: Product): Promise<void> { async onShareRequested(product: Product): Promise<void> {
@@ -300,16 +305,16 @@ export class CatalogContainerComponent {
const result = await this.shareService.shareProduct(product, productUrl); const result = await this.shareService.shareProduct(product, productUrl);
if (result === 'native') { if (result === 'native') {
this.notifications.show('Product shared', 'success'); this.notifications.show(this.translate.t('ux.productShared'), 'success');
return; return;
} }
if (result === 'copied') { if (result === 'copied') {
this.notifications.show('Product link copied', 'success'); this.notifications.show(this.translate.t('ux.productLinkCopied'), 'success');
return; return;
} }
this.notifications.show('Sharing is not supported on this device', 'warning'); this.notifications.show(this.translate.t('ux.sharingUnsupported'), 'warning');
} }
saveCurrentSearch(): void { saveCurrentSearch(): void {
@@ -319,7 +324,7 @@ export class CatalogContainerComponent {
const query = this.state().search.trim(); const query = this.state().search.trim();
if (!query.length) { if (!query.length) {
this.notifications.show('Enter a search query before saving', 'warning'); this.notifications.show(this.translate.t('ux.enterQueryBeforeSave'), 'warning');
return; return;
} }
@@ -332,7 +337,7 @@ export class CatalogContainerComponent {
filters: this.filterState() filters: this.filterState()
}, this.userExperienceConfig().savedSearches.maxItems); }, 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 { useSavedSearch(id: string): void {
@@ -483,7 +488,7 @@ export class CatalogContainerComponent {
const definitions: FilterDefinition[] = [ const definitions: FilterDefinition[] = [
{ {
id: 'price', id: 'price',
label: 'Price', label: this.translate.t('catalog.filterPrice'),
type: 'range', type: 'range',
min: priceValues.length ? Math.min(...priceValues) : 0, min: priceValues.length ? Math.min(...priceValues) : 0,
max: priceValues.length ? Math.max(...priceValues) : 0, max: priceValues.length ? Math.max(...priceValues) : 0,
@@ -491,72 +496,72 @@ export class CatalogContainerComponent {
}, },
{ {
id: 'availability', id: 'availability',
label: 'Availability', label: this.translate.t('catalog.filterAvailability'),
type: 'multi-select', type: 'multi-select',
options: [ options: [
{ id: 'in-stock', label: 'In stock', value: 'in-stock' }, { id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock' },
{ id: 'low-stock', label: 'Low stock', value: 'low-stock' }, { id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock' },
{ id: 'out-of-stock', label: 'Out of stock', value: 'out-of-stock' } { id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock' }
], ],
enabled: enabled.has('availability') enabled: enabled.has('availability')
}, },
{ {
id: 'rating', id: 'rating',
label: 'Rating', label: this.translate.t('catalog.filterRating'),
type: 'multi-select', 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') enabled: enabled.has('rating')
}, },
{ {
id: 'brand', id: 'brand',
label: 'Brand', label: this.translate.t('catalog.filterBrand'),
type: 'multi-select', type: 'multi-select',
options: brands.map(value => ({ id: `brand-${value}`, label: value, value })), options: brands.map(value => ({ id: `brand-${value}`, label: value, value })),
enabled: enabled.has('brand') enabled: enabled.has('brand')
}, },
{ {
id: 'category', id: 'category',
label: 'Category', label: this.translate.t('catalog.filterCategory'),
type: 'multi-select', 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') enabled: enabled.has('category')
}, },
{ {
id: 'subcategory', id: 'subcategory',
label: 'Subcategory', label: this.translate.t('catalog.filterSubcategory'),
type: 'multi-select', type: 'multi-select',
options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })), options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })),
enabled: enabled.has('subcategory') enabled: enabled.has('subcategory')
}, },
{ {
id: 'discount', id: 'discount',
label: 'Discount', label: this.translate.t('catalog.filterDiscount'),
type: 'toggle', type: 'toggle',
enabled: enabled.has('discount') enabled: enabled.has('discount')
}, },
{ {
id: 'new', id: 'new',
label: 'New', label: this.translate.t('catalog.filterNew'),
type: 'toggle', type: 'toggle',
enabled: enabled.has('new') enabled: enabled.has('new')
}, },
{ {
id: 'color', id: 'color',
label: 'Color', label: this.translate.t('catalog.filterColor'),
type: 'multi-select', type: 'multi-select',
options: colors.map(value => ({ id: `color-${value}`, label: value, value })), options: colors.map(value => ({ id: `color-${value}`, label: value, value })),
enabled: enabled.has('color') enabled: enabled.has('color')
}, },
{ {
id: 'size', id: 'size',
label: 'Size', label: this.translate.t('catalog.filterSize'),
type: 'multi-select', type: 'multi-select',
options: sizes.map(value => ({ id: `size-${value}`, label: value, value })), options: sizes.map(value => ({ id: `size-${value}`, label: value, value })),
enabled: enabled.has('size') enabled: enabled.has('size')
}, },
{ {
id: 'attributes', id: 'attributes',
label: 'Attributes', label: this.translate.t('catalog.filterAttributes'),
type: 'multi-select', type: 'multi-select',
options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })), options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })),
enabled: enabled.has('attributes') enabled: enabled.has('attributes')
@@ -579,7 +584,7 @@ export class CatalogContainerComponent {
page: 1 page: 1
} }
})); }));
this.searchSummary.set('No results'); this.searchSummary.set(this.translate.t('catalog.noResults'));
this.searchResult.set(null); this.searchResult.set(null);
this.searchSuggestions.set([]); this.searchSuggestions.set([]);
return; return;

View File

@@ -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"> <div class="product-gallery-main">
@if (media[selectedIndex]?.video) { @if (media[selectedIndex]?.video) {
<video [src]="media[selectedIndex].url" controls></video> <video [src]="media[selectedIndex].url" controls></video>

View File

@@ -13,6 +13,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: box-shadow 0.24s ease, border-color 0.24s ease;
img, img,
video { video {
@@ -36,12 +37,18 @@
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease;
&.active { &.active {
border-color: #497671; border-color: #497671;
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.18); box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.18);
} }
&:hover {
transform: translateY(-1px);
border-color: #497671;
}
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -59,3 +66,10 @@
color: #fff; color: #fff;
z-index: 1; z-index: 1;
} }
@media (max-width: 640px) {
.product-gallery-thumbs {
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));
gap: 8px;
}
}

View File

@@ -1,10 +1,12 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Product } from '../../../../../core/products/models/product-domain.model'; import { Product } from '../../../../../core/products/models/product-domain.model';
import { getMainImage } from '../../../../../utils/item.utils'; import { getMainImage } from '../../../../../utils/item.utils';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-product-gallery', selector: 'app-product-gallery',
standalone: true, standalone: true,
imports: [TranslatePipe],
templateUrl: './product-gallery.component.html', templateUrl: './product-gallery.component.html',
styleUrls: ['./product-gallery.component.scss'], styleUrls: ['./product-gallery.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -3,7 +3,7 @@
@if (product.badges?.length) { @if (product.badges?.length) {
<div class="product-badges"> <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> <span class="product-badge" [class]="getBadgeClass(badge)">{{ badge }}</span>
} }
</div> </div>

View File

@@ -99,8 +99,25 @@
font-size: 1rem; font-size: 1rem;
font-weight: 900; font-weight: 900;
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
&:hover { &:hover {
background: #3d635f; 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;
} }
} }

View File

@@ -1,5 +1,5 @@
<section class="product-specifications card section"> <section class="product-specifications card section">
<h3>Specifications</h3> <h3>{{ 'itemDetail.specifications' | translate }}</h3>
@if (descriptionFields.length > 0) { @if (descriptionFields.length > 0) {
<dl> <dl>
@@ -11,6 +11,6 @@
} }
</dl> </dl>
} @else { } @else {
<p>No specifications provided yet.</p> <p>{{ 'productDetails.specificationsEmpty' | translate }}</p>
} }
</section> </section>

View File

@@ -1,9 +1,11 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Product } from '../../../../../core/products/models/product-domain.model'; import { Product } from '../../../../../core/products/models/product-domain.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-product-specifications', selector: 'app-product-specifications',
standalone: true, standalone: true,
imports: [TranslatePipe],
templateUrl: './product-specifications.component.html', templateUrl: './product-specifications.component.html',
styleUrls: ['./product-specifications.component.scss'], styleUrls: ['./product-specifications.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,8 +1,8 @@
<section class="product-warranty card section"> <section class="product-warranty card section">
<h3>Warranty and returns</h3> <h3>{{ 'productDetails.warrantyTitle' | translate }}</h3>
<ul> <ul>
<li>Warranty terms are provided by the seller and local law.</li> <li>{{ 'productDetails.warrantyItem1' | translate }}</li>
<li>Return eligibility depends on item condition and category.</li> <li>{{ 'productDetails.warrantyItem2' | translate }}</li>
<li>Detailed return instructions are available after purchase.</li> <li>{{ 'productDetails.warrantyItem3' | translate }}</li>
</ul> </ul>
</section> </section>

View File

@@ -1,8 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-product-warranty', selector: 'app-product-warranty',
standalone: true, standalone: true,
imports: [TranslatePipe],
templateUrl: './product-warranty.component.html', templateUrl: './product-warranty.component.html',
styleUrls: ['./product-warranty.component.scss'], styleUrls: ['./product-warranty.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -4,7 +4,7 @@
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}</span> <span class="variant-label">{{ 'itemDetail.colour' | translate }}</span>
<div class="variant-options"> <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> <button type="button" class="colour-swatch" [class.active]="selectedColour === colour" [style.background-color]="colour" [attr.aria-label]="colour" (click)="colourSelected.emit(colour)"></button>
} }
</div> </div>
@@ -20,7 +20,7 @@
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}</span> <span class="variant-label">{{ 'itemDetail.size' | translate }}</span>
<div class="variant-options"> <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> <button type="button" class="size-chip" [class.active]="selectedSize === size" (click)="sizeSelected.emit(size)">{{ size }}</button>
} }
</div> </div>

View File

@@ -4,13 +4,13 @@
padding: 24px; padding: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 42px; gap: 32px;
} }
.product-details-layout { .product-details-layout {
display: grid; display: grid;
grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1fr); grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 1fr);
gap: 42px; gap: 28px;
align-items: start; align-items: start;
} }
@@ -22,7 +22,7 @@
.product-engagement { .product-engagement {
display: grid; display: grid;
gap: 16px; gap: 14px;
} }
.product-tab-content { .product-tab-content {
@@ -32,7 +32,7 @@
} }
.product-engagement-stacked { .product-engagement-stacked {
gap: 20px; gap: 16px;
} }
.product-details-loading, .product-details-loading,
@@ -91,12 +91,28 @@
@media (max-width: 860px) { @media (max-width: 860px) {
.product-details-page { .product-details-page {
padding: 16px; padding: 18px;
gap: 32px; gap: 24px;
} }
.product-details-layout { .product-details-layout {
grid-template-columns: 1fr; 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;
} }
} }

View File

@@ -7,6 +7,7 @@ import { ConfigService } from '../../../../core/config/config.service';
import { ProductFacade } from '../../../../facades/platform/product.facade'; import { ProductFacade } from '../../../../facades/platform/product.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade'; import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../i18n/translate.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { CartService } from '../../../../services'; import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
@@ -56,6 +57,7 @@ export class ProductDetailsContainerComponent {
private readonly uxFacade = inject(UserExperienceFacade); private readonly uxFacade = inject(UserExperienceFacade);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly translate = inject(TranslateService);
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig()); readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
@@ -422,17 +424,17 @@ export class ProductDetailsContainerComponent {
private getTabLabel(key: ProductTabKey): string { private getTabLabel(key: ProductTabKey): string {
switch (key) { switch (key) {
case 'description': case 'description':
return 'Description'; return this.translate.t('itemDetail.description');
case 'specifications': case 'specifications':
return 'Specifications'; return this.translate.t('itemDetail.specifications');
case 'reviews': case 'reviews':
return 'Reviews'; return this.translate.t('itemDetail.reviews');
case 'questions': case 'questions':
return 'Questions'; return this.translate.t('itemDetail.qna');
case 'delivery': case 'delivery':
return 'Delivery'; return this.translate.t('cart.deliveryLabel');
case 'warranty': case 'warranty':
return 'Warranty'; return this.translate.t('footer.guarantee');
default: default:
return key; return key;
} }

View File

@@ -1,5 +1,5 @@
@if (tabs.length > 0) { @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) { @for (tab of tabs; track tab.key) {
<button <button
type="button" type="button"

View File

@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
export type ProductTabKey = 'description' | 'specifications' | 'reviews' | 'questions' | 'delivery' | 'warranty'; export type ProductTabKey = 'description' | 'specifications' | 'reviews' | 'questions' | 'delivery' | 'warranty';
@@ -12,6 +13,7 @@ export interface ProductTabItem {
@Component({ @Component({
selector: 'app-product-tabs', selector: 'app-product-tabs',
standalone: true, standalone: true,
imports: [TranslatePipe],
templateUrl: './product-tabs.component.html', templateUrl: './product-tabs.component.html',
styleUrls: ['./product-tabs.component.scss'], styleUrls: ['./product-tabs.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -12,9 +12,9 @@
<div class="answer"> <div class="answer">
<div class="answer-meta"> <div class="answer-meta">
<strong>{{ answer.author }}</strong> <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) { @if (answer.isAccepted) {
<span class="accepted-pill">Accepted answer</span> <span class="accepted-pill">{{ 'productDetails.acceptedAnswer' | translate }}</span>
} }
</div> </div>
<p>{{ answer.text }}</p> <p>{{ answer.text }}</p>
@@ -24,7 +24,7 @@
} }
<footer> <footer>
<button type="button" disabled>Like ({{ question.likes }})</button> <button type="button" disabled>{{ 'productDetails.like' | translate }} ({{ question.likes }})</button>
<button type="button" disabled>Dislike ({{ question.dislikes }})</button> <button type="button" disabled>{{ 'productDetails.dislike' | translate }} ({{ question.dislikes }})</button>
</footer> </footer>
</article> </article>

View File

@@ -1,11 +1,12 @@
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Question } from '../../../../../../core/products/models/product-engagement.model'; import { Question } from '../../../../../../core/products/models/product-engagement.model';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-question-card', selector: 'app-question-card',
standalone: true, standalone: true,
imports: [DatePipe], imports: [DatePipe, TranslatePipe],
templateUrl: './question-card.component.html', templateUrl: './question-card.component.html',
styleUrls: ['./question-card.component.scss'], styleUrls: ['./question-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,19 +1,19 @@
<form class="question-form card" (ngSubmit)="onSubmit()"> <form class="question-form card" (ngSubmit)="onSubmit()">
<h3>Ask a question</h3> <h3>{{ 'productDetails.questionFormTitle' | translate }}</h3>
<textarea <textarea
id="question-text" id="question-text"
name="text" name="text"
rows="4" rows="4"
[(ngModel)]="text" [(ngModel)]="text"
placeholder="Ask about size, delivery, compatibility or usage"></textarea> [placeholder]="'productDetails.questionFormPlaceholder' | translate"></textarea>
<label class="check"> <label class="check">
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" /> <input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
<span>Ask anonymously</span> <span>{{ 'productDetails.questionFormAnonymous' | translate }}</span>
</label> </label>
<button type="submit" [disabled]="submitting || !text.trim()"> <button type="submit" [disabled]="submitting || !text.trim()">
@if (submitting) { Submitting... } @else { Submit question } @if (submitting) { {{ 'productDetails.submitting' | translate }} } @else { {{ 'productDetails.submitQuestion' | translate }} }
</button> </button>
</form> </form>

View File

@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model'; import { SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-question-form', selector: 'app-question-form',
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule, TranslatePipe],
templateUrl: './question-form.component.html', templateUrl: './question-form.component.html',
styleUrls: ['./question-form.component.scss'], styleUrls: ['./question-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,6 +1,6 @@
<section class="question-list section"> <section class="question-list section">
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())"> <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> </button>
@if (askFormVisible()) { @if (askFormVisible()) {
@@ -19,14 +19,14 @@
} }
</div> </div>
} @else { } @else {
<p class="empty">No questions yet.</p> <p class="empty">{{ 'productDetails.questionsEmpty' | translate }}</p>
} }
@if (result && totalPages > 1) { @if (result && totalPages > 1) {
<div class="pager"> <div class="pager">
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button> <button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
<span>Page {{ result.page }} / {{ totalPages }}</span> <span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button> <button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
</div> </div>
} }
</section> </section>

View File

@@ -12,6 +12,13 @@
color: var(--text-primary); color: var(--text-primary);
padding: 0 14px; padding: 0 14px;
font-weight: 700; 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 { .question-items {
@@ -37,6 +44,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-primary); background: var(--bg-primary);
padding: 0 12px; padding: 0 12px;
cursor: pointer;
} }
.skeleton-list { .skeleton-list {
@@ -55,3 +63,19 @@
@keyframes shimmer { @keyframes shimmer {
to { background-position: -200% 0; } 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;
}
}

View File

@@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { EngagementListResult, Question, SubmitQuestionInput } from '../../../../../../core/products/models/product-engagement.model'; 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 { QuestionCardComponent } from '../question-card/question-card.component';
import { QuestionFormComponent } from '../question-form/question-form.component'; import { QuestionFormComponent } from '../question-form/question-form.component';
@Component({ @Component({
selector: 'app-question-list', selector: 'app-question-list',
standalone: true, standalone: true,
imports: [QuestionCardComponent, QuestionFormComponent], imports: [QuestionCardComponent, QuestionFormComponent, TranslatePipe],
templateUrl: './question-list.component.html', templateUrl: './question-list.component.html',
styleUrls: ['./question-list.component.scss'], styleUrls: ['./question-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -10,7 +10,7 @@
<div class="headline"> <div class="headline">
<strong class="average">{{ summary.average | number:'1.1-1' }}</strong> <strong class="average">{{ summary.average | number:'1.1-1' }}</strong>
<app-stars [rating]="summary.average" size="lg" /> <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>
<div class="distribution"> <div class="distribution">

View File

@@ -1,12 +1,13 @@
import { DecimalPipe, PercentPipe } from '@angular/common'; import { DecimalPipe, PercentPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model'; import { RatingSummary } from '../../../../../../core/products/models/product-engagement.model';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
import { StarsComponent } from '../stars/stars.component'; import { StarsComponent } from '../stars/stars.component';
@Component({ @Component({
selector: 'app-rating-summary', selector: 'app-rating-summary',
standalone: true, standalone: true,
imports: [DecimalPipe, PercentPipe, StarsComponent], imports: [DecimalPipe, PercentPipe, StarsComponent, TranslatePipe],
templateUrl: './rating-summary.component.html', templateUrl: './rating-summary.component.html',
styleUrls: ['./rating-summary.component.scss'], styleUrls: ['./rating-summary.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -2,7 +2,7 @@
<header class="review-header"> <header class="review-header">
<app-stars [rating]="review.rating" size="sm" /> <app-stars [rating]="review.rating" size="sm" />
<strong class="author">{{ review.author }}</strong> <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> <time>{{ review.createdAt | date:'mediumDate' }}</time>
</header> </header>
@@ -13,7 +13,7 @@
<p>{{ review.text }}</p> <p>{{ review.text }}</p>
<footer class="review-actions"> <footer class="review-actions">
<button type="button" disabled>Like ({{ review.likes }})</button> <button type="button" disabled>{{ 'productDetails.like' | translate }} ({{ review.likes }})</button>
<button type="button" disabled>Dislike ({{ review.dislikes }})</button> <button type="button" disabled>{{ 'productDetails.dislike' | translate }} ({{ review.dislikes }})</button>
</footer> </footer>
</article> </article>

View File

@@ -1,12 +1,13 @@
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Review } from '../../../../../../core/products/models/product-engagement.model'; import { Review } from '../../../../../../core/products/models/product-engagement.model';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
import { StarsComponent } from '../stars/stars.component'; import { StarsComponent } from '../stars/stars.component';
@Component({ @Component({
selector: 'app-review-card', selector: 'app-review-card',
standalone: true, standalone: true,
imports: [DatePipe, StarsComponent], imports: [DatePipe, StarsComponent, TranslatePipe],
templateUrl: './review-card.component.html', templateUrl: './review-card.component.html',
styleUrls: ['./review-card.component.scss'], styleUrls: ['./review-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,23 +1,23 @@
<form class="review-form card" (ngSubmit)="onSubmit()"> <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" /> <app-star-selector [(rating)]="rating" />
<label for="review-title">Title</label> <label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label>
<input id="review-title" name="title" [(ngModel)]="title" placeholder="Short headline" maxlength="120" /> <input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" />
<label for="review-text">Review</label> <label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label>
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" placeholder="Describe your experience"></textarea> <textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate"></textarea>
<label class="check"> <label class="check">
<input type="checkbox" name="anonymous" [(ngModel)]="anonymous" /> <input type="checkbox" name="anonymous" [(ngModel)]="anonymous" />
<span>Submit anonymously</span> <span>{{ 'productDetails.reviewAnonymous' | translate }}</span>
</label> </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()"> <button type="submit" [disabled]="submitting || !rating || !text.trim()">
@if (submitting) { Submitting... } @else { Submit review } @if (submitting) { {{ 'productDetails.submitting' | translate }} } @else { {{ 'productDetails.submitReview' | translate }} }
</button> </button>
</form> </form>

View File

@@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model'; import { SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
import { StarSelectorComponent } from '../star-selector/star-selector.component'; import { StarSelectorComponent } from '../star-selector/star-selector.component';
@Component({ @Component({
selector: 'app-review-form', selector: 'app-review-form',
standalone: true, standalone: true,
imports: [FormsModule, StarSelectorComponent], imports: [FormsModule, StarSelectorComponent, TranslatePipe],
templateUrl: './review-form.component.html', templateUrl: './review-form.component.html',
styleUrls: ['./review-form.component.scss'], styleUrls: ['./review-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -17,14 +17,14 @@
} }
</div> </div>
} @else { } @else {
<p class="empty">No reviews yet.</p> <p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p>
} }
@if (result && totalPages > 1) { @if (result && totalPages > 1) {
<div class="pager"> <div class="pager">
<button type="button" (click)="previousPage()" [disabled]="result.page <= 1">Previous</button> <button type="button" (click)="previousPage()" [disabled]="result.page <= 1">{{ 'productDetails.previous' | translate }}</button>
<span>Page {{ result.page }} / {{ totalPages }}</span> <span>{{ 'productDetails.pageOf' | translate:{ page: result.page, total: totalPages } }}</span>
<button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">Next</button> <button type="button" (click)="nextPage()" [disabled]="result.page >= totalPages">{{ 'productDetails.next' | translate }}</button>
</div> </div>
} }
</section> </section>

View File

@@ -27,6 +27,12 @@
background: var(--bg-primary); background: var(--bg-primary);
padding: 0 12px; padding: 0 12px;
cursor: pointer; 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 { .skeleton-list {
@@ -45,3 +51,14 @@
@keyframes shimmer { @keyframes shimmer {
to { background-position: -200% 0; } to { background-position: -200% 0; }
} }
@media (max-width: 640px) {
.pager {
display: grid;
grid-template-columns: 1fr;
}
.pager span {
text-align: center;
}
}

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { EngagementListResult, Review, SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model'; import { EngagementListResult, Review, SubmitReviewInput } from '../../../../../../core/products/models/product-engagement.model';
import { RatingSummary } 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 { RatingSummaryComponent } from '../rating-summary/rating-summary.component';
import { ReviewCardComponent } from '../review-card/review-card.component'; import { ReviewCardComponent } from '../review-card/review-card.component';
import { ReviewFormComponent } from '../review-form/review-form.component'; import { ReviewFormComponent } from '../review-form/review-form.component';
@@ -8,7 +9,7 @@ import { ReviewFormComponent } from '../review-form/review-form.component';
@Component({ @Component({
selector: 'app-review-list', selector: 'app-review-list',
standalone: true, standalone: true,
imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent], imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent, TranslatePipe],
templateUrl: './review-list.component.html', templateUrl: './review-list.component.html',
styleUrls: ['./review-list.component.scss'], styleUrls: ['./review-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -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) { @for (star of stars; track star) {
<button <button
type="button" type="button"

View File

@@ -1,8 +1,10 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-star-selector', selector: 'app-star-selector',
standalone: true, standalone: true,
imports: [TranslatePipe],
templateUrl: './star-selector.component.html', templateUrl: './star-selector.component.html',
styleUrls: ['./star-selector.component.scss'], styleUrls: ['./star-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -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) { @for (star of stars; track star) {
<span class="star" [class.filled]="star <= rating"></span> <span class="star" [class.filled]="star <= rating"></span>
} }

View File

@@ -1,8 +1,10 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-stars', selector: 'app-stars',
standalone: true, standalone: true,
imports: [TranslatePipe],
templateUrl: './stars.component.html', templateUrl: './stars.component.html',
styleUrls: ['./stars.component.scss'], styleUrls: ['./stars.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -164,6 +164,101 @@ export const en: Translations = {
addToCart: 'Add to cart', addToCart: 'Add to cart',
categoriesCount: '{{count}} categories', categoriesCount: '{{count}} categories',
productsCount: '{{count}} products', productsCount: '{{count}} products',
entriesCount: '{{count}} entries',
categoryBannerPlaceholder: 'Category banner placeholder for backend-driven media and description.',
navigationPlaceholder: 'Navigation layout placeholder prepared for backend/bootstrap-driven rendering.',
removeSavedSearch: 'Remove saved search',
resetFilters: 'Reset',
filtersTitle: 'Filters',
minValue: 'Min {{value}}',
maxValue: 'Max {{value}}',
enabled: 'Enabled',
searchPlaceholder: 'Search products, brands, categories',
searchSubmit: 'Search',
suggestionsTitle: 'Suggestions',
recentSearchesTitle: 'Recent searches',
searchHistoryTitle: 'Search history',
clearHistory: 'Clear',
searchNoResultsHint: 'No results found for this query. Try broader keywords.',
sortBy: 'Sort by',
sortRelevance: 'Relevance',
sortLatest: 'Newest',
sortPriceAsc: 'Price Low -> High',
sortPriceDesc: 'Price High -> Low',
sortRating: 'Highest Rated',
sortPopular: 'Most Popular',
sortDiscount: 'Discount',
layoutGrid: 'Grid',
layoutLargeGrid: 'Large grid',
layoutCompactGrid: 'Compact grid',
layoutList: 'List',
noResults: 'No results',
itemsCount: '{{count}} items',
emptyResultsTitle: 'No results found',
emptyResultsDescription: 'Try changing filters, sorting, or search keywords.',
previousPage: 'Previous',
nextPage: 'Next',
pageOf: 'Page {{page}} / {{total}}',
filterPrice: 'Price',
filterAvailability: 'Availability',
filterInStock: 'In stock',
filterLowStock: 'Low stock',
filterOutOfStock: 'Out of stock',
filterRating: 'Rating',
filterStars: '{{count}} stars',
filterBrand: 'Brand',
filterCategory: 'Category',
filterCategoryValue: 'Category {{value}}',
filterSubcategory: 'Subcategory',
filterDiscount: 'Discount',
filterNew: 'New',
filterColor: 'Color',
filterSize: 'Size',
filterAttributes: 'Attributes',
},
productDetails: {
loading: 'Loading product...',
errorTitle: 'Product unavailable',
retry: 'Try again',
missingTitle: 'Product not found',
missingDescription: 'This product does not exist or is no longer available.',
backToCatalog: 'Back to catalog',
tabsAria: 'Product tabs',
mediaAria: 'Product media',
ratingStarsAria: 'Rating stars',
selectRatingAria: 'Select rating',
reviewsEmpty: 'No reviews yet.',
questionsEmpty: 'No questions yet.',
askQuestion: 'Ask question',
hideQuestionForm: 'Hide question form',
questionFormTitle: 'Ask a question',
questionFormPlaceholder: 'Ask about size, delivery, compatibility or usage',
questionFormAnonymous: 'Ask anonymously',
submitQuestion: 'Submit question',
reviewFormTitle: 'Leave a review',
reviewRatingLabel: 'Rating',
reviewTitleLabel: 'Title',
reviewTitlePlaceholder: 'Short headline',
reviewTextLabel: 'Review',
reviewTextPlaceholder: 'Describe your experience',
reviewAnonymous: 'Submit anonymously',
reviewUploadPlaceholder: 'Photo upload is planned for a future sprint.',
submitReview: 'Submit review',
submitting: 'Submitting...',
specificationsEmpty: 'No specifications provided yet.',
warrantyTitle: 'Warranty and returns',
warrantyItem1: 'Warranty terms are provided by the seller and local law.',
warrantyItem2: 'Return eligibility depends on item condition and category.',
warrantyItem3: 'Detailed return instructions are available after purchase.',
verifiedPurchase: 'Verified purchase',
officialSeller: 'Official seller',
acceptedAnswer: 'Accepted answer',
like: 'Like',
dislike: 'Dislike',
reviewsCountLabel: '{{count}} reviews',
previous: 'Previous',
next: 'Next',
pageOf: 'Page {{page}} / {{total}}',
}, },
subcategories: { subcategories: {
loading: 'Loading subcategories...', loading: 'Loading subcategories...',
@@ -261,5 +356,15 @@ export const en: Translations = {
recentlyViewedEmpty: 'No recently viewed products yet.', recentlyViewedEmpty: 'No recently viewed products yet.',
saveSearch: 'Save search', saveSearch: 'Save search',
goToCatalog: 'Go to catalog', goToCatalog: 'Go to catalog',
wishlistAdded: 'Added to wishlist',
wishlistRemoved: 'Removed from wishlist',
compareAdded: 'Added to compare',
compareRemoved: 'Removed from compare',
compareLimitReached: 'Compare limit reached ({{maxItems}})',
productShared: 'Product shared',
productLinkCopied: 'Product link copied',
sharingUnsupported: 'Sharing is not supported on this device',
enterQueryBeforeSave: 'Enter a search query before saving',
savedSearchNamed: 'Saved search: {{name}}',
}, },
}; };

View File

@@ -164,6 +164,101 @@ export const hy: Translations = {
addToCart: 'Ավելացնել զամբյուղ', addToCart: 'Ավելացնել զամբյուղ',
categoriesCount: '{{count}} կատեգորիա', categoriesCount: '{{count}} կատեգորիա',
productsCount: '{{count}} ապրանք', productsCount: '{{count}} ապրանք',
entriesCount: '{{count}} տարր',
categoryBannerPlaceholder: 'Կատեգորիայի բանները պատրաստ է backend-ից եկող մեդիայի և նկարագրության համար։',
navigationPlaceholder: 'Նավիգացիայի դասավորությունը պատրաստ է backend/bootstrap կարգավորումների համար։',
removeSavedSearch: 'Ջնջել պահպանված որոնումը',
resetFilters: 'Վերակայել',
filtersTitle: 'Ֆիլտրեր',
minValue: 'Նվազ. {{value}}',
maxValue: 'Առավել. {{value}}',
enabled: 'Միացված',
searchPlaceholder: 'Որոնել ապրանքներ, բրենդներ, կատեգորիաներ',
searchSubmit: 'Որոնել',
suggestionsTitle: 'Առաջարկներ',
recentSearchesTitle: 'Վերջին որոնումներ',
searchHistoryTitle: 'Որոնման պատմություն',
clearHistory: 'Մաքրել',
searchNoResultsHint: 'Արդյունքներ չկան։ Փորձեք ավելի լայն բանալի բառեր։',
sortBy: 'Տեսակավորել ըստ',
sortRelevance: 'Համապատասխանության',
sortLatest: 'Նորության',
sortPriceAsc: 'Գին․ աճման',
sortPriceDesc: 'Գին․ նվազման',
sortRating: 'Վարկանիշի',
sortPopular: 'Հանրաճանաչության',
sortDiscount: 'Զեղչի',
layoutGrid: 'Ցանց',
layoutLargeGrid: 'Մեծ ցանց',
layoutCompactGrid: 'Կոմպակտ ցանց',
layoutList: 'Ցանկ',
noResults: 'Արդյունք չկա',
itemsCount: '{{count}} ապրանք',
emptyResultsTitle: 'Արդյունքներ չեն գտնվել',
emptyResultsDescription: 'Փորձեք փոխել ֆիլտրերը, տեսակավորումը կամ որոնման բառերը։',
previousPage: 'Նախորդ',
nextPage: 'Հաջորդ',
pageOf: 'Էջ {{page}} / {{total}}',
filterPrice: 'Գին',
filterAvailability: 'Առկայություն',
filterInStock: 'Առկա է',
filterLowStock: 'Քիչ է մնացել',
filterOutOfStock: 'Առկա չէ',
filterRating: 'Վարկանիշ',
filterStars: '{{count}} աստղ',
filterBrand: 'Բրենդ',
filterCategory: 'Կատեգորիա',
filterCategoryValue: 'Կատեգորիա {{value}}',
filterSubcategory: 'Ենթակատեգորիա',
filterDiscount: 'Զեղչ',
filterNew: 'Նոր',
filterColor: 'Գույն',
filterSize: 'Չափ',
filterAttributes: 'Հատկանիշներ',
},
productDetails: {
loading: 'Ապրանքի բեռնում...',
errorTitle: 'Ապրանքը անհասանելի է',
retry: 'Փորձել կրկին',
missingTitle: 'Ապրանքը չի գտնվել',
missingDescription: 'Այս ապրանքը գոյություն չունի կամ այլևս հասանելի չէ։',
backToCatalog: 'Վերադառնալ կատալոգ',
tabsAria: 'Ապրանքի ներդիրներ',
mediaAria: 'Ապրանքի մեդիա',
ratingStarsAria: 'Աստղային վարկանիշ',
selectRatingAria: 'Ընտրեք գնահատականը',
reviewsEmpty: 'Կարծիքներ դեռ չկան։',
questionsEmpty: 'Հարցեր դեռ չկան։',
askQuestion: 'Տալ հարց',
hideQuestionForm: 'Թաքցնել հարցի ձևը',
questionFormTitle: 'Տալ հարց',
questionFormPlaceholder: 'Հարցրեք չափի, առաքման, համատեղելիության կամ օգտագործման մասին',
questionFormAnonymous: 'Հարցնել անանուն',
submitQuestion: 'Ուղարկել հարցը',
reviewFormTitle: 'Թողնել կարծիք',
reviewRatingLabel: 'Գնահատական',
reviewTitleLabel: 'Վերնագիր',
reviewTitlePlaceholder: 'Կարճ վերնագիր',
reviewTextLabel: 'Կարծիք',
reviewTextPlaceholder: 'Նկարագրեք ձեր փորձը',
reviewAnonymous: 'Ուղարկել անանուն',
reviewUploadPlaceholder: 'Լուսանկար վերբեռնելը նախատեսված է հաջորդ սպրինտում։',
submitReview: 'Ուղարկել կարծիքը',
submitting: 'Ուղարկվում է...',
specificationsEmpty: 'Բնութագրերը դեռ հասանելի չեն։',
warrantyTitle: 'Երաշխիք և վերադարձ',
warrantyItem1: 'Երաշխիքի պայմանները սահմանվում են վաճառողի և տեղական օրենքներով։',
warrantyItem2: 'Վերադարձի իրավունքը կախված է ապրանքի վիճակից և կատեգորիայից։',
warrantyItem3: 'Վերադարձի մանրամասն հրահանգները հասանելի են գնումից հետո։',
verifiedPurchase: 'Հաստատված գնում',
officialSeller: 'Պաշտոնական վաճառող',
acceptedAnswer: 'Ընդունված պատասխան',
like: 'Հավանել',
dislike: 'Չհավանել',
reviewsCountLabel: '{{count}} կարծիք',
previous: 'Նախորդ',
next: 'Հաջորդ',
pageOf: 'Էջ {{page}} / {{total}}',
}, },
subcategories: { subcategories: {
loading: 'Ենթակատեգորիաների բեռնում...', loading: 'Ենթակատեգորիաների բեռնում...',
@@ -261,5 +356,15 @@ export const hy: Translations = {
recentlyViewedEmpty: 'Դուք դեռ ապրանքներ չեք դիտել։', recentlyViewedEmpty: 'Դուք դեռ ապրանքներ չեք դիտել։',
saveSearch: 'Պահպանել որոնումը', saveSearch: 'Պահպանել որոնումը',
goToCatalog: 'Գնալ կատալոգ', goToCatalog: 'Գնալ կատալոգ',
wishlistAdded: 'Ավելացվեց ընտրյալներում',
wishlistRemoved: 'Հեռացվեց ընտրյալներից',
compareAdded: 'Ավելացվեց համեմատման մեջ',
compareRemoved: 'Հեռացվեց համեմատումից',
compareLimitReached: 'Համեմատման սահմանը հասել է ({{maxItems}})',
productShared: 'Ապրանքը կիսվել է',
productLinkCopied: 'Ապրանքի հղումը պատճենվել է',
sharingUnsupported: 'Այս սարքում կիսվելու հնարավորությունը չի աջակցվում',
enterQueryBeforeSave: 'Մինչ պահպանումը մուտքագրեք որոնման հարցում',
savedSearchNamed: 'Պահպանված որոնում՝ {{name}}',
}, },
}; };

View File

@@ -164,6 +164,101 @@ export const ru: Translations = {
addToCart: 'В корзину', addToCart: 'В корзину',
categoriesCount: '{{count}} категорий', categoriesCount: '{{count}} категорий',
productsCount: '{{count}} товаров', productsCount: '{{count}} товаров',
entriesCount: '{{count}} позиций',
categoryBannerPlaceholder: 'Баннер категории подготовлен для медиа и описания из backend.',
navigationPlaceholder: 'Макет навигации подготовлен для конфигурации из backend/bootstrap.',
removeSavedSearch: 'Удалить сохраненный поиск',
resetFilters: 'Сбросить',
filtersTitle: 'Фильтры',
minValue: 'От {{value}}',
maxValue: 'До {{value}}',
enabled: 'Включено',
searchPlaceholder: 'Поиск товаров, брендов и категорий',
searchSubmit: 'Найти',
suggestionsTitle: 'Подсказки',
recentSearchesTitle: 'Недавние запросы',
searchHistoryTitle: 'История поиска',
clearHistory: 'Очистить',
searchNoResultsHint: 'Ничего не найдено. Попробуйте более общий запрос.',
sortBy: 'Сортировать по',
sortRelevance: 'Релевантности',
sortLatest: 'Новизне',
sortPriceAsc: 'Цене: по возрастанию',
sortPriceDesc: 'Цене: по убыванию',
sortRating: 'Рейтингу',
sortPopular: 'Популярности',
sortDiscount: 'Скидке',
layoutGrid: 'Сетка',
layoutLargeGrid: 'Крупная сетка',
layoutCompactGrid: 'Компактная сетка',
layoutList: 'Список',
noResults: 'Нет результатов',
itemsCount: '{{count}} товаров',
emptyResultsTitle: 'Результаты не найдены',
emptyResultsDescription: 'Попробуйте изменить фильтры, сортировку или поисковый запрос.',
previousPage: 'Назад',
nextPage: 'Вперед',
pageOf: 'Страница {{page}} / {{total}}',
filterPrice: 'Цена',
filterAvailability: 'Наличие',
filterInStock: 'В наличии',
filterLowStock: 'Мало в наличии',
filterOutOfStock: 'Нет в наличии',
filterRating: 'Рейтинг',
filterStars: '{{count}} звезд',
filterBrand: 'Бренд',
filterCategory: 'Категория',
filterCategoryValue: 'Категория {{value}}',
filterSubcategory: 'Подкатегория',
filterDiscount: 'Скидка',
filterNew: 'Новинки',
filterColor: 'Цвет',
filterSize: 'Размер',
filterAttributes: 'Атрибуты',
},
productDetails: {
loading: 'Загрузка товара...',
errorTitle: 'Товар недоступен',
retry: 'Попробовать снова',
missingTitle: 'Товар не найден',
missingDescription: 'Этот товар не существует или больше недоступен.',
backToCatalog: 'Вернуться в каталог',
tabsAria: 'Вкладки товара',
mediaAria: 'Медиа товара',
ratingStarsAria: 'Рейтинг в звездах',
selectRatingAria: 'Выберите оценку',
reviewsEmpty: 'Пока нет отзывов.',
questionsEmpty: 'Пока нет вопросов.',
askQuestion: 'Задать вопрос',
hideQuestionForm: 'Скрыть форму вопроса',
questionFormTitle: 'Задать вопрос',
questionFormPlaceholder: 'Спросите о размере, доставке, совместимости или использовании',
questionFormAnonymous: 'Задать анонимно',
submitQuestion: 'Отправить вопрос',
reviewFormTitle: 'Оставить отзыв',
reviewRatingLabel: 'Оценка',
reviewTitleLabel: 'Заголовок',
reviewTitlePlaceholder: 'Короткий заголовок',
reviewTextLabel: 'Отзыв',
reviewTextPlaceholder: 'Опишите ваш опыт использования',
reviewAnonymous: 'Отправить анонимно',
reviewUploadPlaceholder: 'Загрузка фото запланирована в следующем спринте.',
submitReview: 'Отправить отзыв',
submitting: 'Отправка...',
specificationsEmpty: 'Характеристики пока не указаны.',
warrantyTitle: 'Гарантия и возврат',
warrantyItem1: 'Условия гарантии определяются продавцом и местным законодательством.',
warrantyItem2: 'Возможность возврата зависит от состояния и категории товара.',
warrantyItem3: 'Подробная инструкция по возврату доступна после покупки.',
verifiedPurchase: 'Подтвержденная покупка',
officialSeller: 'Официальный продавец',
acceptedAnswer: 'Принятый ответ',
like: 'Нравится',
dislike: 'Не нравится',
reviewsCountLabel: '{{count}} отзывов',
previous: 'Назад',
next: 'Вперед',
pageOf: 'Страница {{page}} / {{total}}',
}, },
subcategories: { subcategories: {
loading: 'Загрузка подкатегорий...', loading: 'Загрузка подкатегорий...',
@@ -261,5 +356,15 @@ export const ru: Translations = {
recentlyViewedEmpty: 'Вы пока не просматривали товары.', recentlyViewedEmpty: 'Вы пока не просматривали товары.',
saveSearch: 'Сохранить поиск', saveSearch: 'Сохранить поиск',
goToCatalog: 'Перейти в каталог', goToCatalog: 'Перейти в каталог',
wishlistAdded: 'Добавлено в избранное',
wishlistRemoved: 'Удалено из избранного',
compareAdded: 'Добавлено в сравнение',
compareRemoved: 'Удалено из сравнения',
compareLimitReached: 'Достигнут лимит сравнения ({{maxItems}})',
productShared: 'Товар отправлен',
productLinkCopied: 'Ссылка на товар скопирована',
sharingUnsupported: 'На этом устройстве нет поддержки шаринга',
enterQueryBeforeSave: 'Введите поисковый запрос перед сохранением',
savedSearchNamed: 'Сохраненный поиск: {{name}}',
}, },
}; };

View File

@@ -162,6 +162,101 @@ export interface Translations {
addToCart: string; addToCart: string;
categoriesCount: string; categoriesCount: string;
productsCount: string; productsCount: string;
entriesCount: string;
categoryBannerPlaceholder: string;
navigationPlaceholder: string;
removeSavedSearch: string;
resetFilters: string;
filtersTitle: string;
minValue: string;
maxValue: string;
enabled: string;
searchPlaceholder: string;
searchSubmit: string;
suggestionsTitle: string;
recentSearchesTitle: string;
searchHistoryTitle: string;
clearHistory: string;
searchNoResultsHint: string;
sortBy: string;
sortRelevance: string;
sortLatest: string;
sortPriceAsc: string;
sortPriceDesc: string;
sortRating: string;
sortPopular: string;
sortDiscount: string;
layoutGrid: string;
layoutLargeGrid: string;
layoutCompactGrid: string;
layoutList: string;
noResults: string;
itemsCount: string;
emptyResultsTitle: string;
emptyResultsDescription: string;
previousPage: string;
nextPage: string;
pageOf: string;
filterPrice: string;
filterAvailability: string;
filterInStock: string;
filterLowStock: string;
filterOutOfStock: string;
filterRating: string;
filterStars: string;
filterBrand: string;
filterCategory: string;
filterCategoryValue: string;
filterSubcategory: string;
filterDiscount: string;
filterNew: string;
filterColor: string;
filterSize: string;
filterAttributes: string;
};
productDetails: {
loading: string;
errorTitle: string;
retry: string;
missingTitle: string;
missingDescription: string;
backToCatalog: string;
tabsAria: string;
mediaAria: string;
ratingStarsAria: string;
selectRatingAria: string;
reviewsEmpty: string;
questionsEmpty: string;
askQuestion: string;
hideQuestionForm: string;
questionFormTitle: string;
questionFormPlaceholder: string;
questionFormAnonymous: string;
submitQuestion: string;
reviewFormTitle: string;
reviewRatingLabel: string;
reviewTitleLabel: string;
reviewTitlePlaceholder: string;
reviewTextLabel: string;
reviewTextPlaceholder: string;
reviewAnonymous: string;
reviewUploadPlaceholder: string;
submitReview: string;
submitting: string;
specificationsEmpty: string;
warrantyTitle: string;
warrantyItem1: string;
warrantyItem2: string;
warrantyItem3: string;
verifiedPurchase: string;
officialSeller: string;
acceptedAnswer: string;
like: string;
dislike: string;
reviewsCountLabel: string;
previous: string;
next: string;
pageOf: string;
}; };
subcategories: { subcategories: {
loading: string; loading: string;
@@ -259,5 +354,15 @@ export interface Translations {
recentlyViewedEmpty: string; recentlyViewedEmpty: string;
saveSearch: string; saveSearch: string;
goToCatalog: string; goToCatalog: string;
wishlistAdded: string;
wishlistRemoved: string;
compareAdded: string;
compareRemoved: string;
compareLimitReached: string;
productShared: string;
productLinkCopied: string;
sharingUnsupported: string;
enterQueryBeforeSave: string;
savedSearchNamed: string;
}; };
} }

View File

@@ -43,7 +43,11 @@ import { Category } from '../../core/categories/models/category-domain.model';
[class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false" [class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false"
> >
@if (resolveWidget(widget, section, model.id) | async; as resolved) { @if (resolveWidget(widget, section, model.id) | async; as resolved) {
@if (widget.type === 'categories') {
<ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data, categorySelectedCallback: onCategorySelected.bind(this) }"></ng-container> <ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data, categorySelectedCallback: onCategorySelected.bind(this) }"></ng-container>
} @else {
<ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data }"></ng-container>
}
} }
</div> </div>
} }