npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zolve/offers

v0.3.3

Published

Motor de ofertas headless (ADR-0083): tipos do documento publicado, decide() e price(), sorteio A/B do PostHog. Sem dependências de runtime, para caber numa Shopify Function

Readme

@zolve/offers

Motor de ofertas headless do Commerce OS (ADR-0083). O Commerce OS publica um documento de ofertas; a API e a ponte de cada loja rodam o mesmo código com ele, e a resposta é a mesma (avaliação remota ou local).

  • Sem dependências de runtime, ESM, sem node:crypto nem TextEncoder: cabe numa Shopify Function.
  • Determinístico: o pacote não lê relógio nem sorteia ao acaso. O instante entra pela chamada.
  • Dinheiro em centavos inteiros na moeda do documento.
import { decide, price, SUPPORTED_SCHEMA_VERSION } from '@zolve/offers';

// Vitrine: que ofertas mostrar nesta posição, a que preço.
const decision = decide(doc, { placement: 'cart', cart, visitorId, at: new Date().toISOString() });
// decision.status: 'ok' | 'unsupported_schema'; decision.offers[i]: offerId, decisionId, kind, price, action, …

// Cobrança: o preço de cada linha aceita (com offerId), conferindo as condições de novo com o carrinho real.
const priced = price(doc, { cart, visitorId, at });
// priced.lines[i]: { status: 'ok', unitPrice, compareAtPrice, discountedQuantity } | { status: 'ineligible', reason }

unitPrice é por unidade e vale para discountedQuantity unidades da linha (0.3.2); o resto da linha vai a preço cheio. compareAtPrice é o preço da loja no documento, quando maior que o da oferta (o mesmo do decide).

offerKind(doc, offer) (0.2.0) dá o tipo e a direção de uma oferta cadastrada sem carrinho, com os gatilhos de cart_has_any no papel do carrinho escolhido: é o que o editor do Commerce OS mostra.

applyVariant(offer, variant) (0.3.0) devolve a oferta como uma variante de teste a mostra (campo ausente na mudança fica como no documento; o conteúdo mescla). É a regra do decide/price, e a do "aplicar vencedor" no Commerce OS.

Testes A/B e entrada congelada (0.3.0)

O decide devolve em experiments o teste e a variante em que o visitante caiu ([{ key, variant }]). A ponte guarda isso (atributo _cos_exp do carrinho/cookie) e manda de volta em assignments, no decide e no price:

decide(doc, { placement: 'cart', cart, visitorId, assignments: [{ key: 'trimag-preco', variant: 'barato' }], at });
price(doc, { cart, visitorId, assignments, at });
  • Teste sem congelar: o sorteio de sempre, pelo visitorId. As assignments não mudam nada (o sorteio é determinístico e dá a mesma variante).
  • Teste com entryFrozen: true: o sorteio é o mesmo, mas só vale para quem traz a atribuição que ele dá. Quem não traz (gente nova) fica fora do teste: vê o documento sem mudança e não é contado. A atribuição não escolhe a variante: uma que o sorteio não confirma (outra variante, outro teste, visitante fora do rollout) é ignorada, então um cookie forjado não leva ninguém à variante mais barata.
  • Pacote anterior à 0.3.0 ignora entryFrozen e segue pondo gente nova nas variantes. Não cobra errado (o preço da variante é um que já aceitamos vender), por isso o campo não subiu o schemaVersion; só a medição perde o congelamento até a ponte atualizar.

decide respeita condições, item disponível e fora do carrinho, vagas (e a vaga condicional), prioridade, um teste A/B por posição (sorteio do PostHog, em duas etapas) e o grupo de fora geral. price devolve a mesma variante de teste que o decide deu ao visitante.

Regras de cobrança (0.3.1)

  • Grupo de fora geral (ADR-0047): ligado, quem cai nele não vê oferta. O decide devolve offers: [] e holdout: true (a ponte manda o exposed do controle geral); o price recusa as linhas dele com holdout. Sem visitorId, holdout é sempre false.
  • Só o que a cliente escolheu é gatilho, subtotal, vaga extra e combo, no decide e na reconferência do price: linha de oferta aceita não sustenta outra. A exceção é a linha de troca (offerId + replacedItem de uma oferta replace do documento, item trocado do mesmo produto), que desde a 0.3.2 conta como o item trocado para todas as ofertas (ver abaixo). O replacedItem fora de doc.items é reconhecido pelo storeProductId da linha (a troca é no mesmo produto da loja). Só conta como escolhida a troca cuja oferta replace está ligada (com a variante do teste do visitante), dentro da validade no at e de tipo que o pacote conhece; senão é linha de oferta comum e não sustenta outra oferta.
  • Vagas na cobrança: o price não cobra mais ofertas por posição do que as vagas (com a extra), na ordem de prioridade; o excesso, e a mesma oferta numa segunda linha da posição ou o mesmo item numa segunda linha (de qualquer posição, desde a 0.3.3), volta no_slot.
  • Linha de oferta é toda linha com offerId, brinde (gift: true) ou não: ocupa a vaga da posição dela no decide e disputa a vaga no price. O gift só a tira do gatilho e do subtotal.
  • Posição de cada linha de oferta, uma regra só para o decide (vagas já tomadas) e o price (cobrança): vale o placement da linha quando a oferta tem aquele id naquela posição; sem ele (ou com outro), a primeira posição, na ordem do documento, em que a oferta é elegível; nenhuma elegível, a primeira. Linha recusada ainda ocupa a vaga no decide (a ponte pode cobrá-la cheia); id que o documento não tem não ocupa vaga.
  • Vitrine e cobrança concordam: para o mesmo documento e carrinho, toda oferta que o decide mostra e a cliente aceita (e, desde a 0.3.2, toda combinação delas, com troca, vaga extra e combo) sai ok no price, ao preço mostrado (é um teste de propriedade do pacote). Para isso a linha acrescentada leva o placement da DecidedOffer: com o id em duas posições e sem ele, a regra acima pode atribuir a linha à outra posição.
  • visitorId no price: a ponte manda sempre o mesmo visitorId (e as mesmas assignments) do decide. Sem ele o price não sorteia teste nem confere o grupo de fora geral: cobra o documento sem variante, e quem estava no grupo de fora leva a oferta.

Um carrinho só e quantidade com teto (0.3.2)

  • O carrinho de antes da troca, no decide e no price: a linha de troca válida volta a ser o item trocado (replacedItem), com a quantidade da linha, como linha escolhida, para tudo: gatilho, cart_has_none, subtotal, vaga extra, combo e contagem de vagas, da própria troca e das outras ofertas. A linha de troca em si ocupa a vaga da posição dela. Assim o price vê exatamente o carrinho que o decide viu: a troca LP → 90 não fecha a vaga extra presa à LP, o cross-sell com gatilho na Intense 30 segue valendo depois da troca 30 → 90, e a troca de R$ 199 não destrava o brinde de R$ 250 (no subtotal entra o item trocado, não o novo). Substitui a regra da 0.3.1 ("a linha de troca conta como o produto que a cliente leva").
  • Subtotal pelo preço do documento: toda linha escolhida de item listado em doc.items entra no subtotal pelo preço do documento; o unitPrice da linha só vale para item fora dele (0 sem nenhum dos dois). O item trocado já saiu do carrinho e o pacote não sabe o preço da linha que sumiu: com o preço do documento, as duas chamadas somam igual. Item trocado fora do documento conta 0 no subtotal depois da troca (a compilação deve listar os itens que uma troca pode tirar). O unitPrice segue sendo teto do preço da linha de oferta.
  • Troca só na posição dela: a linha é troca quando a oferta replace com aquele offerId é a da posição da linha (o placement dela, ou a atribuída pela regra de posição abaixo), ligada, na validade, de tipo conhecido, do mesmo item, e o replacedItem é do mesmo produto. O mesmo id como add em cart e replace em product_page: a linha de cart com replacedItem forjado é add e não sustenta outra oferta.
  • Alvo da troca sem depender da ordem: com mais de uma linha escolhida do mesmo produto, o replaces é a de menor duração no catálogo e, no empate (ou sem duração), a de menor chave de item (source:variantId). Linha que já é troca não é alvo de outra troca.
  • maxQuantity (campo opcional da oferta, inteiro ≥ 1): quantas unidades da linha levam o preço da oferta. Ausente: 1 quando o preço da oferta dá 0 (brinde), a linha toda nos outros casos. O price devolve discountedQuantity por linha ok; a ponte aplica o desconto só nessas unidades (na Shopify, o desconto de linha com quantidade limitada) e o resto vai a preço cheio. maxQuantity que não é inteiro ≥ 1 torna a oferta unsupported_offer. Como a 0.3.2 cobra diferente da 0.3.1 com o mesmo documento (o brinde passa a valer uma unidade mesmo sem o campo), o formato subiu para schemaVersion 2: uma ponte ainda na 0.3.1 recebe unsupported_schema e não cobra, em vez de dar o desconto na linha toda.
  • Linha validada: quantity inteiro ≥ 1 e unitPrice (se vier) centavos inteiros ≥ 0. Linha fora disso não é gatilho nem entra no subtotal; a linha de oferta volta invalid_line. O pacote nunca devolve NaN, negativo ou fração; preço de oferta com valor que não é inteiro também é unsupported_offer.
  • decisionId = sha1 da versão, posição, visitante, carrinho (cada linha com item, quantidade, unitPrice, offerId, placement, replacedItem, storeProductId, gift, sem a ordem) e da resposta inteira (cada oferta com preço, alvo da troca, conteúdo e vaga; apresentação, combo, teste, holdout). Mesma pergunta e mesma resposta = mesmo id; resposta diferente, outro id. Os ids mudam em relação à 0.3.1.

Troca confirmada e item numa linha só (0.3.3)

Correções de cobrança do pacote; o formato do documento não muda (schemaVersion segue 2).

  • Só conta como o item trocado a troca que o price cobra ok. O documento traz todas as variantes do produto e o replacedItem vem da cliente: antes, a linha replace contava como o item trocado para as outras ofertas mesmo recusada, e um replacedItem com a variante mais cara (Intense 90 dizendo que trocou a 180) destravava o brinde por subtotal. Agora a troca precisa passar nas próprias condições no carrinho de antes dela, ganhar a vaga e o replacedItem tem de ser o alvo que o decide daria (a linha escolhida do mesmo produto de menor duração; no empate, a de menor chave de item). Fora disso a troca volta trigger_missing (alvo errado) ou o motivo dela, e a linha é linha de oferta comum: não sustenta gatilho, subtotal, vaga extra nem combo de outra oferta. Efeito na ponte: uma troca recusada agora pode levar junto as ofertas que dependiam do item trocado (o brinde por valor, o cross-sell com gatilho nele); antes elas seguiam ok.
  • O mesmo item vale uma linha só no carrinho, em qualquer posição. O decide já não mostrava item que o carrinho tem; o price dava vaga ao mesmo brinde em cart e em checkout. A segunda linha do item, na ordem de prioridade (depois a do documento e a do carrinho), volta no_slot.

Motivos de ineligible: unknown_offer, inactive, item_mismatch, not_started, expired, trigger_missing, subtotal_out_of_range, unsupported_schema e, desde a 0.3.1:

| Motivo | Quando | |---|---| | unavailable | Item oferecido indisponível ou com preço da loja 0 no documento (nunca sai de graça por falta de preço; brinde a R$ 0 é fixed 0 com preço da loja) | | holdout | Visitante no grupo de fora geral | | no_slot | A posição já tem as vagas tomadas por ofertas de prioridade maior, ou a oferta/item já vale em outra linha | | unsupported_offer | Condição, preço ou ação de tipo que o pacote não conhece (o decide também não mostra) | | invalid_instant | Oferta com validade e at (ou a validade) ilegível | | invalid_line (0.3.2) | Linha com quantity que não é inteiro ≥ 1 ou unitPrice que não é centavo inteiro ≥ 0 |

Documento com schemaVersion maior que SUPPORTED_SCHEMA_VERSION não é decidido nem precificado: a resposta vem com status: 'unsupported_schema' (e toda linha do price inelegível com esse motivo). Atualize o pacote.

O schema Zod que valida o documento na publicação fica no Commerce OS (@commerce-os/contracts), fora deste pacote.