From 64ddf844f0f94ef4a527cf7fc7b293ef8b716d08 Mon Sep 17 00:00:00 2001 From: simbirromanmakarov Date: Wed, 9 Aug 2023 21:42:32 +0400 Subject: [PATCH] feat(PAYMENTS-15236): add finance details component --- package-lock.json | 6 ++ package.json | 1 + src/core/country/country-code.enum.ts | 5 + src/core/currency/currency.enum.ts | 3 + src/core/event-name.enum.ts | 1 + .../finance-details/cart-item.interface.ts | 15 +++ .../finance-details/cart-line.interface.ts | 10 ++ .../finance-details/cart-summary.interface.ts | 11 ++ .../checkout-item.interface.ts | 11 ++ .../finance-details.interface.ts | 12 +++ src/core/finance-details/price.interface.ts | 4 + .../virtual-currency.interface.ts | 10 ++ .../finance-details/xps-finance.interface.ts | 32 ++++++ .../finance-details/xps-purchase.interface.ts | 14 +++ .../guards/finance-details-message.guard.ts | 14 +++ src/core/i18n/dictionary.interface.ts | 8 +- src/core/i18n/localize.service.ts | 3 + .../currency/currency-format.interface.ts | 12 +++ src/core/pipes/currency/currency.pipe.ts | 59 ++++++++++ src/core/pipes/decimal/decimal.pipe.ts | 28 +++++ src/core/pipes/decimal/locale.validator.ts | 14 +++ src/core/pipes/pipe-transform.interface.ts | 3 + .../web-component-tag-name.enum.ts | 2 + .../web-components/web-component.abstract.ts | 16 +++ src/core/web-components/web-components.map.ts | 4 + src/features/headless-checkout/environment.ts | 3 +- .../headless-checkout/headless-checkout.ts | 17 +++ .../get-finance-details.handler.ts | 17 +++ .../finance-details/cart-items.template.ts | 102 ++++++++++++++++++ .../finance-details.component.ts | 52 +++++++++ .../finance-details.template.ts | 85 +++++++++++++++ .../finance-details/price-text.template.ts | 25 +++++ .../price-text/price-text-attributes.enum.ts | 7 ++ .../price-text/price-text.component.ts | 63 +++++++++++ .../price-text/price-text.template.ts | 33 ++++++ .../subtotal-details.template.ts | 101 +++++++++++++++++ .../finance-details/total-details.template.ts | 49 +++++++++ .../secure-connection.component.template.ts | 5 +- .../payment-methods.component.ts | 1 + src/translations/en.json | 30 +++++- src/web-components.ts | 4 + 41 files changed, 885 insertions(+), 7 deletions(-) create mode 100644 src/core/country/country-code.enum.ts create mode 100644 src/core/currency/currency.enum.ts create mode 100644 src/core/finance-details/cart-item.interface.ts create mode 100644 src/core/finance-details/cart-line.interface.ts create mode 100644 src/core/finance-details/cart-summary.interface.ts create mode 100644 src/core/finance-details/checkout-item.interface.ts create mode 100644 src/core/finance-details/finance-details.interface.ts create mode 100644 src/core/finance-details/price.interface.ts create mode 100644 src/core/finance-details/virtual-currency.interface.ts create mode 100644 src/core/finance-details/xps-finance.interface.ts create mode 100644 src/core/finance-details/xps-purchase.interface.ts create mode 100644 src/core/guards/finance-details-message.guard.ts create mode 100644 src/core/pipes/currency/currency-format.interface.ts create mode 100644 src/core/pipes/currency/currency.pipe.ts create mode 100644 src/core/pipes/decimal/decimal.pipe.ts create mode 100644 src/core/pipes/decimal/locale.validator.ts create mode 100644 src/core/pipes/pipe-transform.interface.ts create mode 100644 src/features/headless-checkout/post-messages-handlers/get-finance-details.handler.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/cart-items.template.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/finance-details.component.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/finance-details.template.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/price-text.template.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/price-text/price-text-attributes.enum.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/price-text/price-text.component.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/price-text/price-text.template.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/subtotal-details.template.ts create mode 100644 src/features/headless-checkout/web-components/finance-details/total-details.template.ts diff --git a/package-lock.json b/package-lock.json index 7b4e922..515ced2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "ISC", "dependencies": { + "currency-format": "^1.0.13", "i18next": "^22.5.0", "tsyringe": "^4.7.0" }, @@ -3121,6 +3122,11 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "dev": true }, + "node_modules/currency-format": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/currency-format/-/currency-format-1.0.13.tgz", + "integrity": "sha512-mokQiLDVtf0GgGVGbPE0Epx587z0d2WhOA0ITvjSDunyZ/DexmOR5hRrK9RU7hbyWe3G6Sf7iE29YKhiGjexSQ==" + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", diff --git a/package.json b/package.json index 4060097..66c0b79 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "webpack-cli": "^5.1.1" }, "dependencies": { + "currency-format": "^1.0.13", "i18next": "^22.5.0", "tsyringe": "^4.7.0" } diff --git a/src/core/country/country-code.enum.ts b/src/core/country/country-code.enum.ts new file mode 100644 index 0000000..a96c5cb --- /dev/null +++ b/src/core/country/country-code.enum.ts @@ -0,0 +1,5 @@ +export enum CountryCode { + Croatia = 'HR', + Ghana = 'GH', + India = 'IN', +} diff --git a/src/core/currency/currency.enum.ts b/src/core/currency/currency.enum.ts new file mode 100644 index 0000000..65d1fa9 --- /dev/null +++ b/src/core/currency/currency.enum.ts @@ -0,0 +1,3 @@ +export enum Currency { + EUR = 'EUR', +} diff --git a/src/core/event-name.enum.ts b/src/core/event-name.enum.ts index b58822d..b02ae53 100644 --- a/src/core/event-name.enum.ts +++ b/src/core/event-name.enum.ts @@ -10,5 +10,6 @@ export const enum EventName { getLegalComponentConfig = 'getLegalComponentConfig', legalComponentPing = 'legalComponentPing', legalComponentPong = 'legalComponentPong', + financeDetails = 'financeDetails', nextAction = 'nextAction', } diff --git a/src/core/finance-details/cart-item.interface.ts b/src/core/finance-details/cart-item.interface.ts new file mode 100644 index 0000000..34d2595 --- /dev/null +++ b/src/core/finance-details/cart-item.interface.ts @@ -0,0 +1,15 @@ +import { CartLine } from './cart-line.interface'; +import { Price } from './price.interface'; + +export interface CartItem { + key?: string; + imgSrc?: string; + hasDefaultImg?: boolean; + title: string; + price: Price; + priceBeforeDiscount?: Price; + description?: string | null; + tax?: CartLine | null; + quantity?: number; + isBonus: boolean; +} diff --git a/src/core/finance-details/cart-line.interface.ts b/src/core/finance-details/cart-line.interface.ts new file mode 100644 index 0000000..69e1f4c --- /dev/null +++ b/src/core/finance-details/cart-line.interface.ts @@ -0,0 +1,10 @@ +import { Price } from './price.interface'; + +export interface CartLine { + key?: string; + title?: string; + content?: string; + money?: Price; + rate?: number; + isDateLine?: boolean; +} diff --git a/src/core/finance-details/cart-summary.interface.ts b/src/core/finance-details/cart-summary.interface.ts new file mode 100644 index 0000000..ebb59c0 --- /dev/null +++ b/src/core/finance-details/cart-summary.interface.ts @@ -0,0 +1,11 @@ +import { CartLine } from './cart-line.interface'; + +export interface CartSummary { + transactionDetails?: CartLine[]; + shipping?: CartLine[]; + subtotal?: CartLine; + subtotalPayment?: CartLine; + subtotalDetails?: CartLine[]; + total: CartLine; + totalDetails?: CartLine[]; +} diff --git a/src/core/finance-details/checkout-item.interface.ts b/src/core/finance-details/checkout-item.interface.ts new file mode 100644 index 0000000..db442ba --- /dev/null +++ b/src/core/finance-details/checkout-item.interface.ts @@ -0,0 +1,11 @@ +export interface CheckoutItem { + quantity: number; + amount: number | null; + amount_before_discount?: number | null; + name: string; + image_url: string; + description: string | null; + currency: string; + is_bonus: boolean; + indirect_tax_rate: number; +} diff --git a/src/core/finance-details/finance-details.interface.ts b/src/core/finance-details/finance-details.interface.ts new file mode 100644 index 0000000..312bb5a --- /dev/null +++ b/src/core/finance-details/finance-details.interface.ts @@ -0,0 +1,12 @@ +import { CartItem } from './cart-item.interface'; +import { CartSummary } from './cart-summary.interface'; +import { XpsFinance } from './xps-finance.interface'; +import { XpsPurchase } from './xps-purchase.interface'; + +export interface FinanceDetails { + purchase: XpsPurchase; + finance: XpsFinance; + cartItems: CartItem[]; + cartSummary: CartSummary; + paymentCountry: string; +} diff --git a/src/core/finance-details/price.interface.ts b/src/core/finance-details/price.interface.ts new file mode 100644 index 0000000..785afa7 --- /dev/null +++ b/src/core/finance-details/price.interface.ts @@ -0,0 +1,4 @@ +export interface Price { + amount: number | null; + currency: string; +} diff --git a/src/core/finance-details/virtual-currency.interface.ts b/src/core/finance-details/virtual-currency.interface.ts new file mode 100644 index 0000000..8bc74a0 --- /dev/null +++ b/src/core/finance-details/virtual-currency.interface.ts @@ -0,0 +1,10 @@ +export interface VirtualCurrency { + quantity: number; + amount: number | null; + name: string; + image_url: string; + description: string; + longDescription: string | null; + is_bonus: boolean; + currency: string; +} diff --git a/src/core/finance-details/xps-finance.interface.ts b/src/core/finance-details/xps-finance.interface.ts new file mode 100644 index 0000000..0e5a9f7 --- /dev/null +++ b/src/core/finance-details/xps-finance.interface.ts @@ -0,0 +1,32 @@ +export interface XpsFinance { + payment_country: { iso: string }; + sub_total: { + amount: number; + currency: string; + payment_amount: number; + payment_currency: string; + }; + discount: { amount: number; currency: string }; + vat_user?: { + amount: number; + percent: number; + currency: string; + visible: boolean; + }; + sales_tax: { amount: number; percent: number; currency: string }; + sales_tax_user?: { amount: number; percent: number; currency: string }; + fee: { amount: number; currency: string }; + total: { amount: number; currency: string }; + xsolla_credits?: { + payment_amount: number; + payment_currency: string; + }; + vat: { + amount: number; + percent: number; + currency: string; + visible: boolean; + }; + user_balance?: { amount: number; currency: string }; + grand_total?: { amount: number; currency: string }; +} diff --git a/src/core/finance-details/xps-purchase.interface.ts b/src/core/finance-details/xps-purchase.interface.ts new file mode 100644 index 0000000..24f4e6c --- /dev/null +++ b/src/core/finance-details/xps-purchase.interface.ts @@ -0,0 +1,14 @@ +import { CheckoutItem } from './checkout-item.interface'; +import { VirtualCurrency } from './virtual-currency.interface'; + +export interface XpsPurchase { + virtual_currency?: VirtualCurrency[]; + checkout_items?: CheckoutItem[]; + virtual_items?: VirtualCurrency[]; + checkout?: { + amount: number; + currency: string; + description: string; + is_bonus?: boolean; + }; +} diff --git a/src/core/guards/finance-details-message.guard.ts b/src/core/guards/finance-details-message.guard.ts new file mode 100644 index 0000000..c35a4a0 --- /dev/null +++ b/src/core/guards/finance-details-message.guard.ts @@ -0,0 +1,14 @@ +import { EventName } from '../../core/event-name.enum'; +import { Message } from '../../core/message.interface'; +import { FinanceDetails } from '../finance-details/finance-details.interface'; +import { isEventMessage } from './event-message.guard'; + +export const isFinanceDetailsEventMessage = ( + messageData: unknown +): messageData is Message => { + if (isEventMessage(messageData)) { + return messageData.name === EventName.financeDetails; + } + + return false; +}; diff --git a/src/core/i18n/dictionary.interface.ts b/src/core/i18n/dictionary.interface.ts index 5dad3e4..9f2351d 100644 --- a/src/core/i18n/dictionary.interface.ts +++ b/src/core/i18n/dictionary.interface.ts @@ -1,9 +1,11 @@ import { Lang } from './lang.enum'; +interface Translation { + [key: string]: string | Translation; +} + export type Dictionary = { [key in Lang]?: { - translation: { - [key: string]: string; - }; + translation: Translation; }; }; diff --git a/src/core/i18n/localize.service.ts b/src/core/i18n/localize.service.ts index 3e046dc..08e35fd 100644 --- a/src/core/i18n/localize.service.ts +++ b/src/core/i18n/localize.service.ts @@ -5,6 +5,8 @@ import { Lang } from './lang.enum'; @singleton() export class LocalizeService { + public language = Lang.EN; + public async initDictionaries(): Promise { await i18next.init({ lng: Lang.EN, @@ -25,6 +27,7 @@ export class LocalizeService { lang = Lang.ZH_HANS; } + this.language = lang; await i18next.changeLanguage(lang); } } diff --git a/src/core/pipes/currency/currency-format.interface.ts b/src/core/pipes/currency/currency-format.interface.ts new file mode 100644 index 0000000..88edea0 --- /dev/null +++ b/src/core/pipes/currency/currency-format.interface.ts @@ -0,0 +1,12 @@ +interface SymbolFormat { + grapheme: string; + template: string; + rtl: boolean; +} + +export interface CurrencyFormat { + name: string; + fractionSize: number; + symbol: SymbolFormat; + uniqSymbol: SymbolFormat | null; +} diff --git a/src/core/pipes/currency/currency.pipe.ts b/src/core/pipes/currency/currency.pipe.ts new file mode 100644 index 0000000..ea6efde --- /dev/null +++ b/src/core/pipes/currency/currency.pipe.ts @@ -0,0 +1,59 @@ +import currencyFormat from 'currency-format/currency-format.json'; +import { DecimalPipe } from '../decimal/decimal.pipe'; +import { PipeTransform } from '../pipe-transform.interface'; +import { CurrencyFormat } from './currency-format.interface'; + +export class CurrencyPipe implements PipeTransform { + public transform( + value: number | string | null, + currencyCode?: string + ): string | null { + if (!value || !currencyCode) { + return ''; + } + + return this.transformWithFormat(value, currencyCode); + } + + private getCurrencyConfig(currencyCode: string): CurrencyFormat | null { + // @ts-expect-error json don't have string type index + const formatForCurrency = currencyFormat[ + currencyCode.toUpperCase() + ] as unknown as CurrencyFormat | undefined; + + if (!formatForCurrency) { + return null; + } + + return formatForCurrency; + } + + private transformWithFormat( + value: number | string, + currencyCode: string + ): string | null { + const decimalPipe = new DecimalPipe(); + const formatForCurrency = this.getCurrencyConfig(currencyCode); + + if (!formatForCurrency?.uniqSymbol) { + return null; + } + + const amount = decimalPipe.transform( + value, + 1, + formatForCurrency.fractionSize, + formatForCurrency.fractionSize + ); + + if (!amount) { + return null; + } + + const formattedCurrency = formatForCurrency.uniqSymbol.template + .replace('1', amount) + .replace('$', formatForCurrency.uniqSymbol.grapheme); + + return formattedCurrency; + } +} diff --git a/src/core/pipes/decimal/decimal.pipe.ts b/src/core/pipes/decimal/decimal.pipe.ts new file mode 100644 index 0000000..b212784 --- /dev/null +++ b/src/core/pipes/decimal/decimal.pipe.ts @@ -0,0 +1,28 @@ +import { LocalizeService } from '../../i18n/localize.service'; +import { container } from 'tsyringe'; +import { PipeTransform } from '../pipe-transform.interface'; +import { localeValidator } from './locale.validator'; + +export class DecimalPipe implements PipeTransform { + public transform( + value: number | string, + minIntegerDigits = 1, + minFracDigits = 2, + maxFracDigits = 2, + locale = null + ): string { + value = Number(value); + + if (isNaN(value)) { + return ''; + } + + const localizeService = container.resolve(LocalizeService); + const formatLocale = localeValidator(locale ?? localizeService.language); + return new Intl.NumberFormat(formatLocale, { + minimumIntegerDigits: minIntegerDigits, + minimumFractionDigits: minFracDigits, + maximumFractionDigits: maxFracDigits, + }).format(value); + } +} diff --git a/src/core/pipes/decimal/locale.validator.ts b/src/core/pipes/decimal/locale.validator.ts new file mode 100644 index 0000000..908b6b6 --- /dev/null +++ b/src/core/pipes/decimal/locale.validator.ts @@ -0,0 +1,14 @@ +import { Lang } from '../../i18n/lang.enum'; + +const specialLocaleMap = new Map([ + [Lang.ZH_HANS, Lang.CN], + [Lang.ZH_HANT, Lang.CN], +]); + +export const localeValidator = (locale: Lang): Lang => { + if (specialLocaleMap.has(locale)) { + return specialLocaleMap.get(locale)!; + } + + return locale; +}; diff --git a/src/core/pipes/pipe-transform.interface.ts b/src/core/pipes/pipe-transform.interface.ts new file mode 100644 index 0000000..ea73a2e --- /dev/null +++ b/src/core/pipes/pipe-transform.interface.ts @@ -0,0 +1,3 @@ +export interface PipeTransform { + transform(value: unknown, ...args: unknown[]): unknown; +} diff --git a/src/core/web-components/web-component-tag-name.enum.ts b/src/core/web-components/web-component-tag-name.enum.ts index ac9918f..18505f8 100644 --- a/src/core/web-components/web-component-tag-name.enum.ts +++ b/src/core/web-components/web-component-tag-name.enum.ts @@ -2,5 +2,7 @@ export enum WebComponentTagName { TextComponent = 'psdk-text-component', SubmitButtonComponent = 'psdk-submit-button', PaymentMethodsComponent = 'psdk-payment-methods', + PriceTextComponent = 'psdk-price-text', + FinanceDetailsComponent = 'psdk-finance-details', LegalComponent = 'psdk-legal', } diff --git a/src/core/web-components/web-component.abstract.ts b/src/core/web-components/web-component.abstract.ts index 09fef1c..a229248 100644 --- a/src/core/web-components/web-component.abstract.ts +++ b/src/core/web-components/web-component.abstract.ts @@ -42,4 +42,20 @@ export abstract class WebComponentAbstract extends HTMLElement { protected attributeChangedCallback(): void { this.render(); } + + protected getNumberAttribute(name: string): number | null { + const stringValue = this.getAttribute(name); + + if (!stringValue) { + return null; + } + + const numberValue = parseFloat(stringValue); + + if (isNaN(numberValue)) { + return null; + } + + return numberValue; + } } diff --git a/src/core/web-components/web-components.map.ts b/src/core/web-components/web-components.map.ts index ec4472e..6e4aad1 100644 --- a/src/core/web-components/web-components.map.ts +++ b/src/core/web-components/web-components.map.ts @@ -3,6 +3,8 @@ import { SubmitButtonComponent } from '../../features/headless-checkout/web-comp import { WebComponentTagName } from './web-component-tag-name.enum'; import { PaymentMethodsComponent } from '../../features/headless-checkout/web-components/payment-methods/payment-methods.component'; import { LegalComponent } from '../../features/headless-checkout/web-components/legal/legal.component'; +import { FinanceDetailsComponent } from '../../features/headless-checkout/web-components/finance-details/finance-details.component'; +import { PriceTextComponent } from '../../features/headless-checkout/web-components/finance-details/price-text/price-text.component'; export const webComponents: { [key in WebComponentTagName]: CustomElementConstructor; @@ -10,5 +12,7 @@ export const webComponents: { [WebComponentTagName.TextComponent]: TextComponent, [WebComponentTagName.SubmitButtonComponent]: SubmitButtonComponent, [WebComponentTagName.PaymentMethodsComponent]: PaymentMethodsComponent, + [WebComponentTagName.PriceTextComponent]: PriceTextComponent, + [WebComponentTagName.FinanceDetailsComponent]: FinanceDetailsComponent, [WebComponentTagName.LegalComponent]: LegalComponent, }; diff --git a/src/features/headless-checkout/environment.ts b/src/features/headless-checkout/environment.ts index 7424bdf..ecbb333 100644 --- a/src/features/headless-checkout/environment.ts +++ b/src/features/headless-checkout/environment.ts @@ -1,3 +1,2 @@ -export const headlessCheckoutAppUrl = - 'https://secure.xsolla.com/headless-checkout'; +export const headlessCheckoutAppUrl = 'http://localhost:4400'; export const cdnUrl = 'https://cdn3.xsolla.com'; diff --git a/src/features/headless-checkout/headless-checkout.ts b/src/features/headless-checkout/headless-checkout.ts index ef748c3..8ed8721 100644 --- a/src/features/headless-checkout/headless-checkout.ts +++ b/src/features/headless-checkout/headless-checkout.ts @@ -22,6 +22,8 @@ import { Form } from '../../core/form/form.interface'; import { NextAction } from '../../core/actions/next-action.interface'; import { nextActionHandler } from './post-messages-handlers/next-action.handler'; import { FormSpy } from '../../core/spy/form-spy/form-spy'; +import { FinanceDetails } from '../../core/finance-details/finance-details.interface'; +import { getFinanceDetailsHandler } from './post-messages-handlers/get-finance-details.handler'; @singleton() export class HeadlessCheckout { @@ -142,6 +144,21 @@ export class HeadlessCheckout { ); } + /** + * Returns finance details for created payment + * @returns promise that returns finance details + */ + public async getFinanceDetails(): Promise { + const msg: Message = { + name: EventName.financeDetails, + }; + + return this.postMessagesClient.send( + msg, + getFinanceDetailsHandler + ) as Promise; + } + /** * Returns available payment methods except quick methods * @param country Country that quick methods should be loaded for. diff --git a/src/features/headless-checkout/post-messages-handlers/get-finance-details.handler.ts b/src/features/headless-checkout/post-messages-handlers/get-finance-details.handler.ts new file mode 100644 index 0000000..f6ad8d6 --- /dev/null +++ b/src/features/headless-checkout/post-messages-handlers/get-finance-details.handler.ts @@ -0,0 +1,17 @@ +import { FinanceDetails } from '../../../core/finance-details/finance-details.interface'; +import { isFinanceDetailsEventMessage } from '../../../core/guards/finance-details-message.guard'; +import { Message } from '../../../core/message.interface'; +import { Handler } from '../../../core/post-messages-client/handler.type'; + +export const getFinanceDetailsHandler: Handler = ( + message: Message +): { isHandled: boolean; value?: FinanceDetails | null } | null => { + if (isFinanceDetailsEventMessage(message)) { + console.log('[LOG] handler', message); + return { + isHandled: true, + value: message.data, + }; + } + return null; +}; diff --git a/src/features/headless-checkout/web-components/finance-details/cart-items.template.ts b/src/features/headless-checkout/web-components/finance-details/cart-items.template.ts new file mode 100644 index 0000000..a492346 --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/cart-items.template.ts @@ -0,0 +1,102 @@ +import i18next from 'i18next'; +import { CartItem } from '../../../../core/finance-details/cart-item.interface'; +import { getPriceText } from './price-text.template'; + +const titleTranslationMap = new Map([ + ['checkout', 'finance-details.cart-items.checkout-title'], +]); + +const taxTranslationMap = new Map([ + ['vat', 'finance-details.cart-items.vat'], + ['vat-india', 'finance-details.cart-items.vat-india'], + ['vat-ghana', 'finance-details.cart-items.vat-ghana'], + ['sales-tax', 'finance-details.cart-items.sales-tax'], +]); + +function translateCartItems(items: CartItem[] = []): CartItem[] { + return items.map((item) => { + const title = + !item.title && item.key && titleTranslationMap.has(item.key) + ? i18next.t(titleTranslationMap.get(item.key)!) + : item.title; + + const content = + item.tax?.key && taxTranslationMap.has(item.tax.key) + ? i18next.t(taxTranslationMap.get(item.tax.key)!, { + percent: item.tax.rate, + }) + : item.tax?.content; + + return { + ...item, + title: title ?? item.title, + tax: { + ...item.tax, + content, + }, + }; + }); +} + +function getCartItemImage(item: CartItem): string { + if (!item.imgSrc) { + return ''; + } + + return ` +
+ ${item.title} +
+ `; +} + +function getCartItemText(item: CartItem): string { + const title = + item.quantity && item.quantity > 1 + ? `${item.quantity} x ${item.title}` + : item.title; + const description = item.description?.replace(/\\n/g, '
'); + return ` +
+
${title ?? ''}
+
${description ?? ''}
+ ${item.tax ? getPriceText(item.tax, null, 'tax') : ''} + ${getPriceText(null, item.price, 'price')} + ${ + item.priceBeforeDiscount?.amount + ? getPriceText( + null, + item.priceBeforeDiscount, + 'price-before-discount' + ) + : '' + } +
+ `; +} + +export const getCartItems = (cartItems: CartItem[] = []): string => { + if (!cartItems.length) { + return ''; + } + + const items = translateCartItems(cartItems); + const itemLines = items.map((item) => { + return ` +
+ ${getCartItemImage(item)} + ${getCartItemText(item)} +
+ `; + }); + + return ` +
+ ${itemLines.join('')} +
+ `; +}; diff --git a/src/features/headless-checkout/web-components/finance-details/finance-details.component.ts b/src/features/headless-checkout/web-components/finance-details/finance-details.component.ts new file mode 100644 index 0000000..e9cd7f1 --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/finance-details.component.ts @@ -0,0 +1,52 @@ +import { EventName } from '../../../../core/event-name.enum'; +import { container } from 'tsyringe'; +import { FinanceDetails } from '../../../../core/finance-details/finance-details.interface'; +import { HeadlessCheckoutSpy } from '../../../../core/spy/headless-checkout-spy/headless-checkout-spy'; +import { WebComponentAbstract } from '../../../../core/web-components/web-component.abstract'; +import { HeadlessCheckout } from '../../headless-checkout'; +import { getFinanceDetailsHandler } from '../../post-messages-handlers/get-finance-details.handler'; +import { getFinanceDetailsTemplate } from './finance-details.template'; + +export class FinanceDetailsComponent extends WebComponentAbstract { + private readonly headlessCheckout: HeadlessCheckout; + private readonly headlessCheckoutSpy: HeadlessCheckoutSpy; + private financeDetails?: FinanceDetails | null = null; + + public constructor() { + super(); + this.headlessCheckoutSpy = container.resolve(HeadlessCheckoutSpy); + this.headlessCheckout = container.resolve(HeadlessCheckout); + } + + protected connectedCallback(): void { + if (!this.headlessCheckoutSpy.appWasInit) { + this.headlessCheckoutSpy.listenAppInit(() => this.connectedCallback()); + return; + } + void this.headlessCheckout.events.onCoreEvent( + EventName.financeDetails, + getFinanceDetailsHandler, + (financeDetails) => this.financeDetailsLoadedHandler(financeDetails) + ); + } + + protected getHtml(): string { + return this.getFinanceDetailsHtml() ?? ''; + } + + private readonly financeDetailsLoadedHandler = ( + financeDetails?: FinanceDetails | null + ): void => { + this.financeDetails = financeDetails; + + this.render(); + }; + + private getFinanceDetailsHtml(): string | null { + if (this.financeDetails) { + return getFinanceDetailsTemplate(this.financeDetails); + } + + return null; + } +} diff --git a/src/features/headless-checkout/web-components/finance-details/finance-details.template.ts b/src/features/headless-checkout/web-components/finance-details/finance-details.template.ts new file mode 100644 index 0000000..a0c36a6 --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/finance-details.template.ts @@ -0,0 +1,85 @@ +import i18next from 'i18next'; +import { CountryCode } from '../../../../core/country/country-code.enum'; +import { Currency } from '../../../../core/currency/currency.enum'; +import { CartSummary } from '../../../../core/finance-details/cart-summary.interface'; +import { FinanceDetails } from '../../../../core/finance-details/finance-details.interface'; +import { getCartItems } from './cart-items.template'; +import { getPriceText } from './price-text.template'; +import { getSubtotalDetails } from './subtotal-details.template'; +import { getTotalDetails } from './total-details.template'; + +function getShipping(cartSummary: CartSummary): string { + const shipping = cartSummary.shipping; + + if (!shipping?.length) { + return ''; + } + + const lines = shipping.map((item) => { + return ` +
+
${item.title ?? ''}
+ ${getPriceText(item)} +
+ `; + }); + + return ` +
+ ${lines.join('')} +
`; +} + +function getTotalRow(cartSummary: CartSummary): string { + return ` +
+
${i18next.t('finance-details.total-title')}
+ ${getPriceText(null, cartSummary.total.money)} +
`; +} + +function getCroatianExchangeRate(financeDetails: FinanceDetails): string { + const cartSummary = financeDetails.cartSummary; + const isCroatia = financeDetails.paymentCountry === CountryCode.Croatia; + const isEuroCurrency = cartSummary.total.money?.currency === Currency.EUR; + const croatianCurrencyExchangeRate = 7.5345; + + if (!isCroatia || !isEuroCurrency) { + return ''; + } + + function getCroatianCurrencyRate(): number { + const amount = cartSummary.total.money?.amount + ? cartSummary.total.money?.amount + : 0; + const ceilDecimals = 100; + + return ( + Math.ceil(croatianCurrencyExchangeRate * amount * ceilDecimals) / + ceilDecimals + ); + } + + return ` +
+
${i18next.t('finance-details.hrk-equal', { + value: getCroatianCurrencyRate(), + })}
+
${i18next.t( + 'finance-details.hrk-exchange-rate' + )}
+
`; +} + +export const getFinanceDetailsTemplate = ( + financeDetails: FinanceDetails +): string => { + return [ + getCartItems(financeDetails.cartItems), + getShipping(financeDetails.cartSummary), + getSubtotalDetails(financeDetails.finance, financeDetails.cartSummary), + getTotalRow(financeDetails.cartSummary), + getCroatianExchangeRate(financeDetails), + getTotalDetails(financeDetails.finance, financeDetails.cartSummary), + ].join(''); +}; diff --git a/src/features/headless-checkout/web-components/finance-details/price-text.template.ts b/src/features/headless-checkout/web-components/finance-details/price-text.template.ts new file mode 100644 index 0000000..3838d44 --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/price-text.template.ts @@ -0,0 +1,25 @@ +import { Price } from '../../../../core/finance-details/price.interface'; +import { CartLine } from '../../../../core/finance-details/cart-line.interface'; + +export const getPriceText = ( + cartLine?: CartLine | null, + price?: Price | null, + className?: string +): string => { + const priceLineContent = cartLine?.content ?? ''; + const priceLineAmount = cartLine?.money?.amount ?? ''; + const priceLineCurrency = cartLine?.money?.currency ?? ''; + const amount = price?.amount ?? ''; + const currency = price?.currency ?? ''; + + return ` + + `; +}; diff --git a/src/features/headless-checkout/web-components/finance-details/price-text/price-text-attributes.enum.ts b/src/features/headless-checkout/web-components/finance-details/price-text/price-text-attributes.enum.ts new file mode 100644 index 0000000..81d042b --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/price-text/price-text-attributes.enum.ts @@ -0,0 +1,7 @@ +export enum PriceTextAttributes { + priceLineContent = 'price-line-content', + priceLineAmount = 'price-line-amount', + priceLineCurrency = 'price-line-currency', + amount = 'amount', + currency = 'currency', +} diff --git a/src/features/headless-checkout/web-components/finance-details/price-text/price-text.component.ts b/src/features/headless-checkout/web-components/finance-details/price-text/price-text.component.ts new file mode 100644 index 0000000..402c004 --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/price-text/price-text.component.ts @@ -0,0 +1,63 @@ +import { WebComponentAbstract } from '../../../../../core/web-components/web-component.abstract'; +import { PriceTextAttributes } from './price-text-attributes.enum'; +import { getPriceTextTemplate } from './price-text.template'; + +export class PriceTextComponent extends WebComponentAbstract { + private priceLineContent: string | null = null; + private priceLineAmount: number | null = null; + private priceLineCurrency: string | null = null; + private amount: number | null = null; + private currency: string | null = null; + + public static get observedAttributes(): string[] { + return [ + PriceTextAttributes.priceLineContent, + PriceTextAttributes.priceLineAmount, + PriceTextAttributes.priceLineCurrency, + PriceTextAttributes.amount, + PriceTextAttributes.currency, + ]; + } + + protected connectedCallback(): void { + this.readAttributes(); + this.render(); + } + + protected getHtml(): string { + const priceLine = + this.priceLineAmount === null + ? null + : { + amount: this.priceLineAmount, + currency: this.priceLineCurrency ?? '', + }; + + const price = + this.amount === null + ? null + : { + amount: this.amount, + currency: this.currency ?? '', + }; + + return getPriceTextTemplate(this.priceLineContent, priceLine, price); + } + + private readAttributes(): void { + this.priceLineContent = this.getAttribute( + PriceTextAttributes.priceLineContent + ); + this.priceLineContent = this.getAttribute( + PriceTextAttributes.priceLineContent + ); + this.priceLineAmount = this.getNumberAttribute( + PriceTextAttributes.priceLineAmount + ); + this.priceLineCurrency = this.getAttribute( + PriceTextAttributes.priceLineCurrency + ); + this.amount = this.getNumberAttribute(PriceTextAttributes.amount); + this.currency = this.getAttribute(PriceTextAttributes.currency); + } +} diff --git a/src/features/headless-checkout/web-components/finance-details/price-text/price-text.template.ts b/src/features/headless-checkout/web-components/finance-details/price-text/price-text.template.ts new file mode 100644 index 0000000..ebf6afd --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/price-text/price-text.template.ts @@ -0,0 +1,33 @@ +import { CurrencyPipe } from '../../../../../core/pipes/currency/currency.pipe'; +import { Price } from '../../../../../core/finance-details/price.interface'; + +export const getPriceTextTemplate = ( + content: string | null, + priceLine: Price | null, + price: Price | null +): string => { + const lines = []; + const curencyPipe = new CurrencyPipe(); + + if (content) { + lines.push(`
${content}
`); + } + + if (priceLine) { + lines.push( + `
${ + curencyPipe.transform(priceLine.amount, priceLine.currency) ?? '' + }
` + ); + } + + if (price) { + lines.push( + `
${ + curencyPipe.transform(price.amount, price.currency) ?? '' + }
` + ); + } + + return lines.join(''); +}; diff --git a/src/features/headless-checkout/web-components/finance-details/subtotal-details.template.ts b/src/features/headless-checkout/web-components/finance-details/subtotal-details.template.ts new file mode 100644 index 0000000..ee82baa --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/subtotal-details.template.ts @@ -0,0 +1,101 @@ +import i18next from 'i18next'; +import { CartLine } from '../../../../core/finance-details/cart-line.interface'; +import { CartSummary } from '../../../../core/finance-details/cart-summary.interface'; +import { XpsFinance } from '../../../../core/finance-details/xps-finance.interface'; +import { getPriceText } from './price-text.template'; + +const translationMap = new Map([ + ['fee', 'finance-details.subtotal-details.fee'], + ['discount', 'finance-details.subtotal-details.discount'], + ['user-balance', 'finance-details.subtotal-details.user-balance'], + ['vat', 'finance-details.subtotal-details.vat'], + ['vat-india', 'finance-details.subtotal-details.vat-india'], + ['vat-ghana', 'finance-details.subtotal-details.vat-ghana'], + ['sales-tax', 'finance-details.subtotal-details.sales-tax'], +]); + +function translateSubtotalDetails( + finance: XpsFinance, + items: CartLine[] = [] +): CartLine[] { + const vatPercent = finance.vat_user?.percent; + return items.map((item) => { + if (!item.key || !translationMap.has(item.key)) { + return item; + } + + return { + ...item, + title: i18next.t(translationMap.get(item.key)!, { + percent: vatPercent, + }), + }; + }); +} + +function getSubtotalTitle(cartSummary: CartSummary): string { + const lines = []; + + if (cartSummary.subtotal) { + lines.push( + `${i18next.t( + 'finance-details.subtotal-title' + )}` + ); + } + + if (cartSummary.subtotal && cartSummary.subtotalPayment) { + const hasConversion = + cartSummary.subtotal.money?.currency !== + cartSummary.subtotalPayment.money?.currency; + + if (hasConversion) { + lines.push( + `${getPriceText(cartSummary.subtotal, null, 'price converted-price')} =` + ); + } + + lines.push(` + ${getPriceText(cartSummary.subtotalPayment, null, 'price')} + `); + } + + return lines.length + ? ` +
+ ${lines.join('')} +
` + : ''; +} + +function getSubtotalContent( + finance: XpsFinance, + cartSummary: CartSummary +): string { + const lines = translateSubtotalDetails( + finance, + cartSummary.subtotalDetails + ).map((details) => { + if (!details.money?.amount && !details.content) { + return ''; + } + + return ` +
+
${details.title ?? ''}
+ ${getPriceText(details, null, 'price')} +
`; + }); + + return lines?.join('') ?? ''; +} + +export const getSubtotalDetails = ( + finance: XpsFinance, + cartSummary: CartSummary +): string => { + return [ + getSubtotalTitle(cartSummary), + getSubtotalContent(finance, cartSummary), + ].join(''); +}; diff --git a/src/features/headless-checkout/web-components/finance-details/total-details.template.ts b/src/features/headless-checkout/web-components/finance-details/total-details.template.ts new file mode 100644 index 0000000..b837a91 --- /dev/null +++ b/src/features/headless-checkout/web-components/finance-details/total-details.template.ts @@ -0,0 +1,49 @@ +import i18next from 'i18next'; +import { CartLine } from '../../../../core/finance-details/cart-line.interface'; +import { CartSummary } from '../../../../core/finance-details/cart-summary.interface'; +import { XpsFinance } from '../../../../core/finance-details/xps-finance.interface'; +import { getPriceText } from './price-text.template'; + +const translationMap = new Map([ + ['vat', 'finance-details.total-details.vat'], + ['vat-india', 'finance-details.total-details.vat-india'], + ['vat-ghana', 'finance-details.total-details.vat-ghana'], + ['sales-tax', 'finance-details.total-details.sales-tax'], +]); + +function translateTotalDetails( + finance: XpsFinance, + items: CartLine[] = [] +): CartLine[] { + const vatPercent = finance.vat.percent; + return items.map((item) => { + if (!item.key || !translationMap.has(item.key)) { + return item; + } + + return { + ...item, + title: i18next.t(translationMap.get(item.key)!, { + percent: vatPercent, + }), + }; + }); +} + +export const getTotalDetails = ( + finance: XpsFinance, + cartSummary: CartSummary +): string => { + const totalDetailsRows = translateTotalDetails( + finance, + cartSummary.totalDetails + )?.map((totalDetails) => { + return ` +
+
${totalDetails.title ?? ''}
+ ${getPriceText(null, totalDetails.money)} +
`; + }); + + return totalDetailsRows?.join(''); +}; diff --git a/src/features/headless-checkout/web-components/legal/secure-connection.component.template.ts b/src/features/headless-checkout/web-components/legal/secure-connection.component.template.ts index fa21ed0..2fe4fc4 100644 --- a/src/features/headless-checkout/web-components/legal/secure-connection.component.template.ts +++ b/src/features/headless-checkout/web-components/legal/secure-connection.component.template.ts @@ -1,6 +1,9 @@ import { LegalComponentConfig } from './legal-component.config.interface'; -import logo from '../../../../assets/icons/logo.svg'; +// import logo from '../../../../assets/icons/logo.svg'; import i18next from 'i18next'; + +const logo = 'logo-path.png'; + export const getSecureConnectionTemplate = ( secureConnection?: LegalComponentConfig['secureConnection'] ): string => { diff --git a/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts b/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts index ad0911e..310d9e0 100644 --- a/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts +++ b/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts @@ -29,6 +29,7 @@ export class PaymentMethodsComponent extends WebComponentAbstract { public constructor() { super(); + this.headlessCheckoutSpy = container.resolve(HeadlessCheckoutSpy); this.headlessCheckout = container.resolve(HeadlessCheckout); } diff --git a/src/translations/en.json b/src/translations/en.json index 5a3a115..d281eea 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8,7 +8,35 @@ "privacy-policy": "Privacy policy", "refund-policy": "Refund policy", "sctl-indications": "SCTL Indications", - "secure-connection": "Secure connection" + "secure-connection": "Secure connection", + "finance-details": { + "total-title": "Total", + "subtotal-title": "Subtotal", + "hrk-equal": "Equals {{value}} HRK", + "hrk-exchange-rate": "Exchange rate: 1 EUR = 7.5345 HRK", + "total-details": { + "vat": "Including {{percent}}% VAT", + "vat-india": "Including {{percent}}% GST", + "vat-ghana": "Including VAT, NHIL, GETFund Levy, and Covid-19 Levy", + "sales-tax": "Sales tax included" + }, + "subtotal-details": { + "fee": "Payment system fee", + "discount": "Discount", + "user-balance": "Balance", + "vat": "VAT {{percent}}%", + "vat-india": "GST {{percent}}%", + "vat-ghana": "VAT, NHIL, GETFund Levy, and Covid-19 Levy", + "sales-tax": "Sales tax" + }, + "cart-items": { + "checkout-title": "In-game purchase", + "vat": "VAT {{percent}}%", + "vat-india": "GST {{percent}}%", + "vat-ghana": "VAT, NHIL, GETFund Levy, and Covid-19 Levy", + "sales-tax": "Sales tax {{percent}}%" + } + } } } } diff --git a/src/web-components.ts b/src/web-components.ts index f7e44a8..e767dc5 100644 --- a/src/web-components.ts +++ b/src/web-components.ts @@ -2,10 +2,14 @@ import { SubmitButtonComponent } from './features/headless-checkout/web-componen import { TextComponent } from './features/headless-checkout/web-components/text-component/text.component'; import { PaymentMethodsComponent } from './features/headless-checkout/web-components/payment-methods/payment-methods.component'; import { LegalComponent } from './features/headless-checkout/web-components/legal/legal.component'; +import { FinanceDetailsComponent } from './features/headless-checkout/web-components/finance-details/finance-details.component'; +import { PriceTextComponent } from './features/headless-checkout/web-components/finance-details/price-text/price-text.component'; export { SubmitButtonComponent, TextComponent, PaymentMethodsComponent, + PriceTextComponent, + FinanceDetailsComponent, LegalComponent, };