Hoy vengo a presentar un software de comercio electrónico, tienda online o e-commerce escrito en Angular, con los estilos de Angular Material, y con una API escrita con JavaScript del lado del servidor usando Node con Express. Para la base de datos se ha usado MySQL.
El software es open source; puede ser descargado y modificado por cualquier persona. Entre sus características encontramos:
Quiero aclarar que no es un software listo para producción, y más bien es un proyecto escolar que puede servir ya sea como base para un proyecto completo de un e-commerce o para otro proyecto escolar.
Veamos ahora cómo es que está programado, en dónde se puede descargar. etcétera.
Comenzamos viendo la gestión de productos con fotos. Para registrar un producto tenemos el siguiente formulario:
Después tenemos la lista de productos. Esta lista es para el administrador, pero los productos también aparecen así en la tienda.
Como lo dije, aparecen en la tienda para el cliente. Se muestra solo el título y precio, además de una foto:
Ahora, cuando se ven los detalles se muestran todas las imágenes:
Si se agrega al carrito, el producto aparecerá como ya agregado. Además, en la parte superior el carrito aparecerá con el total de productos y el costo total:
Se pueden agregar varios productos al carrito de compras. Como lo dije, se muestra una lista de los productos así como el precio y una opción para terminar la compra:
En caso de que el cliente quiera terminar la compra, se dirige al apartado para verificar todos los productos:
Cuando se continúa, se le pide al cliente los datos de envío:
Y al final se muestra un “Gracias por su compra”:
Ahora en las ventas se verán los datos de envío, etcétera:
También se puede ver el detalle de venta:
En resumen esos son los módulos del software. No hace falta mencionar lo que le falta porque lo sé bien, pero repito, puede servir como base para un proyecto más grande o para un proyecto escolar. De entrada se me ocurre que falta:
Ahora veamos cómo es que está compuesto el software, en dónde se puede descargar, etcétera.
Como lo dije, del lado del servidor se utiliza JavaScript con Node. Es una simple API creada con express, y la misma se encarga de gestionar todas las rutas para cada acción. Por poner un fragmento:
app.delete("/producto", async (req, res) => {
if (!req.query.id) {
res.end("Not found");
return;
}
const idProducto = req.query.id;
await productoModel.eliminar(idProducto);
res.json(true);
});
//Todo: separar rutas
/*
Compras
*/app.get("/detalle_venta", async (req, res) => {
if (!req.query.id) {
res.end("Not found");
return;
}
const idVenta = req.query.id;
const venta = await ventaModel.obtenerPorId(idVenta);
venta.productos = await ventaModel.obtenerProductosVendidos(idVenta);
res.json(venta);
})
app.get("/ventas", async (req, res) => {
const ventas = await ventaModel.obtener();
res.json(ventas);
});
app.post("/compra", async (req, res) => {
const {nombre, direccion} = req.body;
let total = 0;
const carrito = req.session.carrito || [];
carrito.forEach(p => total += p.precio);
const idCliente = await clienteModel.insertar(nombre, direccion);
const idVenta = await ventaModel.insertar(idCliente, total);
// usamos for en lugar de foreach por el await
for (let m = 0; m < carrito.length; m++) {
const productoActual = carrito[m];
await productoVendidoModel.insertar(idVenta, productoActual.id);
}
// Limpiar carrito...
req.session.carrito = [];
// ¡listo!
res.json(true);
});
app.get("/carrito", (req, res) => {
res.json(req.session.carrito || []);
})
// No está en un DELETE porque no permite datos en el body ._.
app.post("/carrito/eliminar", async (req, res) => {
const idProducto = req.body.id;
const indice = indiceDeProducto(req.session.carrito, idProducto);
if (indice >= 0 && req.session.carrito) {
req.session.carrito.splice(indice, 1);
}
res.json(true);
});
app.post("/carrito/existe", async (req, res) => {
const idProducto = req.body.id;
const producto = await productoModel.obtenerPorId(idProducto);
const existe = existeProducto(req.session.carrito || [], producto);
res.json(existe);
});
app.post("/carrito/agregar", async (req, res) => {
const idProducto = req.body.id;
const producto = await productoModel.obtenerPorId(idProducto);
if (!req.session.carrito) {
req.session.carrito = [];
}
// por el momento no se pueden llevar más de dos productos iguales
if (existeProducto(req.session.carrito, producto)) {
res.json(true);
return;
}
req.session.carrito.push(producto);
res.json(req.body);
});
También es importante mencionar que las fotos de los productos son subidas gracias a formidable:
app.post('/fotos_producto', (req, res) => {
const form = formidable({
multiples: true,
uploadDir: DIRECTORIO_FOTOS,
});
form.parse(req, async (err, fields, files) => {
const idProducto = fields.idProducto;
for (let clave in files) {
const file = files[clave];
const nombreArchivo = file.name;
await productoModel.agregarFoto(idProducto, nombreArchivo)
}
});
form.on("fileBegin", (name, file) => {
const extension = path.extname(file.name);
const nuevoNombre = uuidv4().concat(extension);
file.path = path.join(DIRECTORIO_FOTOS, nuevoNombre);
file.name = nuevoNombre;
})
form.on("end", () => {
res.json({
respuesta: true,
})
})
});
Se renombran a un nombre único usando uuid v4. Debido a que para el modo desarrollo se necesita habilitar CORS, la configuración es:
app.use((req, res, next) => {
res.set("Access-Control-Allow-Credentials", "true");
res.set("Access-Control-Allow-Origin", DOMINIO_PERMITIDO_CORS);
res.set("Access-Control-Allow-Headers", "Content-Type");
res.set("Access-Control-Allow-Methods", "DELETE");
next();
});
Tenemos algunas funciones que manejan el estado del carrito de compras, que no he movido a un módulo separado por cuestiones de tiempo:
const indiceDeProducto = (carrito, idProducto) => {
return carrito.findIndex(productoDentroDelCarrito => productoDentroDelCarrito.id === idProducto);
}
const existeProducto = (carrito, producto) => {
return indiceDeProducto(carrito, producto.id) !== -1;
}
Por cierto, el carrito es manejado a través de la sesión:
app.use(session({
secret: process.env.SESSION_KEY,
saveUninitialized: true,
resave: true,
}))
Y la conexión con la base de datos se hace en módulos separados.
He separado las rutas de los modelos. Cada modelo gestiona una entidad, por ejemplo, los productos. La mayoría ofrece un simple CRUD. Por ejemplo, el siguiente maneja productos:
const conexion = require("./conexion")
const fs = require("fs");
const path = require("path");
module.exports = {
insertar(nombre, descripcion, precio) {
return new Promise((resolve, reject) => {
conexion.query(`insert into productos
(nombre, descripcion, precio)
values
(?, ?, ?)`,
[nombre, descripcion, precio], (err, resultados) => {
if (err) reject(err);
else resolve(resultados.insertId);
});
});
},
agregarFoto(idProducto, nombreFoto) {
return new Promise((resolve, reject) => {
conexion.query(`insert into fotos_productos
(id_producto, foto)
values
(?, ?)`,
[idProducto, nombreFoto], (err, resultados) => {
if (err) reject(err);
else resolve(resultados.insertId);
});
});
},
obtener() {
return new Promise((resolve, reject) => {
conexion.query(`select id, nombre, descripcion, precio from productos`,
(err, resultados) => {
if (err) reject(err);
else resolve(resultados);
});
});
},
obtenerConFotos() {
return new Promise((resolve, reject) => {
conexion.query(`select * from productos`,
async (err, resultados) => {
if (err) reject(err);
else {
/*
Si existe un dios, que me disculpe por este no-optimizado e ineficiente fragmento de código
*/ for (let x = 0; x < resultados.length; x++) {
resultados[x].foto = await this.obtenerPrimeraFoto(resultados[x].id);
}
resolve(resultados);
}
});
});
},
obtenerPrimeraFoto(idProducto) {
return new Promise((resolve, reject) => {
conexion.query(`select foto from fotos_productos WHERE id_producto = ? limit 1`,
[idProducto],
(err, resultados) => {
if (err) reject(err);
else resolve(resultados[0].foto);
});
});
},
obtenerFotos(idProducto) {
return new Promise((resolve, reject) => {
conexion.query(`select id_producto, foto FROM fotos_productos WHERE id_producto = ?`,
[idProducto],
(err, resultados) => {
if (err) reject(err);
else resolve(resultados);
});
});
},
obtenerPorId(id) {
return new Promise((resolve, reject) => {
conexion.query(`select id, nombre,descripcion, precio from productos where id = ?`,
[id],
(err, resultados) => {
if (err) reject(err);
else resolve(resultados[0]);
});
});
},
actualizar(id, nombre, precio) {
return new Promise((resolve, reject) => {
conexion.query(`update productos
set nombre = ?,
precio = ?
where id = ?`,
[nombre, precio, id],
(err) => {
if (err) reject(err);
else resolve();
});
});
},
eliminar(id) {
return new Promise(async (resolve, reject) => {
const fotos = await this.obtenerFotos(id);
for (let m = 0; m < fotos.length; m++) {
await fs.unlinkSync(path.join(__dirname, "fotos_productos", fotos[m].foto));
}
conexion.query(`delete from productos
where id = ?`,
[id],
(err) => {
if (err) reject(err);
else resolve();
});
});
},
}
En el caso de eliminarlos, también se elimina la foto del disco duro. Para ello primero se obtiene la lista de fotos, se recorre, elimina cada foto y finalmente se elimina el producto.
Si te fijas, se está importando a un archivo de conexión, el cual es el siguiente:
const mysql = require("mysql");
// Coloca aquí tus credenciales
module.exports = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
Mismo que a su vez lee variables del entorno, de ese modo no comprometes tus credenciales dentro del código.
Las configuraciones están dentro del archivo .env y en mi caso se ve así:
DB_HOST=localhost
DB_NAME=ecommerce
DB_USER=root
DB_PASSWORD=
SESSION_KEY=987f4bd6d4315c20b2ec70a46ae846d19d0ce563450c02c5b1bc71d5d580060b
Lo que importa es la clave de la sesión; recomiendo generarla de manera aleatoria, si quieres puedes usar mi generador de claves hexadecimales escrito en Go.
Del lado del cliente tenemos una aplicación web con Angular. La misma separa los componentes y usa el enrutador para tener una SPA. Veamos por ejemplo el componente principal:
<div class="contenedor-padre">
<mat-toolbar class="barra" color="warn">
<mat-toolbar-row>
<button (click)="cajon.toggle()" mat-icon-button>
<mat-icon>menu</mat-icon>
</button>
<span>
Mi tienda | By parzibyte
</span>
<span class="example-fill-remaining-space"></span>
<button mat-raised-button [matMenuTriggerFor]="menu">
<span style="margin-right: 10px" [matBadge]="productos.length+''" matBadgeOverlap="false"
matBadgePosition="below after">Carrito de compras</span>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item *ngFor="let p of productos;">{{p.nombre}} {{p.precio | currency}}</button>
<button mat-menu-item>
<strong>Total: {{total() | currency}}</strong>
</button>
<a *ngIf="productos.length > 0" mat-menu-item routerLink="/terminar_compra">
<mat-icon>shopping_cart</mat-icon>
Terminar compra</a>
</mat-menu>
<a style="margin-left: 10px" mat-raised-button color="primary"
href="https://parzibyte.me/blog/contrataciones-ayuda/" target="_blank">
Ayuda y soporte
</a>
</mat-toolbar-row>
</mat-toolbar>
<mat-sidenav-container class="contenido">
<mat-sidenav style="min-width: 300px;" #cajon opened mode="side">
<mat-nav-list>
<p style="margin-left: 4px;">Administrador</p>
<a mat-list-item routerLink="/productos">
<mat-icon color="primary">list_alt</mat-icon>
Productos
</a>
<a mat-list-item routerLink="/ventas">
<mat-icon color="primary">local_shipping</mat-icon>
Ventas
</a>
<mat-divider></mat-divider>
<p style="margin-left: 4px;">Cliente</p>
<a mat-list-item routerLink="/tienda">
<mat-icon color="primary">store</mat-icon>
Ver tienda
</a>
<mat-divider></mat-divider>
<a mat-list-item href="https://parzibyte.me/blog/" target="_blank">
Creado por Parzibyte
</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content class="padding-10">
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</div>
Por favor nota que estamos utilizando Angular Material.
En la línea 55 colocamos router-outlet
; eso hará que ahí se inyecte el componente dependiendo de la navegación. Por cierto, el router se ve así:
import {NgModule} from '@angular/core';
import {Routes, RouterModule} from '@angular/router';
import {ProductosComponent} from './productos/productos.component';
import {ClientesComponent} from './clientes/clientes.component';
import {VentasComponent} from './ventas/ventas.component';
import {TiendaComponent} from './tienda/tienda.component';
import {AgregarProductoComponent} from "./agregar-producto/agregar-producto.component";
import {DetalleDeProductoComponent} from "./detalle-de-producto/detalle-de-producto.component";
import {TerminarCompraComponent} from "./terminar-compra/terminar-compra.component";
import {DetalleDeVentaComponent} from "./detalle-de-venta/detalle-de-venta.component";
const routes: Routes = [
{path: 'productos', component: ProductosComponent},
{path: 'productos/agregar', component: AgregarProductoComponent},
{path: 'clientes', component: ClientesComponent},
{path: 'ventas', component: VentasComponent},
{path: 'tienda', component: TiendaComponent},
{path: 'producto/detalle/:id', component: DetalleDeProductoComponent},
{path: 'terminar_compra', component: TerminarCompraComponent},
{path: 'detalle-venta/:id', component: DetalleDeVentaComponent},
{path: '', redirectTo: "/tienda", pathMatch: "full"},
{path: '**', redirectTo: "/tienda"},
];
@NgModule({
imports: [RouterModule.forRoot(routes, {
useHash: true, // <- Indicar que se use el hash
})],
exports: [RouterModule]
})
export class AppRoutingModule {
}
He separado algunos componentes, por ejemplo la tarjeta de producto se ve así:
<mat-card>
<div style="">
<div class="contenedor-imagen">
<img src="{{resolverRuta()}}" alt="">
</div>
<div style="display: inline-block; vertical-align: top">
<h2>{{producto.nombre}}</h2>
<h1>{{producto.precio | currency}}</h1>
<button (click)="detalles()" mat-stroked-button color="warn">Ver detalles
<mat-icon>arrow_right</mat-icon>
</button>
</div>
</div>
</mat-card>
Y al momento de mostrarla en la tienda, se repite con un ngFor
pasándole el producto:
<app-tarjeta-producto [producto]="producto" *ngFor="let producto of productos"></app-tarjeta-producto>
Lo mismo pasa con este loading button:
<button mat-flat-button [disabled]="cargando" color="accent">
<span *ngIf="!cargando">{{texto}}</span>
<mat-spinner *ngIf="cargando" diameter="30"></mat-spinner>
</button>
Para comunicarse con el servidor se utiliza simplemente fetch. Por ejemplo, el servicio de productos se ve así:
import {Injectable} from '@angular/core';
import {Producto} from "./producto";
import {HttpService} from "./http.service";
@Injectable({
providedIn: 'root'
})
export class ProductosService {
constructor(private http: HttpService) {
}
public async eliminarProducto(idProducto) {
return await this.http.delete("/producto?id=".concat(idProducto));
}
public async agregarProducto(producto: Producto) {
return await this.http.post("/producto", producto);
}
/*
El formdata debe tener el id del producto
*/ public async agregarFotosDeProducto(fotos: FormData) {
return await this.http.formdata("/fotos_producto", fotos);
}
public async obtenerProductos() {
return await this.http.get("/productos");
}
public async obtenerProductosConFotos() {
return await this.http.get("/productos_con_fotos");
}
public async obtenerProductoConFotosPorId(idProducto) {
return await this.http.get("/producto?id=".concat(idProducto));
}
}
Y el servicio HTTP es una envoltura:
import {Injectable} from '@angular/core';
import {environment} from "../environments/environment";
@Injectable({
providedIn: 'root'
})
export class HttpService {
rutaServidor = environment.baseUrl;
constructor() {
}
public async post(ruta: string, payload: any) {
const respuestaRaw = await fetch(this.rutaServidor + ruta, {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
credentials: "include",
});
return await respuestaRaw.json();
}
public async formdata(ruta: string, payload: FormData) {
const respuestaRaw = await fetch(this.rutaServidor + ruta, {
body: payload,
method: "POST",
});
return await respuestaRaw.json();
}
async get(ruta: string) {
// Por defecto se hace una petición GET
const respuestaRaw = await fetch(this.rutaServidor + ruta, {
credentials: "include",
});
return await respuestaRaw.json();
}
async delete(ruta: string) {
const respuestaRaw = await fetch(this.rutaServidor + ruta, {
credentials: "include",
method: "DELETE",
});
return await respuestaRaw.json();
}
}
De este modo se exponen los 4 métodos básicos (y también para enviar un formdata), incluyendo credenciales para mantener la sesión y comunicándose con JSON.
La barra del carrito de compras y los otros componentes están totalmente aislados, por lo tanto debía haber una forma de implementar la comunicación entre componentes. Así que investigando encontré la forma de crear un servicio para compartir información:
import {Injectable} from '@angular/core';
import {BehaviorSubject} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class DataSharingService {
private messageSource = new BehaviorSubject('default message');
currentMessage = this.messageSource.asObservable();
constructor() {
}
changeMessage(message: string) {
this.messageSource.next(message)
}
}
En el carrito de compras (que vive en la app principal dentro de la barra) escuchamos el mensaje:
constructor(private carritoService: CarritoService, private dataSharingService: DataSharingService) {
// Comunicación entre componentes
this.dataSharingService.currentMessage.subscribe(mensaje => {
if (mensaje == "car_updated") {
this.refrescarCarrito();
}
})
}
Ahora, para enviar el mensaje en otro componente, hacemos esto:
this.dataSharingService.changeMessage("car_updated")
Así, cualquier componente puede decirle al carrito: Hey tú, actualízate.
Por poco lo olvido. La base de datos, o mejor dicho, las tablas, son las siguientes:
create table productos(
id bigint unsigned not null auto_increment primary key,
nombre varchar(255) not null,
descripcion varchar(1024) not null,
precio decimal(9,2) not null
);
create table fotos_productos(
id_producto bigint unsigned not null,
foto varchar(255) not null,
foreign key(id_producto) references productos(id) on delete cascade on update cascade
);
create table clientes(
id bigint unsigned not null auto_increment primary key,
nombre varchar(255) not null,
direccion varchar(255) not null
);
create table ventas(
id bigint unsigned not null auto_increment primary key,
id_cliente bigint unsigned not null,
total decimal(9,2) not null,
foreign key(id_cliente) references clientes(id) on delete cascade on update cascade
);
create table productos_vendidos(
id_venta bigint unsigned not null,
id_producto bigint unsigned not null,
foreign key(id_venta) references ventas(id) on delete cascade on update cascade,
foreign key(id_producto) references productos(id) on delete cascade on update cascade
);
Vas a encontrar el código fuente en mi GitHub. Las instrucciones están en el README; básicamente se trata de una app escrita con Angular y Node, así que con contar con NPM y la CLI de Angular basta.
Aquí el enlace: software de comercio electrónico open source. Espero poder grabar un vídeo de su uso e instalación.
Aquí he grabado un vídeo explicando algunas partes del código, así como demostrando la funcionalidad de esta tienda electrónica con node js y angular:
Recuerda que todo el código está en GitHub, aquí he omitido algunas partes ya que el post se haría muy largo en caso de explicar línea por línea.
Escribí la aplicación web en una semana. Me siento orgulloso pues me parece que esa semana ha sido una de las más pesadas que he tenido en mi vida (sin exagerar); justamente en esos días tenía varios trabajos pendientes y algunos exámenes por hacer (no míos) de otra zona horaria.
Si no crees que lo hice en una semana puedes explorar los commits del mismo. Espero algún día volver a este post y tener un recuerdo del esfuerzo que he realizado.
Las cosas que aprendí las fui documentando (el resto lo haré tan pronto tenga tiempo) y están dentro de las categorías Node y Angular.
Hoy te voy a presentar un creador de credenciales que acabo de programar y que…
Ya te enseñé cómo convertir una aplicación web de Vue 3 en una PWA. Al…
En este artículo voy a documentar la arquitectura que yo utilizo al trabajar con WebAssembly…
En un artículo anterior te enseñé a crear un PWA. Al final, cualquier aplicación que…
Al usar Comlink para trabajar con los workers usando JavaScript me han aparecido algunos errores…
En este artículo te voy a enseñar cómo usar un "top level await" esperando a…
Esta web usa cookies.
Ver comentarios
Como lo haces para agregar mas de una imagen al producto ?
Programando lo necesario
Buen trabajo
Pero tengo una consulta..
¿Cómo se realiza el SEO de cada producto?
Gracias..
Muy buen trabajo. Muchas gracias,