Skip to content

Commit

Permalink
Merge pull request #138 from Nosto/hit-decorators
Browse files Browse the repository at this point in the history
Add formatting hit decorators
  • Loading branch information
timowestnosto authored Jan 7, 2025
2 parents d4af445 + 2a05ccb commit ed54356
Show file tree
Hide file tree
Showing 13 changed files with 426 additions and 15 deletions.
11 changes: 6 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@nosto/nosto-js": "^1.0.5",
"@nosto/nosto-js": "^1.2.1",
"@preact/compat": "^18.3.1",
"@rollup/plugin-alias": "^5.1.1",
"@rollup/plugin-commonjs": "^28.0.1",
Expand Down
74 changes: 74 additions & 0 deletions spec/search-js/decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { priceDecorator } from "../../src/search-js/decorator"
import { SearchProduct } from "@nosto/nosto-js/client"
import { mockNostojs } from "@nosto/nosto-js/testing"

mockNostojs({
internal: {
getSettings: () => ({ currencySettings: {} })
}
})

describe("priceDecorator", () => {
const decorator = priceDecorator()

it("should format prices and list prices", () => {
const product = {
price: 100,
listPrice: 150,
priceCurrencyCode: "USD",
skus: []
} satisfies SearchProduct
const result = decorator(product)
expect(result.priceText).toBe("$100.00")
expect(result.listPriceText).toBe("$150.00")
})

it("should format prices for skus", () => {
const product = {
price: 100,
listPrice: 150,
priceCurrencyCode: "USD",
skus: [
{ price: 50, listPrice: 75 },
{ price: 60, listPrice: 80 }
]
} satisfies SearchProduct
const result = decorator(product)
expect(result.skus?.[0].priceText).toBe("$50.00")
expect(result.skus?.[0].listPriceText).toBe("$75.00")
expect(result.skus?.[1].priceText).toBe("$60.00")
expect(result.skus?.[1].listPriceText).toBe("$80.00")
})

it("should return the original product if no prices are present", () => {
const product = {
priceCurrencyCode: "USD",
skus: []
} satisfies SearchProduct
const result = decorator(product)
expect(result).toEqual(product)
})

it("should handle products with undefined prices", () => {
const product = {
price: undefined,
listPrice: undefined,
priceCurrencyCode: "USD",
skus: []
} satisfies SearchProduct
const result = decorator(product)
expect(result.priceText).toBeUndefined()
expect(result.listPriceText).toBeUndefined()
})

it("should use provided currency as fallback currency", () => {
const product = {
price: 100,
listPrice: 150,
skus: []
} satisfies SearchProduct
const result = priceDecorator({ defaultCurrency: "EUR" })(product)
expect(result.priceText).toBe("100,00 €")
expect(result.listPriceText).toBe("150,00 €")
})
})
88 changes: 88 additions & 0 deletions spec/search-js/formatting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { getCurrencyFormatting } from "../../src/search-js/currencies"
import { mockNostojs } from "@nosto/nosto-js/testing"

const currencyFormatsMock = {
GBP: {
currencyBeforeAmount: true,
currencyToken: "£",
decimalCharacter: ".",
groupingSeparator: "",
decimalPlaces: 1
},
EUR: {
currencyBeforeAmount: true,
currencyToken: "€",
decimalCharacter: ",",
groupingSeparator: ".",
decimalPlaces: 0
},
USD: {
currencyBeforeAmount: false,
currencyToken: "$",
decimalCharacter: ".",
groupingSeparator: ",",
decimalPlaces: 2
},
INR: {
currencyBeforeAmount: true,
currencyToken: "₹",
decimalCharacter: ".",
groupingSeparator: ",",
decimalPlaces: 2
}
}

describe("currency formatting", () => {
it("should use currency formatting settings", () => {
const { formatCurrency: format } = getCurrencyFormatting({
defaultCurrency: "GBP",
currencySettings: currencyFormatsMock
})

expect(format(12345.0)).toEqual("£12345.0")
expect(format(123450.0)).toEqual("£123450.0")

expect(format(12345.0, "EUR")).toEqual("€12.345")
expect(format(123450.0, "EUR")).toEqual("€123.450")

expect(format(12345.0, "USD")).toEqual("12,345.00$")
expect(format(123450.0, "USD")).toEqual("123,450.00$")

expect(format(12345.0, "INR")).toEqual("₹12,345.00")
expect(format(123450.0, "INR")).toEqual("₹1,23,450.00")

// uses fallback
expect(format(12345.0, "AUD")).toEqual("$12,345.00")
expect(format(123450.0, "AUD")).toEqual("$123,450.00")
})

it("should have proper fallback behaviour", () => {
const { formatCurrency: format } = getCurrencyFormatting({
defaultCurrency: "GBP",
currencySettings: {}
})

expect(format(12345.0)).toEqual("£12,345.00")
expect(format(123450.0)).toEqual("£123,450.00")

expect(format(12345.0, "EUR")).toEqual("12.345,00 €")
expect(format(123450.0, "EUR")).toEqual("123.450,00 €")

expect(format(12345.0, "USD")).toEqual("$12,345.00")
expect(format(123450.0, "USD")).toEqual("$123,450.00")
})

it("should load currency settings from nosto", async () => {
const mockApi = {
internal: {
getSettings: jest.fn(() => ({
currencySettings: currencyFormatsMock
}))
}
}
mockNostojs(mockApi)

getCurrencyFormatting()
expect(mockApi.internal.getSettings).toHaveBeenCalledTimes(1)
})
})
51 changes: 51 additions & 0 deletions spec/search-js/search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { mockNostojs } from "@nosto/nosto-js/testing"
import { search, HitDecorator } from "../../src/search-js"
import {
SearchQuery,
SearchProduct,
} from "@nosto/nosto-js/client"

describe("search", () => {
beforeEach(() => {
mockNostojs({
search: jest.fn().mockResolvedValue({
products: {
hits: [
{ id: "1", name: "Product 1" },
{ id: "2", name: "Product 2" },
],
},
}),
})
})

const query: SearchQuery = { query: "test" }

it("should perform a search without decorators", async () => {
const result = await search(query)
expect(result.products?.hits).toEqual([
{ id: "1", name: "Product 1" },
{ id: "2", name: "Product 2" },
])
})

it("should apply hit decorators to the search results", async () => {
const decorator: HitDecorator = (hit: SearchProduct) => ({
...hit,
name: `${hit.name} Decorated`,
})
const result = await search(query, { hitDecorators: [decorator] })
expect(result.products?.hits).toEqual([
{ id: "1", name: "Product 1 Decorated" },
{ id: "2", name: "Product 2 Decorated" },
])
})

it("should handle empty search results", async () => {
mockNostojs({
search: jest.fn().mockResolvedValue({ products: { hits: [] } }),
})
const result = await search(query)
expect(result.products?.hits).toEqual([])
})
})
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SearchQuery } from "@nosto/nosto-js/client"
import { SearchAutocompleteOptions } from "./autocomplete"
import { search } from "./search"
import { HitDecorator } from "./search-js"

/**
* @group Autocomplete
Expand Down Expand Up @@ -75,6 +76,10 @@ export interface AutocompleteConfig<State> {
* Google Analytics configuration. Set to `false` to disable.
*/
googleAnalytics?: GoogleAnalyticsConfig | boolean
/**
* Decorate each search hit before rendering
*/
hitDecorators?: HitDecorator[]
}

export const defaultGaConfig = {
Expand All @@ -88,6 +93,7 @@ export function getDefaultConfig<State>() {
minQueryLength: 2,
historyEnabled: true,
historySize: 5,
hitDecorators: [],
nostoAnalytics: true,
googleAnalytics: defaultGaConfig,
submit: (query, config, options) => {
Expand All @@ -102,6 +108,7 @@ export function getDefaultConfig<State>() {
{
redirect: true,
track: config.nostoAnalytics ? "serp" : undefined,
hitDecorators: config.hitDecorators,
...options,
}
)
Expand Down
1 change: 1 addition & 0 deletions src/entries/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { autocomplete, type AutocompleteInstance } from "../autocomplete"
export { search } from "../search"
export { type AutocompleteConfig, type GoogleAnalyticsConfig } from "../config"
export { type DefaultState } from "../utils/state"
export { priceDecorator } from "../search-js/decorator"
83 changes: 83 additions & 0 deletions src/search-js/currencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { CurrencyFormats } from "@nosto/nosto-js/client"
import { nostojs } from "@nosto/nosto-js"

const defaultConfig = {
defaultCurrency: "EUR",
defaultLocale: "en-US",
/** @hidden */
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
currencySettings: {} as CurrencyFormats
}

export type CurrencyConfig = typeof defaultConfig

const currencyLocales: Record<string, string> = {
EUR: "de-DE",
GBP: "en-GB",
USD: "en-US",
AUD: "en-AU",
CAD: "en-CA",
//India, Afghanistan, Bangladesh, Bhutan, Myanmar, Nepal, and Pakistan uses lakhs and crores notation
INR: "en-IN",
AFN: "en-IN",
BDT: "en-IN",
BTN: "en-IN",
MMK: "en-IN",
NPR: "en-IN",
PKR: "en-IN"
}

export function getCurrencyFormatting(overrides: Partial<CurrencyConfig> = {}) {
const config = {
...defaultConfig,
...overrides
}
if (!overrides.currencySettings) {
nostojs(api => {
config.currencySettings = api.internal.getSettings().currencySettings
})
}

/**
* Format the given monetary value using the Nosto currency settings.
*/
function formatCurrency(value: number, currency?: string) {
const { defaultCurrency, currencySettings, defaultLocale } = config
const currencyCode = currency ?? defaultCurrency
const locale = currencyLocales[currencyCode] ?? defaultLocale

if (currencyCode in currencySettings) {
// formatting using Nosto settings
const settings = currencySettings[currencyCode]!
const result = new Intl.NumberFormat(locale, {
useGrouping: !!settings.groupingSeparator,
minimumFractionDigits: settings.decimalPlaces,
maximumFractionDigits: settings.decimalPlaces
}).formatToParts(value)

const normalised = result
.map(it => {
if (it.type === "group") return settings.groupingSeparator
if (it.type === "decimal") return settings.decimalCharacter
return it.value
})
.join("")

if (settings?.currencyBeforeAmount) {
return `${settings.currencyToken}${normalised}`
}
return `${normalised}${settings?.currencyToken}`
}

// fallback logic
const numberFormat = new Intl.NumberFormat(locale, {
style: "currency",
currency: currencyCode
})
return numberFormat.format(value)
}

return {
formatCurrency
}
}
Loading

0 comments on commit ed54356

Please sign in to comment.