Skip to Content
Envío gratis desde $499 · Hasta 18 MSI · Garantía oficial 12 meses Inicio Catálogo Producto Carrito Checkout Promos Lanzamiento Estados Móvil Entrega
m
Xiaomi
{{ c.label }}
{{ col.t }}
{{ it }} Ver todo →
{{ megaFKicker }} {{ megaFDisc }}
{{ megaFName }}
{{ megaFPrice }}
Xiaomi 17T Pro
Xiaomi 17T Pro · co-engineered with Leica
Telefoto Máster
Regalos: REDMI Pad 2 9.7 + Xiaomi Buds 6
Desde$20,999.00
Comprar ahora Más información
Xiaomi Pad 8
Xiaomi Pad 8
Productividad sin límites
Desde$9,499.00$9,999.00
Comprar ahora Más información
Aire Acondicionado Mijia Pro Eco Inverter
Aire Acondicionado Mijia Pro Eco Inverter
¡Descuento directo de $2,500!
$7,999.00$10,499.00
Comprar ahora Más información
POCO X8 Pro
POCO X8 Pro
Velocidad al Máximo
Procesador Dimensity 8500-Ultra
Desde$6,699.00$6,899.00
Comprar ahora Más información
Nuevo · Xiaomi 15

Tecnología que
se siente en casa

Fotografía Leica, pantalla AMOLED y batería para dos días. Llévatelo hoy a 12 meses sin intereses.

Desde $1,666/mes · 12 MSI
43fa088019c2025c561a74ff6f0721ec

Destacados de la semana

Ver todo →
{{ p.name }}
{{ p.badge }}
{{ p.cat }}
{{ p.name }}
{{ p.rating }}
{{ p.price }}{{ p.was }}
{{ p.msi }}
Flagship

Xiaomi 15

Leica · desde $1,666/mes

xiaomi-15-blanco
Regreso a clases

Agrega Buds 6 Play por $10

Ver ofertas →
Canje

Hasta $4,000 por tu equipo

Estimar mi equipo →
Servicio

WhatsApp 9:00–19:00

Escríbenos →
Comunidad

Concurso de fotografía

Participar →

Los más vendidos

Ver ranking →
{{ t.n }}
{{ t.name }}
{{ t.name }}
{{ t.price }}
{{ t.msi }}

Entrega tu equipo actual y ahorra

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

Inicio·Celulares·Xiaomi 15
Xiaomi 15 Verde
xiaomi-15-lente-ptico-leica-summilux
xiaomi-hyperaixiaomi-hyperai
snapdragon-8-elite-3nm
xiaomi-shield-glass
Nuevo4.8 · 312 reseñas

Xiaomi 15

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

$19,999$22,999-13%
Color
Verde
Blanco
Almacenamiento
12 + 256 GB · $19,999 12 + 512 GB · $21,999
Meses sin intereses
BBVA · Banamex · Santander · HSBC
3 meses 6 meses 12 meses 18 meses
Pago mensual{{ msiPago }}× {{ msiPlazo }} meses · sin intereses
Total financiado $19,999 · sin comisión por apertura. También con Kueski Pay o Mercado Pago.
Calcula tu plan →
Pago seguro conVisaMastercardAmExMercado PagoPayPalKueski PayOXXO
Garantía oficial 12 meses
Centros de servicio en 18 estados
Envío gratis 2–4 días
Rastreo desde tu cuenta
30 días de devolución
Sin costo, recolección en tu domicilio

Lo esencial

{{ s.title }}
{{ s.kicker }}
{{ s.title }}
{{ s.body }}

Comparar modelos

Ver solo diferencias
Xiaomi 15Xiaomi 17T ProPOCO X8 Pro
{{ r.k }}{{ r.a }}{{ r.b }}{{ r.c }}

Reseñas verificadas

4.8
★★★★★
312 reseñas
{{ b.n }}{{ b.c }}
{{ v.ini }}
{{ v.name }}
{{ v.meta }}
{{ v.stars }}
{{ v.text }}
Xiaomi 15
Xiaomi 15
Verde · 12 + 256 GB · Entrega 2–4 días
$19,999
{{ msiPago }} × {{ msiPlazo }} MSI
Regreso a clases

¿Qué llevarás
esta vez?

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.

03
días
11
horas
48
min
Back To School
Regreso a clases · Redmi Buds 6 Play
Agrega por
$10$309
Redmi Buds 6 Play

En la compra de POCO M8 5G (8+512 GB), POCO Pad C1 (4+64 GB) o Redmi Pad 2 (8+256 GB).

Regreso a clases · Xiaomi Smart Band 10
Agrega por
$99$959
Xiaomi Smart Band 10

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.

Ordenar Mayor descuento Precio menor a mayor Mejor evaluados Novedades Solo disponibles en línea
{{ o.name }}
{{ o.badge }}
{{ o.cat }}
{{ o.name }}
{{ o.price }}{{ o.was }}
{{ o.msi }}
{{ o.stock }}

Paga a tu ritmo

Hasta 18 meses sin intereses con tarjetas participantes, o compra sin tarjeta con Kueski Pay y Mercado Pago. Sin comisión por apertura.

VisaMastercardAmExMercado PagoPayPalKueski PayOXXO18 MSI
Xiaomi 15
Xiaomi 15
Leica Summilux · Snapdragon 8 Elite
DescripciónEspecificacionesComparar
$19,999
o $1,666/mes · 12 MSI
Nuevo flagship

Xiaomi 15

Fotografía Leica de nivel profesional. Una obra de ingeniería que cabe en tu bolsillo.

$19,999 o $1,666/mes con 12 MSI
Xiaomi 15
{{ s.title }}
{{ s.kicker }}
{{ s.title }}
{{ s.body }}

Reserva hoy, recibe el día uno

Aparta con $500 y obtén Xiaomi Buds 6 de regalo. Cargo restante al enviar.

Inicio·Celulares

Celulares

48 resultados · precios con IVA · envío gratis desde $499

Estado (demo)
Resultados Cargando Error Vacío
Activos: Xiaomi ✕ POCO ✕ 256 GB ✕ 18 MSI ✕
Ordenar: {{ ordenLabel }}
{{ o.label }}
{{ p.name }}
{{ p.badge }}
{{ p.cat }}
{{ p.name }}
★ {{ p.rating }}
{{ p.price }}{{ p.was }}
{{ p.msi }}

No pudimos cargar los productos

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 esto

Ningún equipo coincide con estos filtros

Prueba quitando el rango de precio o el almacenamiento. Estos son los más buscados esta semana:

Xiaomi 15POCO X8 ProMenos de $8,000
1 2 3 6

Tu carrito

3 productos · los precios incluyen IVA

← Seguir comprando
{{ c.name }}
{{ c.name }}
{{ c.variant }}
{{ c.stock }}
{{ c.qty }}
Eliminar Guardar para después
{{ c.price }}
{{ c.was }}
{{ c.msi }}
Redmi Buds 6 Pro
Súmale Redmi Buds 6 Pro por $10

Válido con tu Xiaomi 15 · promoción Regreso a clases hasta el 31 de agosto.

Envío gratis
Llega el 3 de agosto
30 días
Devolución sin costo
Garantía 12 meses
Centros oficiales MX

Resumen

Subtotal (3 productos)$30,897
Regreso a clases−$2,600
Envío estándarGratis
Total$28,297
o 18 MSI de $1,572 con tarjetas participantes
VisaMastercardAmExKueskiMercado PagoOXXO
Pago protegido · 3-D Secure

Finalizar compra

1Envío 2Pago 3Confirmación
Pago seguro

¿A dónde lo enviamos?

Nombre completo
Teléfono
Correo
Código postal
Calle y número
Colonia
Alcaldía o municipio
Referencias para el repartidor (opcional)

Entrega

¿Cómo quieres pagar?

Número de tarjeta
Faltan 4 dígitos. Revisa el número impreso en tu tarjeta.
Vencimiento
CVV
Nombre como aparece en la tarjeta
Meses sin interesesVer plan por banco →
3 · $9,4326 · $4,71612 · $2,35818 · $1,572
← Volver al envío

¡Gracias, Ana!

Tu pedido MX-48219 está confirmado. Te enviamos el comprobante a ana.mendez@correo.com y te avisaremos por WhatsApp en cada paso.

ConfirmadoHoy, 11:42
En preparaciónMañana
En camino1 de agosto
Entregado3 de agosto

Tu pedido

{{ c.name }}
{{ c.name }}
{{ c.variant }} · {{ c.qty }}
{{ c.price }}
Subtotal$30,897
Descuento−$2,600
EnvíoGratis
Total$28,297
18 MSI de $1,572 · sin comisión por diferimiento
Referencia de implementación

Estados, avisos y overlays

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.

1 · Toast / snackbar

esquina inferior derecha · 366 px · máximo 3 en pila
Agregado al carrito
Xiaomi 15 · Verde 12 + 256 GB
Ver carrito
4 s
No pudimos procesar el pago
Tu banco rechazó la operación. Intenta con otro método.
Cambiar método
manual
Producto eliminado
Lo guardamos 30 días en “Guardados”.
Deshacer
6 s
Confirmando tu pedido
Estamos validando el pago con tu banco…
hasta resolver
AcciónTipoDuraciónAcción en el aviso
Agregar al carritook4 sVer carrito
Guardar en favoritosinfo3.5 s
Eliminar del carritoinfo6 sDeshacer
Cupón inválido o expiradoerrmanual
Pago rechazadoerrmanualCambiar método
Confirmar pedidoload → okhasta resolver → 5 sVer mi pedido
Iniciar sesiónok3.5 s
Sin stock al agregarerrmanualAvísame cuando llegue
Sin conexiónerrmientras dureReintentar
Reglas fijas
  • Posición abajo a la derecha en escritorio, arriba y de ancho completo en móvil.
  • Máximo 3 visibles; el resto se encola en orden de llegada.
  • El temporizador se pausa al pasar el cursor y al enfocar con teclado.
  • Los de tipo err no se autocierran: los cierra la persona.
  • Un aviso no sustituye un error de formulario: ese va junto al campo.
  • aria-live="polite", y assertive solo para error.
// 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' })

2 · Overlays centralizados

un solo store, un solo overlay abierto a la vez
{{ o.kind }}
{{ o.name }}

{{ o.when }}

Tamaño: {{ o.size }} Cierre: {{ o.close }} Foco: {{ o.focus }}
// 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())

3 · Carga

skeleton solo si la espera pasa de 300 ms
Skeleton de tarjeta
Skeleton de fila (carrito, pedidos)
Botones y espera en línea
Calculando envío a 06700…
Cantidad y favoritos son optimistas: la UI cambia al instante y se revierte con un aviso err si la petición falla.

4 · Errores y reintento

ninguna pantalla se queda en blanco
Error de campo · junto al input, nunca en un toast
Código postal
Un C.P. mexicano tiene 5 dígitos.
Se valida al salir del campo, no en cada tecla. Al enviar, el foco salta al primer campo con error.
Error de red · en el bloque que falló
No cargaron las reseñas
El resto de la ficha sigue funcionando.
  • 2 reintentos automáticos con espera creciente antes de mostrar el bloque.
  • El error se contiene en su bloque: nunca tumba la pantalla completa.
  • Sin conexión (useOnline()): aviso persistente y reintento al volver.
// 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" />

5 · Tokens que hay que agregar

no existen en el tema actual
TokenValorPara qué
--color-dangeroklch(.56 .17 27)Error, destructivo, campo inválido
--color-danger-100 / -200 / -800rampa en la misma escalaFondo del aviso, borde y texto sobre él
--z-scrim / -drawer / -dialog / -toast40 / 41 / 42 / 44Orden fijo de capas; nadie improvisa un z-index
--dur-fast.16 sTinte de hover, cambio de chip
--dur-base.26 sEntrada del scrim y de los diálogos
--dur-slow.34 sDeslizamiento del drawer y de los avisos
--space-* / --radius-*ya en el temaNunca px sueltos en los componentes
Un solo tema, con la puerta abierta

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.

--color-bg--color-surface--color-brand--color-accent--color-accent-2--color-danger
/* 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)
Móvil primero

Las cinco pantallas críticas en 390 px

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.

sm 640md 768lg 1024xl 1280 el corte real de esta tienda es md · smaller('md')
9:41
m
Buscar en Xiaomi
CelularesAudioWearablesHogar
Nuevo
Xiaomi 15
Desde $19,999 · 12 MSI
Xiaomi 15
Destacados
POCO X8 Pro Max
POCO X8 Pro Max
$8,999
Smart Band 10
Smart Band 10
$999
Inicio Categorías Buscar Carrito Cuenta
Agregado al carrito
Smart Band 10 · Rosa
Inicio · aviso arriba, ancho completo
9:41Catálogo
Celulares
48 resultados
Xiaomi 15
Xiaomi 15
$19,999
POCO X8 Pro
POCO X8 Pro
$6,699
Filtros
Limpiar
Marca
XiaomiPOCORedmi
Precio
Catálogo · filtros en hoja inferior
9:41Ficha
Xiaomi 15
Nuevo
Xiaomi 15
Cámara Leica Summilux · Snapdragon 8 Elite · 90 W
$19,999$21,999
12 MSI de $1,666
Color
Capacidad
256 GB512 GB
$19,999
12 MSI de $1,666
Ficha · barra de compra fija
9:41Carrito
Tu carrito
Envío gratis aplicado
Xiaomi 15
Xiaomi 15
Verde · 12 + 256 GB
1
$19,999
Xiaomi Watch S4
Xiaomi Watch S4
Plata · 46 mm
1
$3,999
Buds 6
Buds 6 por $10
Con tu Xiaomi 15
Subtotal$23,998
Total$23,998
Carrito · total fijo abajo
9:41Pago
Pagar
Paso 2 de 3 · Método de pago
361218 · $1,333
Pago protegido · 3-D Secure
Checkout · un paso por pantalla

Reglas de adaptación

  • Barra inferior de 5 destinos en lugar del menú superior; el mega-menú se vuelve drawer de categorías.
  • Filtros y selectores de variante viven en hojas inferiores, no en modales centrados.
  • Precio y acción principal siempre fijos abajo, sobre fondo m-glass y con env(safe-area-inset-bottom).
  • Cuadrícula de 2 columnas en catálogo; carrusel horizontal para carruseles de la home.
  • Avisos arriba, de ancho completo, uno a la vez.
  • Toque mínimo 44 px; tipografía base 16 px para que iOS no haga zoom en los campos.
// 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')
Entrega a desarrollo

Cómo se construye esta tienda en Vue 3

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.

Vue 3.5 · script setupPiniaVue Router 4@vueuse/coreofetchSin librería de UI

1 · Estructura del proyecto

un archivo por responsabilidad, nada de utilidades genéricas
src/
├─ main.js                  createApp · pinia · router
├─ App.vue                  layout + RouterView + overlays (montados 1 vez)
├─ assets/theme.css         el único archivo con valores literales
├─ router/index.js          rutas con import diferido
├─ stores/
│  ├─ cart.js               líneas, totales, MSI, persistencia
│  ├─ catalog.js            filtros, orden, paginación
│  ├─ notifications.js      pila de avisos · máximo 3
│  ├─ ui.js                 overlay abierto + payload
│  └─ user.js               sesión, direcciones, favoritos
├─ composables/
│  ├─ useAsyncState.js      carga · error · reintento
│  ├─ useBreakpoints.js     md = el corte móvil de esta tienda
│  ├─ useLocalStorage.js    carrito y tema
│  └─ useMoney.js           formato MXN + cálculo de MSI
├─ components/
│  ├─ base/                 Button · Input · Chip · Tag · Qty
│  ├─ feedback/             ToastStack · SkeletonCard · ErrorBlock · EmptyState
│  ├─ overlay/              CartDrawer · AppDialog · BottomSheet
│  ├─ product/              ProductCard · ProductGrid · PriceBlock · MsiPicker
│  └─ layout/               AppHeader · MegaMenu · AppFooter · MobileTabBar
└─ views/                   una por pantalla de este prototipo
Cinco reglas que no se negocian
  • Ningún componente escribe un hex, un px ni un radio: todo sale de var(--*).
  • Las vistas no hacen fetch: piden a un store o a un composable.
  • Los overlays y los avisos se montan en App.vue, nunca dentro de una vista.
  • Precios siempre por useMoney(); nadie concatena '$' a mano.
  • Un componente base/ no conoce stores; recibe props y emite eventos.
// 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')

2 · Rutas y pantallas

las nueve pantallas de este prototipo, una por vista
RutaVistaDe dónde saca los datosEstados que debe pintar
/HomeViewapi /home · carruseles y destacadosSkeleton de carrusel · error por bloque
/c/:categoriaCatalogViewcatalogStore · filtros desde la URLok · loading · error · vacío
/p/:slugProductViewapi /producto/:slug + reseñas aparteSkeleton · sin stock · reseñas caídas
/promocionesPromosViewapi /campañasContador vivo · promoción terminada
/lanzamiento/:slugLaunchViewapi /lanzamientoAntes · durante · agotado
/carritoCartViewcartStore · persistido en localVacío · sin stock · cupón inválido
/checkoutCheckoutViewcartStore + userStore · 3 pasosErrores de campo · pago rechazado · confirmado
/cuenta/*AccountViewuserStore · requiere sesiónSin sesión → modal de login
/:pathMatch(.*)*NotFoundViewSugerencias de categoría
// 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

3 · theme.css

cópialo tal cual; es la fuente de todos los valores
/* 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 }
}
Las tres texturas y cuándo se usa cada una
--neo-out · .m-neo
Lo que se toca y sobresale: íconos, chips, botones secundarios.
--neo-in · .morph-neo-inset
Lo que recibe algo: campos, buscador, opción activa, pulsado.
--clay · .m-clay
Contenedores de contenido: tarjetas, tablas, diálogos.
.m-glass
Solo lo que flota sobre contenido: barras fijas y mega-menú.
Tema oscuro, cuando toque

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;
}

4 · Stores de Pinia

cinco stores, límites claros · notifications y ui ya están en la hoja de Estados
cart.js
Líneas, cantidades, totales, envío gratis, MSI y persistencia local.
No hace: No abre el drawer; se lo pide a ui.
catalog.js
Filtros, orden y página, sincronizados con la URL.
No hace: No guarda el producto individual.
ui.js
Qué overlay está abierto y con qué payload. Uno a la vez.
No hace: No sabe qué hay dentro del overlay.
notifications.js
La pila de avisos, su tipo y su duración.
No hace: No valida formularios ni sustituye errores de campo.
user.js
Sesión, direcciones, favoritos y puntos Mi.
No hace: No toca el carrito de invitado hasta que se fusiona.
// 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 }
})

5 · Composables

cuatro, y ninguno más sin discutirlo
// 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" />

6 · Qué se declara y qué no

el sistema base es CSS, no una librería de componentes

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).

A
Ya resuelto por el tema

Existe como clase en el sistema. No se declara componente: se usa la clase y se acabó.

.tagBadge, etiqueta de estado, chip de filtro estático
.tableTablas de datos y comparativas
.hr · .washedRegla y tratamiento de fotografía
.segGrupo de opciones cortas: 2 a 3 valores
select + .inputDropdown · nativo, accesible y sin JS
B
Se envuelve una vez

La clase existe, pero hay estado o variantes que repetir. Un componente delgado sobre la clase, sin restilizarla.

BaseButton.btn + variant, loading y disabled
BaseInput.input + label, error y hint accesibles
BaseCheck / BaseRadio.radio con área de toque de 44 px
AppDialog.dialog-backdrop + foco atrapado y Esc
CardShell.card como contenedor; el contenido lo pone quien lo usa
C
Se construye desde cero

El tema no lo cubre. Es lo que de verdad hay que diseñar y programar en esta tienda.

PriceBlock · MsiPickerPrecio, ahorro y meses sin intereses
BaseQtySelector de cantidad con límite por pedido
ProductCard · ProductGridTarjeta y cuadrícula adaptativa del catálogo
ToastStack · CartDrawerAvisos y carrito lateral, montados en App.vue
BottomSheet · MobileTabBarSolo móvil: filtros, variantes y navegación
Skeleton · Error · EmptyLos tres estados que ninguna librería regala

Inventario a construir

en orden de dependencia · dieciséis piezas, ni una más sin discutirlo
ComponentePropsEmiteDónde aparece
BaseButtonvariant, size, loading, blockclickTodas las pantallas
BaseInputmodelValue, label, error, hintupdate, blurCheckout, login, búsqueda
BaseQtymodelValue, min, maxupdateCarrito, drawer, ficha
PriceBlockprecio, antes, msiTarjeta, ficha, carrito
MsiPickerprecio, plazos, modelValueupdateFicha, checkout
ProductCardproducto, compactadd, compare, favHome, catálogo, promociones
ProductGriditems, colsCatálogo, promociones
SkeletonCardcountCualquier bloque en carga
ErrorBlocktitle, bodyretryCatálogo, reseñas, envío
EmptyStatetitle, body, ctaclearCatálogo, carrito, favoritos
ToastStack— (lee el store)App.vue, una sola vez
CartDrawer— (lee cart + ui)App.vue, una sola vez
AppDialogname, size, dismissiblecloseApp.vue · login, comparar, confirmar
BottomSheettitle, snapcloseSolo móvil: filtros y variantes
MegaMenucategoriasnavigateAppHeader en ≥ md
MobileTabBar— (lee la ruta)Layout en < md

7 · Listo es listo

se revisa pantalla por pantalla antes de cerrar la tarea
Cuatro estados por bloque de datos
ok, carga, error con reintento y vacío con salida. Ninguna pantalla en blanco.
Ningún valor literal en el componente
Buscar hex, px y nombres de fuente en el diff: deben ser cero.
Teclado completo
Tab llega a todo, Esc cierra overlays, el foco vuelve al disparador y se queda atrapado dentro del modal.
Aviso correcto para cada acción
Contra la tabla de la hoja de Estados: tipo, duración y acción del aviso.
Errores de campo junto al campo
Validación al salir del input; al enviar, el foco salta al primer error.
Precios por useMoney()
Formato es-MX sin decimales y la línea de MSI calculada, nunca escrita a mano.
390 px revisado a mano
Toques de 44 px, base 16 px en campos, barra de compra fija con safe-area.
Carrito persistente
Recargar y abrir otra pestaña no pierde líneas ni cantidades.
Imágenes con medidas y alt
width y height siempre, formato moderno, y texto alternativo con nombre y variante.
prefers-reduced-motion respetado
Carrusel, drawer y avisos aparecen sin desplazamiento cuando la persona lo pidió.
Lo que falta decidir con diseño

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.

¿Eliminar Xiaomi 15 del carrito?

Podrás recuperarlo desde “Guardados” durante 30 días.

{{ t.title }}
{{ t.body }}
{{ t.action }}