From d7d73c2a1056a392ae1bfb607312d8da9d150f9f Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 5 Jul 2026 01:05:48 +0400 Subject: [PATCH] Add reusable marketplace product card --- .../product-card/product-card.component.html | 55 +++++ .../product-card/product-card.component.scss | 219 ++++++++++++++++++ .../product-card/product-card.component.ts | 38 +++ .../pages/category/category.component.html | 54 +---- src/app/pages/category/category.component.ts | 4 +- src/app/pages/search/search.component.html | 62 +---- src/app/pages/search/search.component.ts | 6 +- 7 files changed, 332 insertions(+), 106 deletions(-) create mode 100644 src/app/components/product-card/product-card.component.html create mode 100644 src/app/components/product-card/product-card.component.scss create mode 100644 src/app/components/product-card/product-card.component.ts diff --git a/src/app/components/product-card/product-card.component.html b/src/app/components/product-card/product-card.component.html new file mode 100644 index 0000000..db7a67a --- /dev/null +++ b/src/app/components/product-card/product-card.component.html @@ -0,0 +1,55 @@ +
+ +
+ + @if (item.discount > 0) { +
-{{ item.discount }}%
+ } + @if (item.badges && item.badges.length > 0) { +
+ @for (badge of item.badges; track badge) { + {{ badge }} + } +
+ } +
+ +
+

{{ title || item.name }}

+ + @if (showDescription && description) { +

{{ description }}

+ } + + @if (showRating) { +
+ {{ item.rating | number:'1.1-1' }} + ({{ item.callbacks?.length || item.comments?.length || 0 }}) +
+ } + +
+ @if (item.discount > 0) { + {{ item.price | number:'1.2-2' }} {{ item.currency }} + {{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }} + } @else { + {{ item.price | number:'1.2-2' }} {{ item.currency }} + } +
+ + @if (showStock && item.remainings) { + + } +
+
+ + +
diff --git a/src/app/components/product-card/product-card.component.scss b/src/app/components/product-card/product-card.component.scss new file mode 100644 index 0000000..8e0c6b4 --- /dev/null +++ b/src/app/components/product-card/product-card.component.scss @@ -0,0 +1,219 @@ +.product-card { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-4px); + } +} + +.product-link { + text-decoration: none; + color: inherit; + flex: 1; + display: flex; + flex-direction: column; +} + +.product-image { + position: relative; + width: 100%; + aspect-ratio: 4 / 3; + border: 1px solid #d3dad9; + border-radius: 13px 13px 0 0; + box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #f0f0f0; + + img { + width: 100%; + height: 100%; + object-fit: contain; + background: white; + padding: 12px; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + &:hover img { + transform: scale(1.05); + } +} + +.discount-badge { + position: absolute; + top: 12px; + right: 12px; + background: #ef4444; + color: white; + padding: 6px 12px; + border-radius: 20px; + font-weight: 700; + font-size: 0.9rem; + z-index: 1; +} + +.product-badges-overlay { + position: absolute; + left: 10px; + top: 10px; + display: flex; + gap: 6px; + flex-wrap: wrap; + z-index: 1; +} + +.product-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 999px; + background: #497671; + color: #fff; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.product-details { + width: 100%; + border: 1px solid #d3dad9; + border-top: none; + border-radius: 0 0 13px 13px; + padding: 12px 16px; + box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15); + background: #f5f3f9; + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.product-name { + font-weight: 700; + font-size: clamp(14px, 1.4vw, 18px); + color: #1e3c38; + margin: 0; + line-height: 1.3; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + min-height: calc(2 * 1.3em); +} + +.product-description { + margin: 0; + color: #697777; + font-size: 0.9rem; + line-height: 1.45; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.product-rating { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: #697777; + + .rating-stars { + color: #b45309; + font-weight: 700; + } +} + +.product-price { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.original-price { + text-decoration: line-through; + color: #a1b4b5; + font-size: 0.9rem; +} + +.discounted-price, +.current-price { + color: #1e3c38; + font-size: 1.1rem; + font-weight: 800; +} + +.product-stock { + margin-top: auto; +} + +.stock-bar { + display: flex; + gap: 4px; + height: 4px; +} + +.bar-segment { + flex: 1; + border-radius: 999px; + background: #d3dad9; + + &.filled.high { + background: #16a34a; + } + + &.filled.medium { + background: #f59e0b; + } + + &.filled.low { + background: #ef4444; + } +} + +.add-to-cart-btn { + width: 100%; + margin-top: 10px; + min-height: 42px; + border: 0; + border-radius: 13px; + background: #497671; + color: #fff; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.15s ease; + + &:hover { + background: #3d635f; + transform: translateY(-1px); + } + + &:focus-visible { + outline: 3px solid rgba(73, 118, 113, 0.25); + outline-offset: 2px; + } +} + +.product-card-compact { + .product-details { + padding: 10px 12px; + } + + .product-description, + .product-stock { + display: none; + } +} diff --git a/src/app/components/product-card/product-card.component.ts b/src/app/components/product-card/product-card.component.ts new file mode 100644 index 0000000..f516c36 --- /dev/null +++ b/src/app/components/product-card/product-card.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { Item } from '../../models'; +import { LangRoutePipe } from '../../pipes/lang-route.pipe'; +import { getBadgeClass, getDiscountedPrice, getMainImage } from '../../utils/item.utils'; + +export type ProductCardAppearance = 'standard' | 'compact'; + +@Component({ + selector: 'app-product-card', + standalone: true, + imports: [DecimalPipe, RouterLink, LangRoutePipe], + templateUrl: './product-card.component.html', + styleUrls: ['./product-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ProductCardComponent { + @Input({ required: true }) item!: Item; + @Input() title = ''; + @Input() description = ''; + @Input() addToCartLabel = 'Add to cart'; + @Input() appearance: ProductCardAppearance = 'standard'; + @Input() showDescription = false; + @Input() showStock = true; + @Input() showRating = true; + + @Output() addToCart = new EventEmitter<{ itemID: number; event: Event }>(); + @Output() preview = new EventEmitter(); + + readonly getMainImage = getMainImage; + readonly getDiscountedPrice = getDiscountedPrice; + readonly getBadgeClass = getBadgeClass; + + onAddToCart(event: Event): void { + this.addToCart.emit({ itemID: this.item.itemID, event }); + } +} diff --git a/src/app/pages/category/category.component.html b/src/app/pages/category/category.component.html index 2df3e7a..90c5259 100644 --- a/src/app/pages/category/category.component.html +++ b/src/app/pages/category/category.component.html @@ -9,53 +9,13 @@ @if (!error()) {
@for (item of items(); track trackByItemId($index, item)) { - + } @if (loading() && items().length > 0) { diff --git a/src/app/pages/category/category.component.ts b/src/app/pages/category/category.component.ts index b53349b..9754e23 100644 --- a/src/app/pages/category/category.component.ts +++ b/src/app/pages/category/category.component.ts @@ -1,5 +1,4 @@ import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy, inject } from '@angular/core'; -import { DecimalPipe } from '@angular/common'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { CartService } from '../../services'; import { PrefetchService } from '../../services/prefetch.service'; @@ -11,10 +10,11 @@ import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants'; import { ProductFacade } from '../../facades/platform/product.facade'; +import { ProductCardComponent } from '../../components/product-card/product-card.component'; @Component({ selector: 'app-category', - imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe], + imports: [RouterLink, LangRoutePipe, TranslatePipe, ProductCardComponent], templateUrl: './category.component.html', styleUrls: ['./category.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/pages/search/search.component.html b/src/app/pages/search/search.component.html index 9a57409..80f898a 100644 --- a/src/app/pages/search/search.component.html +++ b/src/app/pages/search/search.component.html @@ -56,59 +56,15 @@ @if (items().length > 0) { diff --git a/src/app/pages/search/search.component.ts b/src/app/pages/search/search.component.ts index aa09ea0..637536d 100644 --- a/src/app/pages/search/search.component.ts +++ b/src/app/pages/search/search.component.ts @@ -1,7 +1,5 @@ import { Component, signal, HostListener, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core'; -import { DecimalPipe } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; import { CartService } from '../../services'; import { PrefetchService } from '../../services/prefetch.service'; import { Item } from '../../models'; @@ -9,15 +7,15 @@ import { Subject, Subscription } from 'rxjs'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; import { LanguageService } from '../../services/language.service'; -import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslateService } from '../../i18n/translate.service'; import { SEARCH_DEBOUNCE_MS, ITEMS_PER_PAGE, SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS } from '../../config/constants'; import { ProductFacade } from '../../facades/platform/product.facade'; +import { ProductCardComponent } from '../../components/product-card/product-card.component'; @Component({ selector: 'app-search', - imports: [DecimalPipe, FormsModule, RouterLink, LangRoutePipe, TranslatePipe], + imports: [FormsModule, TranslatePipe, ProductCardComponent], templateUrl: './search.component.html', styleUrls: ['./search.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush