fix(storefront): premium UX polish for product, compare, wishlist

- delivery-information, product-actions, product-description,
  product-gallery, product-information, related-products,
  variant-selector: normalize hardcoded hex colors to design tokens
  (--text-primary, --text-secondary, --border-color, --bg-primary/
  --bg-secondary, --primary-color/--primary-hover); stock status and
  discount badge now use semantic --success-color/--warning-color/
  --error-color instead of near-duplicate literal hex
- product-actions: add aria-pressed to wishlist/compare toggle
  buttons; add hover/active/disabled states to action buttons
- product-gallery: add aria-current + aria-label to active thumbnail
  button; add focus-visible ring and hover state on thumbnails and
  toolbar buttons
- variant-selector: add aria-pressed to colour/size option buttons;
  add a visible checkmark glyph on the selected colour swatch so
  selection isn't color-only; add hover states
- product-tabs: add role="tab"/aria-selected to tab buttons
- star-selector: add per-star aria-label (new starsLabel i18n key
  added to en/hy/ru + translations.ts interface)
- question-list: add aria-expanded to the ask-question disclosure
  toggle; swap plain empty-state <p> for app-empty-state; add
  hover/disabled states to pager buttons
- review-list: swap plain empty-state <p> for app-empty-state; add
  hover/disabled states to pager and load-more buttons
- question-card, question-form, review-form: normalize accepted/
  success/error colors to semantic tokens; add focus-visible and
  hover/disabled states to inputs and submit buttons
- compare-table: add scope="col"/scope="row" to table headers; make
  header row and attribute column sticky for easier comparison on
  long tables
- compare-page: add hover/focus states to the remove-from-compare
  chip button

Build verified green via `npm run build`.

Out of scope / skipped:
- src/app/pages/item-detail/* is dead code (not referenced by any
  route or component) - left untouched
- wishlist page and product-details-container were already fully
  composed with shared skeleton/empty-state/button components from
  the RC-Visual-02 pass - no changes needed
- stars.component display-only rating glyphs use a light gray not an
  exact token match - left as-is to avoid an unintended visual shift

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-23 11:47:57 +04:00
parent 9ea8c98faf
commit ea1a5d9b8a
29 changed files with 271 additions and 81 deletions

View File

@@ -1,15 +1,20 @@
.delivery-information {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #d3dad9;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 13px);
background: #f8faf9;
background: var(--bg-secondary);
transition: box-shadow var(--transition-normal);
&:hover {
box-shadow: var(--shadow-sm);
}
h2 {
margin: 0;
color: #1e3c38;
color: var(--text-primary);
font-size: var(--font-size-lg, 1rem);
}
}
@@ -19,7 +24,7 @@
padding: 6px 10px;
border-radius: var(--radius-full, 999px);
background: rgba(73, 118, 113, 0.12);
color: #3d635f;
color: var(--primary-hover);
font-weight: 800;
}
@@ -33,7 +38,7 @@
display: flex;
justify-content: space-between;
gap: 14px;
color: #697777;
color: var(--text-secondary);
div {
display: flex;
@@ -42,7 +47,7 @@
}
strong {
color: #1e3c38;
color: var(--text-primary);
white-space: nowrap;
}
}

View File

@@ -8,11 +8,11 @@
}
@if (config.wishlist) {
<button type="button" class="secondary" [class.active]="inWishlist" (click)="wishlist.emit()">{{ 'productDetails.wishlist' | translate }}</button>
<button type="button" class="secondary" [class.active]="inWishlist" [attr.aria-pressed]="inWishlist" (click)="wishlist.emit()">{{ 'productDetails.wishlist' | translate }}</button>
}
@if (config.compare) {
<button type="button" class="secondary" [class.active]="inCompare" (click)="compare.emit()">{{ 'productDetails.compare' | translate }}</button>
<button type="button" class="secondary" [class.active]="inCompare" [attr.aria-pressed]="inCompare" (click)="compare.emit()">{{ 'productDetails.compare' | translate }}</button>
}
@if (config.share) {

View File

@@ -7,32 +7,60 @@
button {
min-height: 40px;
border-radius: 10px;
border: 1px solid #d3dad9;
background: #fff;
color: #1e3c38;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-weight: var(--font-weight-bold, 700);
padding: 0 14px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
}
button:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
button:active {
transform: translateY(0);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
box-shadow: none;
}
button.primary {
border-color: #497671;
background: #497671;
color: #fff;
border-color: var(--primary-color);
background: var(--primary-color);
color: var(--bg-primary);
}
button.primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
button.primary.ghost {
background: #eef4f3;
color: #2e5e59;
background: var(--accent-color);
color: var(--primary-hover);
}
button.primary.ghost:hover {
background: var(--accent-hover);
}
button.secondary.active {
border-color: #497671;
border-color: var(--primary-color);
color: var(--primary-hover);
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.18);
}
button:focus-visible {
outline: 2px solid #497671;
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}

View File

@@ -9,13 +9,13 @@
h2 {
margin: 0;
color: #1e3c38;
font-size: 1.45rem;
color: var(--text-primary);
font-size: var(--font-size-3xl);
}
p {
margin: 0;
color: #697777;
line-height: 1.65;
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
}
}

View File

@@ -39,7 +39,7 @@
@if (media.length > 1) {
<div class="product-gallery-thumbs">
@for (item of media; track $index) {
<button type="button" class="product-gallery-thumb" [class.active]="selectedIndex === $index" (click)="select($index)">
<button type="button" class="product-gallery-thumb" [class.active]="selectedIndex === $index" [attr.aria-current]="selectedIndex === $index ? 'true' : null" [attr.aria-label]="('productDetails.photo' | translate) + ' ' + ($index + 1)" (click)="select($index)">
@if (item.type === 'video') {
<span class="product-gallery-video"></span>
} @else if (item.type !== 'image') {

View File

@@ -7,14 +7,14 @@
.product-gallery-main {
position: relative;
aspect-ratio: 1;
border: 1px solid #d3dad9;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 13px);
overflow: hidden;
background: #fff;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.24s ease, border-color 0.24s ease;
transition: box-shadow var(--transition-slow), border-color var(--transition-slow);
img,
video,
@@ -39,11 +39,22 @@
min-height: 30px;
border: 1px solid rgba(30, 60, 56, 0.25);
background: rgba(255, 255, 255, 0.9);
color: #1e3c38;
color: var(--text-primary);
border-radius: var(--radius-full, 999px);
padding: 0 10px;
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
transition: background var(--transition-fast), box-shadow var(--transition-fast);
}
.toolbar-btn:hover {
background: var(--bg-primary);
box-shadow: var(--shadow-sm);
}
.toolbar-btn:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.document-viewer {
@@ -55,7 +66,7 @@
p {
margin: 0;
color: #1e3c38;
color: var(--text-primary);
font-weight: var(--font-weight-bold, 700);
}
@@ -81,21 +92,27 @@
.product-gallery-thumb {
position: relative;
aspect-ratio: 1;
border: 1px solid #d3dad9;
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
background: #fff;
background: var(--bg-primary);
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease;
padding: 0;
transition: transform var(--transition-normal), border-color var(--transition-normal), box-shadow var(--transition-normal);
&.active {
border-color: #497671;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.18);
}
&:hover {
transform: translateY(-1px);
border-color: #497671;
border-color: var(--primary-color);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
img {

View File

@@ -5,7 +5,7 @@
h1 {
margin: 0;
color: #1e3c38;
color: var(--text-primary);
font-size: clamp(1.8rem, 4vw, 2.8rem);
line-height: 1.08;
}
@@ -28,19 +28,19 @@
min-height: 26px;
padding: 3px 9px;
border-radius: var(--radius-full, 999px);
background: #497671;
color: #fff;
background: var(--primary-color);
color: var(--bg-primary);
font-size: var(--font-size-xs, 0.75rem);
font-weight: 900;
text-transform: uppercase;
}
.discount-badge {
background: #dc2626;
background: var(--error-color);
}
.product-rating {
color: #697777;
color: var(--text-secondary);
font-weight: 800;
}
@@ -50,35 +50,35 @@
gap: 4px;
strong {
color: #1e3c38;
color: var(--text-primary);
font-size: var(--font-size-4xl, 2rem);
line-height: 1;
}
}
.old-price {
color: #a1b4b5;
color: var(--secondary-color);
text-decoration: line-through;
}
.product-stock {
color: #697777;
color: var(--text-secondary);
font-weight: 800;
&.out {
color: #b91c1c;
color: var(--error-color);
}
&.low {
color: #b45309;
color: var(--warning-color);
}
&.medium {
color: #497671;
color: var(--primary-color);
}
&.high {
color: #15803d;
color: var(--success-color);
}
}
@@ -94,16 +94,28 @@
min-height: 48px;
border: 0;
border-radius: var(--radius-lg, 13px);
background: #497671;
color: #fff;
background: var(--primary-color);
color: var(--bg-primary);
font-size: var(--font-size-lg, 1rem);
font-weight: 900;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
transition: transform var(--transition-normal), background-color var(--transition-normal), box-shadow var(--transition-normal);
&:hover {
background: #3d635f;
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
&:active {
transform: translateY(0);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
box-shadow: none;
}
&:focus-visible {

View File

@@ -5,8 +5,8 @@
h2 {
margin: 0;
color: #1e3c38;
font-size: 1.45rem;
color: var(--text-primary);
font-size: var(--font-size-3xl);
}
}

View File

@@ -6,9 +6,13 @@
<div class="variant-options">
@for (option of group.options; track option.value) {
@if (group.key === 'colour') {
<button type="button" class="colour-swatch" [class.active]="isActive(group.key, option.value)" [style.background-color]="option.value" [attr.aria-label]="option.label || option.value" (click)="onSelect(group.key, option.value)"></button>
<button type="button" class="colour-swatch" [class.active]="isActive(group.key, option.value)" [attr.aria-pressed]="isActive(group.key, option.value)" [style.background-color]="option.value" [attr.aria-label]="option.label || option.value" (click)="onSelect(group.key, option.value)">
@if (isActive(group.key, option.value)) {
<app-icon name="check" [size]="14" />
}
</button>
} @else {
<button type="button" class="size-chip" [class.active]="isActive(group.key, option.value)" (click)="onSelect(group.key, option.value)">{{ option.label || option.value }}</button>
<button type="button" class="size-chip" [class.active]="isActive(group.key, option.value)" [attr.aria-pressed]="isActive(group.key, option.value)" (click)="onSelect(group.key, option.value)">{{ option.label || option.value }}</button>
}
}
</div>

View File

@@ -11,7 +11,7 @@
}
.variant-label {
color: #697777;
color: var(--text-secondary);
font-size: var(--font-size-base, 0.875rem);
font-weight: 800;
}
@@ -25,12 +25,22 @@
.colour-swatch {
width: 34px;
height: 34px;
border: 2px solid #d3dad9;
border: 2px solid var(--border-color);
border-radius: 50%;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--bg-primary);
transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
&:hover {
transform: translateY(-1px);
}
&.active {
border-color: #1e3c38;
border-color: var(--text-primary);
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.2);
}
@@ -40,7 +50,7 @@
}
&:focus-visible {
outline: 2px solid #1e3c38;
outline: 2px solid var(--text-primary);
outline-offset: 2px;
}
}
@@ -48,17 +58,23 @@
.size-chip {
min-height: 34px;
padding: 0 14px;
border: 1px solid #d3dad9;
border: 1px solid var(--border-color);
border-radius: var(--radius-full, 999px);
background: #fff;
color: #1e3c38;
background: var(--bg-primary);
color: var(--text-primary);
font-weight: 800;
cursor: pointer;
transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
&:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
}
&.active {
border-color: #497671;
background: #497671;
color: #fff;
border-color: var(--primary-color);
background: var(--primary-color);
color: var(--bg-primary);
}
&.readonly {
@@ -68,7 +84,7 @@
}
&:focus-visible {
outline: 2px solid #497671;
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { IconComponent } from '../../../../../shared/ui/icon/icon.component';
export interface VariantOption {
value: string;
@@ -15,7 +16,7 @@ export interface VariantOptionGroup {
@Component({
selector: 'app-product-variant-selector',
standalone: true,
imports: [TranslatePipe],
imports: [TranslatePipe, IconComponent],
templateUrl: './variant-selector.component.html',
styleUrls: ['./variant-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -4,7 +4,9 @@
<button
type="button"
class="tab-btn"
role="tab"
[class.active]="activeKey === tab.key"
[attr.aria-selected]="activeKey === tab.key"
[disabled]="tab.enabled === false"
(click)="selectTab(tab)">
<span>{{ tab.label }}</span>

View File

@@ -60,8 +60,8 @@ header {
}
.accepted-pill {
background: #e9f9ec;
color: #18723a;
background: color-mix(in srgb, var(--success-color) 16%, white);
color: var(--success-color);
}
footer {

View File

@@ -16,6 +16,11 @@ textarea {
padding: 10px 12px;
font: inherit;
resize: vertical;
transition: border-color var(--transition-fast);
}
textarea:focus-visible {
border-color: var(--primary-color);
}
.check {
@@ -30,11 +35,22 @@ button {
border: 0;
border-radius: var(--radius-sm);
background: var(--primary-color);
color: #fff;
color: var(--bg-primary);
padding: 0 16px;
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast);
}
button:hover:not(:disabled) {
background: var(--primary-hover);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -1,6 +1,6 @@
<section class="question-list section">
@if (allowSubmission) {
<button type="button" class="ask-btn" (click)="askFormVisible.set(!askFormVisible())">
<button type="button" class="ask-btn" [attr.aria-expanded]="askFormVisible()" (click)="askFormVisible.set(!askFormVisible())">
@if (askFormVisible()) { {{ 'productDetails.hideQuestionForm' | translate }} } @else { {{ 'productDetails.askQuestion' | translate }} }
</button>
} @else {
@@ -23,7 +23,7 @@
}
</div>
} @else {
<p class="empty">{{ 'productDetails.questionsEmpty' | translate }}</p>
<app-empty-state class="empty" [title]="'productDetails.questionsEmpty' | translate" />
}
@if (result && totalPages > 1) {

View File

@@ -51,6 +51,17 @@
background: var(--bg-primary);
padding: 0 12px;
cursor: pointer;
transition: border-color var(--transition-fast), transform var(--transition-fast);
}
.pager button:hover:not(:disabled) {
border-color: var(--primary-color);
transform: translateY(-1px);
}
.pager button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.skeleton-list {

View File

@@ -3,11 +3,12 @@ import { EngagementListResult, Question, SubmitQuestionInput } from '../../../..
import { TranslatePipe } from '../../../../../../i18n/translate.pipe';
import { QuestionCardComponent } from '../question-card/question-card.component';
import { QuestionFormComponent } from '../question-form/question-form.component';
import { EmptyStateComponent } from '../../../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-question-list',
standalone: true,
imports: [QuestionCardComponent, QuestionFormComponent, TranslatePipe],
imports: [QuestionCardComponent, QuestionFormComponent, TranslatePipe, EmptyStateComponent],
templateUrl: './question-list.component.html',
styleUrls: ['./question-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -22,6 +22,12 @@ textarea {
border-radius: var(--radius-sm);
padding: 10px 12px;
font: inherit;
transition: border-color var(--transition-fast);
}
input:focus-visible,
textarea:focus-visible {
border-color: var(--primary-color);
}
textarea {
@@ -48,12 +54,12 @@ textarea {
}
.success {
color: #166534;
color: var(--success-color);
font-weight: var(--font-weight-bold, 700);
}
.error {
color: #991b1b;
color: var(--error-color);
}
button {
@@ -62,10 +68,19 @@ button {
border: 0;
border-radius: var(--radius-sm);
background: var(--primary-color);
color: #fff;
color: var(--bg-primary);
padding: 0 16px;
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast);
}
button:hover:not(:disabled) {
background: var(--primary-hover);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {

View File

@@ -17,7 +17,7 @@
}
</div>
} @else {
<p class="empty">{{ 'productDetails.reviewsEmpty' | translate }}</p>
<app-empty-state class="empty" [title]="'productDetails.reviewsEmpty' | translate" />
}
@if (result && totalPages > 1 && mode === 'pages') {

View File

@@ -35,6 +35,11 @@
transform: translateY(-1px);
}
.pager button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.load-more-wrap {
display: grid;
justify-items: center;
@@ -50,6 +55,17 @@
padding: 0 16px;
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.load-more:hover:not(:disabled) {
background: var(--primary-color);
color: var(--bg-primary);
}
.load-more:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.load-more-wrap small {

View File

@@ -5,11 +5,12 @@ 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';
import { EmptyStateComponent } from '../../../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-review-list',
standalone: true,
imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent, TranslatePipe],
imports: [RatingSummaryComponent, ReviewCardComponent, ReviewFormComponent, TranslatePipe, EmptyStateComponent],
templateUrl: './review-list.component.html',
styleUrls: ['./review-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -5,6 +5,7 @@
class="star-btn"
[class.selected]="star <= rating"
[attr.aria-checked]="star === rating"
[attr.aria-label]="star + ' ' + ('productDetails.starsLabel' | translate)"
role="radio"
(click)="ratingChange.emit(star)">