From 4510eb769ade0e444cfa50a3d9b8c612cf67c613 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 01:45:12 +0400 Subject: [PATCH] feat: client-side currency conversion with admin-configurable rates - CurrencyRatesService: RUB-based rates, persisted via localStorage - CurrencyConvertPipe: impure pipe converting item price to selected currency - Admin settings page: editable currency rates form - Applied conversion to product-card, product-information, quick-view-dialog, delivery-information, compare-table, cart totals + payment payload Co-Authored-By: Claude Sonnet 5 --- .../product-card/product-card.component.html | 6 +-- .../product-card/product-card.component.ts | 9 +++- .../pages/admin-settings-page.component.html | 21 ++++++++ .../pages/admin-settings-page.component.scss | 22 ++++++++ .../pages/admin-settings-page.component.ts | 30 ++++++++++- .../delivery-information.component.html | 2 +- .../delivery-information.component.ts | 9 +++- .../product-information.component.html | 6 +-- .../product-information.component.ts | 9 +++- .../quick-view-dialog.component.html | 6 +-- .../quick-view-dialog.component.ts | 9 +++- .../components/compare-table.component.ts | 8 ++- src/app/i18n/en.ts | 4 ++ src/app/i18n/hy.ts | 4 ++ src/app/i18n/ru.ts | 4 ++ src/app/i18n/translations.ts | 4 ++ src/app/pages/cart/cart.component.html | 14 ++--- src/app/pages/cart/cart.component.ts | 11 +++- src/app/pipes/currency-convert.pipe.ts | 34 ++++++++++++ src/app/services/currency-rates.service.ts | 53 +++++++++++++++++++ 20 files changed, 235 insertions(+), 30 deletions(-) create mode 100644 src/app/pipes/currency-convert.pipe.ts create mode 100644 src/app/services/currency-rates.service.ts diff --git a/src/app/components/product-card/product-card.component.html b/src/app/components/product-card/product-card.component.html index 3a17925..debfa43 100644 --- a/src/app/components/product-card/product-card.component.html +++ b/src/app/components/product-card/product-card.component.html @@ -51,10 +51,10 @@
@if (item.discount > 0) { - {{ item.price | number:'1.2-2' }} {{ item.currency }} - {{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }} + {{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }} + {{ getDiscountedPrice(item) | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }} } @else { - {{ item.price | number:'1.2-2' }} {{ item.currency }} + {{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }} }
diff --git a/src/app/components/product-card/product-card.component.ts b/src/app/components/product-card/product-card.component.ts index 4e0f19f..8e8d7bd 100644 --- a/src/app/components/product-card/product-card.component.ts +++ b/src/app/components/product-card/product-card.component.ts @@ -1,9 +1,11 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { RouterLink } from '@angular/router'; import { Product } from '../../core/products/models/product-domain.model'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; +import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe'; +import { LanguageService } from '../../services/language.service'; import { cleanDescription, getBadgeClass, getDiscountedPrice, getMainImage, onImageError } from '../../utils/item.utils'; const STOCK_LABEL_KEYS: Record = { @@ -18,12 +20,15 @@ export type ProductCardAppearance = 'standard' | 'compact'; @Component({ selector: 'app-product-card', standalone: true, - imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe], + imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe, CurrencyConvertPipe], templateUrl: './product-card.component.html', styleUrls: ['./product-card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductCardComponent { + private readonly languageService = inject(LanguageService); + readonly displayCurrency = this.languageService.currentCurrency; + @Input({ required: true }) item!: Product; @Input() title = ''; @Input() description = ''; diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.html b/src/app/features/admin/settings/pages/admin-settings-page.component.html index 619a953..00ba29e 100644 --- a/src/app/features/admin/settings/pages/admin-settings-page.component.html +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.html @@ -11,4 +11,25 @@ {{ 'adminSettings.densityCompact' | translate }} + +
+

{{ 'adminSettings.currencyRates' | translate }}

+

{{ 'adminSettings.currencyRatesExplain' | translate }}

+
+ {{ currency.code }} + +
+
+ + {{ 'adminSettings.currencyRatesSaved' | translate }} +
+
diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.scss b/src/app/features/admin/settings/pages/admin-settings-page.component.scss index 67d3c18..cd549be 100644 --- a/src/app/features/admin/settings/pages/admin-settings-page.component.scss +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.scss @@ -12,3 +12,25 @@ .settings-explain { margin: 0; color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); } .toggle-row { display: flex; align-items: center; gap: 8px; font-weight: var(--font-weight-normal, 400); } + +.rate-row { display: flex; align-items: center; gap: 12px; } +.rate-code { width: 48px; font-weight: var(--font-weight-medium, 500); } +.rate-input { + width: 120px; + padding: 6px 8px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-sm, 4px); + background: var(--bg-primary, #fff); +} +.rate-input:disabled { opacity: 0.6; } + +.rate-actions { display: flex; align-items: center; gap: 12px; margin-top: 4px; } +.save-button { + padding: 8px 16px; + border: none; + border-radius: var(--radius-sm, 4px); + background: var(--color-primary, #16a34a); + color: #fff; + cursor: pointer; +} +.saved-message { color: var(--color-success, #16a34a); font-size: var(--font-size-sm, 0.8125rem); } diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.ts b/src/app/features/admin/settings/pages/admin-settings-page.component.ts index de90235..da46840 100644 --- a/src/app/features/admin/settings/pages/admin-settings-page.component.ts +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.ts @@ -1,21 +1,47 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { AdminPreferencesService } from '../services/admin-preferences.service'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component'; +import { CurrencyRatesService } from '../../../../services/currency-rates.service'; +import { LanguageService } from '../../../../services/language.service'; + +const SAVED_MESSAGE_DURATION_MS = 2000; @Component({ selector: 'app-admin-settings-page', standalone: true, - imports: [FormsModule, TranslatePipe, ToggleComponent], + imports: [CommonModule, FormsModule, TranslatePipe, ToggleComponent], templateUrl: './admin-settings-page.component.html', styleUrls: ['./admin-settings-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminSettingsPageComponent { readonly preferences = inject(AdminPreferencesService); + readonly currencyRates = inject(CurrencyRatesService); + readonly languageService = inject(LanguageService); + + readonly rateDrafts = signal>({ ...this.currencyRates.rates() }); + readonly showSavedMessage = signal(false); onCompactToggle(compact: boolean): void { this.preferences.setDensity(compact ? 'compact' : 'comfortable'); } + + onRateInput(code: string, value: string): void { + const parsed = Number(value); + this.rateDrafts.set({ ...this.rateDrafts(), [code]: parsed }); + } + + saveRates(): void { + for (const currency of this.languageService.currencies) { + const rate = this.rateDrafts()[currency.code]; + if (Number.isFinite(rate) && rate > 0) { + this.currencyRates.setRate(currency.code, rate); + } + } + this.showSavedMessage.set(true); + setTimeout(() => this.showSavedMessage.set(false), SAVED_MESSAGE_DURATION_MS); + } } diff --git a/src/app/features/website/product/components/delivery-information/delivery-information.component.html b/src/app/features/website/product/components/delivery-information/delivery-information.component.html index 26430b7..ca76c5a 100644 --- a/src/app/features/website/product/components/delivery-information/delivery-information.component.html +++ b/src/app/features/website/product/components/delivery-information/delivery-information.component.html @@ -16,7 +16,7 @@ {{ 'cart.deliveryTime' | translate }}: {{ option.deliveryTime }} } - {{ option.deliveryPrice | number:'1.2-2' }} {{ currency }} + {{ option.deliveryPrice | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }} } diff --git a/src/app/features/website/product/components/delivery-information/delivery-information.component.ts b/src/app/features/website/product/components/delivery-information/delivery-information.component.ts index a0352e3..b65ab22 100644 --- a/src/app/features/website/product/components/delivery-information/delivery-information.component.ts +++ b/src/app/features/website/product/components/delivery-information/delivery-information.component.ts @@ -1,17 +1,22 @@ import { DecimalPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core'; import { Product } from '../../../../../core/products/models/product-domain.model'; import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { CurrencyConvertPipe } from '../../../../../pipes/currency-convert.pipe'; +import { LanguageService } from '../../../../../services/language.service'; @Component({ selector: 'app-product-delivery-information', standalone: true, - imports: [DecimalPipe, TranslatePipe], + imports: [DecimalPipe, TranslatePipe, CurrencyConvertPipe], templateUrl: './delivery-information.component.html', styleUrls: ['./delivery-information.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductDeliveryInformationComponent { + private readonly languageService = inject(LanguageService); + readonly displayCurrency = this.languageService.currentCurrency; + @Input({ required: true }) product!: Product; @Input() currency = ''; diff --git a/src/app/features/website/product/components/product-information/product-information.component.html b/src/app/features/website/product/components/product-information/product-information.component.html index 560b663..8488708 100644 --- a/src/app/features/website/product/components/product-information/product-information.component.html +++ b/src/app/features/website/product/components/product-information/product-information.component.html @@ -19,12 +19,12 @@
@if (product.discount > 0) {
- {{ price | number:'1.2-2' }} {{ currency }} + {{ price | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }} -{{ product.discount }}%
- {{ discountedPrice | number:'1.2-2' }} {{ currency }} + {{ discountedPrice | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }} } @else { - {{ price | number:'1.2-2' }} {{ currency }} + {{ price | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }} }
diff --git a/src/app/features/website/product/components/product-information/product-information.component.ts b/src/app/features/website/product/components/product-information/product-information.component.ts index 8bf0722..9bb3928 100644 --- a/src/app/features/website/product/components/product-information/product-information.component.ts +++ b/src/app/features/website/product/components/product-information/product-information.component.ts @@ -1,18 +1,23 @@ import { DecimalPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core'; import { Product } from '../../../../../core/products/models/product-domain.model'; import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { CurrencyConvertPipe } from '../../../../../pipes/currency-convert.pipe'; +import { LanguageService } from '../../../../../services/language.service'; import { getBadgeClass } from '../../../../../utils/item.utils'; @Component({ selector: 'app-product-information', standalone: true, - imports: [DecimalPipe, TranslatePipe], + imports: [DecimalPipe, TranslatePipe, CurrencyConvertPipe], templateUrl: './product-information.component.html', styleUrls: ['./product-information.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class ProductInformationComponent { + private readonly languageService = inject(LanguageService); + readonly displayCurrency = this.languageService.currentCurrency; + @Input({ required: true }) product!: Product; @Input() title = ''; @Input() price = 0; diff --git a/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.html b/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.html index 9355a60..4a3594e 100644 --- a/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.html +++ b/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.html @@ -13,10 +13,10 @@

{{ product.name }}

@if (hasDiscount) { - {{ product.price | number:'1.2-2' }} {{ product.currency }} - {{ discountedPrice | number:'1.2-2' }} {{ product.currency }} + {{ product.price | currencyConvert:product.currency | number:'1.2-2' }} {{ displayCurrency() }} + {{ discountedPrice | currencyConvert:product.currency | number:'1.2-2' }} {{ displayCurrency() }} } @else { - {{ product.price | number:'1.2-2' }} {{ product.currency }} + {{ product.price | currencyConvert:product.currency | number:'1.2-2' }} {{ displayCurrency() }} }

@if (product.simpleDescription) { diff --git a/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.ts b/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.ts index b66a7b5..cfdf3e9 100644 --- a/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.ts +++ b/src/app/features/website/product/components/quick-view-dialog/quick-view-dialog.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { RouterLink } from '@angular/router'; import { Product } from '../../../../../core/products/models/product-domain.model'; @@ -6,17 +6,22 @@ import { DialogComponent } from '../../../../../shared/ui/dialog/dialog.componen import { ButtonComponent } from '../../../../../shared/ui/button/button.component'; import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe'; +import { CurrencyConvertPipe } from '../../../../../pipes/currency-convert.pipe'; +import { LanguageService } from '../../../../../services/language.service'; import { getDiscountedPrice, getMainImage } from '../../../../../utils/item.utils'; @Component({ selector: 'app-quick-view-dialog', standalone: true, - imports: [DialogComponent, ButtonComponent, TranslatePipe, LangRoutePipe, DecimalPipe, RouterLink], + imports: [DialogComponent, ButtonComponent, TranslatePipe, LangRoutePipe, DecimalPipe, RouterLink, CurrencyConvertPipe], templateUrl: './quick-view-dialog.component.html', styleUrl: './quick-view-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class QuickViewDialogComponent { + private readonly languageService = inject(LanguageService); + readonly displayCurrency = this.languageService.currentCurrency; + @Input() product: Product | null = null; @Input() loading = false; diff --git a/src/app/features/website/user-experience/compare/components/compare-table.component.ts b/src/app/features/website/user-experience/compare/components/compare-table.component.ts index d1d925e..e3da135 100644 --- a/src/app/features/website/user-experience/compare/components/compare-table.component.ts +++ b/src/app/features/website/user-experience/compare/components/compare-table.component.ts @@ -3,6 +3,7 @@ import { Product } from '../../../../../core/products/models/product-domain.mode import { TranslateService } from '../../../../../i18n/translate.service'; import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { LanguageService } from '../../../../../services/language.service'; +import { CurrencyRatesService } from '../../../../../services/currency-rates.service'; import { getTranslatedField } from '../../../../../utils/item.utils'; interface CompareRow { @@ -30,6 +31,7 @@ const STOCK_LABEL_KEYS: Record = { export class CompareTableComponent { private readonly i18n = inject(TranslateService); private readonly languageService = inject(LanguageService); + private readonly currencyRates = inject(CurrencyRatesService); @Input() products: Product[] = []; @Input() hideIdentical = false; @@ -41,8 +43,12 @@ export class CompareTableComponent { return []; } + const targetCurrency = this.languageService.currentCurrency(); + const baseRows: CompareRow[] = [ - this.toRow('price', this.i18n.t('ux.comparePrice'), products.map(product => `${product.price.toFixed(2)} ${product.currency}`)), + this.toRow('price', this.i18n.t('ux.comparePrice'), products.map(product => + `${this.currencyRates.convert(product.price, product.currency, targetCurrency).toFixed(2)} ${targetCurrency}` + )), this.toRow('rating', this.i18n.t('ux.compareRating'), products.map(product => `${(product.rating ?? 0).toFixed(1)}`)), this.toRow('stock', this.i18n.t('ux.compareStock'), products.map(product => this.stockLabel(product.remainings))), this.toRow('discount', this.i18n.t('ux.compareDiscount'), products.map(product => `${product.discount ?? 0}%`)), diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 4cbdca0..db16bc2 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1947,6 +1947,10 @@ export const en: Translations = { density: 'Table density', densityExplain: 'Reduce row padding across backoffice list pages for a more compact view.', densityCompact: 'Compact rows', + currencyRates: 'Currency rates', + currencyRatesExplain: 'Rates relative to 1 RUB, used to convert storefront prices while the backend does not return prices per currency.', + currencyRatesSave: 'Save rates', + currencyRatesSaved: 'Rates saved', }, adminAnalytics: { topProductsEmptyTitle: 'No product sales in this period', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index c270425..5b76a0b 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1942,6 +1942,10 @@ export const hy: Translations = { density: 'Աղյուսակի խտություն', densityExplain: 'Փոքրացնել տողերի հեռավորությունը ադմինիստրատիվ վահանակի ցուցակներում՝ ավելի կոմպակտ տեսքի համար։', densityCompact: 'Կոմպակտ տողեր', + currencyRates: 'Արժույթների փոխարժեքներ', + currencyRatesExplain: 'Փոխարժեքներ՝ 1 RUB-ի նկատմամբ, օգտագործվում են կայքի գները փոխարկելու համար, քանի դեռ բեքենդը գներ չի վերադարձնում ըստ արժույթի։', + currencyRatesSave: 'Պահպանել փոխարժեքները', + currencyRatesSaved: 'Փոխարժեքները պահպանվեցին', }, adminAnalytics: { topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index e2d09de..21174c8 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1942,6 +1942,10 @@ export const ru: Translations = { density: 'Плотность таблиц', densityExplain: 'Уменьшить отступы строк в списках панели управления для более компактного вида.', densityCompact: 'Компактные строки', + currencyRates: 'Курсы валют', + currencyRatesExplain: 'Курсы относительно 1 RUB, используются для конвертации цен на сайте, пока бэкенд не возвращает цены в разных валютах.', + currencyRatesSave: 'Сохранить курсы', + currencyRatesSaved: 'Курсы сохранены', }, adminAnalytics: { topProductsEmptyTitle: 'Нет продаж товаров за этот период', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index a301214..fd940a0 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1955,6 +1955,10 @@ export interface Translations { density: string; densityExplain: string; densityCompact: string; + currencyRates: string; + currencyRatesExplain: string; + currencyRatesSave: string; + currencyRatesSaved: string; }; adminAnalytics: { topProductsEmptyTitle: string; diff --git a/src/app/pages/cart/cart.component.html b/src/app/pages/cart/cart.component.html index 2c011a3..c46f3b2 100644 --- a/src/app/pages/cart/cart.component.html +++ b/src/app/pages/cart/cart.component.html @@ -70,11 +70,11 @@
@if (item.discount > 0) {
- {{ item.price }} {{ item.currency }} - {{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }} + {{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ currentCurrency }} + {{ getDiscountedPrice(item) | currencyConvert:item.currency | number:'1.2-2' }} {{ currentCurrency }}
} @else { - {{ item.price }} {{ item.currency }} + {{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ currentCurrency }} }
@@ -118,13 +118,13 @@
{{ 'cart.items' | translate }} ({{ itemCount() }}) - {{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }} + {{ convertTotal(totalPrice()) | number:'1.2-2' }} {{ currentCurrency }}
@if (hasDeliveryPrice()) {
{{ 'cart.deliveryLabel' | translate }} - {{ totalDeliveryPrice() | number:'1.2-2' }} {{ currentCurrency }} + {{ convertTotal(totalDeliveryPrice()) | number:'1.2-2' }} {{ currentCurrency }}
} @@ -137,7 +137,7 @@
{{ 'cart.toPay' | translate }} - {{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }} + {{ convertTotal(totalWithDelivery()) | number:'1.2-2' }} {{ currentCurrency }}
@@ -237,7 +237,7 @@
{{ 'cart.amountToPay' | translate }} - {{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }} + {{ convertTotal(totalWithDelivery()) | number:'1.2-2' }} {{ currentCurrency }}
diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index 8cc9110..3bf6226 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -22,12 +22,14 @@ import { TenantResolverService } from '../../core/config/tenant-resolver.service import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service'; import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component'; import { DialogComponent } from '../../shared/ui/dialog/dialog.component'; +import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe'; +import { CurrencyRatesService } from '../../services/currency-rates.service'; type PaymentMethod = 'qr' | 'card'; @Component({ selector: 'app-cart', - imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent, DialogComponent], + imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent, DialogComponent, CurrencyConvertPipe], templateUrl: './cart.component.html', styleUrls: ['./cart.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -80,6 +82,7 @@ export class CartComponent implements OnDestroy { private configService = inject(ConfigService); private tenantResolver = inject(TenantResolverService); + private currencyRates = inject(CurrencyRatesService); constructor( private cartService: CartService, @@ -183,6 +186,10 @@ export class CartComponent implements OnDestroy { itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); } itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); } get currentCurrency(): string { return this.langService.currentCurrency(); } + /** Cart totals are summed in the base currency (RUB); convert to whatever the shopper has selected. */ + convertTotal(amountInBaseCurrency: number): number { + return this.currencyRates.convert(amountInBaseCurrency, this.currencyRates.baseCurrency, this.currentCurrency); + } get isCheckoutDisabled(): boolean { return !this.termsAccepted || !this.isAuthenticated() || !this.allRequiredDeliveriesSelected(); } selectDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void { @@ -255,7 +262,7 @@ export class CartComponent implements OnDestroy { createPayment(paymentMethod: PaymentMethod): void { const orderId = this.generateOrderId(); const paymentPayload = { - amount: Number(this.totalWithDelivery()), + amount: Number(this.convertTotal(this.totalWithDelivery())), currency: this.langService.currentCurrency(), siteuserID: this.getPaymentUserId(), siteorderID: orderId, diff --git a/src/app/pipes/currency-convert.pipe.ts b/src/app/pipes/currency-convert.pipe.ts new file mode 100644 index 0000000..82be697 --- /dev/null +++ b/src/app/pipes/currency-convert.pipe.ts @@ -0,0 +1,34 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { LanguageService } from '../services/language.service'; +import { CurrencyRatesService } from '../services/currency-rates.service'; + +@Pipe({ + name: 'currencyConvert', + pure: false +}) +export class CurrencyConvertPipe implements PipeTransform { + private readonly langService = inject(LanguageService); + private readonly ratesService = inject(CurrencyRatesService); + + private lastAmount: number | null = null; + private lastFromCurrency = ''; + private lastTargetCurrency = ''; + private lastResult = 0; + + transform(amount: number | null | undefined, fromCurrency: string | null | undefined): number { + const value = amount ?? 0; + const from = fromCurrency || this.ratesService.baseCurrency; + const to = this.langService.currentCurrency(); + + if (value === this.lastAmount && from === this.lastFromCurrency && to === this.lastTargetCurrency) { + return this.lastResult; + } + + this.lastAmount = value; + this.lastFromCurrency = from; + this.lastTargetCurrency = to; + this.lastResult = this.ratesService.convert(value, from, to); + + return this.lastResult; + } +} diff --git a/src/app/services/currency-rates.service.ts b/src/app/services/currency-rates.service.ts new file mode 100644 index 0000000..1d53b0c --- /dev/null +++ b/src/app/services/currency-rates.service.ts @@ -0,0 +1,53 @@ +import { Injectable, Signal, inject, signal } from '@angular/core'; +import { LocalStorageService } from '../core/storage/local-storage.service'; + +const RATES_KEY = 'currencyRates.v1'; + +/** Fallback rates relative to RUB (1 RUB = rate[code] units of code), used until admin overrides them. */ +const DEFAULT_RATES: Record = { + RUB: 1, + USD: 0.011, + EUR: 0.01, + AMD: 4.3, +}; + +@Injectable({ providedIn: 'root' }) +export class CurrencyRatesService { + private readonly localStorage = inject(LocalStorageService); + + private readonly ratesSignal = signal>(this.readStoredRates()); + + readonly rates: Signal> = this.ratesSignal.asReadonly(); + + /** Base currency all rates are relative to. */ + readonly baseCurrency = 'RUB'; + + getRate(code: string): number { + return this.ratesSignal()[code] ?? DEFAULT_RATES[code] ?? 1; + } + + setRate(code: string, rate: number): void { + if (!Number.isFinite(rate) || rate <= 0) { + return; + } + const next = { ...this.ratesSignal(), [code]: rate }; + this.ratesSignal.set(next); + this.localStorage.setJSON(RATES_KEY, next); + } + + /** Converts an amount expressed in `fromCurrency` into `toCurrency` via the RUB base rate. */ + convert(amount: number, fromCurrency: string, toCurrency: string): number { + if (fromCurrency === toCurrency) { + return amount; + } + const fromRate = this.getRate(fromCurrency); + const toRate = this.getRate(toCurrency); + const amountInBase = amount / fromRate; + return amountInBase * toRate; + } + + private readStoredRates(): Record { + const stored = this.localStorage.getJSON>(RATES_KEY); + return { ...DEFAULT_RATES, ...stored }; + } +}