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 5d5db4f..f79762c 100644 --- a/src/core/event-name.enum.ts +++ b/src/core/event-name.enum.ts @@ -11,5 +11,6 @@ export const enum EventName { getPaymentStatus = 'getPaymentStatus', 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/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..1408ce4 --- /dev/null +++ b/src/core/pipes/currency/currency.pipe.ts @@ -0,0 +1,73 @@ +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 { + private readonly decimalPipe = new DecimalPipe(); + + public transform( + value: number | string | null, + currencyCode?: string + ): string | null { + if (!value || !currencyCode) { + return ''; + } + + return this.formatCurrency(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 formatCurrency( + value: number | string, + currencyCode: string + ): string | null { + const formatForCurrency = this.getCurrencyConfig(currencyCode); + + if (!formatForCurrency?.uniqSymbol) { + return this.fallbackFormatCurency(value, currencyCode); + } + + const amount = this.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; + } + + private fallbackFormatCurency( + value: number | string, + currencyCode: string + ): string | null { + const amount = this.decimalPipe.transform(value, 1, 2, 2); + + if (!amount) { + return null; + } + + return `${amount}${currencyCode}`; + } +} diff --git a/src/core/pipes/decimal/decimal.pipe.ts b/src/core/pipes/decimal/decimal.pipe.ts new file mode 100644 index 0000000..4d42010 --- /dev/null +++ b/src/core/pipes/decimal/decimal.pipe.ts @@ -0,0 +1,27 @@ +import i18next from 'i18next'; +import { Lang } from 'src/core/i18n/lang.enum'; +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: Lang | null = null + ): string { + value = Number(value); + + if (isNaN(value)) { + return ''; + } + + const formatLocale = localeValidator(locale ?? (i18next.language as Lang)); + 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/headless-checkout.ts b/src/features/headless-checkout/headless-checkout.ts index 7a3d599..62137e2 100644 --- a/src/features/headless-checkout/headless-checkout.ts +++ b/src/features/headless-checkout/headless-checkout.ts @@ -25,6 +25,8 @@ import { getUserBalanceHandler } from './post-messages-handlers/get-user-balance import { nextActionHandler } from './post-messages-handlers/next-action.handler'; import { getPaymentStatusHandler } from './post-messages-handlers/get-payment-status/get-payment-status.handler'; import { headlessCheckoutAppUrl } from './environment'; +import { FinanceDetails } from '../../core/finance-details/finance-details.interface'; +import { getFinanceDetailsHandler } from './post-messages-handlers/get-finance-details.handler'; @singleton() export class HeadlessCheckout { @@ -166,6 +168,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..8ae0df3 --- /dev/null +++ b/src/features/headless-checkout/post-messages-handlers/get-finance-details.handler.ts @@ -0,0 +1,16 @@ +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)) { + 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..f3c28a8 --- /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..e887194 --- /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..91a119b --- /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..992eed0 --- /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..2b8205a 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,7 @@ import { LegalComponentConfig } from './legal-component.config.interface'; import logo from '../../../../assets/icons/logo.svg'; import i18next from 'i18next'; + 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..40ed168 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8,7 +8,27 @@ "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", + "finance-details-subtotal-title": "Subtotal", + "finance-details-hrk-equal": "Equals {{value}} HRK", + "finance-details-hrk-exchange-rate": "Exchange rate: 1 EUR = 7.5345 HRK", + "finance-details-total-details-vat": "Including {{percent}}% VAT", + "finance-details-total-details-vat-india": "Including {{percent}}% GST", + "finance-details-total-details-vat-ghana": "Including VAT, NHIL, GETFund Levy, and Covid-19 Levy", + "finance-details-total-details-sales-tax": "Sales tax included", + "finance-details-subtotal-details-fee": "Payment system fee", + "finance-details-subtotal-details-discount": "Discount", + "finance-details-subtotal-details-user-balance": "Balance", + "finance-details-subtotal-details-vat": "VAT {{percent}}%", + "finance-details-subtotal-details-vat-india": "GST {{percent}}%", + "finance-details-subtotal-details-vat-ghana": "VAT, NHIL, GETFund Levy, and Covid-19 Levy", + "finance-details-subtotal-details-sales-tax": "Sales tax", + "finance-details-cart-items-checkout-title": "In-game purchase", + "finance-details-cart-items-vat": "VAT {{percent}}%", + "finance-details-cart-items-vat-india": "GST {{percent}}%", + "finance-details-cart-items-vat-ghana": "VAT, NHIL, GETFund Levy, and Covid-19 Levy", + "finance-details-cart-items-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, };