Skip to content

fix(storefront): Show kit contents and pick variations on product page - #799

Open
vitorrgg wants to merge 2 commits into
mainfrom
fix/product-details-kit-composition
Open

fix(storefront): Show kit contents and pick variations on product page#799
vitorrgg wants to merge 2 commits into
mainfrom
fix/product-details-kit-composition

Conversation

@vitorrgg

@vitorrgg vitorrgg commented Aug 3, 2026

Copy link
Copy Markdown
Member

Problema

Produtos do tipo kit (com kit_composition) eram renderizados na seção de detalhes como um produto comum: a composição nunca era listada e, quando os itens do kit tinham variações, eles iam para o carrinho sem SKU selecionado.

Exemplo real (loja demo 1011, DEMO-NICHO-kit-camisaria-masculina): três camisas, cada uma com has_variations: true e 5 tamanhos — não havia como escolher o tamanho de cada peça.

Junto com isso, a camada de dados tinha três defeitos:

  • Estoque do kit calculado ao contrárioloadKitItems pegava o maior floor(estoque / qnt) entre os itens em vez do menor, liberando mais kits do que os componentes permitem.
  • Item do kit se fundia com item avulsokit_product era gravado depois do addCartItem, então um componente já presente no carrinho era mesclado e passava a ser tratado como item de kit.
  • Frete calculado com o produto-kit, que normalmente não tem peso nem dimensões, em vez dos componentes reais.

Mudanças

use-product-card.ts

  • kitItemFields passa a trazer variations, visible, base_price e min_quantity
  • loadKitItems deduplica IDs, é idempotente e usa o mínimo entre os itens, respeitando kit_composition[].variation_id fixo
  • loadToCart aceita kitVariationIds, valida SKU e estoque item a item, agrupa produtos repetidos na composição e monta kit_product (com variation_id em composition) antes de inserir no carrinho

use-product-details.ts

  • expõe isKit, kitComposition, selectKitVariation e isLoadingKitItems
  • carrega os itens do kit no onMounted
  • isSkuSelected passa a exigir variação escolhida em cada slot do kit, reaproveitando o alerta já existente
  • shippedItems passa a ser a composição real do kit

KitComposition.vue (novo, em @@sf/components)

  • lista "Este kit contém" com foto, nome linkado, badge de quantidade, aviso de indisponível e skeletons durante o carregamento
  • slot variations para o tema injetar seu próprio seletor de SKU, com <select> como fallback

Tema

A renderização em si exige uma alteração correspondente em ecomplus/store (functions/ssr/src/components/ProductDetails.vue): incluir o <KitComposition> passando o SkuSelector do tema no slot, esconder o "Comprar agora" direto em kits (o link por product_id não explode a composição) e usar i19buyKit no CTA. Vai em PR separado.

Verificação

  • tsc --noEmit e ESLint limpos nos arquivos alterados
  • templates Vue compilam sem erro
  • dev server contra a loja demo 1011: /kit-camisaria-tres-basicas responde 200 com o bloco da composição renderizado, e uma página não-kit segue idêntica
  • API confirmada devolvendo as 5 variações com specifications de cada componente

Não deu para exercitar o clique/hidratação (escolher tamanho → adicionar ao carrinho) sem navegador — vale um teste manual antes do merge.

🤖 Generated with Claude Code

vitorrgg and others added 2 commits August 3, 2026 19:24
Kit products (`kit_composition`) were rendered like plain products: the
composition was never listed and kit items with variations went to cart
without a selected SKU.

- `use-product-details`: expose `isKit`, `kitComposition` and
  `selectKitVariation`, load kit items on mount and require a variation
  per composition slot before buying; shipping is now calculated with the
  kit items instead of the (weightless) kit product
- `use-product-card`: fetch `variations` for kit items, honor a fixed
  `kit_composition[].variation_id`, cap kit quantity by the least
  available item (was taking the max) and set `kit_product` before adding
  to cart, otherwise a matching standalone item on cart was merged into
  the kit
- new `KitComposition.vue` shared component listing each item with
  picture, link and quantity, with a `variations` slot for theme SKU
  selectors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Splits the kit branch into `matchKitItem`, `sumToKitComposition` and
`parseKitCartItems`, dropping `loadToCart` back under the complexity
threshold flagged by CodeFactor. No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <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.

Boa PR — a direção está certa e três das quatro correções eu consegui confirmar contra dado real, não só lendo o código:

  • A inversão maxmin no estoque do kit é a correção mais valiosa daqui. Simulei os dois algoritmos contra a API da tia sônia (loja 1024, 19 kits): o "Kit Amor que nutre" anunciava 289 unidades e o mínimo real dos componentes é 15; o "Kit com 5 Granolas" anunciava 726 contra 96 reais. Estávamos vendendo kit que não existe.
  • kit_product antes do addCartItem está certo e o motivo é exatamente o descrito: add-cart-item.ts:29 só entra no laço de merge quando !newItem.kit_product, então gravar depois realmente fundia o componente com o item avulso que já estava no carrinho.
  • shippedItems pela composição encaixa direitinho na peça que já existe — use-shipping-calculator.ts:169-184 já sabe buscar peso e dimensão por _id e aplicar a variação por variation_id, então o cálculo passa a usar o componente real sem precisar de nada novo do outro lado.
  • A extração do commit 2 deixou loadToCart legível de verdade. E o básico está conferido: AImg/ALink/Skeleton são globais registrados em pages/_vue.ts:3-6, i19selectVariation e i19outOfStock existem em packages/i18n, e variation_id dentro de kit_product.composition está previsto no schema (packages/api/types/carts.d.ts:207).

O que me preocupa está quase todo concentrado nos quatro campos novos do kitItemFields.


🔴 Bloqueante — o gate de visible derruba um kit que está à venda hoje na tia sônia

use-product-card.ts:64 adiciona visible ao kitItemFields e use-product-card.ts:108 passa a recusar o item:

if (!kitItem?.available || kitItem.visible === false) return null;

Fui atrás do dado na loja 1024:

KIT  PIC5533  "Kit Cookies Chips + Cartela de Adesivos"   /kit-cookies-chips
     visible: true   available: true   R$ 26,90   estoque para 40 kits
     └─ componente  RV0078  "Cartela de Adesivos – Cookies Chips"
        visible: FALSE   available: true   quantity: 219

É o padrão clássico: a cartela de adesivos é um brinde de R$2,90 que só existe dentro do kit, então está escondida do catálogo de propósito. Com essa PR o matchKitItem devolve null, o parseKitCartItems aborta, e o cliente que clicar em comprar recebe só o isFailedToCart genérico — sem nenhuma pista do que houve. Antes funcionava.

A tia sônia agrava o caso porque o ProductDetails.vue dela usa useProductCard direto (ProductDetails.vue:245), não o useProductDetails — ou seja, ela nem ganha a lista de composição pra dar contexto ao erro; só o botão que falha calado.

O ponto de fundo é que oculto do catálogo ≠ não vendável dentro do kit. Se a ideia era barrar componente desligado, available já cobre isso e já era checado antes da PR. Eu tiraria visible do gate. Se houver motivo pra mantê-lo, que seja pelo menos visible === false && available === false, e nunca sozinho.

🟠 min_quantity no kitItemFields só tem um consumidor, e é o errado

Os dois lugares que precisam de min_quantity no código novo o sobrescrevem — use-product-card.ts:153 e use-product-details.ts:102 passam a quantidade exigida pelo kit. Sobra um único consumidor real: o parseProduct, em parse-product.ts:24:

quantity: minQuantity > 0 ? Math.max(minQuantity, quantity) : quantity,

Antes o campo não vinha no fields, então isso era inerte. Agora, um componente com min_quantity: 6 numa composição que pede 1 unidade gera item de carrinho com quantity: 6, enquanto kit_product.composition[].quantity e pack_quantity seguem em 1 — o carrinho passa a exibir quantidade e subtotal inflados. No checkout o fix-items.ts:122 já derrubava esse item de qualquer jeito, então não é dinheiro perdido; é o cliente vendo um carrinho que não corresponde ao que vai ser cobrado.

Nenhum dos 30 componentes de kit da tia sônia tem min_quantity > 1 hoje, então não materializa lá — mas é latente pra qualquer loja. Eu simplesmente tiraria o campo do kitItemFields.

🟠 isSkuSelected responde "sim" enquanto os itens do kit ainda estão carregando

use-product-details.ts:116-118 decide pelo kitComposition, mas durante o carregamento todo slot tem product: nullvariations: []isSelected: true. Resultado: isSkuSelected fica true, o checkVariation deixa passar, o loadToCart espera o load e só então descobre que falta SKU — e devolve o mesmo isFailedToCart genérico, sem o alerta de campo pendente que existe justamente pra isso.

O isLoadingKitItems já está exposto pra resolver isso; falta consumi-lo no gate — isSkuSelected retornar false (ou o addToCart aguardar) enquanto o load não terminou.

🟠 O seletor de variação some quando a variação escolhida zera

KitComposition.vue:62-72 encadeia o aviso de esgotado e o seletor no mesmo v-if/v-else-if, e isInStock (use-product-details.ts:97-104) já leva a variação escolhida em conta. O caminho fica: cliente escolhe o tamanho P → P está sem estoque → o bloco vira "esgotado" e o <select> desaparece → não há como voltar e trocar por M. O kit inteiro fica travado numa escolha ruim.

Aviso e seletor precisam conviver, não se substituir.

🟠 O agrupamento de produto repetido monta um carrinho que o checkout rejeita

O corpo da PR lista "agrupa produtos repetidos na composição" como resolvido, mas o fix-items.ts não aceita o formato. O use-product-card.ts:158-163 funde por _id + variation_id; do outro lado, fix-items.ts:186-195 calcula packQuantity como a soma das quantidades da composição e fix-items.ts:214 valida kitTotalQuantity % (minPacks * packQuantity) === 0.

Para uma composição [A×1, A×1, B×1]: o cliente manda A=2 e B=1 (total 3); o servidor calcula packQuantity = 3 e minPacks = 2, cai em 3 % 6 ≠ 0 e remove todos os itens do kit (fix-items.ts:230-239).

Não é regressão — o código antigo também não passava nessa validação. Mas vale alinhar: o caso que de fato funciona é o mesmo produto com variações diferentes (aí as chaves não fundem e o servidor aceita), e a forma suportada de pedir duas unidades é uma entrada só com quantity: 2. Ou ajusta o texto da PR, ou trata de verdade — o que exigiria mexer no fix-items.ts junto.

🟠 Pré-existente, mas está exatamente na função que a PR reescreve: comprar N kits mostra o preço de 1

use-product-card.ts:152,156 acumula packQuantity += (comp.quantity || 1) * quantityToAdd, ou seja, já multiplicado pelo número de packs. Aí o shopping-cart.ts:156 faz final_price = kit_product.price / pack_quantity e divide pelo total inflado.

Comprando 2 kits de R$100 com composição [A×1, B×1]: pack_quantity = 4, final_price = 25, e o carrinho exibe R$100 em vez de R$200. O fix-items.ts:217,223 recalcula pack_quantity sem multiplicar pelos packs, então o pedido sai certo — a divergência é só no carrinho, mas é o cliente vendo um valor e pagando outro.

A aritmética é idêntica à do código antigo, então não é regressão desta PR. Só que a tia sônia tem 18 kits vivos com seletor de quantidade, e a correção aqui é não multiplicar quantityToAdd no packQuantity — uma linha, dentro de uma função que você já está reescrevendo. Se preferir deixar pra outra PR, tudo bem, mas vale abrir a issue.

🟢 Minors

  • use-product-card.ts:108 usa !kitItem?.available e use-product-details.ts:98 usa available !== false — mesma regra escrita de dois jeitos, e a UI pode marcar como disponível o que o carrinho recusa.
  • use-product-card.ts:144 — a mesma referência do array composition vai em todos os itens do kit e o add-cart-item.ts:48 só faz cópia rasa; mutar um mexe em todos.
  • use-product-card.ts:165Object.keys(...).map((key) => cartItemsByKey[key]) é Object.values(cartItemsByKey).map(...).
  • use-product-card.ts:90-96getKitItemStock só considera a variação quando a composição fixa uma; pra componente com variação livre usa o estoque agregado do pai, então o product.quantity do kit fica otimista.
  • use-product-card.ts:283 — o memo loadingKitItems nunca invalida no sucesso: enquanto o refetchStock atualiza o produto-kit, os componentes ficam com o estoque da primeira carga pelo resto da sessão.
  • use-product-details.ts:143 — o as Array<Record<string, any>> joga fora o tipo à toa; use-shipping-calculator.ts:51 já aceita (ShippedItem | CartOrProductItem)[].
  • use-product-details.ts:162 — o if (kitShippedItems.length) mantém o produto-kit no shippedItems quando o load falha, e o frete volta a ser calculado com o item sem peso, que é justamente o bug que a PR resolve.
  • KitComposition.vue:71 — o slot expõe { item, selectVariation } mas não hasSelectionAlert, então o tema não consegue reproduzir o destaque de campo pendente que o <select> de fallback tem.
  • KitComposition.vue:50target="_blank" sem rel="noopener".

Pra entrar antes do merge

  1. Tirar visible do gate do matchKitItem — é o único item que quebra loja em produção.
  2. Tirar min_quantity do kitItemFields — não tem consumidor útil e distorce o parseProduct.
  3. Gate de loading no isSkuSelected, e o seletor de variação convivendo com o aviso de esgotado no KitComposition.
  4. A PR do tema. Procurei em ecomplus/store e ela ainda não existe (só a #61 do renovate e a #26 do A/B). Sem ela, esta PR entrega só a validação mais rígida e nenhuma UI de seleção — e como todo ProductCard.vue chama loadToCart(1) sem kitVariationIds, kit com componente com variação fica sem caminho de compra em qualquer tema. As duas precisam subir juntas.

⚠️ Nota de deploy — tia sônia

A correção de estoque está certa, mas muda número em produção no dia que subir. Rodei os dois algoritmos sobre o catálogo da loja 1024: 16 dos 18 kits visíveis passam a anunciar bem menos estoque. Amostra (snapshot de hoje — são números vivos):

Kit Granola Premium com Chocolate   726 → 90     Kit Amor que nutre         289 → 15
Kit Clássico da Torcida             726 → 202    Kit Sabor que nutre        289 → 42
Kit com 5 Granolas - Linha Sabores  726 → 96     Kit Granola Zero Low Carb  432 → 118
Kit Granola Sabores / Mais Sabor    726 → 74     Kit Cookies Chips          219 → 40

Boa notícia: nenhum kit sai do ar. Os dois com componente zerado ("Kit mais Coquinho e Castanha" e "Kit Sabores do Brasil") já apareciam como 0 antes — o break em !kitItem.quantity do código antigo já os zerava. A mudança é só de teto de estoque, para menos, que é o ponto.

Ainda assim vale avisar a loja: quem olha o painel vai ver o estoque dos kits despencar de um dia pro outro.

A barra do céu (loja 3967) não tem nenhum produto com kit_composition — varri os 5.040 produtos. Está fora de risco, apesar do kit_composition no fields do SelectedProductsSection.astro:38.

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