# @repobit/dex-store

Typed, framework‑agnostic pricing/store core with pluggable providers, rich product/option models, and convenient helpers for formatting, overrides, and experimentation.

- Fetch products/options via built‑in providers (init, vlaicu) or your own
- Strongly typed `Product` and `ProductOption` APIs
- Locale/currency formatting with overrideable formatter
- Option overrides and campaign resolver hooks
- Trial link mapping and buy‑link transformers

## Install

```bash
npm i @repobit/dex-store
```

## At a glance

```ts
import { Store } from '@repobit/dex-store';

const store = new Store({
  locale  : 'en-us',
  provider: { name: 'vlaicu' },

  // Optional async campaign resolver per product id
  campaign: async ({ id, campaign }) => campaign,

  // Optional per-variation overrides (keyed as `${devices}-${subscription}`)
  overrides: {
    'com.bitdefender.tsmd.v2': {
      campaign: 'WINTER2025',
      options : {
        '5-12': { discountedPrice: 49.99 },
        // delete a variation by setting null
        '1-1' : null
      }
    }
  },

  // Optional trial-link mapping: productAlias → campaign → variation → trial-days → URL
  trialLinks: {
    'com.bitdefender.tsmd.v2': {
      default: {
        '5-12': { '30 days': 'https://example.test/trial?devices=5&months=12' }
      }
    }
  },

  // Optional link transformers for A/B, UTM, etc.
  transformers: {
    buyLink: async ({ buyLink }) => new URL(buyLink).toString(),
    trialLink: async ({ name, trialLink, buyLink }) => {
      const url = new URL(trialLink);
      url.searchParams.set('trial', name);
      url.searchParams.set('buyLink', buyLink);
      return url.toString();
    }
  },

  // Optional number -> string formatter (defaults to Intl currency)
  formatter: ({ price, currency, locale }) => {
    if (!currency) return price; // return number when currency missing
    return new Intl.NumberFormat(locale ?? 'en-us', { style: 'currency', currency }).format(price);
  }
});

const product = await store.getProduct({ id: 'com.bitdefender.tsmd.v2' });
const option  = await product?.getOption({ devices: 5, subscription: 12 });

console.log({
  price          : option?.getPrice(),                 // "$109.99"
  discounted     : option?.getDiscountedPrice(),       // "$59.99"
  discount       : option?.getDiscount(),              // "$50"
  buyLink        : option?.getBuyLink(),
  trialLink      : option?.getTrialLink('30 days'),
  devices        : option?.getDevices(),               // 5
  subscription   : option?.getSubscription()           // 12
});
```

## Store

```ts
new Store(config: {
  provider   : { name: 'init' | 'vlaicu' } | new (...args) => Provider,
  locale     : `${string}-${string}`,
  campaign?  : ({ id, campaign }: { id: string; campaign?: string }) => Promise<string | undefined>,
  overrides? : {
    [productId: string]: {
      campaign?: string,
      options  : { [variation: `${string}-${string}`]: Partial<UnboundProductOptionData> | null | undefined }
    }
  },
  trialLinks?: {
    [productAlias: string]: {
      default?: { [variation: `${string}-${string}`]: { [trialDays: string]: string } },
      [campaign: string]: { [variation: `${string}-${string}`]: { [trialDays: string]: string } } | undefined
    }
  },
  transformers?: { buyLink: (param: { buyLink: string, product: UnboundProductData, option: UnboundProductOptionData }) => Promise<string> },
  formatter?  : (p: { price: number; currency?: string; locale?: Intl.UnicodeBCP47LocaleIdentifier }) => string | number
})
```

- `getProduct(selector)` returns a `Product | undefined` (or an array if you pass an array of selectors).
- Built‑in providers:
  - `init`: legacy Init Selector
  - `vlaicu`: platform API v1
- The store caches by adapted product id + campaign. If you ask for the same product id with different raw ids that adapt to the same platform id, options are aggregated.

### Price formatting
- The store exposes a `formatter` hook. Defaults to Intl currency via `formatPrice`.
- All string price/discount getters (`getPrice()`, `getDiscountedPrice()`, `getDiscount()`) call `store.formatPrice` under the hood when returning strings.
- If you pass `{ currency: false }`/`{ symbol: false }`, the numeric variants are returned.

## Product

Represents a product with many options (variations). Computes min/max aggregates for price and discount across all options (including monthly breakdowns).

Key methods:

```ts
product.getId(): string
product.getName(): string
product.getCampaign(): string | undefined
product.getCurrency(): string
product.getOption(): Promise<ProductOption[]>                 // all options
product.getOption({ devices, subscription }): Promise<ProductOption | undefined>
product.getPrice({ monthly?: boolean, currency?: boolean }): { min: string|number, max: string|number }
product.getDiscountedPrice({ monthly?: boolean, currency?: boolean }): { min: string|number, max: string|number }
product.getDiscount({ percentage?: boolean, symbol?: boolean }): { min: string|number, max: string|number }
product.getDevices(): { min: number, max: number, values: number[] }
product.getSubscriptions(): { min: number, max: number, values: number[] }
```

## ProductOption

A single product variation.

```ts
option.getVariation(): `${devices}-${subscription}`
option.getDevices(): number
option.getSubscription(): number

// Price
option.getPrice({ monthly?: boolean, currency?: boolean }): string | number
option.getDiscountedPrice({ monthly?: boolean, currency?: boolean }): string | number
option.getDiscount({ percentage?: boolean, symbol?: boolean, monthly?: boolean }): string | number

// Links
option.getBuyLink(): string
option.getTrialLink(trialDays: string): string | undefined

// Navigate
option.getOption({ devices?: number; subscription?: number }): Promise<ProductOption | undefined>
option.nextOption({ devices?: 'prev' | 'next' | number; subscription?: 'prev' | 'next' | number }): Promise<ProductOption | undefined>

// Bundling
option.toogleBundle({ option: ProductBundleOption; devicesFixed?: boolean; subscriptionFixed?: boolean }): Promise<ProductOption | undefined>
```

Notes:
- String getters use the store’s formatter; numeric variants are returned when asked via `{ currency: false }` or `{ symbol: false }`.
- `nextOption` accepts deltas per dimension. `'next'/'prev'` move by one index. Numeric deltas are relative.

## Overrides and trial links

- `overrides` mutate/patch individual options before instantiation. Set a variation to `null` to drop it.
- `trialLinks` map trial-period keys (e.g. `'30 days'`, `'45 days'`) to URLs for each product › campaign › option variation. Multiple durations per variation are supported. Retrieve with `option.getTrialLink('30 days')`.
- `transformers.option.buyLink` lets you rewrite buy links uniformly (e.g., add UTM, swap host).

## Custom providers

Implement your own by extending the abstract `Provider`:

```ts
import { Provider } from '@repobit/dex-store/src/providers/provider.base';
import type { UnboundProductData } from '@repobit/dex-store/src/products/product.base';

class MyProvider extends Provider {
  async fetch({ id, campaign }): Promise<UnboundProductData | undefined> {
    // call your API, adapt { devices, subscription } using this.adaptTo(...)
    return {
      id,
      name: 'My Product',
      currency: 'USD',
      campaign,
      campaignType: '',
      platformId: 'P123',
      options: new Map([
        ['5-12', {
          devices: 5,
          subscription: 12,
          price: 109.99,
          discountedPrice: 59.99,
          buyLink: 'https://example/order',
          // trialLinks are applied from the store config; no need to set them in option data
          bundle: []
        }]
      ])
    };
  }
}

const store = new Store({ locale: 'en-us', provider: MyProvider });
```

## Formatting helper

`formatPrice({ price, currency, locale })` is exported for convenience. It uses `Intl.NumberFormat` and returns a string when `currency` is provided, otherwise the raw number.

## Caching and aggregation

- The store caches results per adapted product id + campaign. The adapter can normalize/alias ids before the fetch.
- If you request the same logical product using multiple raw ids that adapt to the same platform id, the store aggregates options:
  - First fetch stores a base Product instance in the cache.
  - Later fetches for the same cache key merge their options into the base via `SET_OPTION`, so you see a superset of options.
- This is helpful when product variations are delivered under multiple source ids that map to a single platform product.

## Built‑in providers

- `init` (Init Selector)
  - Posts a small JSON payload to Bitdefender’s selector endpoint, returns a nested map of variations.
  - Computes buy links, adapts `{ devices, subscription }`, and extracts discounted price when available.
  - Respects the `campaign` resolver and an internal ignore list (to disable promotions).

- `vlaicu` (Platform API v1)
  - Fetches a flat list of options with `{ slots, months, price, discountedPrice, buyLink }`.
  - Adapts `{ devices, subscription }` using the store adapter.
  - Uses store locale for currency formatting.

Both providers:
- Respect `overrides`, `trialLinks` and `transformers.buyLink`.
- Use the store’s adapter for id/variation normalization.

## Troubleshooting

- Price shows as a number instead of a formatted string
  - Ensure the product provides a currency, or pass a `formatter` in Store config.

- `getOption`/`nextOption` returns `undefined`
  - The requested `{ devices, subscription }` is outside the product’s min/max or not present in the values list.

- Overrides had no effect
  - Check the `overrides` key matches the product id returned by the provider (after adaptation).
  - Ensure variation keys are `${devices}-${subscription}` strings.

- Trial link isn’t set
  - Confirm `trialLinks` is keyed by product alias → campaign → variation → trial days, e.g. `{ productAlias: { default: { '5-12': { '30 days': url } } } }`.
  - Verify `option.getTrialLink('30 days')` is called with the exact trial-days string used as the key.

- Different ids return a single product
  - That’s expected when ids adapt to the same platform id; options are aggregated into one Product instance.

## License

ISC
