# @repobit/dex-store-elements

Lightweight HTML custom elements + attribute renderers for building dynamic pricing UIs on top of `@repobit/dex-store`.

- Custom elements: `<bd-root>`, `<bd-product>`, `<bd-option>`, `<bd-state>`, `<bd-context>`
- Unified attribute-based renderers (no framework required)
- Eta templates for text/HTML and attributes
- Single, merged data context across product, option and state
- Extensible “derived” variables/functions you can compute and use anywhere

## Requirements
- Node 18+

## Install

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

Peer dependencies are resolved automatically by npm; no extra install command is required.

## Quick start

```html
<!-- index.html -->
<script type="module">
  import { registerContextNodes, registerActionNodes, registerRenderNodes } from '@repobit/dex-store-elements';
  import { Store } from '@repobit/dex-store';

  window.addEventListener('DOMContentLoaded', async () => {
    registerContextNodes();
    const root = document.querySelector('bd-root');
    root.store = new Store({
      locale: 'en-us',
      provider: { name: 'vlaicu' }
    });

    // Optional: analytics data layer callback
    // Fires once per <bd-option> that sets `data-layer-event`
    root.dataLayer = ({ option, event }) => {
      // Example GTM-style push; adapt fields as needed
      window.dataLayer?.push({
        event,
        productId   : option.getProduct().getId(),
        campaign    : option.getProduct().getCampaign(),
        variation   : option.getVariation(), // e.g. "5-12"
        devices     : option.getDevices(),
        subscription: option.getSubscription(),
        price       : option.getDiscountedPrice({ currency: false })
      });
    };

    // Optional: define derived values/functions for templates + hide DSL
    root.derived = async ({ option }) => ({
      mails: (p) => ((option?.getDevices?.() ?? 0) / p) * 100
    });

    const disposeActions = registerActionNodes(root);
    const disposeRenders = registerRenderNodes(root);

    // Optional teardown (for SPA route changes/unmounts)
    // disposeActions();
    // disposeRenders();
  });
</script>

<bd-root store-name="root">
  <bd-product store-name="product" product-id="com.bitdefender.tsmd.v2">
    <bd-option devices="5" subscription="12" data-layer-event="info">
      <!-- Attribute renderers (see below) -->
      <div data-store-render data-store-devices></div>
      <div data-store-render data-store-subscription data-store-subscription-type="years"></div>
      <div data-store-render data-store-price="discounted || full"></div>
      <a data-store-render data-store-buy-link>Buy</a>
      <!-- Eta template (text) -->
      <p>Now at only {{= it.option.price.discounted }}!</p>
      <!-- Eta template (attribute, implicit) -->
      <div title="Devices {{= it.option.devices }}"></div>
      <!-- Hide via DSL using merged context -->
      <div data-store-render data-store-hide="!it.option.price.discounted">
        Hidden when discounted price doesn't exists
      </div>
      <!-- Actions -->
      <button data-store-action data-store-set-devices="25">25 devices</button>
    </bd-option>
  </bd-product>
</bd-root>
```

**Store Config**
- `trialLinks` and `overrides` come from `@repobit/dex-store` and work transparently with these elements. You pass them when creating the `Store` and render them via attributes like `data-store-trial-link` or by relying on overridden option fields.

Example advanced config when constructing the store:

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

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

  // Map productId -> campaign -> optionVariation -> trial URL
  // productId is the final id after adaptor mapping (e.g. 'com.bitdefender.tsmd.v2').
  // optionVariation key format: '<devices>-<subscription>' (e.g. '5-12').
  trialLinks: {
    'com.bitdefender.tsmd.v2': {
      default: {
        '5-12' : 'https://trial.example.com/default/5-12',
        '10-12': 'https://trial.example.com/default/10-12'
      },
      PromoX: {
        '5-12' : 'https://trial.example.com/promox/5-12',
        '10-12': 'https://trial.example.com/promox/10-12'
      }
    }
  },

  // Per-product overrides for campaign and/or options
  // - Set/redirect campaign via `default.campaign` or `[campaign].campaign`
  // - Merge option fields per variation; use `null` to remove an option
  overrides: {
    'com.bitdefender.tsmd.v2': {
      // Applies when no explicit campaign is requested
      default: {
        campaign: 'OvDefault'
      },
      PromoX: {
        campaign: 'PromoX',
        options : {
          '5-12' : { discountedPrice: 49.99, buyLink: 'https://example.com/override/buy' },
          '10-12': null // delete this variation
        }
      }
    }
  }
});
```

Notes
- `data-store-trial-link` uses `trialLinks` to set the anchor `href`. If no mapping exists, the attribute is left untouched.
- `overrides.options` merges into each option; you can update `buyLink`, `discountedPrice`, etc., or delete an entire variation with `null`.
- Keys are resolved against the product id returned by the provider (after adaptor mapping). If you don’t use mappings, it’s the id you pass in `<bd-product product-id="...">`.

## Rendering model
- Add `data-store-render` to any element you want updated by the pipeline.
- A single binder subscribes to option, product and aggregated state contexts and renders attributes + Eta templates.
- Scoping is natural: a node sees the nearest provider up the tree (e.g., `it.option.*` is only available inside `<bd-option>`).

## Supported attributes

- `data-store-devices`
  - Renders option devices to text nodes, `<input>` value, or `<select>` options (adds `data-store-set-devices` on each option)
  - Optional label helpers: `data-store-text-single="device"`, `data-store-text-many="devices"`

- `data-store-subscription`
  - Renders option subscription similarly; add `data-store-subscription-type="years|months"`
  - Label helpers: `data-store-text-single`, `data-store-text-many`

- `data-store-price`
  - Allowed tokens: `full`, `discounted`, `full-monthly`, `discounted-monthly`
  - Supports OR semantics via `||` to choose the first available variant:
    - `data-store-price="discounted || full"`

- `data-store-discount`
  - Allowed tokens: `value`, `percentage`, `value-monthly`, `percentage-monthly`
  - Supports `||` fallbacks

- Aggregated state (min/max across options):
  - `data-store-context-price` tokens: `min-full`, `max-full`, `min-full-monthly`, `max-full-monthly`, `min-discounted`, `max-discounted`, `min-discounted-monthly`, `max-discounted-monthly`
  - `data-store-context-discount` tokens: `min-value`, `max-value`, `min-value-monthly`, `max-value-monthly`, `min-percentage`, `max-percentage`, `min-percentage-monthly`, `max-percentage-monthly`

- Links
  - `data-store-buy-link` sets anchor `href` and useful `data-*` attributes
  - `data-store-trial-link` sets anchor `href` to the trial link (if configured in `@repobit/dex-store` store config)

- Hide DSL
  - `data-store-hide="<boolean expression>"` with an optional `data-store-hide-type="display|opacity|visibility"`
  - Expression is compiled and evaluated against the unified context:
    - `it.option.*` current option data. Price- and discount-related fields are formatted strings (currency-aware). Do not rely on numeric math for prices; they vary by currency. Devices/subscription remain numeric.
    - `it.product.*` id/campaign/name
    - `it.state.*` aggregated min/max data (also available under `it.ctx`)
    - any keys returned from your `root.derived`
  - Examples:
    - `data-store-hide="!it.option.price.discounted"` (hide when no discounted price)
    - `data-store-hide="it.product.campaign === 'test'"`

## Eta templates
- Text/HTML: any element that is not a provider and doesn’t contain nested providers is treated as a whole-template; `innerHTML` is compiled once and morphed via nanomorph. This preserves existing DOM event listeners and state.
- Attributes:
  - Implicit: any attribute whose value contains `{{` is rendered via Eta
- The Eta context variable is `it` (Eta default). It contains:
  - `it.option.*` (inside `<bd-option>`)
  - `it.product.*` (inside `<bd-product>`)
  - `it.state.*` and `it.ctx.*` (inside any provider subtree)
  - your derived overlay merged at top-level (see below)

## Derived variables/functions
- Provide a function at the root: `root.derived = async ({ option, product, state }) => ({ ... })`
- The returned object is merged into the Eta/DSL context:
  - Example: `({ mails: (p) => (option?.getDevices?.()/p)*100, option: { someVar: state.discount.value.min } })`
  - Use it in Eta: `{{= it.mails(10) }}` or `{{= it.option.someVar }}`
  - Use it in hide: `data-store-hide="it.mails(10) >= 50"`

## DSL context reference
The DSL and Eta contexts use Eta’s default variable name `it`. The following keys are available:

- it.option
  - price
    - full: formatted full price (string)
    - discounted: formatted discounted price (string)
    - fullMonthly: formatted monthly full price (string)
    - discountedMonthly: formatted monthly discounted price (string)
  - discount
    - value: formatted discount amount (string)
    - percentage: formatted percentage discount with symbol (string)
    - valueMonthly: formatted monthly discount amount (string)
    - percentageMonthly: formatted monthly percentage with symbol (string)
  - links
    - buy: buy URL (string)
    - trial: trial URL if available (string)
  - devices: number
  - subscription: number

- it.product
  - id: string
  - campaign: string
  - name: string

- it.state (also available as `it.ctx`)
  - price
    - full
      - min: formatted string
      - max: formatted string
      - monthly
        - min: formatted string
        - max: formatted string
    - discounted
      - min: formatted string
      - max: formatted string
      - monthly
        - min: formatted string
        - max: formatted string
  - discount
    - percentage
      - min: formatted string
      - max: formatted string
      - monthly
        - min: formatted string
        - max: formatted string
    - value
      - min: formatted string
      - max: formatted string
      - monthly
        - min: formatted string
        - max: formatted string

Notes
- All price/discount values in the DSL are formatted strings (currency-aware). Do not perform numeric comparisons on them. Prefer truthiness checks (e.g., `!it.option.price.discounted`).
- `it.ctx` is an alias of `it.state` for convenience.

## Data Layer
- Provide a function on `<bd-root>`: `root.dataLayer = ({ option, event }) => { ... }`.
  - Called once per `<bd-option>` instance that declares `data-layer-event` (on first successful load).
  - Safe if attached after the option loads; it still fires once when available.
  - Intended for analytics (e.g., pushing to `window.dataLayer`).
- Event name is set on each `<bd-option>` with `data-layer-event`.
  - Canonical values: `all`, `info`, `comparison`. Any custom string is also accepted.
- Payload shape passed to your callback:
  - `event`: string event name.
  - `option`: `ProductOption` from `@repobit/dex-store` with getters like `getProduct()`, `getVariation()`, `getDevices()`, `getSubscription()`, `getBuyLink()`, `getDiscountedPrice()`.

Example:

```html
<script type="module">
  import { registerContextNodes, registerActionNodes } from '@repobit/dex-store-elements';
  import { Store } from '@repobit/dex-store';

  window.addEventListener('DOMContentLoaded', async () => {
    registerContextNodes();
    const root = document.querySelector('bd-root');
    root.store = new Store({ locale: 'en-us', provider: { name: 'vlaicu' } });

    root.dataLayer = ({ option, event }) => {
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({
        event,
        productId: option.getProduct().getId(),
        campaign : option.getProduct().getCampaign(),
        variation: option.getVariation(),
        devices  : option.getDevices(),
        subscription: option.getSubscription()
      });
    };

    const disposeActions = registerActionNodes(root);

    // Optional teardown (for SPA route changes/unmounts)
    // disposeActions();
  });
</script>

<bd-root store-name="root">
  <bd-product store-name="product" product-id="com.bitdefender.tsmd.v2">
    <bd-option devices="5" subscription="12" data-layer-event="info">
      <!-- your UI here -->
    </bd-option>
  </bd-product>
</bd-root>
```

Behavior notes
- Fires only once per `<bd-option>` instance (first load). Subsequent changes via actions or deltas do not trigger the callback again.
- Set different `data-layer-event` values on different options if you need multiple distinct analytics events.

## Actions
- Add `data-store-action` to elements to emit store events.
  - Set absolute values: `data-store-set-devices`, `data-store-set-subscription`, `data-store-set-id`, `data-store-set-campaign`
  - Update by delta/sequence: `data-store-set-type="devices|subscription"`, `data-store-set-delta="next|prev|<number>"`
  - Source identifier: add `data-store-id="someId"` to tag the event's `storeId`. Providers with `ignore-events` that include `someId` will drop these events.
- Initialize once per mount with:
  - `import { registerActionNodes } from '@repobit/dex-store-elements'`
  - `const disposeActions = registerActionNodes(root)`
  - Call `disposeActions()` on teardown/unmount to disconnect observers.

## Registration and initialization
- Element registration:
  - `import { registerContextNodes } from '@repobit/dex-store-elements'; registerContextNodes();`
- Rendering: `import { registerRenderNodes } from '@repobit/dex-store-elements'`
- Actions: `import { registerActionNodes } from '@repobit/dex-store-elements'`
- `registerRenderNodes(root)` returns a disposer: `() => void`
- `registerActionNodes(root)` returns a disposer: `() => void`
- Call disposers during teardown/unmount in long-lived apps (SPA route changes, dynamic mounts).

Note: The package is side-effect free; elements are registered only when `registerContextNodes()` is called.

## State & event controls
These attributes are available on `<bd-state>` and any element that extends it (`<bd-root>`, `<bd-product>`, `<bd-option>`).
- `ignore-events="store-a, store-b"`
  - Comma-separated list of action ids. Events whose source element has a matching `data-store-id` are ignored by this node (and its subtree). Works for both action and delta events.
  - Coupling with `data-store-id` (on action elements):
    - Example:
      - `<button data-store-action data-store-id="devicesBtn" data-store-set-devices="25">` dispatches an event with `storeId: "devicesBtn"`.
      - `<bd-option ignore-events="devicesBtn">` ignores that event while still accepting events from other buttons.
    - You can list multiple ids: `ignore-events="devicesBtn, subscriptionBtn"`.
- `ignore-events-parent`
  - Ignores events received from ancestor providers and only reacts to DOM-bubbled events that originate within the current subtree.
  - `<bd-context>` is a convenience element that defaults this behavior to enabled. It’s equivalent to `<bd-state ignore-events-parent>`.
  - Example:
    ```html
    <bd-state store-name="outer">
      <!-- Global action updates state here -->
      <button data-store-action data-store-set-devices="25"></button>

      <!-- Isolated island: only inner actions are observed -->
      <bd-context store-name="island">
        <button data-store-action data-store-set-devices="5"></button>
      </bd-context>
    </bd-state>
    ```
    In this setup, the inner `<bd-context>` ignores the outer button’s events.
- `no-collect`
  - Turns off automatic option collection for aggregated state. Set this when you want the node to work locally without contributing to shared min/max computations (e.g., for preview widgets).

## Caveats
- Scoping: attribute Eta and hide can only see contexts provided by ancestors. For example, `it.option.*` is only available inside `<bd-option>`.
- Nested providers: inner providers render their own subtrees; outer nodes can still render attributes safely even when they contain nested providers.

## TypeScript
- Custom element classes are exported from a dedicated entry for advanced use:
  - `import { RootNode, ProductNode, OptionNode, StateNode } from '@repobit/dex-store-elements'`
- The derived signature:
  ```ts
  import type { derivedContextType } from '@repobit/dex-store-elements/src/contexts/context.derived';
  const derived: derivedContextType = async ({ option, product, state, store }) => ({ ... });
  ```

## License
ISC
