Un carrito de compra es un problema clásico de gestión de estado. El estado del carrito debe compartirse entre varias páginas (lista de productos, cajón del carrito, checkout), persistir al recargar la página y actualizarse desde distintos lugares. Un store de Pinia es la solución natural.
Store del carrito
ts
// stores/cart.ts
interface CartItem {
id: string
name: string
price: number
image: string
qty: number
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.qty, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
function addItem(product: Omit<CartItem, 'qty'>, qty = 1) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.qty += qty
} else {
items.value.push({ ...product, qty })
}
}
function removeItem(id: string) {
items.value = items.value.filter(i => i.id !== id)
}
function updateQty(id: string, qty: number) {
const item = items.value.find(i => i.id === id)
if (!item) return
if (qty <= 0) {
removeItem(id)
} else {
item.qty = qty
}
}
function clear() {
items.value = []
}
return { items, totalItems, totalPrice, addItem, removeItem, updateQty, clear }
})Lista de productos (añadir al carrito)
vue
<!-- components/ProductCard.vue -->
<script setup lang="ts">
const props = defineProps<{
id: string
name: string
price: number
image: string
}>()
const cart = useCartStore()
function addToCart() {
cart.addItem({
id: props.id,
name: props.name,
price: props.price,
image: props.image
})
}
</script>
<template>
<div class="product-card">
<img :src="image" :alt="name" />
<h3>{{ name }}</h3>
<p>{{ price.toFixed(2) }} €</p>
<button @click="addToCart">Add to cart</button>
</div>
</template>Cajón del carrito (ver y editar)
vue
<!-- components/CartDrawer.vue -->
<script setup lang="ts">
const cart = useCartStore()
</script>
<template>
<aside class="cart-drawer">
<h2>Cart ({{ cart.totalItems }})</h2>
<p v-if="!cart.items.length">Your cart is empty.</p>
<div v-for="item in cart.items" :key="item.id" class="cart-item">
<img :src="item.image" :alt="item.name" />
<div>
<p>{{ item.name }}</p>
<p>{{ item.price.toFixed(2) }} € × {{ item.qty }}</p>
</div>
<div class="qty-controls">
<button @click="cart.updateQty(item.id, item.qty - 1)">−</button>
<span>{{ item.qty }}</span>
<button @click="cart.updateQty(item.id, item.qty + 1)">+</button>
</div>
<button @click="cart.removeItem(item.id)">Remove</button>
</div>
<div v-if="cart.items.length" class="cart-total">
<strong>Total: {{ cart.totalPrice.toFixed(2) }} €</strong>
<NuxtLink to="/checkout">Checkout</NuxtLink>
</div>
</aside>
</template>Persistencia con un plugin de Pinia
El carrito desaparece al recargar la página sin persistencia. Usar pinia-plugin-persistedstate o un simple $subscribe:
ts
// plugins/cart-persist.ts
const cart = useCartStore()
if (import.meta.client) {
const saved = localStorage.getItem('cart')
if (saved) {
cart.$patch({ items: JSON.parse(saved) })
}
cart.$subscribe((_, state) => {
localStorage.setItem('cart', JSON.stringify(state.items))
})
}O con el plugin:
bash
npm install pinia-plugin-persistedstatets
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
// ... igual que antes
}, {
persist: true
})Badge del carrito en la cabecera
vue
<!-- components/CartBadge.vue -->
<script setup>
const cart = useCartStore()
</script>
<template>
<button class="cart-icon">
🛒
<span v-if="cart.totalItems" class="badge">{{ cart.totalItems }}</span>
</button>
</template>El badge se actualiza reactivamente desde cualquier página porque todos los componentes comparten la misma instancia del store de Pinia.
Flujo de checkout
vue
<!-- pages/checkout.vue -->
<script setup>
const cart = useCartStore()
const isSubmitting = ref(false)
async function placeOrder() {
isSubmitting.value = true
try {
await $fetch('/api/orders', {
method: 'POST',
body: {
items: cart.items.map(i => ({ id: i.id, qty: i.qty }))
}
})
cart.clear()
navigateTo('/order-confirmation')
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div>
<h1>Checkout</h1>
<div v-for="item in cart.items" :key="item.id">
{{ item.name }} × {{ item.qty }} — {{ (item.price * item.qty).toFixed(2) }} €
</div>
<p><strong>Total: {{ cart.totalPrice.toFixed(2) }} €</strong></p>
<button :disabled="isSubmitting || !cart.items.length" @click="placeOrder">
Place order
</button>
</div>
</template>Resumen de la arquitectura
ProductCard → cart.addItem()
CartDrawer → cart.items, cart.updateQty(), cart.removeItem()
CartBadge → cart.totalItems
Checkout → cart.items, cart.clear()
↓
Store Pinia (fuente única de verdad)
↓
localStorage (plugin de persistencia)