Recibe hasta $4,000 de bonificación al canjear tu celular por un Xiaomi nuevo.





Cámara Leica Summilux, Snapdragon 8 Elite y carga HyperCharge de 90 W.

Del 1 al 31 de agosto: al comprar un equipo participante, agrega audífonos o banda por casi nada. Hasta 18 meses sin intereses y envío gratis desde $499.
En la compra de POCO M8 5G (8+512 GB), POCO Pad C1 (4+64 GB) o Redmi Pad 2 (8+256 GB).
En la compra de POCO M8 Pro 5G (8+256 GB). Disponible en negro y blanco.
Válido del 1 al 31 de agosto de 2026 o hasta agotar existencias. Limitado a una promoción por cliente y por ticket de compra. No acumulable con otros descuentos, promociones ni cupones vigentes. No canjeable por dinero en efectivo. Consulta modelos y colores participantes en tienda. Imágenes con fines ilustrativos.
Hasta 18 meses sin intereses con tarjetas participantes, o compra sin tarjeta con Kueski Pay y Mercado Pago. Sin comisión por apertura.

Fotografía Leica de nivel profesional. Una obra de ingeniería que cabe en tu bolsillo.
Aparta con $500 y obtén Xiaomi Buds 6 de regalo. Cargo restante al enviar.
48 resultados · precios con IVA · envío gratis desde $499
Revisa tu conexión e intenta de nuevo. Si el problema continúa, escríbenos por WhatsApp y lo resolvemos contigo.
Código NET_TIMEOUT · reintento automático 2 veces antes de mostrar estoPrueba quitando el rango de precio o el almacenamiento. Estos son los más buscados esta semana:
3 productos · los precios incluyen IVA

Válido con tu Xiaomi 15 · promoción Regreso a clases hasta el 31 de agosto.
Tu pedido MX-48219 está confirmado. Te enviamos el comprobante a ana.mendez@correo.com y te avisaremos por WhatsApp en cada paso.
Todo lo de esta hoja ya vive en las pantallas del prototipo. Aquí queda fijado el comportamiento: qué lo dispara, cuánto dura, dónde vive el estado y qué componente lo pinta. Los overlays y el stack de avisos se montan una sola vez en App.vue y se controlan desde un store de Pinia — nunca dentro de la pantalla que los invoca.
// stores/notifications.js — una sola fuente de verdad para los avisos
export const useNotifications = defineStore('notifications', () => {
const DUR = { ok: 4000, info: 3500, err: 0, load: 0 } // 0 = cierre manual
const items = ref([])
function push(type, title, body, opts = {}) {
const id = crypto.randomUUID()
items.value = [...items.value, { id, type, title, body, ...opts }].slice(-3)
const ms = opts.timeout ?? DUR[type]
if (ms) setTimeout(() => close(id), ms)
return id // úsalo para reemplazar el 'load'
}
const close = id => { items.value = items.value.filter(t => t.id !== id) }
return { items, push, close,
success: (t, b, o) => push('ok', t, b, o),
error: (t, b, o) => push('err', t, b, o),
info: (t, b, o) => push('info', t, b, o),
loading: (t, b) => push('load', t, b) }
})
// uso desde cualquier componente
const notify = useNotifications()
notify.success('Agregado al carrito', producto.nombre, { action: 'Ver carrito', to: '/carrito' })
{{ o.when }}
// stores/ui.js — modales, drawers y hojas viven aquí, no en la pantalla
export const useUi = defineStore('ui', () => {
const overlay = ref(null) // 'cart' | 'login' | 'compare' | 'confirm' | null
const payload = ref(null) // el producto, el pedido, lo que necesite el overlay
const open = (name, data = null) => { overlay.value = name; payload.value = data }
const close = () => { overlay.value = null; payload.value = null }
return { overlay, payload, open, close }
})
// App.vue — montados UNA vez al final del layout, después del router-view
<RouterView />
<ToastStack /> // lee useNotifications()
<CartDrawer /> // v-if="ui.overlay === 'cart'"
<AppDialog /> // resuelve login | compare | confirm según ui.overlay
// bloqueo de scroll y cierre por teclado, una sola vez
const { overlay, close } = useUi()
watch(overlay, v => useScrollLock(document.body).value = !!v)
useEventListener(document, 'keydown', e => e.key === 'Escape' && close())
// composables/useProductos.js — el patrón para toda petición de datos
const { state: productos, isLoading, error, execute } = useAsyncState(
() => api.get('/productos', { params: filtros }), [], { immediate: false, resetOnExecute: false }
)
// en la plantilla, en este orden y sin excepciones:
// isLoading && !productos.length → <SkeletonGrid :count="6" />
// error → <ErrorBlock @retry="execute()" />
// !productos.length → <EmptyState @clear="filtros.reset()" />
// resto → <ProductGrid :items="productos" />
Hoy solo hay tema claro. Aun así, cada color se lee como variable en :root y ningún componente escribe un hex: cambiar la campaña (Regreso a clases, El Buen Fin) o agregar tema oscuro después es sustituir un bloque de variables, no tocar componentes.
/* theme.css — el único lugar con valores literales */
:root[data-theme="light"] {
--color-bg: #f5ead8; --color-surface: #ebddc5;
--color-brand: #ff6900; --color-accent: #c67139;
--color-accent-2: #7a8a5e; --color-danger: oklch(.56 .17 27);
}
/* campaña: solo cambia el acento, nada más */
:root[data-campaign="buen-fin"] { --color-accent: #b23a1f; }
// useTheme.js
const theme = useLocalStorage('xm.theme', 'light')
watchEffect(() => document.documentElement.dataset.theme = theme.value)
Mismo store, misma lógica, otra piel: el mega-menú se vuelve drawer, los filtros una hoja inferior, la barra de compra queda fija y los avisos suben al borde superior. Los toques nunca bajan de 44 px.







// un solo store, dos pieles — nunca duplicar lógica
const { smaller } = useBreakpoints({ sm:640, md:768, lg:1024, xl:1280 })
const esMovil = smaller('md')
// el componente cambia, los datos no
const Filtros = computed(() => esMovil.value ? FiltrosSheet : FiltrosSidebar)
const cart = useCartStore() // el mismo store en ambos
// el carrito sobrevive recargas y pestañas
const items = useLocalStorage('xm.cart.v1', [])
// posición de los avisos según viewport
const pos = computed(() => esMovil.value ? 'top-full' : 'bottom-right')
Todo lo que este prototipo demuestra, aquí queda escrito como código de referencia: la estructura de carpetas, el archivo de tema, los cuatro stores, los composables y el inventario de componentes. No es una propuesta abierta: es el contrato. Si algo no aparece en esta hoja, se decide con diseño antes de escribirlo.
// main.js — todo el arranque cabe aquí
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import './assets/theme.css'
import App from './App.vue'
createApp(App).use(createPinia()).use(router).mount('#app')
// router/index.js — carga diferida por vista y guardia solo donde hace falta
const routes = [
{ path: '/', component: () => import('@/views/HomeView.vue') },
{ path: '/c/:categoria', component: () => import('@/views/CatalogView.vue'), props: true },
{ path: '/p/:slug', component: () => import('@/views/ProductView.vue'), props: true },
{ path: '/carrito', component: () => import('@/views/CartView.vue') },
{ path: '/checkout', component: () => import('@/views/CheckoutView.vue'), meta: { auth: true } },
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFoundView.vue') }
]
router.beforeEach(to => {
if (!to.meta.auth) return true
const user = useUser(); if (user.isLogged) return true
useUi().open('login', { then: to.fullPath }) // el modal decide, no una redirección
return false
})
// los filtros del catálogo viven en la URL: /c/celulares?marca=poco&precio=5000-10000&orden=precio-asc
/* assets/theme.css */
:root {
/* superficie y texto */
--color-bg: #f5ead8; --color-surface: #ebddc5;
--color-text: #201e1d; --color-divider: #ded0b6;
--color-neutral-300: #cdbfa4; --color-neutral-500: #9a8e79;
--color-neutral-600: #7d7362; --color-neutral-800: #4a443a;
/* marca y acentos */
--color-brand: #ff6900; --color-brand-600: #e65f00;
--color-brand-700: #cc5400;
--color-accent: #c67139; --color-accent-700: #8c491a;
--color-accent-2: #7a8a5e; --color-accent-2-700: #55613f;
/* estado */
--color-danger: oklch(.56 .17 27);
--color-danger-100: oklch(.945 .04 27);
--color-danger-800: oklch(.42 .13 27);
/* tipografía */
--font-heading: "MiSans Latin VF", Rubik, system-ui, sans-serif;
--font-body: Rubik, system-ui, sans-serif;
/* espacio, radio, capas, tiempo */
--space-1: 4px; --space-2: 9px; --space-3: 13px;
--space-4: 18px; --space-5: 26px; --space-6: 35px;
--radius-sm: 12px; --radius-md: 18px; --radius-lg: 26px; --radius-pill: 999px;
--z-scrim: 40; --z-drawer: 41; --z-dialog: 42; --z-toast: 44;
--dur-fast: .16s; --dur-base: .26s; --dur-slow: .34s;
/* relieve: las tres únicas sombras del sistema */
--neo-out: 6px 6px 14px #cdbfa4, -6px -6px 14px #fffdf4;
--neo-in: inset 5px 5px 11px #cdbfa4, inset -5px -5px 11px #fffdf4;
--clay: 0 16px 28px rgba(140,73,26,.16),
inset 0 8px 13px rgba(255,255,255,.7),
inset 0 -10px 15px rgba(140,73,26,.14);
}
/* campaña: se sustituye un bloque, no un componente */
:root[data-campaign="buen-fin"] { --color-accent: #b23a1f; }
/* accesibilidad: obligatorio en todo lo interactivo */
:where(button, a, input, select, [tabindex]):focus-visible {
outline: 2px solid var(--color-accent); outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .01ms !important; transition-duration: .01ms !important }
}
No entra en esta entrega, pero el camino ya está abierto: el relieve claro depende de dos sombras (una oscura y una clara). En oscuro se invierten y el resto del sistema no se toca.
:root[data-theme="dark"] {
--color-bg: #21201d; --color-surface: #2b2926;
--color-text: #f2ece1;
--neo-out: 6px 6px 14px #141312, -6px -6px 14px #2e2c28;
--neo-in: inset 5px 5px 11px #141312, inset -5px -5px 11px #2e2c28;
}
// stores/cart.js — el store más usado de la tienda
export const useCart = defineStore('cart', () => {
const lines = useLocalStorage('xm.cart.v1', []) // sobrevive recarga y pestañas
const notify = useNotifications()
const count = computed(() => lines.value.reduce((n, l) => n + l.qty, 0))
const subtotal = computed(() => lines.value.reduce((n, l) => n + l.precio * l.qty, 0))
const descuento = computed(() => lines.value.reduce((n, l) => n + (l.antes - l.precio) * l.qty, 0))
const envio = computed(() => subtotal.value >= 499 ? 0 : 149)
const total = computed(() => subtotal.value + envio.value)
function add(producto, variante, qty = 1) {
const key = `${producto.sku}:${variante.id}`
const hit = lines.value.find(l => l.key === key)
hit ? (hit.qty += qty) : lines.value.push({ key, qty, ...producto, ...variante })
notify.success('Agregado al carrito', `${producto.nombre} · ${variante.nombre}`,
{ action: 'Ver carrito', run: () => useUi().open('cart') })
}
// optimista: la UI cambia ya y se revierte con aviso si el servidor dice no
async function setQty(key, qty) {
const line = lines.value.find(l => l.key === key)
const antes = line.qty
line.qty = Math.max(1, Math.min(qty, line.maxPorPedido ?? 5))
try { await api.patch(`/carrito/${key}`, { qty: line.qty }) }
catch { line.qty = antes; notify.error('No pudimos actualizar la cantidad', 'Vuelve a intentarlo.') }
}
function remove(key) { // siempre con Deshacer, nunca en silencio
const i = lines.value.findIndex(l => l.key === key)
const [fuera] = lines.value.splice(i, 1)
notify.info('Producto eliminado', 'Lo guardamos 30 días en “Guardados”.',
{ timeout: 6000, action: 'Deshacer', run: () => lines.value.splice(i, 0, fuera) })
}
return { lines, count, subtotal, descuento, envio, total, add, setQty, remove }
})
// composables/useMoney.js — todo precio de la tienda pasa por aquí
const mxn = new Intl.NumberFormat('es-MX',
{ style: 'currency', currency: 'MXN', maximumFractionDigits: 0 })
export function useMoney() {
const precio = v => mxn.format(v) // $19,999
const msi = (v, meses) => `${meses} MSI de ${mxn.format(Math.round(v / meses))}`
const ahorro = (antes, hoy) => Math.round((1 - hoy / antes) * 100) + '%'
return { precio, msi, ahorro }
}
// composables/useBreakpoints.js — un solo corte real: md
export const bp = useBreakpoints({ sm: 640, md: 768, lg: 1024, xl: 1280 })
export const esMovil = bp.smaller('md')
// composables/useLocalStorage.js — reexporta VueUse con prefijo y versión
export const useLocalStorage = (key, val) =>
vueUseLocalStorage(`xm.${key}`, val, { mergeDefaults: true })
// composables/useAsyncState.js — el patrón de TODA petición
export function useAsyncState(fn, inicial, { immediate = true, retries = 2 } = {}) {
const state = ref(inicial)
const isLoading = ref(false)
const error = ref(null)
async function execute(intento = 0) {
isLoading.value = true; error.value = null
try { state.value = await fn() }
catch (e) {
if (intento < retries) { // espera creciente antes de rendirse
await new Promise(r => setTimeout(r, 400 * 2 ** intento))
return execute(intento + 1)
}
error.value = e
}
finally { isLoading.value = false }
}
if (immediate) execute()
return { state, isLoading, error, execute }
}
// en la vista, este orden exacto y sin excepciones
<SkeletonGrid v-if="isLoading && !items.length" :count="6" />
<ErrorBlock v-else-if="error" @retry="execute()" />
<EmptyState v-else-if="!items.length" @clear="filtros.reset()" />
<ProductGrid v-else :items="items" />
El tema no entrega componentes: entrega clases y variables. Por eso no se declara un componente por cada elemento visible — se declara solo cuando hay comportamiento, estado o composición que repetir. Un botón sí (variantes, carga, deshabilitado); una etiqueta no (es .tag y ya).
Existe como clase en el sistema. No se declara componente: se usa la clase y se acabó.
La clase existe, pero hay estado o variantes que repetir. Un componente delgado sobre la clase, sin restilizarla.
El tema no lo cubre. Es lo que de verdad hay que diseñar y programar en esta tienda.
Tema oscuro y multi-campaña (hay ruta, no hay valores aprobados) · recuperación del checkout cuando el pago se rechaza dos veces · estados de pedido y devoluciones en Mi cuenta.
Guarda favoritos, sigue tus pedidos y acumula puntos Mi.
| Característica | Xiaomi 15 | Xiaomi 17T Pro | POCO X8 Pro |
|---|---|---|---|
| {{ r.k }} | {{ r.a }} | {{ r.b }} | {{ r.c }} |
Podrás recuperarlo desde “Guardados” durante 30 días.
El plazo se elige al pagar y lo aplica tu banco. Solo tarjetas de crédito participantes; el monto mínimo para 18 y 24 meses es de $6,000. Kueski Pay no requiere tarjeta y se cobra por quincena.