Skip to content

Commit

Permalink
Add search-js content
Browse files Browse the repository at this point in the history
  • Loading branch information
timowestnosto committed Jan 7, 2025
1 parent bf5fd3d commit 8e57590
Show file tree
Hide file tree
Showing 12 changed files with 366 additions and 44 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/decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { priceDecorator } from "../../src/search/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/formatting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { getCurrencyFormatting } from "../../src/search/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)
})
})
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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/decorator"
13 changes: 6 additions & 7 deletions src/search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SearchOptions, SearchQuery } from "@nosto/nosto-js/client"
import { getNostoClient } from "./api/client"
import type { SearchQuery } from "@nosto/nosto-js/client"
import { search as searchFn, Options } from "./search/search"

const defaultProductFields = [
"productId",
Expand Down Expand Up @@ -78,16 +78,15 @@ const defaultProductFields = [
* })
* ```
*/
export async function search(query: SearchQuery, options?: SearchOptions) {
const { redirect = false, track, isKeyword = false } = options ?? {}
export async function search(query: SearchQuery, options?: Options) {
const { redirect = false, track, isKeyword = false, hitDecorators } = options ?? {}

const fields = query.products?.fields ?? defaultProductFields
const facets = query.products?.facets ?? ["*"]
const size = query.products?.size ?? 20
const from = query.products?.from ?? 0

const api = await getNostoClient()
const response = await api.search(
const response = await searchFn(
{
...query,
products: {
Expand All @@ -98,7 +97,7 @@ export async function search(query: SearchQuery, options?: SearchOptions) {
from,
},
},
{ redirect, track, isKeyword }
{ redirect, track, isKeyword, hitDecorators }
)

return { query, response }
Expand Down
83 changes: 83 additions & 0 deletions src/search/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
}
}
52 changes: 52 additions & 0 deletions src/search/decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { SearchProduct, SearchProductSku } from "@nosto/nosto-js/client"
import { CurrencyConfig, getCurrencyFormatting } from "./currencies"

type Prices = Pick<SearchProduct, "price" | "listPrice">

type FormattedPrices = {
priceText?: string
listPriceText?: string
}

/**
* Exposes currency formatting logic as a SearchProduct decorator
*/
export function priceDecorator(config?: Partial<CurrencyConfig>) {
const { formatCurrency } = getCurrencyFormatting(config)

function formatPrices<T extends Prices>(obj: T, currency?: string) {
const formatted: FormattedPrices = {}
if (obj.price !== undefined) {
formatted.priceText = formatCurrency(obj.price, currency)
}
if (obj.listPrice !== undefined) {
formatted.listPriceText = formatCurrency(obj.listPrice, currency)
}
return Object.assign({}, obj, formatted)
}

function hasPrices(obj: Prices) {
return obj.price !== undefined || obj.listPrice !== undefined
}

type Result = SearchProduct &
FormattedPrices & {
skus?: (SearchProductSku & FormattedPrices)[]
}

return function decorator(hit: SearchProduct): Result {
if (hasPrices(hit)) {
const copy = formatPrices(hit, hit.priceCurrencyCode)
if (copy.skus && copy.skus.some(hasPrices)) {
copy.skus = copy.skus.map(sku => {
if (hasPrices(sku)) {
return formatPrices(sku, hit.priceCurrencyCode)
}
return sku
})
}
return copy
}
return hit
}
}
3 changes: 3 additions & 0 deletions src/search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./currencies"
export * from "./decorator"
export * from "./search"
Loading

0 comments on commit 8e57590

Please sign in to comment.