Sistema completo de sincronização, recuperação e limpeza de carrinhos abandonados em ambientes VTEX IO.
Este módulo implementa:
- Persistência de sessões de carrinho no Master Data v1
- Recuperação inteligente de carrinhos anteriores em múltiplas sessões
- Sugestão de recuperação com modal na loja (Store Framework)
- Sincronização após pedido realizado para evitar itens duplicados
- Padronização de payload via Base64 (btoa/atob)
- Validação estruturada com Zod
- Compatível com Loja (Store) e Checkout (v5/v6)
📌 Sumário
- 🛒 Abandoned Cart Sync – VTEX IO
- 📌 Sumário
- 🎯 Objetivo
- 🧱 Arquitetura
- 🔥 Endpoints da API
- 🔐 Formato do Payload (Base64)
- 📏 Esquemas (Zod)
- 🔄 Fluxo funcional
- 🧩 Componentes React da loja
- 🔒 Considerações de segurança
- 📄 Licença
🎯 Objetivo
Este projeto existe para resolver um problema comum em e-commerces com múltiplas sessões:
O cliente começa um carrinho em um dispositivo e continua em outro → carrinhos ficam divergentes.
Este módulo fornece:
- Persistência confiável das sessões de carrinho
- Recuperação automática das sessões anteriores
- Sincronização pós-pedido removendo itens já comprados
- Modal reativa para sugerir a recuperação do carrinho
🧱 Arquitetura
_10[Loja / Store Framework] --\_10[Checkout v5/v6] -----------> [Middleware VTEX IO] --> [Master Data v1]_10 |_10 --> Persistência de carrinhos_10 --> Limpeza pós-compra_10 --> Recuperação de itens
🔥 Endpoints da API
Todos os endpoints utilizam payload Base64 via
{ "data": "<base64>" }.
POST /cart-sync/upsertAbandonedCart
Cria ou atualiza um registro de carrinho abandonado.
Request
Headers
_10Content-Type: application/json
Body
_10{_10 "data": "BASE64(JSON)"_10}
JSON interno (antes do btoa):
_12{_12 "orderFormId": "string >= 5 chars",_12 "email": "user@example.com",_12 "items": [_12 {_12 "skuId": "string",_12 "productId": "string",_12 "quantity": 1,_12 "sellerId": "1"_12 }_12 ]_12}
orderFormId(string): ID do orderForm da VTEX.email(string, email válido): e-mail do cliente.items(array): lista de itens do carrinho.
Cada item (AbandonedCartItem):
skuId(string, obrigatório)productId(string, obrigatório)quantity(int > 0, obrigatório)sellerId(string, opcional, default"1")
Validação
No backend:
dataé decodificado:parseJSONFromAtob(body.data).- Validado com
zodviaupsertSchema:orderFormId:z.string().min(5)email:z.string().email()items:z.array(itemSchema)
Se falhar:
_10HTTP 400_10{_10 "error": "Invalid payload",_10 "details": "<mensagem de erro do Zod>"_10}
Comportamento
- Busca um documento existente por
orderFormIdemAC(Master Data v1). - Se não existe:
- Cria um novo documento:
orderFormIdemailitemsupdatedDate= agora (ISO)completed=false
- Retorna
201com o documento criado.
- Cria um novo documento:
- Se já existe:
- Atualiza:
emailitemsupdatedDate
- Retorna
200com{ id, updatedDate }.
- Atualiza:
POST /cart-sync/getAbandonedCarts
Retorna carrinhos abertos para um email, exceto o carrinho da sessão atual.
Request
Headers
_10Content-Type: application/json
Body
_10{_10 "data": "BASE64(JSON)"_10}
JSON interno:
_10{_10 "orderFormId": "string >= 5 chars",_10 "email": "user@example.com"_10}
Response
_15[_15 {_15 "id": "a8a61e91-2ebc-4914-801d-1eac530fa2e1",_15 "orderFormId": "5da8cd1cc9704c98b762ee0767fa7a66",_15 "items": [_15 {_15 "skuId": "40184",_15 "productId": "7763",_15 "quantity": 1,_15 "sellerId": "1",_15 "scId": "1"_15 }_15 ]_15 }_15]
Validação
- Decode via
parseJSONFromAtob. - Validação com
getAbandonedCartsSchema(zod):orderFormId:z.string().min(5)email:z.string().email()
Erro:
_10HTTP 400_10{_10 "error": "Invalid payload",_10 "details": "<mensagem do Zod>"_10}
Comportamento
- Normaliza o e-mail:
email.trim(). - Define uma janela de consulta (por exemplo
days = 7). - Chama
md.searchOpenWithinDays(email, orderFormId, days):completed = falseupdatedDateentrenow - daysenoworderFormId \<\> <orderFormId atual>
- Retorna
200com array deAbandonedCart:
_11HTTP 200_11[_11 {_11 "id": "string",_11 "orderFormId": "string",_11 "email": "user@example.com",_11 "items": [ ... ],_11 "updatedDate": "2025-11-14T12:34:56.789Z",_11 "completed": false_11 }_11]
POST /cart-sync/setAbandonedCartCompleted
Usado quando um pedido é finalizado (orderPlaced).
Remove itens dos carrinhos antigos e marca como completed quando esvaziados.
Request
Headers
_10Content-Type: application/json
Body
_10{_10 "data": "BASE64(JSON)"_10}
JSON interno:
_11{_11 "email": "user@example.com",_11 "items": [_11 {_11 "productId": "string",_11 "skuId": "string",_11 "quantity": 2,_11 "sellerId": "1"_11 }_11 ]_11}
orderFormIdpode existir no payload (opcional), mas não é obrigatório na abordagem atual.
Validação
Schema setAbandonedCompletedSchema (Zod):
_10const setAbandonedCompletedSchema = z.object({_10 orderFormId: z.string().optional(),_10 email: z.string().email(),_10 items: z.array(itemSchema).nonempty('At least one item is required'),_10})
Erros de parse/validação → 400:
_10{_10 "error": "Invalid payload",_10 "details": "<mensagem do Zod>"_10}
Comportamento
-
Normaliza o e-mail:
email.trim(). -
Monta mapa
purchasedQtyBySku(quantidades compradas por SKU):_10const purchasedQtyBySku: Record<string, number> = {}_10_10for (const item of purchasedItems) {_10const skuKey = String(item.skuId)_10purchasedQtyBySku[skuKey] =_10(purchasedQtyBySku[skuKey] || 0) + Number(item.quantity || 0)_10} -
Busca todos os carrinhos abertos para aquele e-mail:
openDocs = md.searchOpenWithinDays(email, undefined, 30)
-
Normaliza os
itemsde cada doc (caso venham como string do Master Data):_15const mdItemsSchema = z.preprocess((value) => {_15if (typeof value === 'string') {_15try {_15return JSON.parse(value)_15} catch {_15return []_15}_15}_15return value_15}, itemsSchema)_15_15openDocs = openDocs.map(openDoc => ({_15...openDoc,_15items: mdItemsSchema.parse(openDoc.items || []),_15})) -
Para cada doc:
- Itera sobre
originalItems. - Se
skunão foi comprado → item permanece. - Se
quantitycomprada for maior/igual → remove item. - Se for menor → reduz
quantityno carrinho. - Se não sobra nenhum item →
completed = true. - Se sobra algo → atualiza
itemscom a nova lista.
- Itera sobre
-
Resposta:
_10HTTP 200_10{_10 "message": "Order completion sync executed (by email/items)",_10}
🔐 Formato do Payload (Base64)
Todos os endpoints devem receber:
_10{_10 "data": "BASE64(JSON_STRING)"_10}
Exemplo de envio no front:
_10const body = {_10 data: btoa(JSON.stringify(payload)),_10}_10_10await fetch('/cart-sync/upsertAbandonedCart', {_10 method: 'POST',_10 headers: { 'Content-Type': 'application/json' },_10 body: JSON.stringify(body),_10})
Decodificação no backend:
_10parseJSONFromAtob(body.data)_10// = atob(base64) -> JSON.parse(...)
Motivos:
- Evitar logs legíveis de dados sensíveis
- Padronizar entrada
- Evitar erros quando o payload contém caracteres especiais
📏 Esquemas (Zod)
itemSchema
_10const itemSchema = z.object({_10 skuId: z.string().min(1),_10 productId: z.string().min(1),_10 quantity: z.number().int().positive(),_10 sellerId: z.string().optional().default('1'),_10})
upsertSchema
_10const upsertSchema = z.object({_10 orderFormId: z.string().min(5),_10 email: z.string().email(),_10 items: z.array(itemSchema),_10})
getAbandonedCartsSchema
_10const getAbandonedCartsSchema = z.object({_10 orderFormId: z.string().min(5),_10 email: z.string().email(),_10})
setAbandonedCompletedSchema
_10const setAbandonedCompletedSchema = z.object({_10 orderFormId: z.string().optional(),_10 email: z.string().email(),_10 items: z.array(itemSchema).nonempty('At least one item is required'),_10})
🔄 Fluxo funcional
1. Cliente navega na loja
O componente AbandonedCartSync roda na loja inteira (exceto checkout) e:
- observa mudanças no
orderForm(useOrderForm) - envia
orderFormId,email,items→/cart-sync/upsertAbandonedCart
2. Cliente volta em outra sessão
O componente AbandonedCartRetriever:
- consulta
/cart-sync/getAbandonedCarts - compara
itemsantigos vs carrinho atual - busca detalhes no catálogo (
/api/catalog_system/pub/products/search) - mostra uma modal com cards de produtos para recuperação
- adiciona os itens ao carrinho via
addItemsdovtex.order-manager
3. Cliente finaliza pedido
Na página de orderPlaced:
- o componente
AbandonedCartSyncCleanUp:- coleta
emaileitemsdo pedido (viavtex.order-placed) - envia payload →
/cart-sync/setAbandonedCartCompleted
- coleta
4. Backend limpa carrinhos antigos
- Remove quantidades compradas dos carrinhos abertos do mesmo email
- Se um carrinho ficar sem itens →
completed = true - Se ainda sobraram itens → carrinho permanece aberto, com
itemsajustados
🧩 Componentes React da loja
AbandonedCartSync
- Executado na loja (Store Framework), exceto em rotas de checkout:
- Detecta rota via
useRuntime+ fallback emwindow.location.
- Detecta rota via
- Usa
useOrderFormpara obter:orderFormIdemailitemssalesChannel
- Envia updates para
/cart-sync/upsertAbandonedCart.
AbandonedCartRetriever
- Executado na loja.
- Usa
useOrderForm:- Pega carrinho atual
- Busca carrinhos antigos via
/cart-sync/getAbandonedCarts - Exibe modal com:
- imagem do produto
- preço de/por
- quantidade
- Ao clicar em “Sim, adicionar”:
- Usa
addItemsdovtex.order-managerpara adicionar ao carrinho atual.
- Usa
- Ao clicar em “Não, obrigado”:
- Marca recusa em
sessionStoragepara não exibir novamente na mesma sessão.
- Marca recusa em
AbandonedCartSyncCleanUp
- Executado apenas na página
orderPlaced(store.orderplaced). - Usa
OrderGroupContextdevtex.order-placed:- obtém
ordersdo grupo - extrai
emaileitems
- obtém
- Envia payload para
/cart-sync/setAbandonedCartCompleted.
🔒 Considerações de segurança
- Base64 não é criptografia, mas:
- reduz risco de log de dados sensíveis em texto puro.
- evita problemas com caracteres especiais em logs/transporte.
emaile itens são validados com Zod:- garante tipos corretos
- evita payload malformado
- Itens provenientes do Master Data:
- podem vir como
string(JSON serializado) ou como array. - são sempre normalizados e validados com Zod antes de uso.
- podem vir como
📄 Licença
MIT — Sinta-se livre para adaptar, expandir ou reutilizar este módulo em outros projetos VTEX IO.