diff --git a/.github/workflows/README.md b/.github/workflows/README.md
new file mode 100644
index 0000000..5f9de3e
--- /dev/null
+++ b/.github/workflows/README.md
@@ -0,0 +1,167 @@
+# GitHub Actions Workflows
+
+Este directorio contiene los workflows de CI/CD para el proyecto Base Angular App.
+
+## 📁 Workflows Disponibles
+
+### 1. `ci.yml` - Continuous Integration
+**Ejecuta:** En cada push o pull request a `main` o `develop`
+
+**Tareas:**
+- ✅ Instala dependencias
+- 🔍 Lint del código (si está configurado)
+- 🧪 Ejecuta tests
+- 🏗️ Construye la aplicación para producción
+- 📊 Muestra información del build
+- 📦 Genera artifacts (dist + deployment package)
+- 💾 Guarda artifacts por 30 días
+
+**Artifacts generados:**
+- `angular-app-dist-{sha}`: Carpeta dist completa
+- `deployment-package-{sha}`: ZIP listo para desplegar
+
+### 2. `deploy.yml` - Prepare Release
+**Ejecuta:**
+- Manualmente desde GitHub Actions UI
+- Automáticamente al crear un tag (v1.0.0, v2.1.0, etc.)
+
+**Tareas:**
+- 🏗️ Build de producción
+- 📦 Genera paquetes TAR.GZ y ZIP
+- 🔐 Crea checksums SHA256
+- 📋 Incluye nginx.conf
+- 🚀 Crea GitHub Release (si es un tag)
+- 💾 Guarda artifacts por 90 días
+
+## 🔐 Configuración (Opcional)
+
+Los workflows de CI funcionan sin configuración adicional. No necesitas configurar secrets a menos que quieras automatizar despliegues a servicios específicos.
+
+### Para Auto-Deploy (Opcional)
+
+Si quieres desplegar automáticamente a servicios cloud, puedes agregar secrets según el servicio:
+
+**Digital Ocean:**
+- `DO_API_TOKEN`: Token de API de Digital Ocean
+
+**AWS:**
+- `AWS_ACCESS_KEY_ID`: Access Key de AWS
+- `AWS_SECRET_ACCESS_KEY`: Secret Key de AWS
+- `AWS_REGION`: Región (ej: us-east-1)
+
+**Azure:**
+- `AZURE_CREDENTIALS`: JSON con credenciales de service principal
+
+**Vercel/Netlify:**
+- Conecta directamente desde su dashboard (no requiere secrets)
+
+## 🚀 Cómo Usar
+
+### Ejecutar Tests Automáticamente
+```bash
+# Los tests se ejecutan automáticamente en cada push/PR
+git push origin develop
+```
+
+### Desplegar Manualmente
+1. Ve a la pestaña "Actions" en GitHub
+2. Selecciona "CD - Deploy to Server"
+3. Click en "Run workflow"
+4. Selecciona la rama y confirma
+
+### Desplegar Automáticamente
+```bash
+# Simplemente haz push a main
+git push origin main
+```1. Crear Pull Request (Desarrollo)
+```bash
+# Crea una rama feature
+git checkout -b feature/nueva-funcionalidad
+
+# Haz tus cambios y commity un resumen al final
+- Recibirás notificaciones por email si algún workflow falla
+- Los artifacts se pueden descargar desde la página del workflow
+
+## 🌐 Opciones de Deployment
+
+Ver [DEPLOYMENT-CLOUD.md](../../DEPLOYMENT-CLOUD.md) para guías detalladas de deployment en:
+
+- 🌊 **Digital Ocean** (App Platform o Droplet)
+- ☁️ **AWS S3 + CloudFront**
+- 🔷 **Azure Static Web Apps**
+- ▲ **Vercel** (recomendado para simplicidad)
+- 🌐 **Netlify**
+- 📄 **GitHub Pages** (gratis para repos públicos)
+
+## 🔧 Troubleshooting
+
+### Los tests fallan en GitHub pero pasan localmente
+- Verifica que no dependas de configuraciones locales
+- Asegúrate de que todos los assets de test estén en el repo
+- Revisa las versiones de Node.js (workflow usa 20.x)
+
+### Build falla
+- Verifica que todas las dependencias estén en `package.json`
+- Revisa los logs en la pestaña Actions
+- Prueba el build localmente: `npm ci && npm run build`
+
+### No se generan artifacts
+- Verifica que el build complete exitosamente
+- Los artifacts solo se generan si todos los pasos anteriores pasan
+- Artifacts de CI duran 30 días, artifacts de release duran 90 días
+
+### Error al crear Release
+- Solo se crean releases automáticas para tags que empiecen con "v"
+- Verifica que tengas permisos de escritura en el repo
+- El token `GITHUB_TOKEN` debe tener permiso para crear releases
+
+## 📝 Notas
+
+- **Artifacts de CI**: Se mantienen por 30 días
+- **Artifacts de Release**: Se mantienen por 90 días
+- **GitHub Releases**: Permanentes (hasta que se borren manualmente)
+- Los workflows de CI se ejecutan en pull requests sin acceso a secrets (por seguridad)
+- El deployment es siempre manual o mediante releases para mejor control
+git pull origin main
+git tag -a v1.0.0 -m "Release version 1.0.0"
+git push origin v1.0.0
+```
+
+Esto:
+- 🏗️ Construye la aplicación
+- 📦 Crea paquetes de deployment
+- 🚀 Genera un GitHub Release con todos los assets
+- 📋 Incluye instrucciones de deploy para cada plataforma
+
+### 4. Descargar y Desplegar
+
+**Opción A: Desde GitHub Release (para tags)**
+1. Ve a la sección "Releases" en GitHub
+2. Descarga el archivo que necesites:
+ - `.tar.gz` para Linux/Mac
+ - `.zip` para Windows
+3. Sigue las instrucciones de deployment en [DEPLOYMENT-CLOUD.md](../../DEPLOYMENT-CLOUD.md)
+
+**Opción B: Desde Artifacts (cualquier commit)**
+1. Ve a la pestaña "Actions" en GitHub
+2. Selecciona el workflow ejecutado
+3. Descarga el artifact `deployment-package-{sha}`
+4. Úsalo para desplegar en tu servicio cloud preferido
+
+### 5. Deploy Manual (sin tag)
+```bash
+# Ejecuta el workflow manualmente
+# Ve a Actions → CD - Prepare Release → Run workflow
+# Especifica una versión (ej: v1.1.0-beta) de tu archivo `nginx.conf`
+- Ejecuta `sudo nginx -t` en el servidor para verificar
+
+### Build falla
+- Verifica que todas las dependencias estén en `package.json`
+- Revisa los logs en la pestaña Actions para más detalles
+
+## 📝 Notas
+
+- Los artifacts de build se mantienen por 7 días
+- Cada despliegue crea un backup automático con timestamp
+- El workflow de CI no despliega, solo valida el código
+- El workflow de CD requiere que CI pase exitosamente
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3c3fd8f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,88 @@
+name: CI - Build and Test
+
+on:
+ push:
+ branches: [ main, develop, release/* ]
+ pull_request:
+ branches: [ main, develop, release/* ]
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [20.x]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: TypeScript syntax check
+ run: npm run typecheck
+
+ - name: Run tests
+ run: npm test -- --watch=false
+
+ - name: Build application for production
+ run: npm run build
+
+ - name: Display build output structure
+ run: |
+ echo " Build completed successfully!"
+ echo "Build output structure:"
+ ls -lah dist/
+ if [ -d "dist/base-angular-app/browser" ]; then
+ echo "Browser bundle size:"
+ du -sh dist/base-angular-app/browser
+ fi
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: angular-app-dist-${{ github.sha }}
+ path: dist/
+ retention-days: 30
+ compression-level: 9
+
+ - name: Create deployment package
+ run: |
+ cd dist
+ zip -r ../angular-app-${{ github.sha }}.zip .
+ cd ..
+ echo " Package size: $(du -h angular-app-${{ github.sha }}.zip | cut -f1)"
+
+ - name: Upload deployment package
+ uses: actions/upload-artifact@v4
+ with:
+ name: deployment-package-${{ github.sha }}
+ path: angular-app-${{ github.sha }}.zip
+ retention-days: 30
+
+ - name: Summary
+ run: |
+ echo "### Build Summary" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "- **Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Node Version:** ${{ matrix.node-version }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo " **Artifacts generated:**" >> $GITHUB_STEP_SUMMARY
+ echo "- angular-app-dist-${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
+ echo "- deployment-package-${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo " **Ready to deploy to:**" >> $GITHUB_STEP_SUMMARY
+ echo "- Digital Ocean" >> $GITHUB_STEP_SUMMARY
+ echo "- AWS S3 + CloudFront" >> $GITHUB_STEP_SUMMARY
+ echo "- Azure Static Web Apps" >> $GITHUB_STEP_SUMMARY
+ echo "- Vercel" >> $GITHUB_STEP_SUMMARY
+ echo "- Netlify" >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..0a316d8
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,142 @@
+name: CD - Prepare Release
+
+# Este workflow prepara releases que pueden desplegarse en cualquier servicio cloud
+# (Digital Ocean, AWS, Azure, Vercel, Netlify, etc.)
+
+on:
+ workflow_dispatch: # Ejecución manual desde GitHub UI
+ inputs:
+ version:
+ description: 'Release version (e.g., v1.0.0)'
+ required: false
+ default: 'latest'
+ push:
+ tags:
+ - 'v*' # Se activa cuando creas un tag como v1.0.0
+
+jobs:
+ prepare-release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build for production
+ run: npm run build
+
+ - name: Get version
+ id: version
+ run: |
+ if [ "${{ github.event.inputs.version }}" != "" ]; then
+ echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
+ elif [ "${{ github.ref_type }}" == "tag" ]; then
+ echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
+ else
+ echo "version=latest" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create deployment package
+ run: |
+ cd dist
+ tar -czf ../angular-app-${{ steps.version.outputs.version }}.tar.gz .
+ zip -r ../angular-app-${{ steps.version.outputs.version }}.zip .
+ cd ..
+ echo "📦 Package created:"
+ ls -lh angular-app-${{ steps.version.outputs.version }}.*
+
+ - name: Generate checksums
+ run: |
+ sha256sum angular-app-${{ steps.version.outputs.version }}.tar.gz > checksums.txt
+ sha256sum angular-app-${{ steps.version.outputs.version }}.zip >> checksums.txt
+ cat checksums.txt
+
+ - name: Upload release artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-${{ steps.version.outputs.version }}
+ path: |
+ angular-app-${{ steps.version.outputs.version }}.tar.gz
+ angular-app-${{ steps.version.outputs.version }}.zip
+ checksums.txt
+ nginx.conf
+ retention-days: 90
+
+ - name: Create GitHub Release
+ if: github.ref_type == 'tag'
+ uses: softprops/action-gh-release@v1
+ with:
+ files: |
+ angular-app-${{ steps.version.outputs.version }}.tar.gz
+ angular-app-${{ steps.version.outputs.version }}.zip
+ checksums.txt
+ nginx.conf
+ body: |
+ ## 🚀 Release ${{ steps.version.outputs.version }}
+
+ ### 📦 Assets
+ - `angular-app-${{ steps.version.outputs.version }}.tar.gz` - Producción (Linux/Mac)
+ - `angular-app-${{ steps.version.outputs.version }}.zip` - Producción (Windows/Universal)
+ - `nginx.conf` - Configuración de Nginx
+ - `checksums.txt` - SHA256 checksums
+
+ ### 🌐 Deploy Options
+
+ **Digital Ocean App Platform:**
+ ```bash
+ doctl apps create --spec .do/app.yaml
+ ```
+
+ **Digital Ocean Droplet:**
+ ```bash
+ scp angular-app-${{ steps.version.outputs.version }}.tar.gz user@droplet:/tmp/
+ ssh user@droplet
+ cd /var/www/html
+ tar -xzf /tmp/angular-app-${{ steps.version.outputs.version }}.tar.gz
+ ```
+
+ **AWS S3 + CloudFront:**
+ ```bash
+ unzip angular-app-${{ steps.version.outputs.version }}.zip
+ aws s3 sync . s3://your-bucket-name/ --delete
+ aws cloudfront create-invalidation --distribution-id YOUR_DIST_ID --paths "/*"
+ ```
+
+ **Azure Static Web Apps:**
+ ```bash
+ az staticwebapp deploy --name your-app-name --source angular-app-${{ steps.version.outputs.version }}.zip
+ ```
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Summary
+ run: |
+ echo "### 🎉 Release Preparado" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Versión:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### 📦 Archivos Generados" >> $GITHUB_STEP_SUMMARY
+ echo \`\`\`" >> $GITHUB_STEP_SUMMARY
+ ls -lh angular-app-* checksums.txt nginx.conf
+ echo \`\`\`" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### 🚀 Próximos Pasos" >> $GITHUB_STEP_SUMMARY
+ echo "1. Descarga los artifacts desde la pestaña Actions" >> $GITHUB_STEP_SUMMARY
+ echo "2. Elige tu plataforma de deployment:" >> $GITHUB_STEP_SUMMARY
+ echo " - Digital Ocean (Droplet o App Platform)" >> $GITHUB_STEP_SUMMARY
+ echo " - AWS S3 + CloudFront" >> $GITHUB_STEP_SUMMARY
+ echo " - Azure Static Web Apps" >> $GITHUB_STEP_SUMMARY
+ echo " - Vercel" >> $GITHUB_STEP_SUMMARY
+ echo " - Netlify" >> $GITHUB_STEP_SUMMARY
+ echo "3. Sigue las instrucciones de deployment en el README" >> $GITHUB_STEP_SUMMARY
diff --git a/DEPLOYMENT-CLOUD.md b/DEPLOYMENT-CLOUD.md
new file mode 100644
index 0000000..03ffa1d
--- /dev/null
+++ b/DEPLOYMENT-CLOUD.md
@@ -0,0 +1,285 @@
+# Guía de Deployment para Servicios Cloud
+
+Esta guía te muestra cómo desplegar la aplicación Angular en diferentes servicios cloud usando los artifacts generados por GitHub Actions.
+
+## 📦 Obtener los Artifacts
+
+Después de cada build exitoso en GitHub Actions:
+
+1. Ve a la pestaña **Actions** en tu repositorio
+2. Selecciona el workflow ejecutado
+3. En la sección **Artifacts**, descarga:
+ - `deployment-package-{sha}` para deploy directo
+ - O el release completo si creaste un tag
+
+## 🌊 Digital Ocean
+
+### Opción 1: Digital Ocean App Platform (Recomendado)
+
+**App Platform** gestiona automáticamente el hosting, SSL, CDN y escalamiento.
+
+```bash
+# 1. Instalar doctl CLI
+# Windows: https://docs.digitalocean.com/reference/doctl/how-to/install/
+
+# 2. Autenticarse
+doctl auth init
+
+# 3. Crear app.yaml
+cat > .do/app.yaml << EOF
+name: base-angular-app
+services:
+- name: web
+ github:
+ repo: tu-usuario/base-angular
+ branch: main
+ build_command: npm ci && npm run build
+ run_command: npx http-server dist/base-angular-app/browser -p 8080
+ http_port: 8080
+ routes:
+ - path: /
+EOF
+
+# 4. Desplegar
+doctl apps create --spec .do/app.yaml
+```
+
+**Costo aproximado:** $5-12/mes
+
+### Opción 2: Digital Ocean Droplet
+
+Para usar un droplet con Nginx:
+
+```bash
+# 1. Crear droplet ($4-6/mes)
+doctl compute droplet create base-angular \
+ --image ubuntu-24-04-x64 \
+ --size s-1vcpu-1gb \
+ --region nyc1
+
+# 2. Obtener IP
+doctl compute droplet list
+
+# 3. Copiar artifact al droplet
+scp angular-app.zip root@YOUR_DROPLET_IP:/tmp/
+
+# 4. SSH y configurar
+ssh root@YOUR_DROPLET_IP
+
+# Instalar Nginx
+apt update && apt install -y nginx unzip
+
+# Extraer app
+mkdir -p /var/www/base-angular-app
+cd /var/www/base-angular-app
+unzip /tmp/angular-app.zip
+
+# Configurar Nginx (usa el nginx.conf del artifact)
+nano /etc/nginx/sites-available/base-angular-app
+ln -s /etc/nginx/sites-available/base-angular-app /etc/nginx/sites-enabled/
+nginx -t && systemctl reload nginx
+
+# 5. Opcional: Configurar SSL con Let's Encrypt
+apt install -y certbot python3-certbot-nginx
+certbot --nginx -d tudominio.com
+```
+
+## ☁️ AWS S3 + CloudFront
+
+Perfecto para aplicaciones Angular (SPA hosting estático).
+
+```bash
+# 1. Instalar AWS CLI
+# Windows: https://aws.amazon.com/cli/
+
+# 2. Configurar credenciales
+aws configure
+
+# 3. Crear bucket S3
+aws s3 mb s3://base-angular-app
+
+# 4. Habilitar hosting estático
+aws s3 website s3://base-angular-app \
+ --index-document index.html \
+ --error-document index.html
+
+# 5. Desplegar
+unzip angular-app.zip -d dist
+aws s3 sync dist/base-angular-app/browser s3://base-angular-app --delete
+
+# 6. Crear distribución CloudFront (CDN)
+aws cloudfront create-distribution \
+ --origin-domain-name base-angular-app.s3.amazonaws.com \
+ --default-root-object index.html
+
+# 7. Invalidar caché después de cada deploy
+aws cloudfront create-invalidation \
+ --distribution-id YOUR_DISTRIBUTION_ID \
+ --paths "/*"
+```
+
+**Costo aproximado:** $1-5/mes (tráfico incluido en free tier primer año)
+
+## 🔷 Azure Static Web Apps
+
+```bash
+# 1. Instalar Azure CLI
+# Windows: https://aka.ms/installazurecliwindows
+
+# 2. Login
+az login
+
+# 3. Crear Static Web App
+az staticwebapp create \
+ --name base-angular-app \
+ --resource-group myResourceGroup \
+ --location "East US 2"
+
+# 4. Desplegar
+unzip angular-app.zip -d dist
+az staticwebapp deploy \
+ --name base-angular-app \
+ --source-path dist/base-angular-app/browser
+
+# O configurar deployment directo desde GitHub
+az staticwebapp create \
+ --name base-angular-app \
+ --resource-group myResourceGroup \
+ --source https://github.com/tu-usuario/base-angular \
+ --branch main \
+ --app-location "/" \
+ --output-location "dist/base-angular-app/browser"
+```
+
+**Costo:** Free tier generoso (100GB bandwidth/mes)
+
+## ▲ Vercel
+
+La opción más simple para Angular:
+
+```bash
+# 1. Instalar Vercel CLI
+npm install -g vercel
+
+# 2. Login
+vercel login
+
+# 3. Desplegar (desde el directorio del proyecto)
+vercel --prod
+
+# O conectar directamente con GitHub (auto-deploy en cada push)
+# Ve a https://vercel.com/new e importa tu repositorio
+```
+
+**Configuración automática:** Vercel detecta Angular y configura todo automáticamente.
+
+**Costo:** Free tier muy generoso
+
+## 🌐 Netlify
+
+```bash
+# 1. Instalar Netlify CLI
+npm install -g netlify-cli
+
+# 2. Login
+netlify login
+
+# 3. Desplegar
+unzip angular-app.zip
+netlify deploy --prod --dir=dist/base-angular-app/browser
+
+# O configurar auto-deploy desde GitHub
+# Ve a https://app.netlify.com/start e importa tu repositorio
+```
+
+**Costo:** Free tier generoso (100GB bandwidth/mes)
+
+## 🚀 GitHub Pages (Gratis)
+
+Para proyectos públicos:
+
+```bash
+# 1. Instalar angular-cli-ghpages
+npm install -g angular-cli-ghpages
+
+# 2. Build con base-href correcto
+ng build --base-href "https://tu-usuario.github.io/base-angular/"
+
+# 3. Desplegar
+npx angular-cli-ghpages --dir=dist/base-angular-app/browser
+```
+
+O configurar GitHub Actions workflow:
+
+```yaml
+# .github/workflows/gh-pages.yml
+name: Deploy to GitHub Pages
+
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ - run: npm ci
+ - run: npm run build
+ - uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./dist/base-angular-app/browser
+```
+
+## 📊 Comparación de Servicios
+
+| Servicio | Costo/mes | SSL Gratis | CDN | Facilidad | Mejor para |
+|----------|-----------|------------|-----|-----------|------------|
+| **Vercel** | Free-$20 | ✅ | ✅ | ⭐⭐⭐⭐⭐ | Deploy rápido |
+| **Netlify** | Free-$19 | ✅ | ✅ | ⭐⭐⭐⭐⭐ | JAMstack apps |
+| **GitHub Pages** | Free | ✅ | ✅ | ⭐⭐⭐⭐ | Proyectos públicos |
+| **AWS S3+CF** | $1-5 | ✅ | ✅ | ⭐⭐⭐ | Control total |
+| **Azure SWA** | Free-$9 | ✅ | ✅ | ⭐⭐⭐⭐ | Ecosistema Azure |
+| **DO App Platform** | $5-12 | ✅ | ✅ | ⭐⭐⭐⭐ | Simplicidad |
+| **DO Droplet** | $4-6 | Con certbot | ❌ | ⭐⭐ | Máximo control |
+
+## 🔄 Workflow Recomendado
+
+```mermaid
+graph LR
+ A[Crear PR] --> B[Tests automáticos]
+ B --> C[Merge a main]
+ C --> D[Build + Artifacts]
+ D --> E[Desplegar a staging]
+ E --> F[Crear tag v1.x.x]
+ F --> G[GitHub Release]
+ G --> H[Deploy a producción]
+```
+
+## 💡 Tips
+
+1. **Staging + Producción:** Usa branches diferentes (develop → staging, main → prod)
+2. **Environment Variables:** Usa Angular environments para diferentes configs
+3. **Monitoring:** Agrega Google Analytics o Sentry
+4. **Performance:** Habilita compresión gzip/brotli en tu servidor
+5. **Cache:** Configura headers de cache para assets estáticos
+
+## 🆘 Troubleshooting
+
+### Error: "Cannot GET /ruta"
+- Configura routing del servidor para SPA (todas las rutas → index.html)
+- Vercel/Netlify: Lo hacen automático
+- Nginx: Usa el `nginx.conf` incluido en los artifacts
+- S3: Configura error document = index.html
+
+### Error: "Failed to load resource"
+- Verifica el `base-href` en tu build
+- Revisa CORS si usas APIs externas
+
+### Assets no cargan
+- Revisa rutas relativas vs absolutas
+- Verifica que el build incluyó todos los assets
diff --git a/package.json b/package.json
index 56b98c6..243229a 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
- "test": "ng test"
+ "test": "ng test",
+ "typecheck": "tsc --noEmit -p tsconfig.app.json"
},
"prettier": {
"printWidth": 100,
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index ad28f24..026d983 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -27,10 +27,4 @@ export const routes: Routes = [
path: 'products/:id',
loadComponent: () => import('./features/products/components/product-detail.component').then(m => m.ProductDetailComponent)
}
- // Agregar aquí más rutas de features
- // Ejemplo de lazy loading:
- // {
- // path: 'users',
- // loadComponent: () => import('./features/users/users.component').then(m => m.UsersComponent)
- // }
];
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts
index 8b6a367..5553f7a 100644
--- a/src/app/app.spec.ts
+++ b/src/app/app.spec.ts
@@ -1,10 +1,16 @@
import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { Title } from '@angular/platform-browser';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
+ providers: [
+ provideRouter([]),
+ Title
+ ]
}).compileComponents();
});
@@ -14,10 +20,25 @@ describe('App', () => {
expect(app).toBeTruthy();
});
- it('should render title', async () => {
+ it('should have a title property', () => {
const fixture = TestBed.createComponent(App);
- await fixture.whenStable();
+ const app = fixture.componentInstance;
+ expect(app.title).toBeDefined();
+ expect(typeof app.title).toBe('string');
+ });
+
+ it('should set document title on init', () => {
+ const fixture = TestBed.createComponent(App);
+ const titleService = TestBed.inject(Title);
+ const app = fixture.componentInstance;
+
+ expect(titleService.getTitle()).toBe(app.title);
+ });
+
+ it('should render router-outlet', () => {
+ const fixture = TestBed.createComponent(App);
+ fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
- expect(compiled.querySelector('h1')?.textContent).toContain('Hello, base-angular-app');
+ expect(compiled.querySelector('router-outlet')).toBeTruthy();
});
});
diff --git a/src/app/features/products/components/product-detail.component.html b/src/app/features/products/components/product-detail.component.html
index 806b972..370d6e4 100644
--- a/src/app/features/products/components/product-detail.component.html
+++ b/src/app/features/products/components/product-detail.component.html
@@ -1,83 +1,89 @@
-
-
-
-
Cargando producto...
-
+
+ @if (loading$ | async) {
+
+ }
-
-
❌ Error al cargar el producto
-
{{ error.message || 'Error desconocido' }}
-
-
+ @if (error$ | async; as error) {
+
+
+ }
-
-
-
+
+ @if (product$ | async; as product) {
+
+
-
-
- Información General
-
-
-
- {{ product.id }}
-
-
-
- {{ product.name }}
-
-
-
-
{{ product.category }}
+
+
+ General Information
+
+
+
+ {{ product.id }}
+
+
+
+ {{ product.name }}
+
+
+
+ {{ product.category }}
+
-
-
+
-
- Descripción
- {{ product.description || 'Sin descripción' }}
-
+
+ Description
+ {{ product.description || 'No description' }}
+
-
- Pricing & Inventario
-
-
-
- ${{ product.price | number:'1.2-2' }}
-
-
-
-
- {{ product.stock }} unidades
-
+
+ Pricing & Inventory
+
+
+
+ ${{ product.price | number:'1.2-2' }}
+
+
+
+
+ {{ product.stock }} units
+
+
-
-
+
+
-
+ }
-
-
-
❌ Producto no encontrado
-
El producto que buscas no existe o ha sido eliminado.
-
-
+
+ @if (!(loading$ | async) && !(product$ | async) && !(error$ | async)) {
+
+
❌ Product not found
+
The product you are looking for does not exist or has been deleted.
+
+
+ }
diff --git a/src/app/features/products/components/product-detail.component.spec.ts b/src/app/features/products/components/product-detail.component.spec.ts
new file mode 100644
index 0000000..eab0fba
--- /dev/null
+++ b/src/app/features/products/components/product-detail.component.spec.ts
@@ -0,0 +1,192 @@
+import { TestBed } from '@angular/core/testing';
+import { Router, ActivatedRoute } from '@angular/router';
+import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { ProductDetailComponent } from './product-detail.component';
+import * as ProductsActions from '../store/products.actions';
+import * as ProductsSelectors from '../store/products.selectors';
+import { Product } from '../models';
+
+describe('ProductDetailComponent', () => {
+ let component: ProductDetailComponent;
+ let store: MockStore;
+ let router: Router;
+ let activatedRoute: ActivatedRoute;
+
+ const mockProduct: Product = {
+ id: '123',
+ name: 'Test Product',
+ description: 'Test Description',
+ price: 99.99,
+ stock: 50,
+ category: 'Electronics'
+ };
+
+ const initialState = {
+ products: {
+ entities: {},
+ ids: [],
+ loading: false,
+ error: null,
+ selectedProduct: null
+ }
+ };
+
+ beforeEach(async () => {
+ const activatedRouteStub = {
+ snapshot: {
+ paramMap: {
+ get: (key: string) => key === 'id' ? '123' : null
+ }
+ }
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ProductDetailComponent],
+ providers: [
+ provideMockStore({ initialState }),
+ {
+ provide: Router,
+ useValue: {
+ navigate: vi.fn()
+ }
+ },
+ {
+ provide: ActivatedRoute,
+ useValue: activatedRouteStub
+ }
+ ]
+ }).compileComponents();
+
+ store = TestBed.inject(MockStore);
+ router = TestBed.inject(Router);
+ activatedRoute = TestBed.inject(ActivatedRoute);
+
+ store.overrideSelector(ProductsSelectors.selectSelectedProduct, mockProduct);
+ store.overrideSelector(ProductsSelectors.selectProductsLoading, false);
+ store.overrideSelector(ProductsSelectors.selectProductsError, null);
+
+ const fixture = TestBed.createComponent(ProductDetailComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should dispatch loadProduct action on init', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ component.ngOnInit();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ ProductsActions.loadProduct({ id: '123' })
+ );
+ });
+
+ it('should not dispatch loadProduct if no id in route', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ activatedRoute.snapshot.paramMap.get = () => null;
+
+ component.ngOnInit();
+
+ expect(dispatchSpy).not.toHaveBeenCalled();
+ });
+
+ it('should select product from store', async () => {
+ const product = await new Promise
(resolve => {
+ component.product$.subscribe(product => resolve(product));
+ });
+ expect(product).toEqual(mockProduct);
+ });
+
+ it('should select loading state from store', async () => {
+ const loading = await new Promise(resolve => {
+ component.loading$.subscribe(loading => resolve(loading));
+ });
+ expect(loading).toBe(false);
+ });
+
+ it('should select error state from store', async () => {
+ const error = await new Promise(resolve => {
+ component.error$.subscribe(error => resolve(error));
+ });
+ expect(error).toBeNull();
+ });
+
+ it('should navigate to edit page', () => {
+ component.onEdit('123');
+
+ expect(router.navigate).toHaveBeenCalledWith(['/products/edit', '123']);
+ });
+
+ it('should navigate back to products list', () => {
+ component.onBack();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/products']);
+ });
+
+ it('should dispatch deleteProduct and navigate back when confirmed', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+
+ component.onDelete('123', 'Test Product');
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ ProductsActions.deleteProduct({ id: '123' })
+ );
+ expect(router.navigate).toHaveBeenCalledWith(['/products']);
+ });
+
+ it('should not delete product when cancelled', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ vi.spyOn(window, 'confirm').mockReturnValue(false);
+
+ component.onDelete('123', 'Test Product');
+
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
+ ProductsActions.deleteProduct({ id: '123' })
+ );
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should show confirmation dialog with product name', () => {
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+
+ component.onDelete('123', 'Test Product');
+
+ expect(confirmSpy).toHaveBeenCalledWith(
+ 'Are you sure you want to delete the product "Test Product"?'
+ );
+ });
+
+ it('should handle null product gracefully', async () => {
+ store.overrideSelector(ProductsSelectors.selectSelectedProduct, null);
+ store.refreshState();
+
+ const product = await new Promise(resolve => {
+ component.product$.subscribe(product => resolve(product));
+ });
+ expect(product).toBeNull();
+ });
+
+ it('should handle loading state', async () => {
+ store.overrideSelector(ProductsSelectors.selectProductsLoading, true);
+ store.refreshState();
+
+ const loading = await new Promise(resolve => {
+ component.loading$.subscribe(loading => resolve(loading));
+ });
+ expect(loading).toBe(true);
+ });
+
+ it('should handle error state', async () => {
+ const error = { message: 'Product not found' };
+ store.overrideSelector(ProductsSelectors.selectProductsError, error);
+ store.refreshState();
+
+ const err = await new Promise(resolve => {
+ component.error$.subscribe(err => resolve(err));
+ });
+ expect(err).toEqual(error);
+ });
+});
diff --git a/src/app/features/products/components/product-detail.component.ts b/src/app/features/products/components/product-detail.component.ts
index 6aa1946..9e6220c 100644
--- a/src/app/features/products/components/product-detail.component.ts
+++ b/src/app/features/products/components/product-detail.component.ts
@@ -6,18 +6,21 @@ import { Observable } from 'rxjs';
import * as ProductsActions from '../store/products.actions';
import * as ProductsSelectors from '../store/products.selectors';
import { Product } from '../models';
+import { LoadingComponent } from '../../../shared/components/loading.component';
+import { ErrorComponent } from '../../../shared/components/error.component';
/**
- * Componente para ver detalles de un producto (solo lectura)
+ * Product Detail Component
*/
@Component({
selector: 'app-product-detail',
standalone: true,
- imports: [CommonModule, RouterModule],
+ imports: [CommonModule, RouterModule, LoadingComponent, ErrorComponent],
templateUrl: './product-detail.component.html',
styleUrl: './product-detail.component.css'
})
export class ProductDetailComponent implements OnInit {
+ // Injected services
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
@@ -34,6 +37,7 @@ export class ProductDetailComponent implements OnInit {
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
+ console.log('ProductDetailComponent initialized with id:', id);
if (id) {
this.store.dispatch(ProductsActions.loadProduct({ id }));
}
@@ -43,12 +47,20 @@ export class ProductDetailComponent implements OnInit {
this.router.navigate(['/products/edit', id]);
}
+ /**
+ * Go back to the products list
+ */
onBack(): void {
this.router.navigate(['/products']);
}
+ /**
+ * Delete the product after confirmation
+ * @param id The ID of the product to delete
+ * @param name The name of the product to delete
+ */
onDelete(id: string, name: string): void {
- if (confirm(`¿Estás seguro de eliminar el producto "${name}"?`)) {
+ if (confirm(`Are you sure you want to delete the product "${name}"?`)) {
this.store.dispatch(ProductsActions.deleteProduct({ id }));
this.router.navigate(['/products']);
}
diff --git a/src/app/features/products/components/product-form.component.html b/src/app/features/products/components/product-form.component.html
index 11ffff1..ae73dc8 100644
--- a/src/app/features/products/components/product-form.component.html
+++ b/src/app/features/products/components/product-form.component.html
@@ -1,112 +1,126 @@
diff --git a/src/app/features/products/components/product-form.component.spec.ts b/src/app/features/products/components/product-form.component.spec.ts
new file mode 100644
index 0000000..2823711
--- /dev/null
+++ b/src/app/features/products/components/product-form.component.spec.ts
@@ -0,0 +1,300 @@
+import { TestBed } from '@angular/core/testing';
+import { Router, ActivatedRoute } from '@angular/router';
+import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { of } from 'rxjs';
+import { ProductFormComponent } from './product-form.component';
+import * as ProductsActions from '../store/products.actions';
+import * as ProductsSelectors from '../store/products.selectors';
+import { Product } from '../models';
+
+describe('ProductFormComponent', () => {
+ let component: ProductFormComponent;
+ let store: MockStore;
+ let router: Router;
+ let activatedRoute: ActivatedRoute;
+
+ const mockProduct: Product = {
+ id: '1',
+ name: 'Test Product',
+ description: 'Test Description',
+ price: 100,
+ stock: 10,
+ category: 'Test Category'
+ };
+
+ const initialState = {
+ products: {
+ entities: {},
+ ids: [],
+ loading: false,
+ error: null,
+ selectedProduct: null
+ }
+ };
+
+ beforeEach(async () => {
+ const activatedRouteStub = {
+ paramMap: of({ get: () => null }),
+ snapshot: { paramMap: { get: () => null } }
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ProductFormComponent],
+ providers: [
+ provideMockStore({ initialState }),
+ {
+ provide: Router,
+ useValue: {
+ navigate: vi.fn()
+ }
+ },
+ {
+ provide: ActivatedRoute,
+ useValue: activatedRouteStub
+ }
+ ]
+ }).compileComponents();
+
+ store = TestBed.inject(MockStore);
+ router = TestBed.inject(Router);
+ activatedRoute = TestBed.inject(ActivatedRoute);
+
+ store.overrideSelector(ProductsSelectors.selectProductsActionLoading, false);
+ store.overrideSelector(ProductsSelectors.selectProductsError, null);
+ store.overrideSelector(ProductsSelectors.selectSelectedProduct, null);
+
+ const fixture = TestBed.createComponent(ProductFormComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize form with empty values', () => {
+ component.ngOnInit();
+
+ expect(component.productForm).toBeDefined();
+ expect(component.productForm.get('name')?.value).toBe('');
+ expect(component.productForm.get('description')?.value).toBe('');
+ expect(component.productForm.get('price')?.value).toBe(0);
+ expect(component.productForm.get('stock')?.value).toBe(0);
+ expect(component.productForm.get('category')?.value).toBe('');
+ });
+
+ it('should be in create mode by default', () => {
+ component.ngOnInit();
+
+ expect(component.isEditMode).toBe(false);
+ expect(component.productId).toBeNull();
+ });
+
+ it('should validate required fields', () => {
+ component.ngOnInit();
+
+ const nameControl = component.productForm.get('name');
+ const descriptionControl = component.productForm.get('description');
+
+ nameControl?.setValue('');
+ descriptionControl?.setValue('');
+
+ expect(nameControl?.hasError('required')).toBe(true);
+ expect(descriptionControl?.hasError('required')).toBe(true);
+ });
+
+ it('should validate name minlength', () => {
+ component.ngOnInit();
+
+ const nameControl = component.productForm.get('name');
+ nameControl?.setValue('ab');
+
+ expect(nameControl?.hasError('minlength')).toBe(true);
+ });
+
+ it('should validate price minimum value', () => {
+ component.ngOnInit();
+
+ const priceControl = component.productForm.get('price');
+ priceControl?.setValue(0);
+
+ expect(priceControl?.hasError('min')).toBe(true);
+ });
+
+ it('should validate stock minimum value', () => {
+ component.ngOnInit();
+
+ const stockControl = component.productForm.get('stock');
+ stockControl?.setValue(-1);
+
+ expect(stockControl?.hasError('min')).toBe(true);
+ });
+
+ it('should accept valid form values', () => {
+ component.ngOnInit();
+
+ component.productForm.patchValue({
+ name: 'Valid Product Name',
+ description: 'This is a valid description with enough characters',
+ price: 99.99,
+ stock: 50,
+ category: 'Electronics'
+ });
+
+ expect(component.productForm.valid).toBe(true);
+ });
+
+ it('should dispatch createProduct action on submit for new product', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ component.ngOnInit();
+
+ const formValue = {
+ name: 'New Product',
+ description: 'New product description with enough text',
+ price: 50,
+ stock: 100,
+ category: 'Category'
+ };
+
+ component.productForm.patchValue(formValue);
+ component.onSubmit();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ ProductsActions.createProduct({ product: formValue })
+ );
+ });
+
+ it('should not submit invalid form', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ component.ngOnInit();
+
+ component.productForm.patchValue({
+ name: 'ab', // Too short
+ description: 'short', // Too short
+ price: 0, // Below minimum
+ stock: -1, // Below minimum
+ category: ''
+ });
+
+ component.onSubmit();
+
+ expect(dispatchSpy).not.toHaveBeenCalled();
+ });
+
+ it('should navigate back to products list on cancel', () => {
+ component.onCancel();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/products']);
+ });
+
+ it('should detect invalid field', () => {
+ component.ngOnInit();
+
+ const nameControl = component.productForm.get('name');
+ nameControl?.setValue('');
+ nameControl?.markAsTouched();
+
+ expect(component.isFieldInvalid('name')).toBe(true);
+ });
+
+ it('should return correct error message for required field', () => {
+ component.ngOnInit();
+
+ const nameControl = component.productForm.get('name');
+ nameControl?.setValue('');
+ nameControl?.markAsTouched();
+
+ expect(component.getFieldError('name')).toBe('Este campo es requerido');
+ });
+
+ it('should return correct error message for minlength', () => {
+ component.ngOnInit();
+
+ const nameControl = component.productForm.get('name');
+ nameControl?.setValue('ab');
+ nameControl?.markAsTouched();
+
+ expect(component.getFieldError('name')).toContain('Mínimo');
+ });
+
+ it('should return correct error message for min value', () => {
+ component.ngOnInit();
+
+ const priceControl = component.productForm.get('price');
+ priceControl?.setValue(0);
+ priceControl?.markAsTouched();
+
+ expect(component.getFieldError('price')).toContain('El valor mínimo es');
+ });
+
+ it('should load product in edit mode', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ store.overrideSelector(ProductsSelectors.selectSelectedProduct, mockProduct);
+
+ // Create new mock with mutable paramMap
+ const mockActivatedRoute = {
+ paramMap: of({
+ get: (key: string) => key === 'id' ? '1' : null
+ } as any),
+ snapshot: { paramMap: { get: () => null } }
+ };
+ Object.assign(activatedRoute, mockActivatedRoute);
+
+ component.ngOnInit();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ ProductsActions.loadProduct({ id: '1' })
+ );
+ expect(component.isEditMode).toBe(true);
+ expect(component.productId).toBe('1');
+ });
+
+ it('should patch form values in edit mode', async () => {
+ store.overrideSelector(ProductsSelectors.selectSelectedProduct, mockProduct);
+
+ const mockActivatedRoute = {
+ paramMap: of({
+ get: (key: string) => key === 'id' ? '1' : null
+ } as any),
+ snapshot: { paramMap: { get: () => null } }
+ };
+ Object.assign(activatedRoute, mockActivatedRoute);
+
+ component.ngOnInit();
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ expect(component.productForm.get('name')?.value).toBe('Test Product');
+ expect(component.productForm.get('price')?.value).toBe(100);
+ });
+
+ it('should dispatch updateProduct action in edit mode', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ component.ngOnInit();
+
+ // Set component in edit mode
+ component.isEditMode = true;
+ component.productId = '1';
+
+ const updatedProduct = {
+ name: 'Updated Product',
+ description: 'Updated description with enough characters',
+ price: 150,
+ stock: 20,
+ category: 'Updated Category'
+ };
+
+ component.productForm.patchValue(updatedProduct);
+ component.onSubmit();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ ProductsActions.updateProduct({
+ id: '1',
+ product: {
+ id: '1',
+ ...updatedProduct
+ }
+ })
+ );
+ });
+});
diff --git a/src/app/features/products/components/product-form.component.ts b/src/app/features/products/components/product-form.component.ts
index c979e97..8388c29 100644
--- a/src/app/features/products/components/product-form.component.ts
+++ b/src/app/features/products/components/product-form.component.ts
@@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
import * as ProductsActions from '../store/products.actions';
import * as ProductsSelectors from '../store/products.selectors';
import { Product } from '../models';
+import { ErrorComponent } from '../../../shared/components/error.component';
/**
* Componente para crear o editar un producto
@@ -14,7 +15,7 @@ import { Product } from '../models';
@Component({
selector: 'app-product-form',
standalone: true,
- imports: [CommonModule, ReactiveFormsModule],
+ imports: [CommonModule, ReactiveFormsModule, ErrorComponent],
templateUrl: './product-form.component.html',
styleUrl: './product-form.component.css'
})
@@ -37,7 +38,7 @@ export class ProductFormComponent implements OnInit {
ngOnInit(): void {
this.initForm();
-
+
// Verificar si estamos en modo edición
this.route.paramMap.subscribe(params => {
this.productId = params.get('id');
@@ -61,7 +62,7 @@ export class ProductFormComponent implements OnInit {
private loadProduct(id: string): void {
this.store.dispatch(ProductsActions.loadProduct({ id }));
-
+
this.store.select(ProductsSelectors.selectSelectedProduct).subscribe(product => {
if (product) {
this.productForm.patchValue({
diff --git a/src/app/features/products/products.component.html b/src/app/features/products/products.component.html
index 2caeed5..2e5f9af 100644
--- a/src/app/features/products/products.component.html
+++ b/src/app/features/products/products.component.html
@@ -1,86 +1,95 @@
-
-
-
-
-
+
+ @if (categories$ | async; as categories) {
+
+
+ @for (category of categories; track category) {
+
+ }
+
+ }
-
-
-
Cargando productos...
-
+ @if (loading$ | async) {
+
+ }
-
-
❌ Error al cargar productos
-
{{ error.message || 'Error desconocido' }}
-
-
+ @if (error$ | async; as error) {
+
+
+ }
-
-
-
📦 No hay productos disponibles
-
-
+ @if (products$ | async; as products) {
+
+ @if (products.length === 0 && !(loading$ | async)) {
+
+
📦 No products available
+
+
+ }
-
-
-
-
-
{{ product.description }}
-
-
-
-
Precio:
-
${{ product.price | number:'1.2-2' }}
+ @for (product of products; track trackByProductId($index, product)) {
+
+
-
-
Stock:
-
- {{ product.stock }} unidades
-
+
+
+
{{ product.description }}
+
+
+
+ Price:
+ ${{ product.price | number:'1.2-2' }}
+
+
+ Stock:
+
+ {{ product.stock }} units
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+ }
-
+ }
diff --git a/src/app/features/products/products.component.spec.ts b/src/app/features/products/products.component.spec.ts
new file mode 100644
index 0000000..3565921
--- /dev/null
+++ b/src/app/features/products/products.component.spec.ts
@@ -0,0 +1,169 @@
+import { TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { ProductsComponent } from './products.component';
+import * as ProductsActions from './store/products.actions';
+import * as ProductsSelectors from './store/products.selectors';
+import { Product } from './models';
+
+describe('ProductsComponent', () => {
+ let component: ProductsComponent;
+ let store: MockStore;
+ let router: Router;
+
+ const mockProducts: Product[] = [
+ {
+ id: '1',
+ name: 'Product 1',
+ description: 'Description 1',
+ price: 100,
+ stock: 10,
+ category: 'Category A'
+ },
+ {
+ id: '2',
+ name: 'Product 2',
+ description: 'Description 2',
+ price: 200,
+ stock: 20,
+ category: 'Category B'
+ }
+ ];
+
+ const initialState = {
+ products: {
+ entities: {},
+ ids: [],
+ loading: false,
+ error: null
+ }
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ProductsComponent],
+ providers: [
+ provideMockStore({ initialState }),
+ {
+ provide: Router,
+ useValue: {
+ navigate: vi.fn()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ store = TestBed.inject(MockStore);
+ router = TestBed.inject(Router);
+
+ store.overrideSelector(ProductsSelectors.selectAllProducts, mockProducts);
+ store.overrideSelector(ProductsSelectors.selectProductsLoading, false);
+ store.overrideSelector(ProductsSelectors.selectProductsError, null);
+ store.overrideSelector(ProductsSelectors.selectCategories, ['Category A', 'Category B']);
+
+ const fixture = TestBed.createComponent(ProductsComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should dispatch loadProducts on init', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ component.ngOnInit();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(ProductsActions.loadProducts());
+ });
+
+ it('should select products from store', async () => {
+ const products = await new Promise
(resolve => {
+ component.products$.subscribe(products => resolve(products));
+ });
+ expect(products).toEqual(mockProducts);
+ });
+
+ it('should select loading state from store', async () => {
+ const loading = await new Promise(resolve => {
+ component.loading$.subscribe(loading => resolve(loading));
+ });
+ expect(loading).toBe(false);
+ });
+
+ it('should select categories from store', async () => {
+ const categories = await new Promise(resolve => {
+ component.categories$.subscribe(categories => resolve(categories));
+ });
+ expect(categories).toEqual(['Category A', 'Category B']);
+ });
+
+ it('should navigate to new product form', () => {
+ component.onCreateProduct();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/products/new']);
+ });
+
+ it('should navigate to edit product form', () => {
+ const productId = '123';
+
+ component.onEditProduct(productId);
+
+ expect(router.navigate).toHaveBeenCalledWith(['/products/edit', productId]);
+ });
+
+ it('should navigate to product detail', () => {
+ const productId = '123';
+
+ component.onViewProduct(productId);
+
+ expect(router.navigate).toHaveBeenCalledWith(['/products', productId]);
+ });
+
+ it('should dispatch deleteProduct action when confirmed', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+
+ component.onDeleteProduct('1', 'Product 1');
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ ProductsActions.deleteProduct({ id: '1' })
+ );
+ });
+
+ it('should not dispatch deleteProduct action when cancelled', () => {
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+ vi.spyOn(window, 'confirm').mockReturnValue(false);
+
+ component.onDeleteProduct('1', 'Product 1');
+
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
+ ProductsActions.deleteProduct({ id: '1' })
+ );
+ });
+
+ it('should filter products by category', () => {
+ const filteredProducts = [mockProducts[0]];
+ store.overrideSelector(
+ ProductsSelectors.selectProductsByCategory('Category A'),
+ filteredProducts
+ );
+
+ component.filterByCategory('Category A');
+
+ expect(component.selectedCategory).toBe('Category A');
+ });
+
+ it('should show all products when category is null', () => {
+ component.filterByCategory(null);
+
+ expect(component.selectedCategory).toBeNull();
+ });
+
+ it('should track products by id', () => {
+ const product = mockProducts[0];
+ const trackId = component.trackByProductId(0, product);
+
+ expect(trackId).toBe('1');
+ });
+});
diff --git a/src/app/features/products/products.component.ts b/src/app/features/products/products.component.ts
index 16eec63..edfb5ce 100644
--- a/src/app/features/products/products.component.ts
+++ b/src/app/features/products/products.component.ts
@@ -6,15 +6,17 @@ import { Observable } from 'rxjs';
import * as ProductsActions from './store/products.actions';
import * as ProductsSelectors from './store/products.selectors';
import { Product } from './models';
+import { LoadingComponent } from '../../shared/components/loading.component';
+import { ErrorComponent } from '../../shared/components/error.component';
/**
- * Componente principal del feature Products
- * Lista todos los productos
+ * Main component of the Products feature
+ * Lists all products and allows navigation to create, edit, and view details
*/
@Component({
selector: 'app-products',
standalone: true,
- imports: [CommonModule, RouterModule],
+ imports: [CommonModule, RouterModule, LoadingComponent, ErrorComponent],
templateUrl: './products.component.html',
styleUrl: './products.component.css'
})
@@ -49,7 +51,7 @@ export class ProductsComponent implements OnInit {
}
onDeleteProduct(id: string, name: string): void {
- if (confirm(`¿Estás seguro de eliminar el producto "${name}"?`)) {
+ if (confirm(`Are you sure you want to delete the product "${name}"?`)) {
this.store.dispatch(ProductsActions.deleteProduct({ id }));
}
}
@@ -60,7 +62,7 @@ export class ProductsComponent implements OnInit {
filterByCategory(category: string | null): void {
this.selectedCategory = category;
-
+
if (category) {
this.products$ = this.store.select(ProductsSelectors.selectProductsByCategory(category));
} else {
diff --git a/src/app/features/products/services/products.service.ts b/src/app/features/products/services/products.service.ts
index 64fcd02..e53126c 100644
--- a/src/app/features/products/services/products.service.ts
+++ b/src/app/features/products/services/products.service.ts
@@ -42,21 +42,32 @@ export class ProductsService {
* Obtener un producto por ID
*/
getProductById(id: string): Observable {
- return this.http.get(`${this.apiUrl}/${id}`);
+ return this.http.get(`${this.apiUrl}/${id}`).pipe(
+ map(response => {
+ if (!response) {
+ throw new Error(`Product with id ${id} not found`);
+ }
+ return response.data ? response.data : response;
+ })
+ );
}
/**
* Crear un nuevo producto
*/
createProduct(product: CreateProductCommand): Observable {
- return this.http.post(this.apiUrl, product);
+ return this.http.post(this.apiUrl, product).pipe(
+ map(response => response.data ? response.data : response)
+ );
}
/**
* Actualizar un producto existente
*/
updateProduct(id: string, product: UpdateProductCommand): Observable {
- return this.http.put(`${this.apiUrl}/${id}`, product);
+ return this.http.put(`${this.apiUrl}/${id}`, product).pipe(
+ map(response => response.data ? response.data : response)
+ );
}
/**
diff --git a/src/app/features/products/store/products.actions.ts b/src/app/features/products/store/products.actions.ts
index e6d274c..aa1a039 100644
--- a/src/app/features/products/store/products.actions.ts
+++ b/src/app/features/products/store/products.actions.ts
@@ -17,7 +17,7 @@ export const loadProductsFailure = createAction(
);
/**
- * Acciones para cargar un producto específico
+ * Actions to load a single product
*/
export const loadProduct = createAction(
'[Product Detail Page] Load Product',
@@ -35,7 +35,7 @@ export const loadProductFailure = createAction(
);
/**
- * Acciones para crear producto
+ * Actions to create a product
*/
export const createProduct = createAction(
'[Product Form] Create Product',
@@ -53,7 +53,7 @@ export const createProductFailure = createAction(
);
/**
- * Acciones para actualizar producto
+ * Actions to update a product
*/
export const updateProduct = createAction(
'[Product Form] Update Product',
@@ -71,7 +71,7 @@ export const updateProductFailure = createAction(
);
/**
- * Acciones para eliminar producto
+ * Actions to delete a product
*/
export const deleteProduct = createAction(
'[Product List] Delete Product',
@@ -89,7 +89,7 @@ export const deleteProductFailure = createAction(
);
/**
- * Acción para limpiar el producto seleccionado
+ * Action to clear the selected product
*/
export const clearSelectedProduct = createAction(
'[Product Detail] Clear Selected Product'
diff --git a/src/app/features/products/store/products.effects.ts b/src/app/features/products/store/products.effects.ts
index fa93c7c..6161665 100644
--- a/src/app/features/products/store/products.effects.ts
+++ b/src/app/features/products/store/products.effects.ts
@@ -16,7 +16,7 @@ export class ProductsEffects {
private router = inject(Router);
/**
- * Effect para cargar todos los productos
+ * Effect to load all products
*/
loadProducts$ = createEffect(() =>
this.actions$.pipe(
@@ -31,13 +31,15 @@ export class ProductsEffects {
);
/**
- * Effect para cargar un producto específico
+ * Effect to load a single product
*/
loadProduct$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.loadProduct),
switchMap(({ id }) =>
this.productsService.getProductById(id).pipe(
+ tap(product => console.log('Fetched product:', product)),
+ tap(() => console.log('Loaded product with id:', id)),
map(product => ProductsActions.loadProductSuccess({ product })),
catchError(error => of(ProductsActions.loadProductFailure({ error })))
)
@@ -46,7 +48,7 @@ export class ProductsEffects {
);
/**
- * Effect para crear un producto
+ * Effect to create a product
*/
createProduct$ = createEffect(() =>
this.actions$.pipe(
@@ -61,7 +63,7 @@ export class ProductsEffects {
);
/**
- * Effect para redirigir después de crear
+ * Effect to redirect after creating
*/
createProductSuccess$ = createEffect(() =>
this.actions$.pipe(
@@ -72,7 +74,7 @@ export class ProductsEffects {
);
/**
- * Effect para actualizar un producto
+ * Effect to update a product
*/
updateProduct$ = createEffect(() =>
this.actions$.pipe(
@@ -87,7 +89,7 @@ export class ProductsEffects {
);
/**
- * Effect para redirigir después de actualizar
+ * Effect to redirect after updating
*/
updateProductSuccess$ = createEffect(() =>
this.actions$.pipe(
@@ -98,7 +100,7 @@ export class ProductsEffects {
);
/**
- * Effect para eliminar un producto
+ * Effect to delete a product
*/
deleteProduct$ = createEffect(() =>
this.actions$.pipe(
diff --git a/src/app/shared/components/error.component.ts b/src/app/shared/components/error.component.ts
new file mode 100644
index 0000000..6c2c576
--- /dev/null
+++ b/src/app/shared/components/error.component.ts
@@ -0,0 +1,73 @@
+import { Component, Input, Output, EventEmitter } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+/**
+ * Reusable error display component
+ * Shows error message with optional action button
+ */
+@Component({
+ selector: 'app-error',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+
❌
+
{{ title }}
+
{{ message }}
+
+
+ `,
+ styles: [`
+ .error-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ text-align: center;
+ min-height: 200px;
+ }
+
+ .error-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+ }
+
+ h3 {
+ color: #e74c3c;
+ margin-bottom: 0.5rem;
+ }
+
+ p {
+ color: #666;
+ margin-bottom: 1.5rem;
+ max-width: 500px;
+ }
+
+ .btn-secondary {
+ padding: 0.5rem 1rem;
+ background-color: #6c757d;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.95rem;
+ transition: background-color 0.2s;
+ }
+
+ .btn-secondary:hover {
+ background-color: #5a6268;
+ }
+ `]
+})
+export class ErrorComponent {
+ @Input() title = 'Error';
+ @Input() message = 'An error occurred';
+ @Input() actionLabel?: string;
+ @Output() action = new EventEmitter();
+}
diff --git a/src/app/shared/components/loading.component.ts b/src/app/shared/components/loading.component.ts
new file mode 100644
index 0000000..646545e
--- /dev/null
+++ b/src/app/shared/components/loading.component.ts
@@ -0,0 +1,51 @@
+import { Component, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+/**
+ * Reusable loading spinner component
+ * Shows a spinner with optional message
+ */
+@Component({
+ selector: 'app-loading',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+ `,
+ styles: [`
+ .loading-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ min-height: 200px;
+ }
+
+ .spinner {
+ width: 50px;
+ height: 50px;
+ border: 4px solid #f3f3f3;
+ border-top: 4px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+
+ p {
+ margin-top: 1rem;
+ color: #666;
+ font-size: 0.95rem;
+ }
+ `]
+})
+export class LoadingComponent {
+ @Input() message = 'Loading...';
+}