Pequeño e-commerce en Angular, Node y MySQL (tienda online)

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:

  • Gestión de productos
  • Fotos de productos (guardadas en el disco duro)
  • Carrito de compras por cada usuario
  • Registro de venta con dirección de envío
  • Vista de tienda
  • Detalle de producto

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.

Detalles del software

Comenzamos viendo la gestión de productos con fotos. Para registrar un producto tenemos el siguiente formulario:

Registrar nuevo producto en e-commerce con Angular y Node

Después tenemos la lista de productos. Esta lista es para el administrador, pero los productos también aparecen así en la tienda.

Listado de productos

Como lo dije, aparecen en la tienda para el cliente. Se muestra solo el título y precio, además de una foto:

E-Commerce en Angular y Node – Vista principal de tienda

Ahora, cuando se ven los detalles se muestran todas las imágenes:

Detalle de producto – Tienda de comercio electrónico open source

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:

Producto agregado al carrito de compras

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:

Carrito de compras en tienda electrónica con Angular y Node

En caso de que el cliente quiera terminar la compra, se dirige al apartado para verificar todos los productos:

Verificar productos antes de realizar compra

Cuando se continúa, se le pide al cliente los datos de envío:

Datos de envío de productos en e-commerce

Y al final se muestra un “Gracias por su compra”:

Compra terminada en tienda online con Node y Angular

Ahora en las ventas se verán los datos de envío, etcétera:

Ventas realizadas

También se puede ver el detalle de venta:

Detalle de venta – Mostrar productos, datos de cliente y dirección de envío

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:

  • Autenticación de usuarios
  • Registro de clientes
  • Pasarela de pago
  • Existencia de productos
  • Formas de rastrear el envío del producto por parte del cliente
  • Reportes de ventas totales

Ahora veamos cómo es que está compuesto el software, en dónde se puede descargar, etcétera.

Lado del servidor: API con Node

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.

Conexión con MySQL a través de modelos

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.

Variables del entorno

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.

Lado del cliente con Angular

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&nbsp;|&nbsp;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 {
}

Separación de componentes

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>

Comunicación con servicio HTTP

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.

Comunicación entre componentes

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.

Esquema de base de datos

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
);

Código fuente y descargas

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.

Demostració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:

Conclusión y una pequeña historia

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.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

4 comentarios en “Pequeño e-commerce en Angular, Node y MySQL (tienda online)”

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *