Skip to content

fix(storefront): Fix loyalty points not showing at checkout when store has discount preset - #805

Merged
leomp12 merged 3 commits into
mainfrom
fix/storefront-loyalty-points
Aug 8, 2026
Merged

fix(storefront): Fix loyalty points not showing at checkout when store has discount preset#805
leomp12 merged 3 commits into
mainfrom
fix/storefront-loyalty-points

Conversation

@vitorrgg

@vitorrgg vitorrgg commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

  • Stores with discount_option configured in modules settings (e.g. PIX discount) had modulesInfo.list_payments pre-populated from the preset
  • fetchInfo was skipping the API call when any preset data existed, so loyalty_points_programs returned by the loyalty app was never merged
  • waitStorefrontInfo('list_payments', 'loyalty_points_programs') in PointsApplier never resolved — loyalty points toggle was never rendered

Root cause commit: a3ab982

Fix: always fetch from the API; emit preset/cached data immediately as initial value while the API call completes.

Test plan

  • Store with PIX/discount preset: confirm loyalty points toggle appears in checkout after selecting shipping
  • Confirm window.storefront?.info?.list_payments?.loyalty_points_programs is populated after checkout load

…e has discount preset

When a store has discount_option or installments_option configured in modules settings,
modulesInfo[list_payments] was already non-empty from the preset, causing fetchInfo
to skip the API call entirely. As a result, loyalty_points_programs returned by the
loyalty app was never merged into storefront.info, so PointsApplier never received
the programs and the toggle was not rendered.

Fix: always fetch from the API, emitting preset data immediately as an initial value
for fast rendering while the API call completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O diagnóstico está certo e é preciso. A condição antiga realmente confundia "tenho algum dado do preset" com "tenho o dado que preciso" — e como loyalty_points_programs só aparece em runtime (vem do app de fidelidade, packages/apps/loyalty-points/src/loyalty-list-payments.ts:57) e nunca do preset quando a loja não configurou o programa no CMS, qualquer loja com discount_option ou installments_option preenchidos ficava sem buscar. Chegar nisso partindo de "o toggle não renderiza" tem mérito. Deixar apply_discount fora da mudança também está certo — ele continua condicionado a UTM/cupom.

Uma correção de atribuição: o a3ab982da (que é meu) introduziu o modulesInfoEvents e o else que emite o preset, mas o skip do fetch é bem mais antigo — veio no aa587fd97 ("New optional global window.storefront.modulesInfoPreset"). O a3ab982da é onde o contrato de espera por evento nasceu e expôs o problema, não onde ele foi criado.

O que me trava é que "buscar sempre" liga um caminho destrutivo que até agora era inalcançável.

🔴 Bloqueante — o delete de modules-info.ts:122-124 passa a rodar em toda navegação, em toda loja

if (response.ok) {
  Object.keys(modulesInfo[modName]).forEach((key) => {
    delete modulesInfo[modName][key];        // apaga tudo
  });
  const modInfo = {};
  const { result } = await response.json();  // ← await DEPOIS do delete

Antes desta PR esse bloco era código morto na prática: módulo com preset não era buscado (o delete nunca rodava nele) e módulo sem preset já estava vazio (no-op). Com o fetch incondicional ele entra no caminho quente. Quatro consequências:

1. Janela de estado vazio, mesmo quando a API responde tudo. O delete vem antes do await response.json(), e o scheduler do Vue faz flush em microtask — a UI renderiza o {} antes do merge. Como parsePhrase (:243-248) devolve string vazia quando o campo é falsy, usePitchBar.countValidSlides (composables/use-pitch-bar.ts:28-30) descarta o slide e useBanner.hasHeader (composables/use-banner.ts:71-73) esconde o header inteiro. As 4 lojas que tenho aqui têm free_shipping_from_value no content/settings.json (129, 150, 200, 200), então todas passam por isso.

2. Perda definitiva quando a API não repete o campo. O preset sai do content/settings.json, digitado à mão no CMS e independente da config dos apps. Os apps de frete só devolvem free_shipping_from_value se estiver configurado no próprio app (apps/correios/lib-mjs/calculate-shipping.mjs:29, apps/custom-shipping/src/custom-shipping-calculate.ts:79-86 — e este último tem early return em :14-17 quando não há shipping_rules). Divergiu, o número some da vitrine. discount_option tem o mesmo risco: o merge só assume o valor se algum payment_gateways[].discount casar value exato com apply_at !== 'freight' (:156-167); não casou, fica sem — e o preset já foi apagado.

3. Corrompe um objeto já entregue a terceiro. scripts/vbeta-app.ts:317-319 guarda a mesma referência reativa em window.storefront.info[modName]. O delete esvazia por baixo o objeto que o storefront-app já está lendo.

4. O vazio é persistido. sessionStorage.setItem (:187-190) grava o modulesInfo já apagado, então o page load seguinte parte do vazio por até 5 minutos.

Direção: computar o modInfo primeiro e só depois substituir — mover o bloco de delete para depois do await response.json(), limitando a remoção às chaves que o novo modInfo traz (ou trocar por um replace atômico). O objetivo original do delete era não deixar campo obsoleto para trás, e isso continua atingível sem passar por um estado vazio.

🟠 Estrutural — o cache de sessão deixa de evitar requisição

:66-79 restaura modulesInfo do sessionStorage com janela de 5 minutos. Com o fetch incondicional esse cache vira apenas valor inicial e o __timestamp fica vestigial. Na prática todo page view passa a disparar list_payments + calculate_shipping, e afetch é fetch puro, sem dedupe. Cada uma dessas chamadas faz fan-out server-side para todos os apps instalados (packages/modules/src/firebase/call-app-module.ts), vários deles com HTTP externo para transportadora ou gateway.

Não é motivo para reverter a direção, mas vale decidir explicitamente: ou o cache de 5 min volta a valer como short-circuit (buscando só o que falta), ou some e o custo é assumido. Do jeito que fica, o código sugere que há cache e não há.

🟢 Minors

  • Test plan com os dois itens desmarcados — vale rodar o cenário 1 numa loja com discount_option no preset antes de mergear.
  • Com o push incondicional, o forEach de :83-88 só serve para o emit; modulesToFetch podia já nascer com os dois módulos e o loop ficar só com a responsabilidade de emitir.
  • Confirma se o waitStorefrontInfo do storefront-app tolera o emit dobrado (preset + API) que agora acontece em loja com preset — não consigo verificar daqui, o bundle vem de CDN.

Para mergear, só o bloqueante: mover o delete para depois do parse. O ponto do cache dá para tratar em PR separado, mas queria tua leitura antes de abrir issue.

…ng after page load

Refreshing modules info cleared every field before parsing the API response,
so values set on storefront settings disappeared whenever an app returned an
error or the preview request brought no such field.

- Merge over the preset instead of wiping: only fields absent from both the
  response and the preset are removed
- Delete and assign are now adjacent, with no `await` in between, so the
  empty state is never rendered
- Copy the preset objects on load, `modulesInfo` was aliasing (and therefore
  emptying) `window.$storefront.modulesInfoPreset`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtZi8sdmSDeQxrEeyzPJeS
@leomp12

leomp12 commented Aug 7, 2026

Copy link
Copy Markdown
Member

Empurrei dois commits na branch em vez de devolver só o apontamento — o bloqueante dependia de uma decisão de semântica que era mais rápido escrever do que descrever. Segue o que mudou e por quê, pra você conferir se concorda.

eda8ad04c — o bloqueante. Três coisas, todas na mesma causa:

  • O merge agora sobrepõe o preset em vez de zerar: nextInfo = { ...infoPreset[modName], ...modInfo }, e só é removido o campo que não está nem na resposta nem no preset. Shallow de propósito — se a API traz discount_option, ela substitui inteiro; se não traz, o do preset fica. O que motivou: apps/pix/src/pix-list-payments.ts:30-56 devolve responseError quando falta pix_key/certificado/credencial, e o cliente ignora entradas com error. Ou seja, app mal configurado produzia exatamente o mesmo silêncio que "promo removida", e o preset ia junto.
  • delete e Object.assign ficaram colados, sem await no meio. Antes o delete vinha antes do await response.json() e a UI renderizava o estado vazio — com parsePhrase devolvendo string vazia, o slide do pitch bar sumia (use-pitch-bar.ts:28-30) e o header do banner também (use-banner.ts:71-73).
  • Achei um colateral que não estava no radar: Object.assign(modulesInfo, modulesInfoPreset) aliasava os sub-objetos, então modulesInfo.list_payments e window.$storefront.modulesInfoPreset.list_payments eram o mesmo objeto. Com o fetch incondicional, o delete esvaziava também o global público declarado em client.d.ts:49. Agora o preset é copiado na carga, e é aplicado preenchendo lacuna (info persistida, que veio da API, tem precedência) em vez de substituir o sub-objeto — o que de quebra resolve o preset sobrescrever o cache de sessão, que acontecia porque o .then roda em microtask depois do restore síncrono.

6750b6eb6 — o custo. Com "buscar sempre", o cache de 5 min tinha deixado de evitar requisição, e cada page view passava a bater em list_payments + calculate_shipping, cada um com fan-out server-side pra todos os apps instalados. A saída foi separar as duas origens: o preset nunca pode dispensar a chamada (não carrega loyalty_points_programs), mas a info persistida pode, porque só é gravada depois de response.ok. Agora um __fetched no payload do sessionStorage marca, por módulo, o que de fato veio de resposta — e só o que não veio é buscado de novo. Cache velho sem essa chave degrada pro comportamento de buscar tudo.

O efeito líquido: primeira visita busca os dois, as seguintes dentro de 5 min não fazem requisição nenhuma, e o loyalty_points_programs continua chegando porque o cache é API-origin.

Um ponto residual que deixo registrado e não mexi: com o short-circuit, page view servido por cache volta a ter um único emit (no idle). Se algum listener registrar depois disso, ele perde o gatilho. Na prática o vbeta-app registra na avaliação do módulo (vbeta-app.ts:218 é if (!import.meta.env.SSR)) e o /app carrega por <script src> estático, então o registro acontece antes do idle — mas é uma aresta pré-existente que só estava sendo mascarada pelo segundo emit do fetch incondicional. Se quiser fechar de vez, o caminho barato é o vbeta-app semear window.storefront.info a partir do modulesInfo no momento em que registra, já que ele lê o objeto direto (:317).

O test plan continua com você — em especial o cenário 1, e vale conferir também que window.$storefront.modulesInfoPreset segue preenchido depois do idle. Se preferir outra semântica no merge, reverte os dois commits sem dó, a decisão de deixar o preset sobreviver é discutível e foi minha.

@leomp12

leomp12 commented Aug 7, 2026

Copy link
Copy Markdown
Member

Passei uma revisão adversarial em cima dos meus próprios commits, focada em performance e em loja sem programa de fidelidade — que é o caso majoritário e o que menos olhamos até aqui. Achou um bug meu, que já corrigi em 44d0c479d.

O bug: cache negativo de 5 minutos quando um app falha. O gate era só response.ok, mas a API de módulos responde 200 com error por app (packages/modules/src/firebase/functions-checkout/request-to-module.ts:47-58). Cold start ou timeout de um único app produzia modInfo vazio e ainda assim marcava o módulo como buscado. O estado vazio ia pro sessionStorage com __fetched, e as páginas seguintes não buscavam nem emitiam — resultado: até 5 minutos de sessão inteira sem parcelamento, sem selo de desconto e sem frase de frete grátis, em todas as páginas.

Pior era a assimetria: um 5xx não marcava e tentava de novo na página seguinte, mas um 200 com todos os apps em erro congelava. Loja sem preset era a mais exposta, porque não tinha nada pra cair de volta — exatamente a loja sem fidelidade.

Agora só marco o módulo como buscado quando nenhuma entrada do result veio com error. Resposta parcial continua sendo usada como valor inicial, mas é rebuscada na próxima navegação. Array vazio (loja sem app de frete ou pagamento instalado) segue cacheável, senão viraria refetch eterno.

Uma consequência que fica registrada e eu não vou mexer: com "info persistida vence o preset", o HTML do SSR imprime o valor do CMS e a hidratação troca pelo valor real da API em toda navegação dentro do TTL. Numa loja com free_shipping_from_value: 199 no CMS e app de frete devolvendo 149, o pitch bar pinta 199 e vira 149. Em main isso não aparecia porque o preset sobrescrevia o restore — mas ali a loja com preset nunca via o dado real da API, que é justamente o bug que esta PR conserta. Trocar a precedência devolveria o problema, então a inconsistência entre first paint e estado hidratado é o preço, e o caminho pra fechar de vez seria alinhar o número do CMS com o do app.

No eixo de performance não sobrou nada. Vale o registro de que o balanço ficou melhor que main em dois pontos: loja cujos apps devolvem vazio saiu de 2 GETs por página (o main refazia sempre, já que restore vazio não pulava) para 2 por janela de 5 min; e o churn de reatividade caiu, porque o main apagava todas as chaves e reatribuía com o módulo vazio atravessando o await response.json(), enquanto agora delete e assign são contíguos e só disparam effect em chave que realmente mudou. O __fetched acrescenta ~60 bytes ao payload e é limitado a 3 nomes.

@leomp12
leomp12 force-pushed the fix/storefront-loyalty-points branch 2 times, most recently from 9543683 to a20083d Compare August 8, 2026 00:49
@leomp12

leomp12 commented Aug 8, 2026

Copy link
Copy Markdown
Member

Reescrevi a branch (a20083d71): os dois commits de cache viraram um só, e a regra ficou escopada em list_payments.

Por que calculate_shipping saiu. O storefront lê um único campo desse módulo — free_shipping_from_value (modules-info.ts:29-31), e o merge não extrai mais nada (:150-157). O preset seta exatamente esse campo, então ali "não-vazio" já significa "completo" e a regra antiga estava certa. Tinha aplicado o critério de origem nos dois módulos quando ele só fazia sentido em um — o efeito era loja com free_shipping_from_value no CMS passar a buscar frete em toda navegação, sem ganhar nada com isso. Agora calculate_shipping tem exatamente o comportamento do main, inclusive as regras de cache.

Sobra list_payments, que tem três campos (:24-28) e um preset que pode preencher só parte deles. É onde "não-vazio" mente sobre "completo", e é o bug desta PR. Custo final: 1 request por janela de 5 min, só em loja com preset de pagamento. Zero em frete.


TODO — content das lojas

Independente desta PR, vale preencher o programa de fidelidade no CMS das lojas que usam pontos.

modules-info-preset.ts:26-38 só monta loyalty_points_programs no preset quando modules.list_payments.loyalty_points_program tem id e ratio preenchidos. Nas lojas do monorepo esse objeto está com os quatro campos null:

"loyalty_points_program": { "id": null, "name": null, "ratio": null, "earn_percentage": null }

Com id e ratio preenchidos, o preset passa a carregar o campo e o toggle aparece sem depender de resposta da API — inclusive antes desta PR. A tiasonia é o caso que bate exatamente com o perfil do bug (tem discount_option PIX 5% preenchido e o programa de fidelidade zerado), então é por onde eu começaria.

Isso não substitui o fix de código, e vice-versa: o content resolve a loja que for preenchida, o código resolve a loja que configurou fidelidade no app e não duplicou no CMS. Vale lembrar que essa duplicação manual é a mesma origem da divergência que comentei antes — CMS dizendo um número e app devolvendo outro.

The request was skipped whenever module info wasn't empty, and the preset
read from storefront settings also makes it not empty, so stores with a
discount or installments configured never fetched `list_payments`, and
fields that only exist on the response, such as `loyalty_points_programs`,
never arrived.

Skip based on where the info came from, but only for `list_payments`: it
has three fields and the preset may fill just some of them. The storefront
reads a single field from `calculate_shipping`, so a preset for it is
already complete and its cache rules stay exactly as they were.

A `__fetched` mark per module on the session payload carries the origin
across page views. A response yielding no field is not marked, so it is
requested again on the next page view instead of caching empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtZi8sdmSDeQxrEeyzPJeS
@leomp12
leomp12 force-pushed the fix/storefront-loyalty-points branch from a20083d to 1bd5eba Compare August 8, 2026 01:38

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fiz o double-check de não-regressão no diff final (1bd5ebaa4) e está fechado. Resultado por módulo, comparando com main:

calculate_shipping — idêntico ao main em todos os caminhos. Sem preset e sem cache busca; com preset pula e emite; cache com dado pula; cache vazio busca. E o swap de hidratação que eu tinha registrado antes não se aplica mais aqui: loja com preset nunca busca esse módulo, então o cache nunca chega a conter um valor de API divergente do CMS. As regras de cache desse módulo ficaram intactas.

list_payments — exatamente uma célula muda. Loja com preset parcial saía de pula (o bug) para busca. Todo o resto preserva o comportamento anterior, incluindo o TTL de 5 min e o short-circuit por cache.

Também verifiquei: o delete seletivo continua removendo campo que veio só do cache e sumiu de preset+resposta, então não sobra dado obsoleto; apply_discount tem infoPreset indefinido, o que faz nextInfo === modInfo e reproduz o comportamento antigo; cache gravado no formato antigo (sem __fetched) degrada para "busca uma vez e regrava" sem quebrar; e __fetched/__timestamp saem do payload antes do Object.assign, sem colidir com nome de módulo.

Enxuguei também os comentários que eu tinha deixado — de 8 para 5 linhas, num arquivo que não tinha nenhum. Sobraram só os que protegem invariante que um refactor futuro quebraria sem perceber: a cópia do preset (que evita esvaziar window.$storefront.modulesInfoPreset) e a assimetria entre os dois módulos.

Aprovando com duas ressalvas explícitas: os commits que resolvem o bloqueante que eu mesmo levantei são meus, então quem valida o test plan é o @vitorrgg; e não rodei typecheck nem teste em nenhuma iteração — só ESLint, que passou em todas. modules-info.ts não tem cobertura e a validação foi por leitura e rastreamento de fluxo.

Fica o TODO do content das lojas registrado no comentário anterior, que é independente desta PR.

@leomp12
leomp12 merged commit 47ba3e9 into main Aug 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants