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.
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")
Instala Python
- Ve a
python.org/downloadsy descarga la versión estable para Windows. - Ejecuta el instalador. Muy importante: en la primera pantalla marca la casilla "Add python.exe to PATH" antes de hacer clic en "Install Now".
- Verifica en PowerShell:
python --version
Debe responder algo como Python 3.13.x.
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
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.
Registra la app en la configuración
Abre ssr_demo/settings.py y busca la lista INSTALLED_APPS. Agrega tu app al final:
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.
Escribe la vista (el "cerebro" de la página)
Abre catalogo/views.py y reemplaza su contenido por esto:
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)
Crea la plantilla HTML
Crea la carpeta catalogo/templates/catalogo/ (tres niveles) y dentro un archivo 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.
Conecta la URL
Crea el archivo catalogo/urls.py (no existe todavía, créalo tú):
"""
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:
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')),
]
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).
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
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():
[
{ "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 }
]
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.
<!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.
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;
}
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);
});
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.";
});
});
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.";
}
});
});
});
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;
}
);
});
});
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 Application → Manifest, mientras ves tu página con Live Server.
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
})
);
});
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);
});
});
}
Pruébalo con Live Server
- Abre la carpeta
app/completa en VS Code. - Clic derecho en
index.html→ Open with Live Server. - Prueba agregar productos al carrito, activa notificaciones y ubicación (el navegador te pedirá permiso — acepta para probar).
- Abre DevTools (F12) → Application → Service Workers: debe aparecer
sw.jscomo "activated and is running". - Marca la casilla Offline ahí mismo y recarga la página (F5): debe seguir cargando, aunque sin internet.
Parte C · Pruebas y publicación
Audita con Lighthouse
- Con la app abierta por Live Server, abre DevTools (F12).
- Ve a la pestaña Lighthouse.
- Marca al menos la categoría Progressive Web App (puedes dejar las demás también) y haz clic en Analyze page load.
- Revisa el reporte: si algo sale en rojo (por ejemplo, "no maskable icon"), corrígelo y vuelve a correr la auditoría.
Publica en GitHub Pages
- 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"
- 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
- En GitHub, entra al repositorio → Settings → Pages.
- En "Source" elige la rama
mainy la carpeta/ (root)→ Save. - Espera uno o dos minutos y recarga: te dará una URL como
https://tu-usuario.github.io/tienda-escolar-pwa/.
Valida con PWABuilder e instala en tu celular
- Ve a
pwabuilder.com, pega la URL de GitHub Pages y haz clic en Start. - Revisa el reporte: manifest, Service Worker y seguridad deben aparecer en verde.
- 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 →