En el siguiente post aprenderemos a crear una aplicación muy básica y sencilla con React, donde integraremos la API de OMDB (https://www.omdbapi.com/), para obtener una key es sencillo, debes ingresar a la página, ir a la sección de API key, colocar el tipo de cuenta gratis, y colocar tu correo, unos momentos después recibirás la key en tu correo.
Puedes contactarme por telegram si necesitas contratar a un desarrollador Full Stack.
También puedes contactarme por discord Appu#9136
Creación del proyecto
- abrir la terminal
- ubicarnos en la carpeta donde vamos a querer crear nuestro proyecto
- npx create-react-app react-omdb (o como desees nombrarlo)
- cd react-omdb (o el nombre que le hayas colocado)
- code .
El CSS que se usó para este ejemplo es muy sencillo, puedes copiarlo o descargarlo de este link (https://github.com/rtagliaviaz/react-omdb-tut/blob/main/src/App.css) o hacer tu propia interfaz si así lo deseas.
Estructura del proyecto:
react-omdb/
├── node_modules/
├── public/
├── src/
│ ├── components/
│ ├── App.css
│ ├── App.js
│ └── index.css
│ └── index.js
└── package.json
Dependencias
- axios
Para este ejemplo solo instalaremos axios como dependencia adicional, lo haremos abriendo la consola ubicados en nuestro proyecto, seguido de npm i axios
.
Finalmente para poder empezar, volveremos abrir la consola y ejecutaremos el siguiente comando npm start
para poder ver los cambios que realizaremos a lo largo de este post.
Tabla de Contenido
- Creación del componente Main.js
- Integración de la API
- Obtener información de la película
- Creación y Configuración del modal para mostrar los detalles
- Paginación
- Conclusión
Empecemos!
- ## Creación del componente Main.js
Para empezar lo primero que haremos será eliminar los archivos que no usaremos, dejando nuestro proyecto como se muestra mas arriba en la estructura del proyecto.
Abriremos nuestro archivo App.js
ubicado dentro de src, vamos a eliminar el import del logo, y modificaremos nuestro archivo dejándolo de la siguiente manera por los momentos.
import './App.css';
function App() {
return (
<div className="App">
REACT OMDB
</div>
);
}
export default App;
Posterior a esto iremos a nuestra carpeta components dentro de la carpeta src (la crearemos en caso de no haberla creado aun), dentro de components crearemos un archivo llamado Main.js
.
Abriremos nuestro Main.js
, empezaremos importando los hooks useState
y useEffect
ya que los usaremos mas adelante, y también importaremos axios.
Crearemos una constante con el nombre de api donde colocaremos la ruta de la api, en este caso 'https://www.omdbapi.com/?' y también una constante para apiKey a la que le asignaremos nuestra key.
De momento solo retornaremos un div con el nombre de nuestro componente.
import React, {useState, useEffect} from 'react'
import axios from 'axios'
//api
const api = 'https://www.omdbapi.com/?'
//api key
const apiKey = 'apikey=18eaeb4f'
const Main = () => {
return(
<div>
Main
</div>
)
}
export default Main
Volveremos a nuestro App.js
y importaremos nuestro componente Main.js
como se muestra en el código de abajo, veremos que ya nos muestra el componente Main.js
.
import React from 'react';
import './App.css'
//components
import Main from './components/Main'
function App() {
return (
<div className="App">
REACT OMDB
<Main />
</div>
);
}
export default App;
- ## Integración de la API
Ahora volveremos a nuestro componente Main.js
y haremos un formulario para poder realizar una búsqueda con el nombre de la película, y hacer uso del hook useState
, al hacer clic al botón de buscar se ejecutará la función que realizará la petición GET a la api, y de momento obtendremos la respuesta por consola.
import React, { useState, useEffect } from "react";
import axios from "axios";
//api
const api = "https://www.omdbapi.com/?";
//api key
const apiKey = "apikey=18eaeb4f";
const Main = () => {
const [name, setName] = useState("");
//get response from API
const getInfo = () => {
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
console.log(res.data);
}
});
};
//submit the title entered
const handleSubmit = (e) => {
e.preventDefault();
getInfo();
};
return (
<div>
<form>
<div className='searchBar'>
<label htmlFor='name'></label>
<input
type='text'
name='name'
placeholder='movie name'
onChange={(e) => setName(e.target.value)}
/>
<button type='submit' onClick={(e) => handleSubmit(e)}>
Search
</button>
</div>
</form>
</div>
);
};
export default Main;
Estamos recibiendo un objeto, con el número total de resultados (que usaremos mas adelante para crear la funcionalidad de paginación), y un arreglo con los 10 primeros títulos que encontró, con detalles como el poster, la fecha de estreno, el imdbID (que también usaremos mas adelante), y muchos más.
Usaremos nuevamente nuestro useState
, en este caso con un arreglo de películas con el estado inicial vacío, y cambiará cuando obtengamos los resultados. Y ahora en el return devolveremos la lista de resultados si el arreglo tiene elementos, en caso contrario devolveremos null
.
Haremos que nos devuelva por cada título, un poster, el título, y un botón details, que de momento no hace nada, y colocaremos como key el imdbId de cada título.
import React, { useState, useEffect } from "react";
import axios from "axios";
//api
const api = "https://www.omdbapi.com/?";
//api key
const apiKey = "apikey=18eaeb4f";
const Main = () => {
const [name, setName] = useState("");
const [movies, setMovies] = useState([])
//get response from API
const getInfo = () => {
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search)
}
});
};
//submit the title entered
const handleSubmit = (e) => {
e.preventDefault();
getInfo();
};
return (
<div>
<form>
<div className='searchBar'>
<label htmlFor='name'></label>
<input
type='text'
name='name'
placeholder='movie name'
onChange={(e) => setName(e.target.value)}
/>
<button type='submit' onClick={(e) => handleSubmit(e)}>
Search
</button>
</div>
</form>
{movies ?
<div className="movies">
{movies.map(movie => (
<div key={movie.imdbID} className="movie">
<img src={movie.Poster} alt=""/>
<div className="movie-title">
<p>{movie.Title}</p>
</div>
<button className="movie-detailsBtn" >Details</button>
</div>))}
</div>
: null}
</div>
);
};
export default Main;
- ## Obtener información de la película
Ahora para el botón de detalles crearemos una función llamada getDetails
, la cual se la pasará como argumento el id (imdbID) del título y se hará otra petición GET a la api con el id para que nos devuelva específicamente los datos de esa película, de momento los mostraremos por consola.
import React, { useState, useEffect } from "react";
import axios from "axios";
//api
const api = "https://www.omdbapi.com/?";
//api key
const apiKey = "apikey=18eaeb4f";
const Main = () => {
const [name, setName] = useState("");
const [movies, setMovies] = useState([])
//get response from API
const getInfo = () => {
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search)
}
});
};
//submit the title entered
const handleSubmit = (e) => {
e.preventDefault();
getInfo();
};
return (
<div>
<form>
<div className='searchBar'>
<label htmlFor='name'></label>
<input
type='text'
name='name'
placeholder='movie name'
onChange={(e) => setName(e.target.value)}
/>
<button type='submit' onClick={(e) => handleSubmit(e)}>
Search
</button>
</div>
</form>
{movies ?
<div className="movies">
{movies.map(movie => (
<div key={movie.imdbID} className="movie">
<img src={movie.Poster} alt=""/>
<div className="movie-title">
<p>{movie.Title}</p>
</div>
<button className="movie-detailsBtn" >Details</button>
</div>))}
</div>
: null}
</div>
);
};
export default Main;
Vamos a añadir un estado de movieDetails
que este inicializado como un objeto vacío, ahora en lugar de mostrar los detalles por consola vamos a actualizar el estado de movieDetails
con esos datos.
import React, { useState, useEffect } from "react";
import axios from "axios";
//api
const api = "https://www.omdbapi.com/?";
//api key
const apiKey = "apikey=18eaeb4f";
const Main = () => {
const [name, setName] = useState("");
const [movies, setMovies] = useState([])
const [movieDetails, setMovieDetails] = useState({})
//get response from API
const getInfo = () => {
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search)
}
});
};
//get details
const getDetails = (e, id) => {
e.preventDefault()
axios.get(api + apiKey + `&i=${id}`).then((res) => {
if (res) {
setMovieDetails(res.data)
}
})
}
//submit the title entered
const handleSubmit = (e) => {
e.preventDefault();
getInfo();
};
return (
<div>
<form>
<div className='searchBar'>
<label htmlFor='name'></label>
<input
type='text'
name='name'
placeholder='movie name'
onChange={(e) => setName(e.target.value)}
/>
<button type='submit' onClick={(e) => handleSubmit(e)}>
Search
</button>
</div>
</form>
{movies ?
<div className="movies">
{movies.map(movie => (
<div key={movie.imdbID} className="movie">
<img src={movie.Poster} alt=""/>
<div className="movie-title">
<p>{movie.Title}</p>
</div>
<button className="movie-detailsBtn"
onClick={e => getDetails(e, movie.imdbID)}
>Details</button>
</div>))}
</div>
: null}
</div>
);
};
export default Main;
- ## Creación y Configuración del modal para mostrar los detalles
Para mostrar estos datos usaremos un modal que se muestre cada vez que hagamos clic en el botón "details", para esto iremos a nuestra carpeta components y crearemos un archivo llamado MovieModal.js
, el cual de momento nos va a retornar esta estructura, (recuerda que las clases corresponden al css que cree).
const MovieModal = () => {
return(
<div className="modal display-block">
<section className="modal-main">
<div className="modal-body">
</div>
<button className="modal-closebtn" >Close</button>
</section>
</div>
)
}
export default MovieModal
Ahora para poder abrir este modal debemos volver a nuestro archivo Main.js
:
1- Empezamos importando nuestro nuevo componente.
2- Declaramos un estado para el id seleccionado $const [selectedId, setSelectedId] = useState(null)
.
3- Vamos a crear un estado para mostrar nuestro modal con $const [show, setShow] = useState(false)
que será un booolean y estará inicializado en false.
4- Configuraremos la funcionalidad del modal con 3 funciones.
5- En nuestra función getDetails actualizaremos selectId con el id que se paso como argumento a la función, luego al recibir la respuesta ejecutaremos showModal()
para mostrarlo.
6- Por ultimo en return debajo del botón colocaremos una condicional, si MovieDetails
no esta vacío, si el selectedId es estrictamente igual al imdbdID de la película y si show esta en true, entonces nos va a retornar el componente de nuestro modal, de lo contrario no lo mostrará.
import React, { useState, useEffect } from "react";
import axios from "axios";
//components
import MovieModal from "./MovieModal";
//api
const api = "https://www.omdbapi.com/?";
//api key
const apiKey = "apikey=18eaeb4f";
const Main = () => {
const [name, setName] = useState("");
const [movies, setMovies] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [movieDetails, setMovieDetails] = useState({})
//modal
const [show, setShow] = useState(false)
//modal config
const showModal = () => {
setShow(true)
}
const hideModal = () => {
setShow(false)
setMovieDetails()
}
const handleClose = () => {
hideModal()
}
//get response from API
const getInfo = () => {
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search)
}
});
};
//get details
const getDetails = (e, id) => {
e.preventDefault()
setSelectedId(id)
axios.get(api + apiKey + `&i=${id}`).then((res) => {
if (res) {
setMovieDetails(res.data)
showModal()
}
})
}
//submit the title entered
const handleSubmit = (e) => {
e.preventDefault();
getInfo();
};
return (
<div>
<form>
<div className='searchBar'>
<label htmlFor='name'></label>
<input
type='text'
name='name'
placeholder='movie name'
onChange={(e) => setName(e.target.value)}
/>
<button type='submit' onClick={(e) => handleSubmit(e)}>
Search
</button>
</div>
</form>
{movies ?
<div className="movies">
{movies.map(movie => (
<div key={movie.imdbID} className="movie">
<img src={movie.Poster} alt=""/>
<div className="movie-title">
<p>{movie.Title}</p>
</div>
<button className="movie-detailsBtn"
onClick={e => getDetails(e, movie.imdbID)}
>Details</button>
{/* modal */}
{movieDetails && (selectedId===movie.imdbID) && show ?
<MovieModal/> :
<div className="modal display-none"></div>
}
</div>))}
</div>
: null}
</div>
);
};
export default Main;
Como podrás haber notado, no muestra la información y tampoco se puede cerrar, volvemos a Main.js
y en la parte donde retornamos el modal vamos a pasar $handleClose
, y los detalles con una propiedad que llamaremos movieInfo
.
{/* modal */}
{movieDetails && (selectedId===movie.imdbID) && show ?
<MovieModal
movieInfo={movieDetails}
handleClose={handleClose}/> :
<div className="modal display-none"></div>
}
Volvemos a nuestro MovieModal.js
, pasaremos los props
de la siguiente manera.
const MovieModal = ({movieInfo, handleClose}) => {
return(
.
.
.
)
}
export default MovieModal
Ahora modificamos el return para que devuelva algunos datos, (puedes pasar mas si lo deseas), y al botón de close haremos que ejecute la función handleClose
cuando se le haga clic.
const MovieModal = ({ movieInfo, handleClose }) => {
return (
<div className='modal display-block'>
<section className='modal-main'>
<div className='modal-body'>
<div className='modal-img'>
<img src={movieInfo.Poster} alt='Poster' />
</div>
</div>
<div className='modal-info'>
<p>
<b>Actors:</b> {movieInfo.Actors}
</p>
<p>
<b>Genre:</b> {movieInfo.Genre}
</p>
<p>
<b>Director:</b> {movieInfo.Director}
</p>
<p>
<b>Released:</b> {movieInfo.Released}
</p>
<p>
<b>Plot:</b> {movieInfo.Plot}
</p>
</div>
<button className='modal-closebtn' onClick={handleClose}>
Close
</button>
</section>
</div>
);
};
export default MovieModal;
- ## Paginación
Ahora que tenemos esa sección lista, crearemos la funcionalidad de paginación y asi poder ver mas resultados.
Vovlemos a nuestro componente Main.js, al realizar la peticion GET a la api nos devuelven un objeto con una propiedad llamada totalResults
, esta propiedad será clave para crear la funcionalidad de paginación.
Lo primero que haremos será añadir un nuevo estado, luego iremos a nuestra función getInfo y la modificaremos para que actualice totalResults
con los datos correspondientes
const Main = () => {
const [name, setName] = useState("");
const [movies, setMovies] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [movieDetails, setMovieDetails] = useState({})
//modal
const [show, setShow] = useState(false)
//pagination
const [totalResults, setTotalResults] = useState()
.
.
.
//get response from API
const getInfo = () => {
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search)
setTotalResults(res.data.totalResults)
}
});
};
.
.
.
Con ayuda de totalResults
podremos calcular el número de páginas que tendrá la busqueda que realicemos, crearemos otro estado const [numberOfPages, setNumberOfPages] = useState()
y construiremos una función getNumberOfPages
para obtener el número de páginas de nuestra busqueda.
En la función divideremos entre 10 porque solo obtenemos 10 resultados como máximo por página, por lo que usaremos el operador módulo para ver si la división arroja residuo, en caso de que arroje residuo, argegaremos una página adicional. usaremos parseInt()
ya que totalResults
es un string. Finalmente actualizaremos numberOfPages
con el valor del número de páginas.
.
.
.
//pagination
const [totalResults, setTotalResults] = useState()
const [numberOfPages, setNumberOfPages] = useState()
.
.
.
const getNumberOfPages = () => {
if (totalResults % 10 > 0) {
const numberOfpages = parseInt((totalResults / 10) + 1)
setNumberOfPages(numberOfpages)
return
}
const numberOfpages = parseInt(totalResults / 10)
setNumberOfPages(numberOfpages)
}
Ahora crearemos un estado que se va a encargar de actualizarce con la página actual, que será la página que seleccionemos const [currentPage, setCurrentPage] = useState()
Modificaremos la función getInfo()
de la siguiente manera para que cuando hagamos nuestra primera busqueda, actualice currentPage
en 1, y empezaremos a mostrar los números de las páginas con la siguiente lógica después del return.
Si ya tenemos el número de páginas, entonces mostramos un div con las páginas, de lo contrario no lo renderizaremos. Como mostraremos las páginas enumeradas haremos lo siguiente, si la página anterior currentPage -1
es igual a 0, no la mostrarémos, de lo contrario mostraremos currentPage - 1
, luego la página actual, es decir currentPage
, y por ultimo la página siguiente con currentPage + 1
.
.
.
.
//pagination
const [totalResults, setTotalResults] = useState()
const [numberOfPages, setNumberOfPages] = useState()
const [currentPage, setCurrentPage] = useState()
.
.
.
//get response from API
const getInfo = () => {
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search);
setTotalResults(res.data.totalResults);
setCurrentPage(1)
}
});
};
return(
{numberOfPages ? (
<div className='pages'>
{/* if prev page is 0 it wont show */}
{currentPage - 1 === 0 ? null : (
<b >{currentPage - 1}</b>
)}
<b className='actualPage'>
{currentPage}
</b>
<b >{currentPage + 1}</b>
</div>
) : null}
)
De momento solo podemos ver números, pero no tiene ninguna funcionalidad, hagámosla.
1- usaremos useEffect()
para que ejecute getNumbersOfPages()
.
2- Modificaremos nuestra función getInfo()
y le pasaremos como argumento pageNumber
, de tal manera de que si le pasamos pageNumber
hará la petición GET a la api con el valor de pageNumber
en el parametro de page, caso contrario nos devolvera el valor con el valor de la página en 1 como veniamos haciendo hasta ahora.
3- Crearemos un arreglo de páginas, el cual llenaremos con ayuda de un ciclo for
que empezaremos en 1 e iterará el numero de veces correspondiente a numberOfPages
, ejemplo (si el numero de páginas es 5, pasara 5 veces, y tendremos nuestro arreglo pages con 5 valores).
4- Crearemos una función goTo(pageNumber)
que tendrá como argumento pageNumber
, cada vez que se ejecute esta función actualizaremos currentPage
con el valor de la página seleccionada, es decir pageNumber
. Y luego se ejecutará getInfo
con esa misma página que seleccionamos.
5- En la parte donde renderizamos nuestra paginación, haremos unas modificaciones pata que al hacer clic en el número de la página, ejecute la funcion goTo
con la página que seleccionamos.
.
.
.
//get response from API
const getInfo = (pageNumber) => {
if (pageNumber) {
axios
.get(
api + apiKey + `&s=${name}` + "&type=movie" + `&page=${pageNumber}`
)
.then((res) => {
if (res) {
setMovies(res.data.Search);
setTotalResults(res.data.totalResults);
}
});
return;
}
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search);
setTotalResults(res.data.totalResults);
setCurrentPage(1);
}
});
};
//getnumberOFpageseffect
useEffect(() => {
getNumberOfPages();
});
const pages = [];
for (let i = 1; i <= numberOfPages; i++) {
pages.push(<p key={i} onClick={e => goTo(i)}>{i}</p>)
}
const goTo = (pageNumber) => {
setCurrentPage(pageNumber)
getInfo(pageNumber)
window.scrollTo(0, 0)
}
return(
.
.
.
{numberOfPages ? (
<div className='pages'>
{/* if prev page is 0 it wont show */}
{currentPage - 1 === 0 ? null : <b onClick={e => goTo(currentPage-1)}>{currentPage - 1}</b>}
<b onClick={e => goTo(currentPage)}className='actualPage'>{currentPage}</b>
<b onClick={e => goTo(currentPage+1)}>{currentPage + 1}</b>
</div>
) : null}
)
Finalmente, nuestro archivo debe verse así.
import React, { useState, useEffect } from "react";
import axios from "axios";
//components
import MovieModal from "./MovieModal";
//api
const api = "https://www.omdbapi.com/?";
//api key
const apiKey = "apikey=18eaeb4f";
const Main = () => {
const [name, setName] = useState("");
const [movies, setMovies] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [movieDetails, setMovieDetails] = useState({});
//modal
const [show, setShow] = useState(false);
//pagination
const [totalResults, setTotalResults] = useState(0);
const [numberOfPages, setNumberOfPages] = useState();
const [currentPage, setCurrentPage] = useState();
const getNumberOfPages = () => {
if (totalResults % 10 > 0) {
const numberOfpages = parseInt(totalResults / 10 + 1);
setNumberOfPages(numberOfpages);
return;
}
const numberOfpages = parseInt(totalResults / 10);
setNumberOfPages(numberOfpages);
};
//modal config
const showModal = () => {
setShow(true);
};
const hideModal = () => {
setShow(false);
setMovieDetails();
};
const handleClose = () => {
hideModal();
};
//get response from API
const getInfo = (pageNumber) => {
if (pageNumber) {
axios
.get(
api + apiKey + `&s=${name}` + "&type=movie" + `&page=${pageNumber}`
)
.then((res) => {
if (res) {
setMovies(res.data.Search);
setTotalResults(res.data.totalResults);
}
});
return;
}
axios
.get(api + apiKey + `&s=${name}` + "&type=movie" + "&page=1")
.then((res) => {
if (res) {
setMovies(res.data.Search);
setTotalResults(res.data.totalResults);
setCurrentPage(1);
}
});
};
//get details
const getDetails = (e, id) => {
e.preventDefault();
setSelectedId(id);
axios.get(api + apiKey + `&i=${id}`).then((res) => {
if (res) {
setMovieDetails(res.data);
showModal();
}
});
};
//submit the title entered
const handleSubmit = (e) => {
e.preventDefault();
getInfo();
};
//getnumberOFpageseffect
useEffect(() => {
getNumberOfPages();
});
const pages = [];
for (let i = 1; i <= numberOfPages; i++) {
pages.push(
<p key={i} onClick={(e) => goTo(i)}>
{i}
</p>
);
}
const goTo = (pageNumber) => {
setCurrentPage(pageNumber);
getInfo(pageNumber);
window.scrollTo(0, 0);
};
return (
<div>
<form>
<div className='searchBar'>
<label htmlFor='name'></label>
<input
type='text'
name='name'
placeholder='movie name'
onChange={(e) => setName(e.target.value)}
/>
<button type='submit' onClick={(e) => handleSubmit(e)}>
Search
</button>
</div>
</form>
{movies ? (
<div className='movies'>
{movies.map((movie) => (
<div key={movie.imdbID} className='movie'>
<img src={movie.Poster} alt='' />
<div className='movie-title'>
<p>{movie.Title}</p>
</div>
<button
className='movie-detailsBtn'
onClick={(e) => getDetails(e, movie.imdbID)}
>
Details
</button>
{/* modal */}
{movieDetails && selectedId === movie.imdbID && show ? (
<MovieModal
movieInfo={movieDetails}
handleClose={handleClose}
/>
) : (
<div className='modal display-none'></div>
)}
</div>
))}
</div>
) : null}
{numberOfPages ? (
<div className='pages'>
{/* if prev page is 0 it wont show */}
{currentPage - 1 === 0 ? null : (
<b onClick={(e) => goTo(currentPage - 1)}>{currentPage - 1}</b>
)}
<b onClick={(e) => goTo(currentPage)} className='actualPage'>
{currentPage}
</b>
<b onClick={(e) => goTo(currentPage + 1)}>{currentPage + 1}</b>
</div>
) : null}
</div>
);
};
export default Main;
- ## Conclusión
En este post aprendimos a integrar una api a una aplicación de react de forma básica.
Realmente espero que hayas podido seguir el post sin problemas, y en caso de que no lo hayas podido hacer te pido disculpas, y que por favor me dejes tus dudas o comentarios.
Como mencioné anteriormente, la interfaz que creé para este ejemplo es muy sencilla, se puede mejorar asi como el código, te animo a que lo mejores y le agregues mas funcionalidades.
Puedes contactarme por telegram si necesitas contratar a un desarrollador Full Stack.
También puedes contactarme por discord Appu#9136
Puedes encontrar el repo aquí en caso de que lo desees clonar.
Gracias por tu tiempo.
Top comments (0)