feat: client-side currency conversion with admin-configurable rates
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -11,4 +11,25 @@
|
||||
<span>{{ 'adminSettings.densityCompact' | translate }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h2>{{ 'adminSettings.currencyRates' | translate }}</h2>
|
||||
<p class="settings-explain">{{ 'adminSettings.currencyRatesExplain' | translate }}</p>
|
||||
<div class="rate-row" *ngFor="let currency of languageService.currencies">
|
||||
<span class="rate-code">{{ currency.code }}</span>
|
||||
<input
|
||||
class="rate-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
[disabled]="currency.code === currencyRates.baseCurrency"
|
||||
[ngModel]="rateDrafts()[currency.code]"
|
||||
(ngModelChange)="onRateInput(currency.code, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rate-actions">
|
||||
<button type="button" class="save-button" (click)="saveRates()">{{ 'adminSettings.currencyRatesSave' | translate }}</button>
|
||||
<span class="saved-message" *ngIf="showSavedMessage()">{{ 'adminSettings.currencyRatesSaved' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<Record<string, number>>({ ...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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<span>{{ 'cart.deliveryTime' | translate }}: {{ option.deliveryTime }}</span>
|
||||
}
|
||||
</div>
|
||||
<strong>{{ option.deliveryPrice | number:'1.2-2' }} {{ currency }}</strong>
|
||||
<strong>{{ option.deliveryPrice | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }}</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
<div class="product-price">
|
||||
@if (product.discount > 0) {
|
||||
<div class="price-row">
|
||||
<span class="old-price">{{ price | number:'1.2-2' }} {{ currency }}</span>
|
||||
<span class="old-price">{{ price | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
|
||||
<span class="discount-badge">-{{ product.discount }}%</span>
|
||||
</div>
|
||||
<strong>{{ discountedPrice | number:'1.2-2' }} {{ currency }}</strong>
|
||||
<strong>{{ discountedPrice | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }}</strong>
|
||||
} @else {
|
||||
<strong>{{ price | number:'1.2-2' }} {{ currency }}</strong>
|
||||
<strong>{{ price | currencyConvert:currency | number:'1.2-2' }} {{ displayCurrency() }}</strong>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
<h3 class="quick-view__title">{{ product.name }}</h3>
|
||||
<p class="quick-view__price">
|
||||
@if (hasDiscount) {
|
||||
<span class="quick-view__price-original">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
|
||||
<span class="quick-view__price-final">{{ discountedPrice | number:'1.2-2' }} {{ product.currency }}</span>
|
||||
<span class="quick-view__price-original">{{ product.price | currencyConvert:product.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
|
||||
<span class="quick-view__price-final">{{ discountedPrice | currencyConvert:product.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
|
||||
} @else {
|
||||
<span class="quick-view__price-final">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
|
||||
<span class="quick-view__price-final">{{ product.price | currencyConvert:product.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
|
||||
}
|
||||
</p>
|
||||
@if (product.simpleDescription) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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}%`)),
|
||||
|
||||
Reference in New Issue
Block a user