This commit is contained in:
2026-05-07 23:18:34 +02:00
commit 07c58f23ab
39 changed files with 6044 additions and 0 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
DB_NAME=
DB_USER=
DB_PASSWORD=
DATABASE_URL=
JWT_SECRET=

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
node_modules/
dist/

5
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

8
backend/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json .
RUN npm install
COPY . .
RUN npx prisma generate
EXPOSE 5000
CMD ["sh", "-c", "npx prisma migrate deploy && node index.js"]

View File

@@ -0,0 +1 @@

38
backend/index.js Normal file
View File

@@ -0,0 +1,38 @@
import express from 'express'
import dotenv from 'dotenv'
import cors from 'cors'
const app = express()
const port = 5000
app.use(express.json())
app.use(cors({ origin: 'http://localhost:5173' }))
dotenv.config()
import { prisma } from './prisma.js'
import { validateToken } from './middleware/auth.js'
import { register, login } from './routes/auth.js'
import { deleteUser } from './routes/user.js'
import { createOffer, deleteOffer } from './routes/offer.js'
/* Test route so nxckwc can test axios*/
app.get('/users', async (req, res) => {
const user = await prisma.user.findMany()
res.json(user)
})
app.post('/login', login)
app.post('/register', register)
app.delete('/user/:userId', deleteUser)
app.post('/crear-oferta', validateToken, createOffer)
app.delete('/oferta/:projectId', validateToken, deleteOffer)
app.listen(port, () => {
console.log(`Project API running on port: ${port}`)
})

View File

@@ -0,0 +1,18 @@
import jwt from 'jsonwebtoken'
export const validateToken = async (req, res, next) => {
const authHeader = req.headers.authorization
if (!authHeader)
return res.status(401).json({ error: "Invalid token" })
const token = authHeader.split(' ')[1]
try {
req.user = jwt.verify(token, process.env.JWT_SECRET)
} catch (error) {
return res.status(401).json({ error: "Invalid token" })
}
next()
}

1752
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
backend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"main": "index.js",
"scripts": {
"start": "nodemon ./index.js"
},
"dependencies": {
"@prisma/client": "^6.19.3",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"prisma": "^6.19.3"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

16
backend/prisma.config.ts Normal file
View File

@@ -0,0 +1,16 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

3
backend/prisma.js Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()

View File

@@ -0,0 +1,54 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"type" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserOferta" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"ofertaId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserOferta_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Oferta" (
"id" SERIAL NOT NULL,
"titulo" TEXT NOT NULL,
"empresa" TEXT NOT NULL,
"ubicacion" TEXT NOT NULL,
"salario" TEXT NOT NULL DEFAULT '0',
"contrato" TEXT NOT NULL,
"descripcion" TEXT NOT NULL,
"url" TEXT NOT NULL,
"authorId" INTEGER NOT NULL,
CONSTRAINT "Oferta_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "UserOferta_userId_ofertaId_key" ON "UserOferta"("userId", "ofertaId");
-- AddForeignKey
ALTER TABLE "UserOferta" ADD CONSTRAINT "UserOferta_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserOferta" ADD CONSTRAINT "UserOferta_ofertaId_fkey" FOREIGN KEY ("ofertaId") REFERENCES "Oferta"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Oferta" ADD CONSTRAINT "Oferta_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,53 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
password String
type String
createdAt DateTime @default(now())
ofertas Oferta[]
postulaciones UserOferta[]
}
model UserOferta {
id Int @id @default(autoincrement())
userId Int
ofertaId Int
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
oferta Oferta @relation(fields: [ofertaId], references: [id])
@@unique([userId, ofertaId])
}
model Oferta {
id Int @id @default(autoincrement())
titulo String
empresa String
ubicacion String
salario String @default("0")
contrato String
descripcion String
url String
authorId Int
author User @relation(fields: [authorId], references: [id])
candidatos UserOferta[]
}

79
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,79 @@
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import { prisma } from '../prisma.js'
export const login = async (req, res) => {
// Get data
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'All fields are required' })
}
// Check data is correct
const user = await prisma.user.findFirst({ where: { email } });
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(400).json({ error: 'Invalid credentials' })
}
// Generate JWT
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '7d' })
// Return
res.status(200).json({
id: user.id,
username: user.username,
email: user.email,
token
})
}
export const register = async (req, res) => {
// Get data
const {username, email, password } = req.body
// Check is not empty
if (!username || !email || !password) {
return res.status(400).json({ error: 'All fields are required' })
}
// Validate data
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (username.length < 3 ||username.length > 14) { return res.status(400).json({ error: 'Username must be between 3 and 14 characters' })}
if (email.length > 30 || !emailRegex.test(email)) { return res.status(400).json({ error: 'Email is not valid' })}
if (password.length < 6 || password.length > 32) { return res.status(400).json({ error: 'Password must be between 6 and 32 characters' })}
// Check email and username doesnt exists
const userExists = await prisma.user.findFirst({
where: {
OR: [ { email }, { username } ]
}
});
// If username or email exists, send error
if (userExists) {
return res.status(409).json({
error: 'User already exists'
})
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10)
// Create user
const user = await prisma.user.create({
data: { username, email, password: hashedPassword, type: "candidato" }
})
// Generates token
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '7d' })
// Return user
res.status(201).json({ id: user.id, username: user.username, email: user.email, token })
}

39
backend/routes/offer.js Normal file
View File

@@ -0,0 +1,39 @@
import { prisma } from '../prisma.js'
export const createOffer = async (req, res) => {
const { titulo, empresa, ubicacion, salario, contrato, descripcion } = req.body;
const authorId = req.user.id
if (!titulo || !empresa || !ubicacion || !salario || !contrato || !descripcion) return res.status(400).json({ error: ""})
try {
const offer = await prisma.oferta.create({
data: {
titulo, empresa, ubicacion, salario, descripcion
}
})
res.status(201).json(offer)
} catch (err) {
res.status(400).json({ error: "Ha ocurrido un error al crear la oferta de trabajo." })
}
}
export const deleteOffer = async (req, res) => {
const { offerId } = req.params;
const authorId = req.user.id;
const id = parseInt(offerId)
try {
const offer = await prisma.offer.findFirst({ where: { id }})
if (!offer) return res.status(404).json({ error: "Invalid project" })
if (authorId !== offer.authorId) return res.status(403).json({ error: "Missing permissions" })
await prisma.offer.delete({ where: { id } })
res.status(200).json({ message: "Project has been deleted" })
} catch (err) {
res.status(400).json({ error: "Ha ocurrido un error al borrar el proyecto" })
}
}

20
backend/routes/user.js Normal file
View File

@@ -0,0 +1,20 @@
import jwt from 'jsonwebtoken'
import { prisma } from '../prisma.js'
export const deleteUser = async (req, res) => {
// Get user ID from url
const { userId } = req.params
try {
// Delete user from database
await prisma.user.delete({ where: { id: Number(userId) } })
// Return status and message
res.status(200).json({ message: 'User deleted' })
} catch (error) {
// If user not found, return error 404
res.status(404).json({ message: 'User not found' })
}
}

35
docker-compose.yml Normal file
View File

@@ -0,0 +1,35 @@
services:
db:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s
retries: 5
backend:
build: ./backend
env_file: .env
ports:
- "5000:5000"
depends_on:
db:
condition: service_healthy
frontend:
build: ./frontend
ports:
- "3001:3000"
depends_on:
- backend
volumes:
postgres_data:

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist .
RUN npm install -g serve
EXPOSE 3000
CMD ["serve", "-s", ".", "-l", "3000"]

16
frontend/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

21
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.googleapis.com/css?family=Rubik:400,700,900" rel="stylesheet" />
<title>HadiJobs</title>
</head>
<body class="bg-[#100E17] text-white font-bold">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3406
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"vite": "^8.0.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

26
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,26 @@
import { BrowserRouter, Routes, Route} from 'react-router-dom'
import Navbar from './components/Navbar'
import Home from './pages/Home'
import NuevaVacante from './pages/NuevaVacante'
import './index.css'
import Login from './pages/Login'
function App() {
return (
<BrowserRouter>
<div className="max-w-[1200px] mx-auto px-4">
<Navbar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/nueva-oferta" element={<NuevaVacante />} />
</Routes>
</div>
</BrowserRouter>
)
}
export default App

View File

@@ -0,0 +1,31 @@
import { useContext } from "react"
import { AuthContext } from "../context/AuthContext"
import { Link } from "react-router-dom"
function Navbar() {
const { user } = useContext(AuthContext)
return (
<nav className="py-10 flex justify-between">
<h1>
<a href="/" className="text-6xl font-extrabold">HadiJobs</a>
</h1>
<div>
{!user ? (
<div className="flex gap-4 items-center">
<Link to="/login" className="text-white font-bold uppercase">Iniciar sesión</Link>
<Link to="/register" className="bg-blue-400 px-4 py-2 rounded font-bold uppercase">Registrarse</Link>
</div>
) : (
<div className="flex gap-4 items-center">
<span className="text-white">Hola, {user.username}</span>
<button className="text-red-400 font-bold uppercase">Cerrar sesión</button>
</div>
)}
</div>
</nav>
)
}
export default Navbar

View File

@@ -0,0 +1,30 @@
import { createContext, useState, useEffect } from "react";
export const AuthContext = createContext()
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
useEffect(() => {
const user = localStorage.getItem('user')
if (user) setUser(JSON.parse(user))
}, [])
const login = (data) => {
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify({ id: data.id, username: data.username, email: data.email }))
setUser({ id: data.id, username: data.username, email: data.email })
}
const logout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
)
}

9
frontend/src/index.css Normal file
View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
font-family: 'Rubik', sans-serif;
}
}

11
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { AuthProvider } from './context/AuthContext.jsx'
createRoot(document.getElementById('root')).render(
<AuthProvider>
<App />
</AuthProvider>,
)

View File

@@ -0,0 +1,45 @@
function Home() {
return (
<>
<div class="py-5 md:py-20 flex flex-col gap-4 border-b border-b-gray-700">
<h2 className="text-4xl font-semibold">HadiJobs</h2>
<p>Encuentra y publica trabajos para desarrolladores</p>
<a className="bg-[#00A4B6] py-3 px-10 rounded uppercase font-extrabold tracking-wider w-fit"
href="/nueva-oferta" >Publica una oferta</a>
</div>
<div className="py-5 md:py-20">
<h2 className="text-4xl font-semibold pb-5">Lista de ofertas</h2>
<div className="flex flex-row justify-between">
<div className="flex flex-col">
<h3 className="text-xl ">Facebook</h3>
<p className="text-gray-500 ">React developer</p>
</div>
<div className="flex flex-col">
<h3 className="text-gray-500 ">Ubicacion</h3>
<p className="">Remoto</p>
</div>
<div className="flex flex-col">
<h3 className="text-gray-500">Contrato</h3>
<p>Tiempo completo</p>
</div>
<div className="flex flex-col">
<a className="bg-[#00C897] py-3 px-10 rounded uppercase font-extrabold tracking-wider w-fit"
href="#">Info</a>
</div>
</div>
</div>
</>
)
}
export default Home

View File

@@ -0,0 +1,71 @@
import { useContext } from "react"
import { useState } from "react"
import { AuthContext } from "../context/AuthContext"
import { useNavigate } from "react-router-dom"
function Login() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const { login } = useContext(AuthContext)
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
const res = await fetch('https://localhost:5000/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
const data = await res.json()
if (res.ok) {
login(data)
navigate('/')
} else {
console.error(data.error)
}
}
return (
<>
<div className="max-w-[800px] mx-auto p-20">
<form onSubmit={handleSubmit}>
<h3 className="text-center text-5xl mb-16">Inicia sesion</h3>
<div className="flex flex-col mb-8">
<label className="w-[9rem] pt-2 text-white font-bold shrink-0 uppercase">
Correo
</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="Escriba su email..." required
className="bg-gray-200 flex-1 px-5 py-1 rounded text-black w-full border-none font-normal"
/>
</div>
<div className="flex flex-col mb-8">
<label className="w-[9rem] pt-2 text-white font-bold shrink-0 uppercase">
Contraseña
</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
placeholder="Escribe su contraseña..." required
className="bg-gray-200 flex-1 px-5 py-1 rounded text-black w-full border-none font-normal"
/>
</div>
<div class="">
<input type="submit" value="Acceder" class="bg-[#00A4B6] hover:bg-[#027481] transition cursor-pointer py-3 px-10 rounded uppercase font-extrabold tracking-wider w-full" />
</div>
</form>
</div>
</>
)
}
export default Login

View File

@@ -0,0 +1,88 @@
function NuevaVacante() {
return (
<>
<div className="py-5 md:py-20 flex flex-col gap-4 border-b border-b-gray-700">
<h2 className="text-4xl font-semibold">Nueva vacante</h2>
<p>Rellena el formulario para publicar tu oferta de trabajo</p>
</div>
<div className="max-w-[800px] mx-auto p-10">
<form>
<h3 className="text-gray-500 mb-8">Informacion general</h3>
<div className="flex mb-8">
<label className="w-[9rem] pt-2 text-white font-bold shrink-0 uppercase">
Titulo
</label>
<input type="text" name="titulo"
placeholder="Ej: React Developer" required
className="bg-gray-200 flex-1 px-5 py-1 rounded text-black w-full border-none font-normal"
/>
</div>
<div className="flex mb-8">
<label className="w-[9rem] pt-2 text-white font-bold shrink-0 uppercase">
Empresa
</label>
<input type="text" name="empresa"
placeholder="Ej: HadiES" required
className="bg-gray-200 flex-1 px-5 py-1 rounded text-black w-full border-none font-normal"
/>
</div>
<div className="flex mb-8">
<label className="w-[9rem] pt-2 text-white font-bold shrink-0 uppercase">
Ubicacion
</label>
<input type="text" name="ubicacion"
placeholder="Ej: España... Global..." required
className="bg-gray-200 flex-1 px-5 py-1 rounded text-black w-full border-none font-normal"
/>
</div>
<div className="flex mb-8">
<label className="w-[9rem] pt-2 text-white font-bold shrink-0 uppercase">
Salario (EUR)
</label>
<input type="text" name="salario"
placeholder="Ej: 50€/hora o 200€" required
className="bg-gray-200 flex-1 px-5 py-1 rounded text-black w-full border-none font-normal"
/>
</div>
<div className="flex mb-20">
<label className="w-[9rem] pt-2 text-white font-bold shrink-0 uppercase">
Contrato
</label>
<select name="contrato" className="bg-gray-200 text-black w-full text-center font-normal">
<option value="" selected disabled>-- Selecciona una opcion --</option>
<option value="freelance">Freelance</option>
<option value="completo">Jornada completa</option>
<option value="medio">Media jornada</option>
</select>
</div>
<div className="flex flex-col gap-4 mb-8">
<h3 className="font-bold uppercase">Descripcion del puesto</h3>
<textarea name="descripcion"
placeholder="Describe el puesto..."
className="bg-gray-200 flex-1 px-5 py-3 rounded text-black border-none font-normal min-h-[200px]"
/>
</div>
<div class="">
<input type="submit" value="Publicar" class="bg-[#00A4B6] py-3 px-10 rounded uppercase font-extrabold tracking-wider w-full" />
</div>
</form>
</div>
</>
)
}
export default NuevaVacante

View File

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})