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.
🏪 ING10-1: ver la guía de mi giro ↓ 🏷️ Subir mi logo y mi menú → 🚀 Entregar mis 4 avances semanales →
Antes de empezar · ¿Qué son CSR y SSR?
Toda esta guía gira alrededor de una sola pregunta: ¿quién arma el HTML que ve el usuario, y cuándo? Hay dos respuestas posibles — CSR y SSR — y vas a construir un ejemplo real de cada una (Parte A y Parte B) para verla con tus propios ojos, no solo leerla.
CSR — Client-Side Rendering
En inglés: Client-Side Rendering.
En español: Renderizado del lado del Cliente (o "en el cliente").
"Renderizar" = construir el HTML final que se ve en pantalla. "Cliente" aquí no es una persona: es
el navegador del usuario (Chrome, Firefox, etc.), por oposición al servidor. En
CSR, el servidor manda un HTML casi vacío más un archivo JavaScript; ese JavaScript, ya corriendo
dentro del navegador, pide los datos (con fetch()) y construye el
contenido después de que la página ya cargó.
SSR — Server-Side Rendering
En inglés: Server-Side Rendering.
En español: Renderizado del lado del Servidor.
Aquí el servidor (en nuestro caso, Django) arma el HTML completo — con los datos ya insertados en el texto — antes de mandarlo. El navegador recibe una página que ya trae todo escrito; no necesita ejecutar JavaScript para ver el contenido.
La diferencia, paso a paso
Compara el orden de los pasos: en SSR los datos llegan dentro del HTML; en CSR llegan después, por separado, y JavaScript los inserta.
Flujo SSR (Parte A, Django)
1. Navegador --- pide /catalogo/ --> Servidor Django
2. Servidor: views.py lee los productos
y los inserta en la plantilla (render())
3. Navegador <-- HTML COMPLETO --- Servidor
(nombres y precios YA están en el HTML)
4. El navegador solo tiene que pintarlo.
Ver "código fuente" (Ctrl+U) muestra
los productos aunque JS esté apagado.
Flujo CSR (Parte B, csr.js)
1. Navegador --- pide index.html --> Servidor estático
2. Navegador <-- HTML CASI VACÍO -- Servidor
(<div id="catalogo"></div> sin productos)
3. El navegador ejecuta csr.js
4. csr.js --- fetch() --> catalogo.json (los datos)
5. csr.js recibe los datos y RECIÉN AHÍ
construye las tarjetas dentro del <div>
(Ver "código fuente" muestra el div vacío)
| SSR (servidor) | CSR (cliente) | |
|---|---|---|
| ¿Dónde se arma el HTML final? | En el servidor, antes de responder | En el navegador, con JavaScript, después de responder |
| ¿Se ve el contenido con JavaScript desactivado? | Sí, completo | No — solo el "esqueleto" vacío |
| ¿Qué trae el HTML crudo (Ctrl+U)? | Los datos ya escritos | Un contenedor vacío |
| ¿Cuándo aparece el contenido en pantalla? | Apenas llega la respuesta | Un instante después: hay que esperar el fetch() |
| Carga de trabajo | La hace el servidor en cada visita | La hace el navegador de cada usuario |
| En esta guía | Parte A · vista Django (views.py + plantilla) | Parte B · js/csr.js + catalogo.json |
Qué aporta cada uno
SSR aporta una primera pintura inmediata y contenido visible para buscadores o navegadores sin JavaScript, a cambio de más trabajo por visita en el servidor. CSR aporta interacciones fluidas sin recargar la página completa (ideal para una PWA que debe funcionar también sin conexión, cacheada por el Service Worker) a cambio de una espera inicial mientras JavaScript hace su trabajo. Por eso esta PWA usa CSR para la app real (Parte B): necesita funcionar offline y sentirse como una app; la Parte A con Django solo existe para que compares el mecanismo con tus propios ojos.
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.
Así debe verse (es normal)
La página SSR se ve sencilla: una lista o tabla con nombre y precio, sin
fotos ni el diseño de tu PWA. Está bien así: las imágenes son opcionales y no se califican; lo
que se evalúa es que tus productos aparezcan escritos en el código fuente (Ctrl+U).
Son dos páginas distintas: tu PWA (index.html con Live Server o GitHub
Pages, con fotos y carrito) es la versión CSR; 127.0.0.1:8000/catalogo/ es la versión SSR
de Django, solo para comparar. Una no reemplaza a la otra.
Si abres 127.0.0.1:8000/ (sin /catalogo/) Django muestra
Page not found (404): es lo esperado, porque solo definiste esa ruta. Si en tu
urls.py le pusiste otro nombre (por ejemplo /catalogo_tunombre/), usa esa.
ING10-1 · Tu giro de negocio
Cada alumno de ING10-1 tiene un negocio asignado. Elige tu nombre para ver la guía de tu giro: qué vendes, a quién, tu producto estrella, 15 productos sugeridos con precio, idea de logo, colores y tips. Úsala para ajustar tu app de ventas (Parte B) y agregar tus 15 productos con fotografía.
🏪Elige tu nombre y aquí aparecerá la guía de tu negocio.
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 ← si usas Workbox (paso 18, camino A) no lo creas a mano: lo genera
Workbox junto con workbox-config.js, workbox-XXXX.js y los .map
catalogo.json
css/
estilos.css
js/
app.js
carrito.js
csr.js
notificaciones.js
geolocalizacion.js
icons/
icon.svg
icon-192.png ← estos dos no los creas ahora: salen del paso 17,
icon-512.png generados a partir de icon.svg con PWABuilder
img/
playera.svg
sudadera.svg
taza.svg
mochila.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(). Cada producto trae también su categoria (la usarás para filtrar en el paso 13) y su foto (un
ícono SVG local en img/, para no depender de internet ni de fotos con derechos de
autor):
[
{ "id": 1, "nombre": "Playera UT Hermosillo", "categoria": "Ropa", "precio": 250, "stock": 40, "imagen": "img/playera.svg" },
{ "id": 2, "nombre": "Sudadera TIC", "categoria": "Ropa", "precio": 480, "stock": 15, "imagen": "img/sudadera.svg" },
{ "id": 3, "nombre": "Taza PWA", "categoria": "Accesorios", "precio": 120, "stock": 60, "imagen": "img/taza.svg" },
{ "id": 4, "nombre": "Mochila 10mo cuatrimestre", "categoria": "Accesorios", "precio": 690, "stock": 8, "imagen": "img/mochila.svg" }
]
Los 4 archivos SVG (íconos simples dibujados a mano, no fotos reales) ya están listos en
sitio/ejemplos/unidad2/app/img/; cópialos a tu propia carpeta img/, o
reemplázalos por fotos reales de tus productos si prefieres (mismo nombre de campo imagen,
cualquier .jpg/.png/.svg funciona igual).
El App Shell — index.html
Este es el "esqueleto" que el Service Worker cacheará completo. Fíjate en el <div
id="catalogo">: no trae productos reales, solo 4 tarjetas "esqueleto" vacías que apartan el
espacio mientras JavaScript los dibuja (lo explica el paso 21).
<!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>
<!-- La descripción es el texto que Google muestra bajo el título en los
resultados de búsqueda (Lighthouse la revisa en la categoría SEO) -->
<meta name="description" content="Tienda Escolar de la UTH: catálogo de productos, carrito de compras y ubicación de la sucursal, disponible incluso sin conexión.">
<!-- 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">
<!-- Leaflet: librería de mapas gratuita (sin API key) que usamos solo para
dibujar la ruta hasta la sucursal (ver js/geolocalizacion.js) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.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). -->
<!-- Mientras llegan los datos, 4 tarjetas "esqueleto" (una por producto de
catalogo.json) apartan el espacio: así el Carrito no "brinca" hacia
abajo cuando csr.js dibuja los productos (Lighthouse lo mide como CLS). -->
<div id="catalogo" class="grid" aria-busy="true" aria-label="Cargando catálogo">
<div class="producto producto--cargando" aria-hidden="true">
<div class="producto__foto"></div>
<strong> </strong> <span class="precio"> </span> <p> </p>
<button type="button" disabled>Agregar al carrito</button>
</div>
<div class="producto producto--cargando" aria-hidden="true">
<div class="producto__foto"></div>
<strong> </strong> <span class="precio"> </span> <p> </p>
<button type="button" disabled>Agregar al carrito</button>
</div>
<div class="producto producto--cargando" aria-hidden="true">
<div class="producto__foto"></div>
<strong> </strong> <span class="precio"> </span> <p> </p>
<button type="button" disabled>Agregar al carrito</button>
</div>
<div class="producto producto--cargando" aria-hidden="true">
<div class="producto__foto"></div>
<strong> </strong> <span class="precio"> </span> <p> </p>
<button type="button" disabled>Agregar al carrito</button>
</div>
</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>Distancia a la sucursal</h2>
<p class="sucursal-info">Sucursal única: <strong>Universidad Tecnológica de Hermosillo (UTH)</strong>, Hermosillo, Sonora.</p>
<button id="btn-ubicacion" type="button">Usar mi ubicación</button>
<p id="ubicacion-mensaje" class="mensaje"></p>
<!-- Empieza oculto: geolocalizacion.js le quita "hidden" y lo llena de mapa
en cuanto tiene tu ubicación y la ruta hasta la sucursal -->
<div id="mapa-ruta" class="mapa" hidden></div>
</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>
<!-- Leaflet debe cargar ANTES de geolocalizacion.js: ese script usa el
objeto global "L" que esta librería crea -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="js/geolocalizacion.js"></script>
<script src="js/app.js"></script>
<!-- Registro del Service Worker. Va aquí, dentro del HTML y en una sola línea
(navigator.serviceWorker.register("sw.js")), porque así lo detecta PWABuilder:
lee el index.html buscando ese texto, y si no lo halla abre la página y pregunta
de inmediato si ya hay un Service Worker; por eso no esperamos al evento "load". -->
<script>
if ("serviceWorker" in navigator) {
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));
}
</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; y
Leaflet se carga antes que geolocalizacion.js por la misma razón (usa su objeto global L).
Si entregas por avances semanales
En el avance 1 todavía no existen manifest.json, icons/icon.svg,
carrito.js, notificaciones.js, geolocalizacion.js, app.js
ni sw.js.
Deja comentadas (<!-- -->) esas líneas, la de Leaflet y el bloque que registra el
Service Worker, y actívalas conforme creas
cada archivo: carrito.js en el avance 2 y las demás en el avance 3. Así tu página en
GitHub Pages abre sin errores 404 en la consola.
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) */
/* El fondo es un verde MÁS OSCURO que la barra: con el blanco semitransparente
el texto blanco quedaba con contraste 4.13:1 y la norma WCAG pide 4.5:1. */
.pill {
background: rgba(0,0,0,.25);
border-radius: 999px;
padding: .25rem .75rem;
font-size: .8rem;
}
.pill.sin-conexion { background: #c2410c; } /* naranja oscuro: 5.2:1 con texto blanco */
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__foto {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 6px;
margin-bottom: .5rem;
background: #f1f5f4;
}
.producto strong { display: block; margin-bottom: .25rem; }
/* #c2410c en vez de #ff7a45: el naranja claro daba contraste 2.58:1 sobre blanco */
.producto .precio { color: #c2410c; font-weight: bold; }
/* Tarjetas esqueleto (ver index.html): ocupan el mismo espacio que las reales
mientras llega catalogo.json, así nada se mueve de lugar (CLS ≈ 0) */
.producto--cargando strong,
.producto--cargando .precio,
.producto--cargando p {
background: #e8f0ed;
border-radius: 4px;
color: transparent;
}
.producto--cargando strong { width: 70%; }
.producto--cargando .precio { display: inline-block; width: 30%; }
.producto--cargando p { width: 45%; }
.producto--cargando button { visibility: hidden; }
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; }
.mensaje a { color: #0e6b5c; }
.sucursal-info { color: #3f5450; font-size: .9rem; margin: 0 0 .6rem; }
/* .mapa: contenedor de Leaflet. Necesita una altura explícita en px/rem;
si se deja en 0 o "auto", Leaflet no tiene dónde dibujar los tiles. */
.mapa {
height: 260px;
border-radius: 8px;
margin-top: .75rem;
border: 1px solid #d7e0dc;
}
/* #carrito-lista: cada renglón separa nombre / cantidad (con +/-) / precio /
quitar en columnas propias, en vez de un solo texto "Nombre x2" pegado. */
#carrito-lista { list-style: none; padding: 0; }
#carrito-lista li {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: .6rem;
border-bottom: 1px solid #e8f0ed;
padding: .5rem 0;
}
.carrito-nombre { font-weight: 600; }
.carrito-cantidad {
display: flex;
align-items: center;
gap: .4rem;
}
.carrito-cantidad button {
padding: .1rem .55rem;
font-size: .95rem;
line-height: 1.4;
}
.carrito-cantidad__valor {
min-width: 1.4rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.carrito-precio { font-weight: bold; color: #c2410c; white-space: nowrap; }
.carrito-quitar {
background: transparent;
color: #b23b3b;
border: 1px solid #e3b8b8;
padding: .25rem .6rem;
font-size: .8rem;
}
.carrito-quitar:hover { background: #fbeaea; color: #8f2d2d; }
@media (max-width: 480px) {
#carrito-lista li { grid-template-columns: 1fr auto; grid-template-areas: "nombre nombre" "cantidad precio" "quitar quitar"; }
.carrito-nombre { grid-area: nombre; }
.carrito-cantidad { grid-area: cantidad; }
.carrito-precio { grid-area: precio; justify-self: end; }
.carrito-quitar { grid-area: quitar; justify-self: start; }
}
La cantidad de cada producto ahora vive en su propia columna, con botones para subirla o bajarla, en vez de ir pegada al nombre como "Playera x2". En pantallas angostas, la lista cambia a un acomodo de dos columnas para que no se encimen los botones.
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 las tarjetas esqueleto y dibujamos las reales
contenedorCatalogo.innerHTML = "";
contenedorCatalogo.removeAttribute("aria-busy");
contenedorCatalogo.removeAttribute("aria-label");
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 = `
<img class="producto__foto" src="${producto.imagen}" alt="${producto.nombre}" loading="lazy">
<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>";
contenedorCatalogo.removeAttribute("aria-busy");
contenedorCatalogo.removeAttribute("aria-label");
console.error("Error al cargar catálogo:", error);
});
Filtra el catálogo por categoría (avance 2)
Cada producto de tu catalogo.json ya trae su categoria. Con tres cambios
en csr.js agregas una fila de botones ("Todos" y uno por categoría) que muestra solo los
productos de la categoría elegida, sin volver a pedir el archivo.
1) Dentro del forEach, justo después de
tarjeta.className = "producto";, guarda la categoría en la tarjeta:
tarjeta.dataset.categoria = producto.categoria; // queda como data-categoria="..." en el HTML
2) Después del forEach (todavía dentro del segundo .then),
crea los botones:
crearFiltros(productos);
3) Al final de csr.js, agrega la función:
// Crea los botones de categoría encima del catálogo y oculta las tarjetas que no coinciden
function crearFiltros(productos) {
// Lista de categorías sin repetir, en el orden en que aparecen en catalogo.json
const categorias = [...new Set(productos.map((p) => p.categoria))];
const barra = document.createElement("div");
barra.className = "filtros";
barra.setAttribute("role", "group");
barra.setAttribute("aria-label", "Filtrar por categoría");
["Todos", ...categorias].forEach((categoria) => {
const boton = document.createElement("button");
boton.type = "button";
boton.textContent = categoria;
boton.setAttribute("aria-pressed", categoria === "Todos" ? "true" : "false");
boton.addEventListener("click", () => {
// marcamos el botón activo
barra.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", "false"));
boton.setAttribute("aria-pressed", "true");
// mostramos u ocultamos cada tarjeta según su data-categoria
contenedorCatalogo.querySelectorAll(".producto").forEach((tarjeta) => {
tarjeta.hidden = categoria !== "Todos" && tarjeta.dataset.categoria !== categoria;
});
});
barra.appendChild(boton);
});
// la barra va justo antes del contenedor del catálogo
contenedorCatalogo.before(barra);
}
Y en css/estilos.css, el estilo de la barra y del botón activo:
/* Botones de categoría (csr.js los crea encima del catálogo) */
.filtros {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-bottom: .75rem;
}
.filtros button {
background: white;
color: #0e6b5c;
border: 1px solid #0e6b5c;
}
.filtros button[aria-pressed="true"] {
background: #0e6b5c;
color: white;
}
Cambia #0e6b5c por el color de tu marca. Pruébalo: al pulsar una categoría deben quedar
solo sus productos, y con "Todos" vuelven los 15.
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();
}
// Sube o baja la cantidad de un producto ya en el carrito con los botones +/-;
// si la cantidad llega a 0, el producto se quita solo.
function cambiarCantidad(id, delta) {
const carrito = leerCarrito();
const item = carrito.find((item) => item.id === id);
if (!item) return;
item.cantidad += delta;
const actualizado = item.cantidad <= 0 ? carrito.filter((item) => item.id !== id) : carrito;
guardarCarrito(actualizado);
pintarCarrito();
}
// Quita un producto del carrito sin importar su cantidad
function quitarDelCarrito(id) {
const carrito = leerCarrito().filter((item) => item.id !== id);
guardarCarrito(carrito);
pintarCarrito();
}
// Dibuja el carrito en pantalla a partir de lo que hay en localStorage.
// La cantidad va en su propia columna (con botones - / +), separada del
// nombre y del precio, en vez del antiguo "Nombre x2" pegado en un solo texto.
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 subtotal = item.precio * item.cantidad;
suma += subtotal;
const li = document.createElement("li");
li.innerHTML = `
<span class="carrito-nombre">${item.nombre}</span>
<span class="carrito-cantidad">
<button type="button" data-accion="restar" data-id="${item.id}" aria-label="Quitar una unidad de ${item.nombre}">−</button>
<span class="carrito-cantidad__valor">${item.cantidad}</span>
<button type="button" data-accion="sumar" data-id="${item.id}" aria-label="Agregar una unidad de ${item.nombre}">+</button>
</span>
<span class="carrito-precio">$${subtotal}</span>
<button type="button" class="carrito-quitar" data-accion="quitar" data-id="${item.id}">Quitar</button>
`;
lista.appendChild(li);
});
total.textContent = suma;
}
// Un solo listener en <ul id="carrito-lista"> atiende los clics de TODOS los
// botones +/-/Quitar (delegación de eventos), incluso los que se crean después
// dinámicamente cada vez que pintarCarrito() vuelve a dibujar la lista.
function manejarClicCarrito(evento) {
const boton = evento.target.closest("button[data-accion]");
if (!boton) return;
const id = Number(boton.dataset.id);
if (boton.dataset.accion === "sumar") cambiarCantidad(id, 1);
if (boton.dataset.accion === "restar") cambiarCantidad(id, -1);
if (boton.dataset.accion === "quitar") quitarDelCarrito(id);
}
// "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("carrito-lista").addEventListener("click", manejarClicCarrito);
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.";
});
});
cambiarCantidad() sube o baja la cantidad con los botones +/- de cada renglón, y la
quita sola si llega a 0. manejarClicCarrito() usa delegación de eventos:
un solo addEventListener en la lista entera, en vez de uno por cada botón — así los
botones que se crean después (cuando pintarCarrito() vuelve a dibujar todo) también
funcionan, sin tener que re-conectarlos cada vez.
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
En vez de solo mostrar la latitud/longitud en crudo, esta versión dibuja un mapa (librería Leaflet, gratuita y sin API key) con dos marcadores — tú y la sucursal — y traza la ruta real por calles entre ambos usando el servicio público de ruteo OSRM. Si no hay internet o el servicio no responde, cae de respaldo a una línea recta (fórmula de Haversine) para que la función nunca se quede sin mostrar nada.
// geolocalizacion.js — Acceso a una característica del dispositivo: el GPS/ubicación,
// más un mapa (librería Leaflet) que dibuja la ruta real desde el usuario
// hasta la sucursal, usando el servicio público de ruteo OSRM.
// El navegador SIEMPRE pide permiso antes de compartir la ubicación.
// Nota: por seguridad, esta API solo funciona en https:// o en localhost.
// Coordenadas de la única sucursal física de la tienda: el campus real de la
// Universidad Tecnológica de Hermosillo (UTH), tomadas directamente del
// enlace "Compartir → Insertar un mapa" de Google Maps para ese lugar.
const SUCURSAL = {
nombre: "Universidad Tecnológica de Hermosillo (UTH)",
lat: 29.00921146687666,
lon: -110.90377522502918,
};
// Fórmula de Haversine: distancia en línea recta (km) entre dos puntos.
// Se usa solo como respaldo si no se puede calcular la ruta real por calles
// (sin internet, o si el servicio de ruteo no responde).
function calcularDistanciaKm(lat1, lon1, lat2, lon2) {
const radioTierraKm = 6371;
const aRadianes = (grados) => (grados * Math.PI) / 180;
const dLat = aRadianes(lat2 - lat1);
const dLon = aRadianes(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(aRadianes(lat1)) * Math.cos(aRadianes(lat2)) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return radioTierraKm * c;
}
// Le pide al servicio público OSRM la ruta real por calles entre el usuario y
// la sucursal. OSRM es gratuito y no necesita API key, pero su servidor de
// demostración no está pensado para producción (límite de peticiones);
// en un proyecto real conviene un servidor propio o un proveedor de pago.
// Devuelve { coords, distanciaKm, duracionMin } listo para dibujar, o null si falla.
async function obtenerRuta(origenLat, origenLon) {
const url =
"https://router.project-osrm.org/route/v1/driving/" +
`${origenLon},${origenLat};${SUCURSAL.lon},${SUCURSAL.lat}` +
"?overview=full&geometries=geojson";
try {
const respuesta = await fetch(url);
const datos = await respuesta.json();
if (datos.code !== "Ok") return null;
const ruta = datos.routes[0];
return {
// GeoJSON trae las coordenadas como [lon, lat]; Leaflet las quiere [lat, lon]
coords: ruta.geometry.coordinates.map(([lon, lat]) => [lat, lon]),
distanciaKm: ruta.distance / 1000,
duracionMin: ruta.duration / 60,
};
} catch (error) {
console.error("No se pudo calcular la ruta:", error);
return null;
}
}
let mapa; // instancia de Leaflet: se crea una sola vez y luego se reutiliza
// Dibuja el mapa con dos marcadores (tú y la sucursal) y la ruta entre ambos.
// "coordsRuta" es un array de [lat, lon] devuelto por OSRM, o null si no se
// pudo obtener — en ese caso se traza una línea recta punteada como respaldo.
function dibujarMapa(origenLat, origenLon, coordsRuta) {
const contenedor = document.getElementById("mapa-ruta");
contenedor.hidden = false;
if (!mapa) {
// "L" es el objeto global que crea la librería Leaflet (cargada en index.html)
mapa = L.map(contenedor);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© colaboradores de OpenStreetMap",
maxZoom: 19,
}).addTo(mapa);
} else {
// si ya existía un mapa de una búsqueda anterior, quita los marcadores/línea viejos
mapa.eachLayer((capa) => {
if (capa instanceof L.Marker || capa instanceof L.Polyline) mapa.removeLayer(capa);
});
}
const puntoUsuario = [origenLat, origenLon];
const puntoSucursal = [SUCURSAL.lat, SUCURSAL.lon];
L.marker(puntoUsuario).addTo(mapa).bindPopup("Tú estás aquí").openPopup();
L.marker(puntoSucursal).addTo(mapa).bindPopup(SUCURSAL.nombre);
const hayRuta = Array.isArray(coordsRuta) && coordsRuta.length > 0;
const linea = hayRuta ? coordsRuta : [puntoUsuario, puntoSucursal];
const estiloLinea = hayRuta
? { color: "#0e6b5c", weight: 5 }
: { color: "#ff7a45", weight: 3, dashArray: "6 6" }; // punteada = "línea recta", no ruta real
L.polyline(linea, estiloLinea).addTo(mapa);
mapa.fitBounds(L.latLngBounds([puntoUsuario, puntoSucursal]), { padding: [30, 30] });
// Bug común de Leaflet: si el contenedor estaba oculto (hidden) al crear el
// mapa, no calcula bien su tamaño hasta que se le avisa explícitamente.
setTimeout(() => mapa.invalidateSize(), 0);
}
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
async (posicion) => {
const { latitude, longitude } = posicion.coords;
const mapaUrl = `https://www.openstreetmap.org/directions?from=${latitude}%2C${longitude}&to=${SUCURSAL.lat}%2C${SUCURSAL.lon}`;
mensaje.textContent = "Calculando la ruta hasta la sucursal…";
const ruta = await obtenerRuta(latitude, longitude);
dibujarMapa(latitude, longitude, ruta ? ruta.coords : null);
if (ruta) {
mensaje.innerHTML =
`Ruta hasta ${SUCURSAL.nombre}: <strong>${ruta.distanciaKm.toFixed(1)} km</strong> · ` +
`~${Math.round(ruta.duracionMin)} min en auto. ` +
`<a href="${mapaUrl}" target="_blank" rel="noopener">Abrir en el mapa</a>`;
} else {
const distanciaKm = calcularDistanciaKm(latitude, longitude, SUCURSAL.lat, SUCURSAL.lon);
mensaje.innerHTML =
`No se pudo calcular la ruta por calles; línea recta de ` +
`<strong>${distanciaKm.toFixed(1)} km</strong> hasta ${SUCURSAL.nombre}. ` +
`<a href="${mapaUrl}" target="_blank" rel="noopener">Abrir en el mapa</a>`;
}
},
// 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;
}
);
});
});
Ajusta la sucursal a la tuya
El objeto SUCURSAL ya trae las coordenadas reales de la UTH. Si tu propia tienda no es
la UTH, reemplázalas por la ubicación real de tu tienda/escuela: abre Google Maps, clic derecho
exactamente sobre el punto y clic en las coordenadas que aparecen arriba del menú — se copian al
portapapeles listas para pegar. Y recuerda
que router.project-osrm.org es un servidor de demostración gratuito: para un proyecto
en producción real conviene uno propio o un proveedor de ruteo de pago.
El manifest — manifest.json
Este archivo es lo que le dice al navegador "esto se puede instalar como app". Cada campo importa:
{
"id": "./",
"name": "Tienda Escolar",
"short_name": "Tienda",
"description": "PWA de ventas de práctica para la Unidad 2",
"start_url": "./index.html",
"scope": "./",
"display": "standalone",
"orientation": "portrait-primary",
"categories": ["shopping", "education"],
"background_color": "#f1f5f4",
"theme_color": "#0e6b5c",
"icons": [
{
"src": "icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
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: Chrome instala con un solo SVG "any", pero PWABuilder (Parte C) exige además íconos PNG de 192×192 y 512×512 — sin ellos marca un error rojo, no solo una sugerencia.id: identificador único de la app; si cambias de dominio o de URL, el sistema operativo la sigue reconociendo como "la misma app" en vez de instalarla dos veces.orientation: en qué orientación prefieres que abra (aquí"portrait-primary", vertical, porque el catálogo es una lista).categories: ayuda a las tiendas de apps a clasificar tu PWA (no afecta a Chrome).
Cómo generar los PNG sin instalar nada
No necesitas Photoshop ni instalar librerías: ve a
pwabuilder.com → sube tu icon.svg a su generador de imágenes ("Image
Generator" dentro del flujo de "Package For Stores"), o usa cualquier convertidor SVG→PNG en línea
(ej. cloudconvert.com) exportando a 192×192 y 512×512. Guarda los resultados como
icons/icon-192.png e icons/icon-512.png.
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
Elige solo uno de los dos caminos. Si generas el sw.js con Workbox
(camino A), no copies el código del camino B: lo reemplazarías.
Camino A — generarlo con Workbox (recomendado, así lo pide el programa):
1) Abre la terminal dentro de la carpeta de tu app. Debe ser la carpeta donde está
index.html (la que en el paso 9 llamamos app/; en tu computadora puede
llamarse distinto, por ejemplo app_pdb). En VS Code: Terminal → New Terminal. Verifica que la
ruta del prompt termine en esa carpeta, por ejemplo:
PS C:\...\app_pdb>
2) Instala Workbox CLI (solo la primera vez; -g lo instala para toda la
computadora):
npm install -g workbox-cli
Puede aparecer un aviso como npm warn deprecated glob@11.1.0: Old versions of glob are not
supported.... Es solo una advertencia de una dependencia interna de Workbox: ignórala.
Si al final dice changed/added NNN packages, la instalación funcionó.
3) Corre el asistente:
workbox wizard
Te hará 5 preguntas. Muévete con las flechas, marca o desmarca con la barra espaciadora y confirma con Enter:
What is the root of your web app (i.e. which directory do you deploy)?
La lista solo muestra las subcarpetas (js,img,icons,css). No elijas ninguna: tu raíz es la carpeta actual, y esa no aparece. Baja hastaManually enter path, pulsa Enter y escribe un punto:
(el..significa "esta misma carpeta", la que tieneindex.html).Which file types would you like to precache?
Deja marcadas todas las extensiones que aparezcan. La lista sale de los archivos que tienes: en la Tienda Escolar sonjs,html,json,svg,pngycss(elpnges paraicon-192.pngeicon-512.png). Si las fotos de tus productos son.jpgo.webp, también saldránjpgowebp: márcalas, o tus fotos no se verán sin internet. De todos modos, el archivo corregido del punto 4 ya las incluye.Where would you like your service worker file to be saved?
Escribe o aceptasw.js.Where would you like to save these configuration options?
Escribe o aceptaworkbox-config.js.Does your web app manifest include search parameter(s) in the 'start_url', other than 'utm_' or 'fbclid' (like '?source=pwa')?
Responde No (Enter, on+ Enter). Elstart_urlde nuestromanifest.json(paso 17) no lleva parámetros como?source=pwa.
Al terminar verás este mensaje. Es normal: todavía no existe el sw.js,
solo la configuración.
To build your service worker, run
workbox generateSW workbox-config.js
as part of a build process.
4) Revisa workbox-config.js antes de generar. El asistente lo creó en tu
carpeta con este contenido:
module.exports = {
globDirectory: '.',
globPatterns: [
'**/*.{js,html,json,svg,png,css}'
],
swDest: 'sw.js',
ignoreURLParametersMatching: [
/^utm_/,
/^fbclid$/
]
};
globDirectory: '.': dónde buscar archivos (la carpeta actual, lo que escribiste en la pregunta 1).globPatterns: qué archivos se guardan en caché (las extensiones de la pregunta 2, en todas las subcarpetas).swDest: el nombre del service worker que se va a generar.ignoreURLParametersMatching: ignora parámetros de rastreo (utm_...,fbclid) para que la misma página no se guarde dos veces.
Cuidado: el patrón **/*.html también atrapa tus reportes
Si en la carpeta de la app tienes reportes de Lighthouse (reporte-lighthouse.html,
localhost_....report.html, del paso 21), la carpeta docs/ (donde el paso 21
guarda los reportes y va tu evidencia) o la carpeta servidor-ssr/ de la Parte A, Workbox
los mete en la caché. Así, un reporte de ~500 kB lo descarga el teléfono de
cada visitante sin necesidad, y el venv/ de Django podría sumar cientos de archivos. Agrega
globIgnores para excluirlos. Así queda el archivo corregido:
module.exports = {
globDirectory: '.',
globPatterns: [
// jpg, jpeg y webp: las fotos de tus 15 productos
'**/*.{js,html,json,svg,png,jpg,jpeg,webp,css}'
],
// Archivos que NO son parte de la app: no se precachean
globIgnores: [
'**/node_modules/**/*',
'servidor-ssr*/**/*',
'docs/**/*',
'**/*.report.html',
'reporte-lighthouse*.html',
'workbox-config.js'
],
swDest: 'sw.js',
// Activa el Service Worker nuevo de inmediato y toma control de las pestañas abiertas
skipWaiting: true,
clientsClaim: true,
ignoreURLParametersMatching: [
/^utm_/,
/^fbclid$/
]
};
Además de globIgnores, la versión corregida cambia o agrega tres cosas que el asistente no
pone:
jpg,jpeg,webpenglobPatterns: el catálogo de tu giro usa fotos.jpg(ve"imagen": "img/....jpg"en tucatalogo.json). Si su extensión no está en la lista, Workbox no las guarda y sin internet aparecen las tarjetas sin foto. Las extensiones que no tengas no estorban.skipWaiting: true: sin esta opción, cadasw.jsnuevo que generes se queda en "waiting to activate" en DevTools hasta que cierres todas las pestañas de la app, y mientras tanto sigues viendo la versión vieja. Con ella se activa de inmediato (es el mismoself.skipWaiting()del camino B).clientsClaim: true: el Service Worker toma control de las pestañas ya abiertas sin tener que recargarlas (es elself.clients.claim()del camino B).
Para confirmar que quedaron, busca skipWaiting() y clientsClaim() dentro del
sw.js generado.
5) Genera el Service Worker (en la misma carpeta):
workbox generateSW workbox-config.js
Si todo salió bien, verás algo así:
Using configuration from C:/.../app_pdb/workbox-config.js.
The service worker files were written to:
• C:/.../app_pdb/sw.js
• C:/.../app_pdb/sw.js.map
• C:/.../app_pdb/workbox-ee13fbdc.js
• C:/.../app_pdb/workbox-ee13fbdc.js.map
The service worker will precache 16 URLs, totaling 44.7 kB.
Las cifras exactas cambian según tus archivos y el código ee13fbdc puede ser otro. Revisa el
número de URLs, que debe ser exactamente el número de archivos de tu app:
9 index.html, manifest.json, catalogo.json, css/estilos.css y los 5 de js/
+ 3 íconos de icons/ (icon.svg, icon-192.png, icon-512.png)
+ N imágenes de img/ (una por producto, más tu logo si está ahí)
-----
Tienda Escolar (4 productos): 9 + 3 + 4 = 16 URLs
Tu app (15 productos, sin logo): 9 + 3 + 15 = 27 URLs
Si te salen más, se coló algo que no es de la app: con 17 URLs y unos
550 kB en la Tienda Escolar, era reporte-lighthouse.html. Agrega el
globIgnores del punto 4 y vuelve a correr el comando. Si te salen menos,
falta alguna extensión en globPatterns (por ejemplo jpg). Si sale
0, globDirectory está mal: debe ser '.'. Anota tu número: lo
volverás a revisar en los pasos 21 y 22.
6) Qué archivo es cada uno. Workbox creó 4 archivos y se suben todos:
sw.js: el Service Worker que registrarás en el paso 19 (register("sw.js")).workbox-ee13fbdc.js(el código cambia): la librería de Workbox.sw.jsla carga conimportScripts, así que no la borres ni la renombres. Si falta en GitHub Pages (paso 22), el Service Worker falla.sw.js.mapyworkbox-ee13fbdc.js.map: solo sirven para depurar en DevTools; no estorban.workbox-config.js: tu configuración. Guárdalo para volver a generar.
Puedes comprobar qué quedó en caché abriendo sw.js: la lista
{url:"index.html",revision:"..."} muestra cada archivo precacheado.
Si cambias opciones de workbox-config.js (como skipWaiting), el
código de la librería puede cambiar (por ejemplo, de workbox-86637ee2.js a
workbox-ee13fbdc.js) y el archivo anterior se queda en la carpeta sin usarse. Para saber cuál
es el bueno, busca workbox- dentro de sw.js: borra los otros
workbox-XXXX.js y .map para no subir basura al repositorio.
Cada vez que cambies un archivo de la app (HTML, CSS, JS, imágenes), vuelve a correr
workbox generateSW workbox-config.js. Workbox recalcula las revision y el navegador
descarga la versión nueva. Así ya quedó listo el paso 18: continúa con el paso 19.
Camino B — escribirlo a mano (para entender exactamente qué hace; sáltalo si usaste el camino A):
📄 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",
"./icons/icon-192.png",
"./icons/icon-512.png",
"./img/playera.svg",
"./img/sudadera.svg",
"./img/taza.svg",
"./img/mochila.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
// Ignora peticiones que no sean http(s): extensiones del navegador (Grammarly,
// gestores de contraseñas, bloqueadores de anuncios...) también disparan "fetch"
// con URLs "chrome-extension://", y Cache Storage no acepta guardarlas.
if (!evento.request.url.startsWith("http")) return;
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: mostrar el estado de conexión (el Service Worker se registra en index.html)
// 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);
El registro del Service Worker está en index.html, no aquí. Es el
<script> que va justo después de app.js, al final del
<body> (paso 11):
<script>
if ("serviceWorker" in navigator) {
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));
}
</script>
"serviceWorker" in navigatorcomprueba que el navegador soporte la API antes de usarla (Safari viejo, por ejemplo, no).register("sw.js")instala el archivo que generó Workbox en el paso 18. La ruta es relativa, así que funciona igual en Live Server y en GitHub Pages (que publica tu app en una subcarpeta).
Por qué en index.html y no en app.js
Funciona en los dos lugares: Chrome registra el Service Worker igual. La diferencia está en PWABuilder (paso 23), que lo busca de dos maneras:
- Lee tu
index.htmlbuscando el texto exactonavigator.serviceWorker.register("..."). Si está enapp.js, o partido en dos líneas (navigator.serviceWorkeren una línea y.register(en otra), no lo encuentra. - Si no lo encuentra, abre tu página en un Chrome automático y, en cuanto termina de cargar, pregunta
si ya hay un Service Worker registrado. Si esperas al evento
load(window.addEventListener("load", ...)), el registro apenas está empezando en ese momento y PWABuilder responde "did not find a Service Worker".
Si tu app.js tenía el registro dentro de window.addEventListener("load", ...),
bórralo de ahí y déjalo solo en index.html. Como cambiaste index.html y
app.js, vuelve a correr workbox generateSW workbox-config.js.
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 y entiende cada puntaje
Lighthouse es un auditor que viene dentro de Chrome. Carga tu página simulando un celular de gama media con 4G lento y te da cinco calificaciones de 0 a 100. Con el código de esta guía (con las correcciones que se explican al final de este paso) el resultado esperado es el siguiente. Performance puede variar de 1 a 3 puntos entre corridas, y eso es normal:
| Performance | Accessibility | Best Practices | SEO | Agentic Browsing | |
|---|---|---|---|---|---|
| Antes de corregir | 75 | 92 | 100 | 91 | 57 |
| Después de corregir | 97 | 100 | 100 | 100 | 100 |
1) Ejecuta la auditoría. Antes de empezar, abre la app con Live Server y confirma que
http://localhost:5500/index.html carga en el navegador. Es mejor localhost que
127.0.0.1: Chrome lo trata como origen seguro, que es lo que necesitan el Service Worker y
la instalación de la PWA. Revisa también que sea la carpeta que estás editando: si Live Server se abrió
desde otra carpeta, auditarás otra copia de tu app.
Camino A · desde la terminal (recomendado). Solo necesitas Node.js (el mismo de
Workbox en el paso 18) y Google Chrome instalado. Deja Live Server encendido y abre una segunda
terminal (Terminal → New Terminal) en la carpeta de tu app, la misma del paso 18 donde está
index.html.
Primero crea la carpeta docs/, donde vivirán tus reportes. Es la misma
carpeta de la evidencia de pruebas, y así los reportes no quedan sueltos junto a index.html:
mkdir docs
Si dice que ya existe, no pasa nada. Ahora genera los dos reportes, uno para celular y otro para escritorio. Copia cada comando completo, en una sola línea:
npx lighthouse http://localhost:5500/index.html --chrome-flags="--headless=new" --output=html --output-path=./docs/lighthouse-movil.html --view
npx lighthouse http://localhost:5500/index.html --preset=desktop --chrome-flags="--headless=new" --output=html --output-path=./docs/lighthouse-escritorio.html --view
Qué hace cada parte:
npx lighthouse: ejecuta Lighthouse sin instalarlo. La primera vez pregunta "Ok to proceed? (y)": escribeyy Enter. Tarda un poco porque lo descarga.http://localhost:5500/index.html: la página que se audita (la de Live Server).--preset=desktop: solo en el segundo comando. Sin él, Lighthouse simula un celular de gama media con 4G lento, que es el modo más exigente.--chrome-flags="--headless=new": corre Chrome sin abrir ventana, para que no le muevas nada mientras mide.--output=html --output-path=./docs/...: guarda el reporte endocs/con un nombre fijo. Sin esto, se guardaría en la carpeta actual con un nombre comolocalhost_2026-10-05_00-01-50.report.html.--view: al terminar, abre el reporte en tu navegador para que lo veas.
Cada comando tarda de 20 a 40 segundos. Cuando termina, el reporte se abre solo en Chrome. Si no se
abre, o si quieres verlo después, entra a la carpeta docs/ y dale doble clic a
lighthouse-movil.html o a lighthouse-escritorio.html. Es un archivo
independiente: funciona sin Live Server y sin internet. Tu carpeta debe quedar así:
app/
index.html
sw.js
workbox-config.js
...
docs/
lighthouse-movil.html ← reporte modo celular
lighthouse-escritorio.html ← reporte modo escritorio
Los reportes en docs/ se suben a GitHub junto con tu código en el paso 22, como parte de tu
evidencia. Pero no deben entrar en la caché de la PWA: cada reporte pesa unos 600 kB y el
celular de cada visitante lo descargaría sin necesidad. Por eso el workbox-config.js del paso
18 tiene 'docs/**/*' en globIgnores. Compruébalo volviendo a generar el Service
Worker con los reportes ya creados:
workbox generateSW workbox-config.js
Debe seguir diciendo el mismo número de URLs que anotaste en el paso 18 (16 en la Tienda
Escolar, 44.7 kB). Si sube 2 URLs y más de 1 MB, a tu globIgnores le falta la línea
'docs/**/*'.
Así deben verse tus dos reportes. Estas capturas son de los reportes reales de la Tienda Escolar de esta guía, generados con los dos comandos de arriba (Lighthouse 13.5). Haz clic en cualquiera para abrir el reporte completo e interactivo:
docs/lighthouse-movil.html · modo celular
Ejemplo
docs/lighthouse-escritorio.html · modo escritorio
| Performance | Accessibility | Best Practices | SEO | Agentic Browsing | FCP | LCP | TBT | CLS | SI | |
|---|---|---|---|---|---|---|---|---|---|---|
| Celular | 97 | 100 | 100 | 100 | 2/2 | 2.1 s | 2.1 s | 0 ms | 0 | 2.1 s |
| Escritorio | 99 | 100 | 100 | 100 | 2/2 | 0.7 s | 0.7 s | 0 ms | 0 | 0.7 s |
Cómo comparar tu reporte con estos:
- Los cinco círculos de arriba deben estar en verde. Agentic Browsing no muestra un número sino 2/2 (pasó sus 2 auditorías), que equivale al 100 de la tabla del inicio de este paso.
- La URL de arriba a la izquierda debe ser
http://localhost:5500/index.html. Si dice otra cosa, auditaste otra página. - La miniatura de la derecha debe mostrar tu app con los productos ya dibujados. Si sale en blanco o con "Cargando catálogo…", el catálogo no cargó.
- En celular, FCP sale en naranja (2.1 s). Es normal y se explica en el punto 5. En escritorio, todo sale en verde porque se simula una computadora con buena conexión.
- Más abajo, en Insights, verás triángulos rojos como "Render-blocking requests". Son sugerencias: no bajan el puntaje directamente (punto 2).
- Performance puede variar de 1 a 3 puntos en cada corrida. Si te sale 95 o 96, está bien.
Camino B · desde Chrome DevTools. Es más visual, pero depende de cómo esté configurado tu Chrome:
- Con la página ya cargada, abre DevTools (F12) y ve a la pestaña Lighthouse.
- Modo Navigation, dispositivo Mobile, marca todas las categorías y haz clic en Analyze page load.
- Para guardarlo, en el menú ⋮ de arriba a la derecha del reporte elige
Save as HTML y guárdalo en tu carpeta
docs/comolighthouse-movil.html. Repite con dispositivo Desktop y guárdalo comolighthouse-escritorio.html.
| Camino A · terminal | Camino B · DevTools (F12) | |
|---|---|---|
| ¿Qué Chrome usa? | Uno limpio y temporal en cada corrida | El tuyo, con tu perfil, tu caché y tu configuración |
| Advertencia de seguridad (interstitial) | No aparece | Puede bloquear la auditoría y dejar todo en "Error!" |
| Extensiones (antivirus, bloqueadores…) | Ninguna | Pueden alterar o bloquear el resultado |
| Resultados | Más estables entre corridas | Varían más |
| Guardar el reporte | Automático, como archivo .html | Hay que exportarlo a mano (menú ⋮ del reporte) |
Las métricas y los puntajes son los mismos en los dos caminos, porque usan el mismo motor. La diferencia es solo el entorno en el que se ejecutan.
Si falla en la terminal
ECONNREFUSEDo "Unable to connect": Live Server está apagado o usa otro puerto. Revisa el número en la barra inferior de VS Code ("Port: 5500").- "npx no se reconoce como un comando": falta Node.js. Instálalo desde
nodejs.org(versión LTS) y abre una terminal nueva. - "Chrome could not be found" o "No Chrome installations found": instala Google Chrome. Lighthouse no funciona con otros navegadores.
- Los puntajes no cambian después de corregir algo: estás auditando otra carpeta. Abre la URL en el navegador y confirma con Ctrl+U que el HTML servido tenga tus cambios.
Si todas las métricas salen en "Error!" (interstitial)
Si el reporte dice "Chrome prevented page load with an interstitial" o "redirected to chrome-error://chromewebdata/", el problema no es tu código. Chrome mostró una pantalla de advertencia antes de la página y Lighthouse no pudo medir nada. Las causas típicas son:
- Tienes activado "Usar siempre conexiones seguras" en
chrome://settings/security. Chrome intenta abrirhttps://y Live Server no tiene HTTPS. Desactívalo o prueba en una ventana de Invitado. - Analizaste otra pestaña, o Live Server estaba apagado. Revisa que la página abra normal antes de auditar.
- Una extensión (antivirus, bloqueador) intercepta la página. Usa la ventana de Invitado, que no tiene extensiones.
- Live Server está sirviendo otra carpeta. Revisa que el archivo que abre sea el mismo que estás editando.
La solución más rápida es usar el Camino A (terminal): como abre un Chrome limpio, no le afecta ninguna de estas causas.
Ojo: las versiones viejas de Lighthouse tenían una categoría "Progressive Web App". Se eliminó en Lighthouse 12. Para validar la PWA ahora se usa DevTools → Application → Manifest y Service Workers, además de PWABuilder (paso 23).
2) Cómo se leen los puntajes. Cada categoría va de 0 a 100: 0–49 rojo (deficiente), 50–89 naranja (necesita mejorar) y 90–100 verde (bueno). Hay dos formas de calcularlos:
- Performance usa métricas medidas en segundos o milisegundos. Cada una se convierte en una calificación de 0 a 1 comparándola con sitios reales, y después se hace un promedio ponderado.
- Accessibility, Best Practices y SEO usan auditorías de pasa / no pasa. El puntaje es el promedio ponderado de las que pasaron. Una sola auditoría fallida puede bajar varios puntos: a SEO le faltaba solo una etiqueta y quedó en 91.
- Las auditorías en gris (Not applicable o Manual) no cuentan. Las secciones Insights y Diagnostics son sugerencias: no suman ni restan directamente.
3) Qué mide cada categoría.
⚡ Performance (rendimiento). Mide qué tan rápido aparece la página y qué tan estable se ve mientras carga. Es el promedio ponderado de cinco métricas:
| Métrica | Peso | Qué representa | Meta (verde) |
|---|---|---|---|
| FCP · First Contentful Paint | 10 % | Cuándo aparece el primer texto o imagen, es decir, cuándo el usuario deja de ver la pantalla en blanco. | ≤ 1.8 s |
| LCP · Largest Contentful Paint | 25 % | Cuándo aparece el elemento más grande visible, normalmente el contenido principal. | ≤ 2.5 s |
| TBT · Total Blocking Time | 30 % | Cuánto tiempo el JavaScript "congela" la página (tareas de más de 50 ms) y no deja hacer clic. | ≤ 200 ms |
| CLS · Cumulative Layout Shift | 25 % | Cuánto se mueven solos los elementos mientras carga, por ejemplo un botón que se recorre justo cuando lo vas a tocar. No tiene unidades; 0 significa que nada se movió. | ≤ 0.1 |
| SI · Speed Index | 10 % | Qué tan rápido se va "llenando" visualmente la pantalla. | ≤ 3.4 s |
Así se calculó el puntaje (calificación de cada métrica × su peso):
Métrica peso antes después
FCP 10 0.79 0.80
LCP 25 0.95 0.95
TBT 30 1.00 1.00
CLS 25 0.14 1.00
SI 10 0.97 0.99
--------------------------------
Total (Σ calificación × peso)
≈ 75 ≈ 97
El CLS de 0.531 se llevaba casi todos los puntos perdidos. Al dejarlo en 0 se recuperaron 22 puntos.
♿ Accessibility (accesibilidad). Revisa que personas con discapacidad puedan usar la
página: contraste de colores suficiente, alt en las imágenes, botones con nombre, idioma
declarado (lang="es"), orden de encabezados, etc. Para texto normal, la norma WCAG AA pide un
contraste de al menos 4.5 : 1. Un 100 aquí no garantiza que la página sea totalmente
accesible, porque la revisión automática solo detecta una parte; también conviene probarla con el
teclado.
✅ Best Practices (buenas prácticas). Revisa seguridad y calidad general: HTTPS, que no haya errores en la consola, que no se usen APIs obsoletas y que no se pidan permisos (ubicación, notificaciones) nada más cargar la página. La tienda saca 100 porque pide la ubicación y las notificaciones solo cuando el usuario presiona un botón.
🔎 SEO (buscadores). Revisa lo básico para que Google encuentre y muestre la página:
<title>, meta description, viewport para móviles, enlaces con texto
descriptivo y que la página no esté bloqueada con noindex.
🤖 Agentic Browsing (navegación por agentes de IA). Es una categoría nueva de Lighthouse 13, todavía experimental. Mide qué tan fácil es que un agente de inteligencia artificial (un asistente que navega y hace clic por ti) entienda y use la página. Hoy solo califica dos cosas: que el árbol de accesibilidad esté bien formado (los agentes "leen" la página igual que un lector de pantalla) y el CLS (si los botones se mueven, el agente hace clic en el lugar equivocado). Las auditorías de WebMCP, llms.txt y ai-catalog.json son opcionales y salen como "Not applicable".
ANTES: (árbol 1 + CLS 0.14) / 2
= 0.57 → 57
DESPUÉS: (árbol 1 + CLS 1.00) / 2
= 1.00 → 100
4) Qué se corrigió (ya viene aplicado en los pasos 11, 12 y 13).
| Qué marcaba Lighthouse | Por qué pasaba | Corrección |
|---|---|---|
| CLS 0.531 (Performance y Agentic Browsing) | El catálogo es CSR: el HTML traía solo "Cargando catálogo…" (una línea). Cuando
csr.js dibujaba las 4 tarjetas con foto cuadrada, el Carrito y todo lo demás
"brincaba" cientos de píxeles hacia abajo. |
index.html trae 4 tarjetas esqueleto
(.producto--cargando) con la misma estructura y tamaño que las reales, y
csr.js las reemplaza sin que nada se mueva. Si agregas productos a
catalogo.json, agrega también una tarjeta esqueleto. |
| Contraste insuficiente (Accessibility) | La píldora "conectado" tenía texto blanco sobre verde claro (4.13 : 1). Los precios eran
naranja #ff7a45 sobre blanco (2.58 : 1). |
En estilos.css: el fondo de .pill cambió a
rgba(0,0,0,.25) (verde más oscuro) y el naranja a #c2410c
(5.2 : 1). |
| Falta meta description (SEO) | Google no tenía un resumen para mostrar debajo del título en los resultados de búsqueda. | Se agregó <meta name="description" content="…"> en el
<head>. |
Después de cambiar archivos, actualiza el Service Worker
La PWA guarda index.html, el CSS y el JS en caché. Si los cambias y no
cambias sw.js, el navegador sigue mostrando la versión vieja. Sube el número de
NOMBRE_CACHE (v1 → v2…) o, si usaste Workbox (camino A del paso
18), vuelve a correr workbox generateSW workbox-config.js. Para verlo en tu navegador,
ve a DevTools → Application → Storage → Clear site data y
recarga. Lighthouse no tiene este problema porque borra la caché antes de cada análisis.
5) ¿Por qué no llega a 100 en Performance? Solo FCP sigue en naranja (2.1 s en el
celular simulado). El CSS de Leaflet se descarga de un CDN en el <head> y el
navegador no pinta nada hasta tenerlo, aunque el mapa solo se usa al presionar "Usar mi ubicación". Es
opcional corregirlo, porque 97 ya es verde. Si quieres el 100, carga ese CSS sin bloquear:
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"
media="print" onload="this.media='all'">
Toma captura del reporte final (los cinco círculos en verde): es parte de tu evidencia de pruebas.
Publica en GitHub Pages — de cero, sin saltarte nada
GitHub Pages solo sirve archivos estáticos, y para que funcione necesita encontrar
tu index.html justo en la raíz del repositorio. Por eso el repositorio
que subas no debe llamarse "app" con el HTML adentro: el contenido de app/ (todo lo que
hiciste en la Parte B) va directo en la raíz del repo. La carpeta
servidor-ssr/ (Django, Parte A) puede ir como una subcarpeta más dentro de ese mismo
repositorio — no se publica ni se ejecuta en Pages, pero queda ahí como evidencia del código SSR.
1) Organiza la carpeta que vas a subir. Crea una carpeta nueva para el repositorio
(el nombre será parte de tu URL pública y debe llevar tu apellido paterno, apellido materno y
nombres: tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres, ej.
kristal-bubble-bar-pwa_moreno-almeida-yoshelyn-kristal — ver
Convención de nombres), copia dentro TODO el
contenido de app/ (no la carpeta "app" en sí, su contenido) y, junto a eso, copia también
la carpeta servidor-ssr/ con todo tu código Django (el venv/ que tenga
adentro se queda fuera del repositorio gracias al .gitignore de abajo):
tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres/ <- esta carpeta es el repositorio (la raíz)
.gitignore <- lista lo que Git NO debe subir (ver abajo)
README.md
index.html <- lo que era app/index.html, ahora en la raíz
manifest.json
catalogo.json
sw.js <- generado por Workbox (paso 18)
sw.js.map
workbox-config.js
workbox-ee13fbdc.js <- el código del nombre puede ser otro; sin este archivo sw.js falla
workbox-ee13fbdc.js.map
css/
js/
icons/
img/
docs/ <- reportes de Lighthouse (paso 21) y tu evidencia
lighthouse-movil.html
lighthouse-escritorio.html
servidor-ssr/ <- la carpeta Django como subcarpeta
manage.py
ssr_demo/
catalogo/
venv/ <- puede estar en tu disco, pero NO se sube (lo ignora .gitignore)
Si entregas por avances semanales
Este árbol es el repositorio terminado. En el avance 1 publicas antes de tenerlo
todo: tu carpeta solo lleva index.html, catalogo.json, css/,
js/csr.js, img/ y el README.md. Lo demás lo agregas en cada
avance: carrito.js, servidor-ssr/ y el .gitignore en el avance 2;
manifest.json, icons/, los demás scripts, el bloque que registra el Service
Worker en index.html y los archivos de Workbox (sw.js,
workbox-config.js, workbox-XXXX.js y los .map) en el avance 3;
docs/ con los reportes de Lighthouse, las capturas y el PDF en el avance 4. Súbelo a
ese mismo repositorio con git add ., git commit -m "Avance N - ..." y
git push; no vuelvas a hacer git init.
Si trabajas en una carpeta y tu repositorio está en otra
Es común hacer los pasos 18 a 21 en tu carpeta de trabajo (app/) y tener el repositorio
de un avance anterior en otra carpeta. Antes de subir, pasa al repositorio todo lo que
cambió:
- Copia
index.html,manifest.json,catalogo.json,sw.js,sw.js.map,workbox-config.js, elworkbox-XXXX.jscon su.map, y las carpetascss/,js/,icons/,img/ydocs/. - Si en el repositorio quedó un
workbox-XXXX.jscon otro código (de un avance anterior), bórralo junto con su.map. El bueno es el que aparece dentro desw.js. - Dentro del repositorio, vuelve a generar el Service Worker y revisa que salga el mismo número de
URLs que anotaste en el paso 18 (16 en la Tienda Escolar). Así compruebas que la carpeta Django no se coló, porque
globIgnoresla excluye con'servidor-ssr*/**/*':workbox generateSW workbox-config.js - Revisa qué va a subir Git antes del commit:
git statusDeben aparecer tus archivos modificados (M), los nuevos (Ao??) y elworkbox-XXXX.jsviejo como borrado (D). No debe aparecer nada devenv/.
Antes del primer git add ., crea el .gitignore. El
venv/ del paso 2 tiene miles de archivos de Django que cada quien instala en su computadora;
db.sqlite3 lo crea Django al correr el servidor, y __pycache__/ son archivos
temporales de Python. Nada de eso es tu código, así que no se sube. En la raíz del repositorio (junto a
index.html) crea un archivo llamado exactamente .gitignore con estas líneas:
servidor-ssr/venv/
servidor-ssr/db.sqlite3
__pycache__/
Las dos primeras líneas deben llevar el nombre real de tu carpeta Django. Si la tuya se
llama, por ejemplo, servidor-ssr-pdb, escribe servidor-ssr-pdb/venv/ y
servidor-ssr-pdb/db.sqlite3. Si el nombre no coincide, Git sube el venv/
completo.
No pongas en el .gitignore los archivos de Workbox (sw.js,
workbox-XXXX.js, los .map) ni la carpeta docs/: todos se suben.
Los reportes sueltos de Lighthouse (localhost_....report.html,
reporte-lighthouse.html) muévelos a docs/ o sácalos de la carpeta antes del
git add ..
Si ya habías subido __pycache__/ o db.sqlite3 (pasa si
hiciste el primer commit antes de crear el .gitignore), el .gitignore solo
no basta: Git sigue rastreando lo que ya estaba subido. Quítalo del repositorio sin borrarlo de tu disco
(cambia servidor-ssr por el nombre de tu carpeta):
git rm -r --cached servidor-ssr/db.sqlite3
git rm -r --cached servidor-ssr/catalogo/__pycache__ servidor-ssr/ssr_demo/__pycache__ servidor-ssr/catalogo/migrations/__pycache__
Si ya habías subido el venv/ por error, quítalo del repositorio (sin borrarlo de tu
disco) y sube el cambio:
git rm -r --cached servidor-ssr/venv
git commit -m "Avance N - APELLIDOPATERNO APELLIDOMATERNO NOMBRES: quita venv del repositorio"
git push
2) Crea tu cuenta y verifica Git (sáltate esto si ya lo hiciste en la Unidad 1).
Necesitas una cuenta en github.com (Sign up) y Git instalado — ambos se explican en la
Guía de la Unidad 1, pasos 4 a 6. Verifica en una terminal:
git --version
3) Convierte esa carpeta en un repositorio local. Abre la carpeta
tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres/ completa en VS Code y, en su terminal integrada (Terminal → New
Terminal — debe abrir ya dentro de esa carpeta):
git init
git add .
git commit -m "Avance 1 - APELLIDOPATERNO APELLIDOMATERNO NOMBRES: estructura y catálogo"
El mensaje de cada commit que entregues lleva tu apellido paterno, apellido materno y
nombres (ej. "Avance 1 - MORENO ALMEIDA YOSHELYN KRISTAL: estructura y catálogo"):
la plataforma de entregas lo verifica y no acepta el hash si falta.
Si aquí te sale un error pidiendo user.name/user.email, es porque falta el
paso 5 de la Guía de la Unidad 1 (configurar tu identidad en Git) — hazlo y
repite el commit.
4) Crea el repositorio vacío en GitHub (desde la página web, sin escribir código):
- Entra a
github.comcon sesión iniciada y haz clic en el + (arriba a la derecha) → New repository. - Nombre: el mismo que usaste para la carpeta, con tu nombre completo:
tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres. Déjalo en Public (Pages gratis solo funciona en repos públicos). - No marques "Add a README" ni ninguna otra casilla — ya tienes archivos locales y eso evita conflictos al subir.
- Clic en Create repository. GitHub te va a mostrar una página con comandos — cópialos, cambia solo el usuario si el tuyo no coincide, y pégalos en tu terminal:
git remote add origin https://github.com/TU-USUARIO/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres.git
git branch -M main
git push -u origin main
Si te pide iniciar sesión al hacer git push
Es normal — GitHub ya no acepta tu contraseña normal para esto. En Windows, Git suele abrir
automáticamente una ventana del navegador para que confirmes tu cuenta (Git Credential Manager);
inicia sesión ahí y el push continúa solo. Si en vez de eso te pide una contraseña en
la terminal, necesitas un token de acceso personal: en GitHub ve a tu foto de
perfil → Settings → Developer settings →
Personal access tokens → Generate new token (marca al menos el
permiso repo), y pega ese token donde te pida la contraseña — no vuelvas a verlo
después, así que guárdalo. Si manejas dos cuentas de GitHub (personal + escuela), revisa la
guía de cuentas Git y VS Code para no mezclarlas.
5) Activa GitHub Pages:
- En la página de tu repositorio en GitHub, entra a Settings → Pages (menú de la izquierda).
- En "Build and deployment" → "Source" elige Deploy from a branch.
- En "Branch" elige
mainy la carpeta/ (root)→ Save. - Espera uno o dos minutos (GitHub está construyendo el sitio) y recarga esa misma página: va a
aparecer un recuadro verde con tu URL pública, algo como
https://tu-usuario.github.io/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres/. Ábrela para confirmar que carga.
Valida con PWABuilder e instala en tu celular
1) Audita con PWABuilder:
- Ve a
pwabuilder.comdesde cualquier navegador (computadora o celular). - Pega la URL completa de tu GitHub Pages (ej.
https://tu-usuario.github.io/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres/, con la/final) y haz clic en Start. - Espera el análisis (de 10 a 30 segundos). También puedes abrir el reporte directo con esta URL,
cambiando la parte final por la tuya:
https://www.pwabuilder.com/reportcard?site=https://tu-usuario.github.io/tu-repositorio/
Así debe verse. Esta captura es el reporte real de la Tienda Escolar publicada en GitHub Pages:
Reporte de PWABuilder
Qué revisar en tu reporte:
| Sección | Qué debe decir | ¿Es obligatorio? |
|---|---|---|
| Arriba a la izquierda | Tu ícono, tu short_name (aquí "Tienda"), tu URL y tu description. Si no sale tu ícono, PWABuilder no pudo leer tu manifest.json. | Sí |
| Action Items | El contador rojo () en 0. El amarillo, el azul y el morado son sugerencias. | Rojo en 0: sí |
| Manifest | "your manifest is ready for packaging!". El número (aquí 20 / 46) cuenta campos opcionales: no necesitas 46. | Sí |
| Service Worker | "has analyzed your Service Worker", con +3 y palomita verde en Has Service Worker, Has Logic y Offline Support. | Sí |
| App Capabilities | +0. Son funciones avanzadas (atajos, widgets, abrir archivos) que esta práctica no pide. | No |
Si dice "did not find a Service Worker"
Si la tarjeta Service Worker dice "PWABuilder has analyzed your site and did not find a Service
Worker" con un +0, aunque en DevTools tu sw.js salga "activated and is
running", el problema es dónde lo registras. PWABuilder busca
navigator.serviceWorker.register("...") escrito en tu index.html, y si no lo
encuentra, pregunta por el registro en cuanto la página termina de cargar. Si lo registras en
app.js dentro de window.addEventListener("load", ...), falla las dos veces.
Mueve el registro a index.html como se explica en el paso 19, vuelve a correr
workbox generateSW workbox-config.js, sube los cambios y presiona el botón ↻ (junto a
View log) para analizar de nuevo. View log muestra el detalle: si dice
"No service worker URL found via HTML parsing", es exactamente este problema.
Si sale una advertencia amarilla
La Tienda Escolar tiene una sola: "Add screenshots to showcase your app". PWABuilder recomienda (no exige) agregar capturas de la app al manifest, o íconos PNG de más tamaños, o un ícono "maskable" aparte. Son sugerencias para empaquetar la app en tiendas de aplicaciones (Play Store, Microsoft Store) y no impiden que Chrome la instale. Para esta evidencia basta con que el contador rojo esté en 0 y que Manifest y Service Worker se vean como en la tabla. No necesitas corregir las advertencias amarillas.
2) Instálala desde tu celular Android (Chrome):
- Abre la URL de tu GitHub Pages en Chrome del celular.
- Toca el menú de tres puntos (⋮, arriba a la derecha) → Instalar aplicación (o "Agregar a pantalla de inicio", según la versión de Chrome). Si la app cumple los requisitos, Chrome puede ofrecértelo solo, con un banner abajo.
- Confirma en el cuadro de diálogo → aparece el ícono en tu pantalla de inicio, como una app más.
- Ábrela desde ese ícono: debe abrir sin la barra de direcciones de Chrome (modo
standalone, tal como configuraste enmanifest.json).
3) Instálala desde un iPhone (Safari — no funciona desde Chrome en iOS):
- Abre la URL de tu GitHub Pages en Safari (en iPhone, todos los navegadores usan el motor de Safari por debajo, pero solo Safari expone esta opción).
- Toca el ícono de Compartir (el cuadrado con una flecha hacia arriba, en la barra inferior).
- Baja en el menú y toca "Agregar a pantalla de inicio".
- Confirma el nombre y toca Agregar (arriba a la derecha).
Para tu evidencia, toma captura del reporte de PWABuilder (paso 1) y de la app ya instalada abierta desde el ícono de tu pantalla de inicio (paso 2 o 3) — son dos de las capturas que pide la Evidencia 2 más abajo.
Evidencias de la Unidad 2
Objetivo de esta sección
Según la rúbrica oficial (ver Anexo), la Unidad 2 se evalúa con una sola evidencia integradora: tu repositorio con la PWA completa, publicado y probado. Esa evidencia se compone de dos entregables verificables:
- Evidencia 1: el repositorio en GitHub mismo, con un
README.mdque sirva de guía breve de instalación — cualquiera que lo lea debe poder abrir tu app. - Evidencia 2: un reporte en PDF con capturas de cada prueba que hiciste (SSR vs CSR, almacenamiento, offline, notificaciones, ubicación, Lighthouse, PWABuilder e instalación), igual que en la Unidad 1.
Ambos se califican por contenido, pero también por que sean verificablemente tuyos — por eso, igual que en la Unidad 1, hay candados anti-copia que deben quedar visibles en tus capturas.
Evidencia 1 — El repositorio y su guía de instalación, paso a paso
Recuerda cómo quedó organizado el repositorio en el paso 22: la PWA vive en la
raíz (para que GitHub Pages la sirva) y servidor-ssr/ es una subcarpeta
dentro de ese mismo repo. El README.md también va en la raíz.
- En la raíz de tu repositorio (junto a
index.htmly la carpetaservidor-ssr/), crea un archivoREADME.md. - Escribe en él, con tus propias palabras, al menos: qué es el proyecto, cómo probarlo en local (Live Server +, opcionalmente, el servidor Django), cómo instalarlo ya publicado y qué incluye. Un punto de partida:
# Tienda Escolar — PWA de ventas
PWA de práctica de la Unidad 2 (Aplicaciones Web Progresivas): catálogo con fotos,
carrito con almacenamiento local, Service Worker offline, notificaciones y una
ubicación que traza la ruta real hasta la sucursal en un mapa.
## Estructura del repositorio
- **Raíz** — la PWA (`index.html`, `manifest.json`, `sw.js`, `css/`, `js/`, `img/`, `icons/`).
Es lo que GitHub Pages publica.
- `servidor-ssr/` — mini-proyecto Django con la vista SSR de comparación (CSR vs SSR).
No se publica en Pages (Pages solo sirve archivos estáticos); solo corre en local.
## Probarla en local
1. Abre esta carpeta en VS Code.
2. Clic derecho en `index.html` → **Open with Live Server**.
3. (Opcional, para comparar SSR) entra a `servidor-ssr/`, activa el entorno virtual y corre
`python manage.py runserver`; abre `http://127.0.0.1:8000/catalogo/` y compara su código
fuente (Ctrl+U) contra el de la PWA.
## Instalarla (app ya publicada)
1. Abre `https://TU-USUARIO.github.io/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres/` en Chrome o Safari.
2. En computadora: clic en el ícono de instalar (⊕) de la barra de direcciones.
En Android: menú (⋮) → "Instalar aplicación". En iPhone (Safari): Compartir →
"Agregar a pantalla de inicio".
3. Actívale el modo avión y vuelve a abrirla: debe seguir cargando (Service Worker).
## Características
- Catálogo con fotos de producto, cargado por `fetch()` desde `catalogo.json` (CSR).
- Carrito con cantidad editable (+/-) y quitar, guardado en `localStorage`.
- Notificaciones del navegador (Notification API).
- Ubicación: calcula y dibuja en un mapa (Leaflet + OSRM) la ruta real hasta la sucursal.
- Service Worker con estrategia "cache first" para funcionar sin conexión.
## Identificación
- Alumno: APELLIDO PATERNO APELLIDO MATERNO NOMBRE(S)
- Grupo: tu grupo (ej. ING10-1) · Negocio y giro
- URL del repositorio: https://github.com/TU-USUARIO/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres
- URL de GitHub Pages: https://TU-USUARIO.github.io/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres/
## Historial de avances
| Avance | Hash del commit entregado | Fecha |
|---|---|---|
| 1 | (hash del avance 1) | 11/10/2026 |
| 2 | (hash del avance 2) | 18/10/2026 |
| 3 | (hash del avance 3) | 25/10/2026 |
| 4 | (se escribe en el reporte PDF) | 01/11/2026 |
El README no puede llevar el hash de su propio commit (al guardarlo, el hash cambia): por eso anotas en Historial de avances el hash de cada avance ya entregado.
Sube el archivo con la app:
git add README.md
git commit -m "Avance 4 - APELLIDOPATERNO APELLIDOMATERNO NOMBRES: README con guía de instalación"
git push
Evidencia 2 — Reporte de pruebas, paso a paso
Igual que en la Unidad 1: abre Word o Google Docs, ve pegando una captura por cada punto de abajo (con un título corto y una línea explicando qué muestra) y, al terminar, expórtalo como PDF. Empieza con una portada igual que en la Evidencia 1 de la Unidad 1 (logo, nombre completo — apellido paterno, apellido materno y nombres —, matrícula, grupo, asignatura, facilitador y fecha) y agrega en ella tu URL del repositorio, el hash del commit evaluado y tu URL de GitHub Pages.
Dónde se guarda: crea la carpeta docs/ en la raíz de tu repositorio.
Ahí van el PDF (APELLIDOPATERNO_APELLIDOMATERNO_NOMBRES_Evidencia2_U2.pdf), las capturas
de Lighthouse y la captura de la app instalada en tu celular, como imágenes sueltas además de dentro
del PDF. Súbela con git add docs/, tu commit con nombre y git push: en el
avance 4 se revisa que esos archivos estén en docs/.
¿Qué hash va en la portada? El de tu último commit de código, el que
ya subiste con git push antes de subir docs/: es el mismo que se ve
en la captura de tu repositorio (punto 12). No pongas el del commit que sube el PDF: ese commit
incluye el propio PDF y, al guardarlo, el hash cambia (igual que pasa con el README). El orden es:
1) commit y git push de tu código; 2) copias ese hash
corto (7 caracteres) en la portada; 3) exportas el PDF y subes docs/ con
su commit con nombre. En el formulario de entrega pegas el hash de ese último commit, porque es
el que trae el PDF en docs/; como solo agrega docs/, el código es el mismo
del hash de la portada.
- Código fuente (Ctrl+U) de
http://127.0.0.1:8000/catalogo/: los productos YA escritos en el HTML (Parte A, paso 8) — evidencia de SSR. - Código fuente (Ctrl+U) de tu
index.htmlabierto con Live Server: el<div id="catalogo">vacío — evidencia de CSR, para contrastar con el punto anterior. - La app con Live Server mostrando el catálogo con sus fotos ya pintado por
csr.js, con al menos un producto agregado al carrito (Parte B, pasos 13-14). - El carrito con la cantidad de un producto subida/bajada con los botones +/- (paso 14).
- DevTools (F12) → Application → Local Storage, mostrando la clave
tienda-escolar-carritocon el contenido guardado. - DevTools → Application → Service Workers, mostrando
sw.jscomo "activated and is running" (paso 20). - La casilla Offline marcada en DevTools y la página recargada, funcionando sin conexión.
- La notificación de prueba mostrada en pantalla tras aceptar el permiso (paso 15).
- El mapa con la ruta trazada desde tu ubicación hasta la sucursal, con la distancia y el tiempo estimado (paso 16).
- El reporte de Lighthouse con los puntajes de Performance, Accesibilidad y Buenas prácticas (paso 21).
- El reporte de PWABuilder con 0 errores rojos, el Manifest "ready for packaging" y el Service Worker en +3 (paso 23).
- Tu repositorio en GitHub ya con el código subido, mostrando el hash del último commit (paso 22).
- Settings → Pages de tu repositorio, mostrando la URL publicada activa.
- La app abierta desde esa URL en Chrome/Safari, con el ícono de instalar visible (⊕) en computadora, o ya instalada y abierta desde el ícono de tu pantalla de inicio en el celular (paso 23).
Revisa la sección siguiente antes de tomar cada captura: son los datos que deben quedar visibles para que cuente como evidencia válida.
03 · Candados anti-copia
Los mismos siete candados de la Unidad 1, aplicados ahora a tu repositorio y tu app publicada. Si falta alguno en una captura, esa evidencia se considera incompleta.
🔒 Ventana completa sin recortar
Captura la ventana entera de Chrome/VS Code (Windows: Alt+PrtScn), no un recorte
ajustado solo al panel que te interesa mostrar.
🔒 URL completa en la barra de direcciones
Debe leerse entera: http://127.0.0.1:8000/catalogo/,
http://127.0.0.1:5500/index.html, o tu URL final de
tu-usuario.github.io/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres/.
🔒 Reloj del sistema visible
La barra de tareas de Windows, con hora y fecha, debe quedar dentro de la captura.
🔒 Tu propio usuario en la ruta
Cuando la terminal muestre una ruta (por ejemplo al correr Django), debe decir
C:\Users\TU-USUARIO\..., no el de un compañero.
🔒 Tu cuenta real de GitHub
Usuario y avatar propios, con sesión iniciada, visibles en las capturas del repositorio y de Settings → Pages.
🔒 Nombre de repo único
Tu repositorio vive bajo tu propia cuenta y su nombre lleva tu apellido paterno, apellido
materno y nombres (tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres), así que tu URL de
GitHub Pages también. Dos alumnos jamás pueden compartir la misma URL de repositorio ni la misma URL
de GitHub Pages.
🔒 Hash de commit visible
En la vista del repositorio debe leerse el identificador corto (7 caracteres) de tu último commit.
⚠️ Política de integridad
Si dos entregas comparten la misma cuenta de GitHub, el mismo hash de commit, la misma URL de GitHub Pages, o capturas idénticas, ambas entregas se invalidan, sin importar quién copió a quién.
04 · Convención de nombres
Cómo nombrar tu repositorio, tus commits y tu reporte
Todo lleva tu nombre completo en este orden: apellido paterno, apellido materno y nombres (igual que en la lista del grupo). La plataforma de entregas lo verifica.
| Qué | Formato | Ejemplo |
|---|---|---|
| 🐙 Repositorio | tu-negocio-pwa_apellidopaterno-apellidomaterno-nombresminúsculas, sin acentos ni ñ, palabras con guion |
kristal-bubble-bar-pwa_moreno-almeida-yoshelyn-kristal |
| 🌐 GitHub Pages | La de ese mismo repositorio | https://tu-usuario.github.io/kristal-bubble-bar-pwa_moreno-almeida-yoshelyn-kristal/ |
| 🔖 Mensaje del commit | Avance N - APELLIDOPATERNO APELLIDOMATERNO NOMBRES: qué hiciste |
Avance 1 - MORENO ALMEIDA YOSHELYN KRISTAL: catálogo de 15 productos |
| 📄 Reporte (Evidencia 2) | APELLIDOPATERNO_APELLIDOMATERNO_NOMBRES_Evidencia2_U2.pdf |
MORENO_ALMEIDA_YOSHELYN_KRISTAL_Evidencia2_U2.pdf |
Encabezado de identificación en todos tus escritos
(README.md, portada del reporte y cualquier documento):
- Nombre completo: apellido paterno, apellido materno y nombres.
- Grupo, negocio y giro.
- URL del repositorio.
- Hash del commit (en el README, en la tabla Historial de avances; en el reporte, el del último commit de código, antes de subir
docs/). - URL de GitHub Pages.
¿Tu repositorio ya existe con otro nombre? Renómbralo en GitHub → Settings → General →
Repository name; GitHub redirige la URL vieja. Después actualiza el remoto con
git remote set-url origin https://github.com/TU-USUARIO/tu-negocio-pwa_apellidopaterno-apellidomaterno-nombres.git
y vuelve a revisar tu URL de GitHub Pages.
Checklist de entrega
- Repositorio publicado con la PWA en la raíz y
servidor-ssr/(la demo Django) como subcarpeta. - Repositorio, URL de GitHub Pages y mensaje del commit con tu apellido paterno, apellido materno y nombres.
- Encabezado de identificación en el
README.mdy en la portada del reporte (nombre completo, URL del repositorio, hash del commit y URL de GitHub Pages). README.mdcon guía breve de instalación, redactado por ti.- Reporte de pruebas (Evidencia 2) entregado en PDF, con portada.
- Captura de la vista SSR con "Ver código fuente" mostrando los productos ya escritos en el HTML.
- Captura de la vista CSR con el
<div id="catalogo">vacío en el código fuente, para contrastar. - Captura del catálogo con fotos, del carrito funcionando (cantidad con +/-) y de
localStoragecon el contenido guardado. - Captura del Service Worker activo y de la app funcionando en modo Offline.
- Captura de notificaciones activadas y del mapa con la ruta hasta la sucursal.
- Captura de los reportes de Lighthouse y de PWABuilder (0 errores).
- Captura del repositorio en GitHub (con hash de commit) y de la configuración de GitHub Pages.
- Captura de la app instalada y abierta desde tu celular (o desde el ícono de instalar en computadora).
- Ventana completa, URL legible, reloj visible y tu propio usuario/cuenta en cada captura.
← Ver el resumen y la rúbrica de la Unidad 2 · Ver el Anexo de rúbricas → · Ver el calendario de commits y evidencia de autoría →