AWP · Curso

Guía paso a paso · Unidad 2

Construye, paso a paso, tu PWA de ventas completa

Vas a construir dos versiones de la misma vista (una CSR y una SSR con Django) para entender la diferencia de verdad, luego un carrito con almacenamiento local, un Service Worker que cachea toda la app, notificaciones, acceso a la ubicación, pruebas con Lighthouse y, al final, publicación real en internet.

Objetivo: repositorio con la PWA completa, publicado y probado

Todo el código de esta guía ya existe, probado, en la carpeta sitio/ejemplos/unidad2/ del proyecto: app/ (la PWA en HTML/CSS/JS) y servidor-ssr/ (el mini-proyecto Django). Puedes copiarlo y seguir la guía para entender cada línea, o escribirlo tú mismo desde cero — ambos caminos llegan al mismo resultado.

Herramientas nuevas de esta unidad

Python

Necesario para correr Django, el servidor que usaremos para la vista SSR.

Django

Framework de Python. Lo usamos SOLO para una vista simple que demuestre el renderizado en servidor — no para toda la app.

Workbox CLI

Genera automáticamente un Service Worker a partir de tus archivos (alternativa a escribirlo a mano).

PWABuilder

Sitio web (pwabuilder.com) que valida tu manifest/Service Worker y empaqueta la app.

Lighthouse

Ya viene integrado en Chrome DevTools: audita si tu PWA es instalable, rápida y accesible.

GitHub Pages

Hosting gratuito donde publicarás la app para obtener la URL que pide la evidencia.

Parte A · La vista SSR con Django (para entender qué es "renderizado en servidor")

1

Instala Python

  1. Ve a python.org/downloads y descarga la versión estable para Windows.
  2. Ejecuta el instalador. Muy importante: en la primera pantalla marca la casilla "Add python.exe to PATH" antes de hacer clic en "Install Now".
  3. Verifica en PowerShell:
python --version

Debe responder algo como Python 3.13.x.

2

Crea un entorno virtual e instala Django

Un entorno virtual ("venv") es una carpeta que aísla las librerías de Python de este proyecto, para que no se mezclen con otros. Créalo dentro de una carpeta nueva, por ejemplo servidor-ssr:

mkdir servidor-ssr cd servidor-ssr python -m venv venv venv\Scripts\activate

Cuando el entorno está activo, verás (venv) al inicio de la línea de tu terminal. Ahora instala Django (solo dentro de este venv):

pip install django
3

Crea el proyecto y la app de Django

startproject genera la configuración general; startapp genera un módulo con una responsabilidad concreta (aquí, el catálogo):

django-admin startproject ssr_demo . python manage.py startapp catalogo

El punto (.) al final del primer comando le dice a Django "créalo aquí mismo", en vez de en una subcarpeta extra.

4

Registra la app en la configuración

Abre ssr_demo/settings.py y busca la lista INSTALLED_APPS. Agrega tu app al final:

📄 ssr_demo/settings.py (fragmento)
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'catalogo',  # nuestra app: la vista SSR del catálogo de ventas
]

Sin esta línea, Django no sabe que tu app existe y no encontrará su plantilla.

5

Escribe la vista (el "cerebro" de la página)

Abre catalogo/views.py y reemplaza su contenido por esto:

📄 catalogo/views.py
from django.shortcuts import render

# "Base de datos" falsa: una lista de diccionarios en memoria.
# En un proyecto real esto vendría de un modelo (models.py) y una tabla,
# pero para ENTENDER qué es SSR no necesitamos base de datos: lo importante
# es que estos datos se insertan en el HTML *antes* de enviarlo al navegador.
PRODUCTOS = [
    {"nombre": "Playera UT Hermosillo", "precio": 250, "stock": 40},
    {"nombre": "Sudadera TIC", "precio": 480, "stock": 15},
    {"nombre": "Taza PWA", "precio": 120, "stock": 60},
    {"nombre": "Mochila 10mo cuatrimestre", "precio": 690, "stock": 8},
]


def vista_ssr(request):
    """
    Vista con Renderizado en Servidor (SSR).

    request: el objeto que representa la petición que hizo el navegador.

    render() hace tres cosas:
      1) toma la plantilla 'catalogo/catalogo_ssr.html'
      2) le inyecta el diccionario de "contexto" (aquí: la lista de productos)
      3) devuelve el HTML final, YA CON LOS DATOS DENTRO, listo para el navegador.

    Por eso es "servidor": el HTML que llega al navegador ya trae escritos
    los nombres y precios. Si haces clic derecho > "Ver código fuente de la
    página" verás los productos en el HTML, aunque JavaScript esté desactivado.
    Compáralo con la vista CSR de la Parte B, donde el HTML llega vacío y es
    JavaScript quien dibuja los productos DESPUÉS, ya en el navegador.
    """
    contexto = {"productos": PRODUCTOS}
    return render(request, 'catalogo/catalogo_ssr.html', contexto)
6

Crea la plantilla HTML

Crea la carpeta catalogo/templates/catalogo/ (tres niveles) y dentro un archivo catalogo_ssr.html:

📄 catalogo/templates/catalogo/catalogo_ssr.html
<!-- Plantilla Django: mezcla HTML normal con etiquetas especiales de Django -->
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Catálogo (SSR) — Demo Django</title>
  <style>
    /* Estilos mínimos solo para que se lea bien en la demo de clase */
    body { font-family: system-ui, sans-serif; background: #f1f5f4; color: #142523; margin: 2rem; }
    h1 { color: #0e6b5c; }
    ul { list-style: none; padding: 0; }
    li { background: #fff; border: 1px solid #d7e0dc; border-radius: 8px; padding: .75rem 1rem; margin-bottom: .5rem; }
    .precio { font-weight: bold; color: #ff7a45; }
  </style>
</head>
<body>
  <h1>Catálogo renderizado en el servidor (SSR)</h1>
  <p>Este HTML ya viene con los productos escritos. Haz "Ver código fuente" (Ctrl+U) y
     compara: aquí SÍ aparecen los nombres y precios en el HTML crudo.</p>

  <ul>
    <!-- el bucle "for" recorre la lista "productos" que la vista (views.py) mandó en el contexto -->
    {% for producto in productos %}
      <li>
        <strong>{{ producto.nombre }}</strong> —
        <span class="precio">${{ producto.precio }}</span>
        (stock: {{ producto.stock }})
      </li>
    {% endfor %}
  </ul>
</body>
</html>

Cuidado con esto

No escribas {% for %} ni {{ algo }} dentro de un comentario HTML <!-- --> pensando que "no se ejecuta": Django lee esas llaves en TODO el archivo, sin importar los comentarios, y puede tronar con un error de sintaxis. Si quieres explicar algo en la plantilla, hazlo con palabras normales, sin llaves de Django.

7

Conecta la URL

Crea el archivo catalogo/urls.py (no existe todavía, créalo tú):

📄 catalogo/urls.py
"""
Rutas propias de la app "catalogo".

Django separa las rutas por app para que cada una sea independiente
y reutilizable en otros proyectos.
"""
from django.urls import path
from . import views  # importamos las funciones definidas en views.py de esta misma carpeta

urlpatterns = [
    # http://127.0.0.1:8000/catalogo/  ->  ejecuta la función vista_ssr()
    path('catalogo/', views.vista_ssr, name='catalogo_ssr'),
]

Y edita ssr_demo/urls.py (el del proyecto general) para delegarle las rutas:

📄 ssr_demo/urls.py
from django.contrib import admin
from django.urls import path, include  # include() nos deja "delegar" rutas a otra app

urlpatterns = [
    # /admin/  -> panel de administración que trae Django de fábrica (no lo usaremos, pero no estorba)
    path('admin/', admin.site.urls),

    # Cualquier ruta que empiece vacía ('') se delega al archivo urls.py de la app "catalogo".
    # Así, /catalogo/ terminará resuelta por catalogo/urls.py
    path('', include('catalogo.urls')),
]
8

Corre el servidor y compara SSR contra CSR

python manage.py runserver

Abre http://127.0.0.1:8000/catalogo/ en Chrome. Haz clic derecho → Ver código fuente de la página (o Ctrl+U): verás los nombres y precios YA escritos en el HTML. Eso es SSR. Más adelante, cuando abras la app CSR de la Parte B y hagas lo mismo, el <div id="catalogo"> aparecerá vacío — porque ahí los llena JavaScript después.

Parte B · La PWA de ventas (HTML, CSS y JavaScript)

Esta es la app principal: la que instalarán en su celular. Créala en una carpeta aparte, por ejemplo app/, junto a servidor-ssr/ (no dentro).

9

Estructura de carpetas

Crea estos archivos y carpetas vacíos primero, los iremos llenando:

app/
  index.html
  manifest.json
  sw.js
  catalogo.json
  css/
    estilos.css
  js/
    app.js
    carrito.js
    csr.js
    notificaciones.js
    geolocalizacion.js
  icons/
    icon.svg
10

El catálogo de datos (simula una API)

En un backend real esto vendría de una base de datos; aquí, un archivo JSON hace exactamente el mismo papel para que puedas practicar fetch():

📄 catalogo.json
[
  { "id": 1, "nombre": "Playera UT Hermosillo", "precio": 250, "stock": 40 },
  { "id": 2, "nombre": "Sudadera TIC", "precio": 480, "stock": 15 },
  { "id": 3, "nombre": "Taza PWA", "precio": 120, "stock": 60 },
  { "id": 4, "nombre": "Mochila 10mo cuatrimestre", "precio": 690, "stock": 8 }
]
11

El App Shell — index.html

Este es el "esqueleto" que el Service Worker cacheará completo. Fíjate en el <div id="catalogo">: empieza vacío a propósito.

📄 index.html
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tienda Escolar — PWA de ventas</title>

  <!-- Estas 3 líneas son las que convierten este sitio en una PWA instalable -->
  <link rel="manifest" href="manifest.json">
  <meta name="theme-color" content="#0e6b5c">
  <link rel="icon" href="icons/icon.svg" type="image/svg+xml">

  <link rel="stylesheet" href="css/estilos.css">
</head>
<body>

  <!-- ============ APP SHELL ============
       El "app shell" es el esqueleto que SIEMPRE se ve igual (header, nav,
       contenedores vacíos). El Service Worker lo cachea completo para que
       cargue instantáneo incluso sin internet; solo los DATOS de adentro
       cambian (los llena JavaScript, ver csr.js). -->

  <header class="appbar">
    <h1>🛍️ Tienda Escolar</h1>
    <!-- este <span> lo actualiza app.js para mostrar conectado/sin conexión -->
    <span id="estado-conexion" class="pill">conectado</span>
  </header>

  <main>
    <!-- ---- Sección 1: Catálogo (CSR) ---- -->
    <section>
      <h2>Catálogo</h2>
      <!-- Este div empieza VACÍO a propósito: si ves el código fuente (Ctrl+U)
           no verás ningún producto aquí. JavaScript (csr.js) lo llena DESPUÉS
           de que la página ya cargó: por eso es "Renderizado en el Cliente" (CSR). -->
      <div id="catalogo" class="grid">
        <p>Cargando catálogo…</p>
      </div>
    </section>

    <!-- ---- Sección 2: Carrito ---- -->
    <section>
      <h2>Carrito</h2>
      <ul id="carrito-lista"></ul>
      <p>Total: $<span id="carrito-total">0</span></p>
      <button id="btn-sincronizar" type="button">Sincronizar carrito</button>
      <p id="carrito-mensaje" class="mensaje"></p>
    </section>

    <!-- ---- Sección 3: Notificaciones ---- -->
    <section>
      <h2>Notificaciones de ofertas</h2>
      <button id="btn-notificar" type="button">Activar notificaciones</button>
      <p id="notificaciones-mensaje" class="mensaje"></p>
    </section>

    <!-- ---- Sección 4: Ubicación (característica del dispositivo) ---- -->
    <section>
      <h2>Tienda más cercana</h2>
      <button id="btn-ubicacion" type="button">Usar mi ubicación</button>
      <p id="ubicacion-mensaje" class="mensaje"></p>
    </section>
  </main>

  <footer>
    <p>Unidad 2 · PWA de ventas de ejemplo</p>
  </footer>

  <!-- Cargamos los scripts al final del <body>: así el HTML ya existe
       cuando el JavaScript intenta buscar los elementos con document.getElementById -->
  <script src="js/carrito.js"></script>
  <script src="js/csr.js"></script>
  <script src="js/notificaciones.js"></script>
  <script src="js/geolocalizacion.js"></script>
  <script src="js/app.js"></script>
</body>
</html>

Orden importante: carrito.js se carga antes que csr.js porque este último llama a la función agregarAlCarrito() que carrito.js define.

12

Los estilos — css/estilos.css

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: #f1f5f4;
  color: #142523;
}

.appbar {
  background: #0e6b5c;
  color: white;
  padding: 1rem 1.25rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

/* .pill: la "píldora" que muestra si hay conexión o no (la mueve app.js) */
.pill {
  background: rgba(255,255,255,.2);
  border-radius: 999px;
  padding: .25rem .75rem;
  font-size: .8rem;
}
.pill.sin-conexion { background: #ff7a45; }

main {
  max-width: 720px;
  margin: 1.5rem auto;
  padding: 0 1rem;
}

section {
  background: white;
  border: 1px solid #d7e0dc;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  margin-bottom: 1rem;
}

/* .grid: acomoda las tarjetas de producto en columnas que se ajustan solas */
.grid {
  display: grid;
  gap: .75rem;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}

.producto {
  border: 1px solid #d7e0dc;
  border-radius: 8px;
  padding: .75rem;
}
.producto strong { display: block; margin-bottom: .25rem; }
.producto .precio { color: #ff7a45; font-weight: bold; }

button {
  background: #0e6b5c;
  color: white;
  border: none;
  border-radius: 8px;
  padding: .5rem 1rem;
  cursor: pointer;
  font-size: .9rem;
}
button:hover { background: #0a4f45; }

.mensaje { color: #3f5450; font-size: .9rem; min-height: 1.2em; }

#carrito-lista { list-style: none; padding: 0; }
#carrito-lista li {
  display: flex;
  justify-content: space-between;
  border-bottom: 1px solid #e8f0ed;
  padding: .4rem 0;
}
13

Renderizado en el cliente — js/csr.js

Aquí es donde JavaScript, ya corriendo en el navegador del usuario, construye el HTML del catálogo:

// csr.js — Renderizado en el Cliente (CSR)
// "CSR" significa que el HTML llega casi vacío y es ESTE archivo JavaScript,
// ya corriendo en el navegador del usuario, quien construye el contenido.

// 1) Buscamos el contenedor vacío que dejamos en index.html
const contenedorCatalogo = document.getElementById("catalogo");

// 2) fetch() le pide el archivo catalogo.json al servidor (aquí simula una API real).
//    fetch() devuelve una "promesa": un valor que llegará más adelante, no de inmediato.
fetch("catalogo.json")
  .then((respuesta) => respuesta.json()) // convierte el texto recibido en un array de objetos JS
  .then((productos) => {
    // 3) Ya tenemos los productos: borramos el "Cargando…" y dibujamos las tarjetas
    contenedorCatalogo.innerHTML = "";

    productos.forEach((producto) => {
      // creamos un <div class="producto"> por cada producto, desde JavaScript puro
      const tarjeta = document.createElement("div");
      tarjeta.className = "producto";

      // usamos el propio objeto "producto" para llenar el texto (sin plantillas de servidor)
      tarjeta.innerHTML = `
        <strong>${producto.nombre}</strong>
        <span class="precio">$${producto.precio}</span>
        <p>Stock: ${producto.stock}</p>
        <button type="button" data-id="${producto.id}">Agregar al carrito</button>
      `;

      contenedorCatalogo.appendChild(tarjeta);

      // conectamos el botón recién creado con la función agregarAlCarrito() de carrito.js
      tarjeta.querySelector("button").addEventListener("click", () => {
        agregarAlCarrito(producto);
      });
    });
  })
  .catch((error) => {
    // si algo falla (por ejemplo, sin conexión y sin caché), avisamos en pantalla
    contenedorCatalogo.innerHTML = "<p>No se pudo cargar el catálogo. Intenta más tarde.</p>";
    console.error("Error al cargar catálogo:", error);
  });
14

Almacenamiento local y sincronización — js/carrito.js

// carrito.js — Almacenamiento local y "sincronización" offline/online
// Aquí usamos localStorage: una caja de guardado que vive DENTRO del navegador
// y sobrevive aunque cierres la pestaña o se vaya el internet.

const CLAVE_CARRITO = "tienda-escolar-carrito";

// Lee el carrito guardado en localStorage (si no hay nada, regresa un array vacío)
function leerCarrito() {
  const guardado = localStorage.getItem(CLAVE_CARRITO); // localStorage solo guarda texto
  return guardado ? JSON.parse(guardado) : []; // JSON.parse convierte ese texto de vuelta a un array
}

// Guarda el array del carrito en localStorage (JSON.stringify lo convierte a texto)
function guardarCarrito(carrito) {
  localStorage.setItem(CLAVE_CARRITO, JSON.stringify(carrito));
}

// Agrega un producto al carrito (o le sube la cantidad si ya estaba)
function agregarAlCarrito(producto) {
  const carrito = leerCarrito();
  const existente = carrito.find((item) => item.id === producto.id);

  if (existente) {
    existente.cantidad += 1;
  } else {
    carrito.push({ id: producto.id, nombre: producto.nombre, precio: producto.precio, cantidad: 1 });
  }

  guardarCarrito(carrito);
  pintarCarrito();
}

// Dibuja el carrito en pantalla a partir de lo que hay en localStorage
function pintarCarrito() {
  const carrito = leerCarrito();
  const lista = document.getElementById("carrito-lista");
  const total = document.getElementById("carrito-total");

  lista.innerHTML = "";
  let suma = 0;

  carrito.forEach((item) => {
    const li = document.createElement("li");
    const subtotal = item.precio * item.cantidad;
    suma += subtotal;
    li.innerHTML = `<span>${item.nombre} x${item.cantidad}</span><span>$${subtotal}</span>`;
    lista.appendChild(li);
  });

  total.textContent = suma;
}

// "Sincronizar": en una app real esto haría un fetch() con method: "POST" al
// servidor. Aquí lo simulamos para enseñar el patrón offline -> online.
function sincronizarCarrito() {
  const mensaje = document.getElementById("carrito-mensaje");

  if (!navigator.onLine) {
    // navigator.onLine: propiedad del navegador que dice si hay conexión
    mensaje.textContent = "Sin conexión: el carrito se sincronizará cuando vuelva el internet.";
    return;
  }

  // Aquí iría: fetch("https://mi-api.com/carrito", { method: "POST", body: ... })
  mensaje.textContent = "Carrito sincronizado con el servidor (simulado). ✓";
}

document.addEventListener("DOMContentLoaded", () => {
  pintarCarrito();
  document.getElementById("btn-sincronizar").addEventListener("click", sincronizarCarrito);

  // Estos dos eventos del navegador avisan cuando se pierde o regresa la conexión
  window.addEventListener("online", sincronizarCarrito);
  window.addEventListener("offline", () => {
    document.getElementById("carrito-mensaje").textContent = "Se perdió la conexión.";
  });
});
15

Notificaciones — js/notificaciones.js

// notificaciones.js — Notification API
// Esta es la API del NAVEGADOR (no del sistema push completo, que necesitaría
// un servidor con claves VAPID). Sirve para mostrar avisos del lado del cliente
// y para entender el permiso que el usuario debe conceder.

document.addEventListener("DOMContentLoaded", () => {
  const boton = document.getElementById("btn-notificar");
  const mensaje = document.getElementById("notificaciones-mensaje");

  boton.addEventListener("click", () => {
    // "Notification" no existe en todos los navegadores/contextos; lo comprobamos primero
    if (!("Notification" in window)) {
      mensaje.textContent = "Este navegador no soporta notificaciones.";
      return;
    }

    // Notification.requestPermission() abre el diálogo del navegador
    // ("bloquear" / "permitir"). Devuelve una promesa con la respuesta del usuario.
    Notification.requestPermission().then((permiso) => {
      if (permiso === "granted") {
        // Creamos una notificación de prueba. title, luego un objeto con opciones.
        new Notification("Tienda Escolar", {
          body: "¡Listo! Te avisaremos cuando haya nuevas ofertas.",
          icon: "icons/icon.svg",
        });
        mensaje.textContent = "Notificaciones activadas.";
      } else {
        mensaje.textContent = "No diste permiso para notificaciones.";
      }
    });
  });
});
16

Acceso al dispositivo (geolocalización) — js/geolocalizacion.js

// geolocalizacion.js — Acceso a una característica del dispositivo: el GPS/ubicación
// El navegador SIEMPRE pide permiso antes de compartir la ubicación.
// Nota: por seguridad, esta API solo funciona en https:// o en localhost.

document.addEventListener("DOMContentLoaded", () => {
  const boton = document.getElementById("btn-ubicacion");
  const mensaje = document.getElementById("ubicacion-mensaje");

  boton.addEventListener("click", () => {
    if (!("geolocation" in navigator)) {
      mensaje.textContent = "Este dispositivo no reporta ubicación.";
      return;
    }

    mensaje.textContent = "Buscando tu ubicación…";

    navigator.geolocation.getCurrentPosition(
      // función que se ejecuta SI el usuario acepta compartir su ubicación
      (posicion) => {
        const { latitude, longitude } = posicion.coords;
        mensaje.textContent = `Ubicación obtenida: ${latitude.toFixed(4)}, ${longitude.toFixed(4)}`;
      },
      // función que se ejecuta SI el usuario rechaza o hay un error
      (error) => {
        mensaje.textContent = "No se pudo obtener tu ubicación: " + error.message;
      }
    );
  });
});
17

El manifest — manifest.json

Este archivo es lo que le dice al navegador "esto se puede instalar como app". Cada campo importa:

{
  "name": "Tienda Escolar",
  "short_name": "Tienda",
  "description": "PWA de ventas de práctica para la Unidad 2",
  "start_url": "./index.html",
  "scope": "./",
  "display": "standalone",
  "background_color": "#f1f5f4",
  "theme_color": "#0e6b5c",
  "icons": [
    {
      "src": "icons/icon.svg",
      "sizes": "any",
      "type": "image/svg+xml",
      "purpose": "any maskable"
    }
  ]
}
  • name/short_name: nombre completo y el que se ve bajo el ícono.
  • start_url: qué archivo abre la app al instalarla.
  • display: "standalone": se ve como app real, sin la barra de direcciones del navegador.
  • background_color: color de la pantalla mientras carga (splash).
  • theme_color: color de la barra de estado del sistema operativo.
  • icons: al menos un ícono; aquí usamos un SVG para no depender de generar PNGs de varios tamaños.

Puedes revisar que Chrome lo está leyendo bien abriendo DevTools (F12) → pestaña ApplicationManifest, mientras ves tu página con Live Server.

18

El Service Worker — dos caminos

Camino A — generarlo con Workbox (recomendado, así lo pide el programa):

npm install -g workbox-cli workbox wizard

El asistente te preguntará: la carpeta raíz de tu app (app/), qué archivos precachear (elige **/*.{html,css,js,json,svg}) y dónde guardar el resultado (sw.js y workbox-config.js). Al terminar, ejecuta:

workbox generateSW workbox-config.js

Esto crea un sw.js ya escrito por la herramienta, con la lógica de caché lista.

Camino B — escribirlo a mano (para entender exactamente qué hace):

📄 sw.js
// sw.js — Service Worker de la Tienda Escolar
// Un Service Worker es un script que el navegador ejecuta APARTE de la página,
// incluso cuando la pestaña está cerrada. Su superpoder es interceptar peticiones
// de red y decidir: ¿la resuelvo con caché, con internet, o con las dos?

const NOMBRE_CACHE = "tienda-escolar-v1"; // cambia este número cuando actualices el app shell

// Lista del "app shell": todo lo necesario para que la app abra sin internet
const APP_SHELL = [
  "./",
  "./index.html",
  "./css/estilos.css",
  "./js/app.js",
  "./js/carrito.js",
  "./js/csr.js",
  "./js/notificaciones.js",
  "./js/geolocalizacion.js",
  "./catalogo.json",
  "./manifest.json",
  "./icons/icon.svg",
];

// Evento "install": se dispara UNA VEZ, cuando el navegador instala este Service Worker
self.addEventListener("install", (evento) => {
  evento.waitUntil(
    // caches.open crea (o reutiliza) una caja de almacenamiento con ese nombre
    caches.open(NOMBRE_CACHE).then((cache) => {
      // cache.addAll descarga y guarda TODOS los archivos de la lista de una vez
      return cache.addAll(APP_SHELL);
    })
  );
  self.skipWaiting(); // activa este SW de inmediato, sin esperar a cerrar todas las pestañas
});

// Evento "activate": limpia cachés de versiones anteriores (si cambiaste NOMBRE_CACHE)
self.addEventListener("activate", (evento) => {
  evento.waitUntil(
    caches.keys().then((nombresGuardados) =>
      Promise.all(
        nombresGuardados
          .filter((nombre) => nombre !== NOMBRE_CACHE) // nos quedamos solo con la caché actual
          .map((nombre) => caches.delete(nombre))
      )
    )
  );
  self.clients.claim(); // toma control de las pestañas abiertas sin necesidad de recargarlas
});

// Evento "fetch": se dispara CADA VEZ que la página pide un archivo (HTML, CSS, JS, imágenes...)
self.addEventListener("fetch", (evento) => {
  if (evento.request.method !== "GET") return; // solo cacheamos lecturas, no envíos de datos

  evento.respondWith(
    // Estrategia "cache first": si ya está guardado, se sirve al instante desde el
    // teléfono/computadora (así funciona sin internet); si no está, se busca en la red
    // y, de paso, se guarda para la próxima vez.
    caches.match(evento.request).then((respuestaGuardada) => {
      if (respuestaGuardada) return respuestaGuardada;

      return fetch(evento.request)
        .then((respuestaDeRed) => {
          const copia = respuestaDeRed.clone(); // clonamos: una copia se entrega, otra se guarda
          caches.open(NOMBRE_CACHE).then((cache) => cache.put(evento.request, copia));
          return respuestaDeRed;
        })
        .catch(() => respuestaGuardada); // si no hay red NI caché, no hay más remedio que fallar
    })
  );
});
19

Registra el Service Worker y controla el estado de conexión — js/app.js

// app.js — arranque general: registrar el Service Worker y mostrar el estado de conexión

// Actualiza la "píldora" de arriba a la derecha según haya o no internet
function actualizarPildoraConexion() {
  const pildora = document.getElementById("estado-conexion");
  if (navigator.onLine) {
    pildora.textContent = "conectado";
    pildora.classList.remove("sin-conexion");
  } else {
    pildora.textContent = "sin conexión";
    pildora.classList.add("sin-conexion");
  }
}

document.addEventListener("DOMContentLoaded", actualizarPildoraConexion);
window.addEventListener("online", actualizarPildoraConexion);
window.addEventListener("offline", actualizarPildoraConexion);

// Registro del Service Worker: "serviceWorker" in navigator comprueba que el
// navegador soporte la API antes de intentar usarla (Safari viejo, por ejemplo, no).
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker
      .register("sw.js")
      .then((registro) => {
        console.log("Service Worker registrado con éxito:", registro.scope);
      })
      .catch((error) => {
        console.error("No se pudo registrar el Service Worker:", error);
      });
  });
}
20

Pruébalo con Live Server

  1. Abre la carpeta app/ completa en VS Code.
  2. Clic derecho en index.htmlOpen with Live Server.
  3. Prueba agregar productos al carrito, activa notificaciones y ubicación (el navegador te pedirá permiso — acepta para probar).
  4. Abre DevTools (F12) → ApplicationService Workers: debe aparecer sw.js como "activated and is running".
  5. Marca la casilla Offline ahí mismo y recarga la página (F5): debe seguir cargando, aunque sin internet.

Parte C · Pruebas y publicación

21

Audita con Lighthouse

  1. Con la app abierta por Live Server, abre DevTools (F12).
  2. Ve a la pestaña Lighthouse.
  3. Marca al menos la categoría Progressive Web App (puedes dejar las demás también) y haz clic en Analyze page load.
  4. Revisa el reporte: si algo sale en rojo (por ejemplo, "no maskable icon"), corrígelo y vuelve a correr la auditoría.
22

Publica en GitHub Pages

  1. Dentro de la carpeta app/, en la terminal de VS Code:
git init git add . git commit -m "PWA de ventas: catálogo, carrito, notificaciones, ubicación y Service Worker"
  1. Crea un repositorio nuevo en GitHub (igual que en la Unidad 1), por ejemplo tienda-escolar-pwa, sin README.
git remote add origin https://github.com/TU-USUARIO/tienda-escolar-pwa.git git branch -M main git push -u origin main
  1. En GitHub, entra al repositorio → SettingsPages.
  2. En "Source" elige la rama main y la carpeta / (root)Save.
  3. Espera uno o dos minutos y recarga: te dará una URL como https://tu-usuario.github.io/tienda-escolar-pwa/.
23

Valida con PWABuilder e instala en tu celular

  1. Ve a pwabuilder.com, pega la URL de GitHub Pages y haz clic en Start.
  2. Revisa el reporte: manifest, Service Worker y seguridad deben aparecer en verde.
  3. Abre esa misma URL desde el celular en Chrome: debe ofrecerte "Agregar a pantalla de inicio" o el ícono de instalar. En computadora, Chrome muestra un ícono de instalar (⊕) en la barra de direcciones.

Evidencia final de la Unidad 2

Repositorio con la PWA

Debe incluir: splash/home (tu index.html + manifest), vistas CSR (app/) y SSR (servidor-ssr/), datos locales/remotos/offline (carrito + catalogo.json + Service Worker), notificaciones y uso de un elemento del dispositivo (geolocalización), más un reporte de pruebas (captura de Lighthouse) y la URL publicada con una guía breve de instalación.

← Ver el resumen y la rúbrica de la Unidad 2 · Ver el Anexo de rúbricas →