fix(storefront): Show kit contents and pick variations on product page - #799
fix(storefront): Show kit contents and pick variations on product page#799vitorrgg wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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
max→minno 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_productantes doaddCartItemestá certo e o motivo é exatamente o descrito:add-cart-item.ts:29só 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.shippedItemspela composição encaixa direitinho na peça que já existe —use-shipping-calculator.ts:169-184já sabe buscar peso e dimensão por_ide aplicar a variação porvariation_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
loadToCartlegível de verdade. E o básico está conferido:AImg/ALink/Skeletonsão globais registrados empages/_vue.ts:3-6,i19selectVariationei19outOfStockexistem empackages/i18n, evariation_iddentro dekit_product.compositionestá 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: null → variations: [] → 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:108usa!kitItem?.availableeuse-product-details.ts:98usaavailable !== 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 arraycompositionvai em todos os itens do kit e oadd-cart-item.ts:48só faz cópia rasa; mutar um mexe em todos.use-product-card.ts:165—Object.keys(...).map((key) => cartItemsByKey[key])éObject.values(cartItemsByKey).map(...).use-product-card.ts:90-96—getKitItemStocksó considera a variação quando a composição fixa uma; pra componente com variação livre usa o estoque agregado do pai, então oproduct.quantitydo kit fica otimista.use-product-card.ts:283— o memoloadingKitItemsnunca invalida no sucesso: enquanto orefetchStockatualiza o produto-kit, os componentes ficam com o estoque da primeira carga pelo resto da sessão.use-product-details.ts:143— oas Array<Record<string, any>>joga fora o tipo à toa;use-shipping-calculator.ts:51já aceita(ShippedItem | CartOrProductItem)[].use-product-details.ts:162— oif (kitShippedItems.length)mantém o produto-kit noshippedItemsquando 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ãohasSelectionAlert, então o tema não consegue reproduzir o destaque de campo pendente que o<select>de fallback tem.KitComposition.vue:50—target="_blank"semrel="noopener".
Pra entrar antes do merge
- Tirar
visibledo gate domatchKitItem— é o único item que quebra loja em produção. - Tirar
min_quantitydokitItemFields— não tem consumidor útil e distorce oparseProduct. - Gate de loading no
isSkuSelected, e o seletor de variação convivendo com o aviso de esgotado noKitComposition. - A PR do tema. Procurei em
ecomplus/storee 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 todoProductCard.vuechamaloadToCart(1)semkitVariationIds, 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.
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 comhas_variations: truee 5 tamanhos — não havia como escolher o tamanho de cada peça.Junto com isso, a camada de dados tinha três defeitos:
loadKitItemspegava o maiorfloor(estoque / qnt)entre os itens em vez do menor, liberando mais kits do que os componentes permitem.kit_productera gravado depois doaddCartItem, então um componente já presente no carrinho era mesclado e passava a ser tratado como item de kit.Mudanças
use-product-card.tskitItemFieldspassa a trazervariations,visible,base_priceemin_quantityloadKitItemsdeduplica IDs, é idempotente e usa o mínimo entre os itens, respeitandokit_composition[].variation_idfixoloadToCartaceitakitVariationIds, valida SKU e estoque item a item, agrupa produtos repetidos na composição e montakit_product(comvariation_idemcomposition) antes de inserir no carrinhouse-product-details.tsisKit,kitComposition,selectKitVariationeisLoadingKitItemsonMountedisSkuSelectedpassa a exigir variação escolhida em cada slot do kit, reaproveitando o alerta já existenteshippedItemspassa a ser a composição real do kitKitComposition.vue(novo, em@@sf/components)variationspara o tema injetar seu próprio seletor de SKU, com<select>como fallbackTema
A renderização em si exige uma alteração correspondente em
ecomplus/store(functions/ssr/src/components/ProductDetails.vue): incluir o<KitComposition>passando oSkuSelectordo tema no slot, esconder o "Comprar agora" direto em kits (o link porproduct_idnão explode a composição) e usari19buyKitno CTA. Vai em PR separado.Verificação
tsc --noEmite ESLint limpos nos arquivos alterados/kit-camisaria-tres-basicasresponde 200 com o bloco da composição renderizado, e uma página não-kit segue idênticaspecificationsde cada componenteNã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