This commit is contained in:
Didier Palma
2026-05-22 19:57:54 -06:00
22 changed files with 608 additions and 330 deletions

13
backend/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Server
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/optihack
# JWT
JWT_SEED=your_super_secret_jwt_seed_here_change_in_production
JWT_EXPIRES_IN=7d
# Bcrypt
BCRYPT_ROUNDS=10

View File

@@ -10,6 +10,6 @@ export default defineConfig({
path: "prisma/migrations",
},
datasource: {
url: env("POSTGRES_URL"),
url: env("DATABASE_URL"),
},
});

View File

@@ -1,15 +1,34 @@
/**
* app.ts
* Punto de entrada de la aplicación.
* - Inicia el servidor
* - Conecta a la base de datos
* - Maneja errores globales
*/
import "dotenv/config";
import { env } from "./config/env.js";
import { AppRoutes } from "./presentation/routes.js";
import { Server } from "./presentation/server.js";
import { prisma } from "./data/postgres/index.js";
async function main() {
const server = new Server( {
port : env.PORT,
routes : AppRoutes.routes,
});
await server.start();
try {
// Verificar conexión a la base de datos
await prisma.$connect();
console.log("✓ Database connected");
// Crear y iniciar el servidor
const server = new Server({
port: env.PORT,
routes: AppRoutes.routes,
});
await server.start();
} catch (error) {
console.error("✗ Error starting application:", error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,18 @@
/**
* bcrypt.ts
* BcryptAdapter: hashea y compara contraseñas.
* Encapsula toda la lógica de bcrypt en un lugar.
*/
import bcrypt from "bcryptjs";
import { env } from "./env.js";
export class BcryptAdapter {
static async hash(password: string): Promise<string> {
return bcrypt.hash(password, env.BCRYPT_ROUNDS);
}
static async compare(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
}

View File

@@ -1,9 +1,38 @@
import 'dotenv/config';
import envVar from 'env-var';
/**
* env.ts
* Lee y valida las variables de entorno.
* Centraliza toda la configuración que viene del .env
*/
const getEnvVar = (key: string, defaultValue?: string): string => {
const value = process.env[key];
if (!value && defaultValue === undefined) {
throw new Error(`Missing environment variable: ${key}`);
}
return (value as string) || (defaultValue as string);
};
const requiredEnvVar = (key: string): string => {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
};
export const env = {
PORT: envVar.get('PORT').required().asPortNumber(),
PUBLIC_PATH: envVar.get('PUBLIC_PATH').default('public').asString(),
POSTGRES_URL: envVar.get('POSTGRES_URL').required().asString()
}
// Server
PORT: Number(getEnvVar("PORT", "3000")),
NODE_ENV: getEnvVar("NODE_ENV", "development"),
// Database
DATABASE_URL: requiredEnvVar("DATABASE_URL"),
// JWT
JWT_SEED: requiredEnvVar("JWT_SEED"),
JWT_EXPIRES_IN: getEnvVar("JWT_EXPIRES_IN", "7d"),
// Bcrypt
BCRYPT_ROUNDS: Number(getEnvVar("BCRYPT_ROUNDS", "10")),
} as const;

View File

@@ -1,18 +1,23 @@
import jwt, { SignOptions } from 'jsonwebtoken';
import jwt, { SignOptions } from "jsonwebtoken";
import { env } from "./env.js";
const JWT_SECRET = process.env.JWT_SECRET ?? 'dev_secret';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN ?? '2h';
export interface JwtPayload {
id: number;
email: string;
}
export class JwtAdapter {
static generateToken(payload: object): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
} as SignOptions);
static generate(payload: JwtPayload): string {
const options: SignOptions = {
expiresIn: env.JWT_EXPIRES_IN as NonNullable<SignOptions["expiresIn"]>,
};
return jwt.sign(payload, env.JWT_SEED, options);
}
static validateToken<T>(token: string): T | null {
static validate(token: string): JwtPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as T;
return jwt.verify(token, env.JWT_SEED) as JwtPayload;
} catch {
return null;
}

View File

@@ -1,9 +1,14 @@
import { env } from "../../config/env.js";
import { PrismaPg } from "@prisma/adapter-pg";
import "dotenv/config";
import { PrismaClient } from "../../generated/prisma/client.js";
const connectionString = `${env.POSTGRES_URL}`;
const adapter = new PrismaPg({ connectionString });
export const prisma = new PrismaClient({ adapter });
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
export const prisma = new PrismaClient({
adapter,
errorFormat: "pretty",
});

View File

@@ -0,0 +1,45 @@
/**
* auth.repository.impl.ts
* AuthRepositoryImpl: implementación real del AuthRepository.
* Aquí es donde hablamos con la base de datos usando Prisma.
*/
import { AuthRepository } from "../../domain/repositories/auth.repository.js";
import { prisma } from "../postgres/index.js";
export class AuthRepositoryImpl implements AuthRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
password: true,
name: true,
},
});
}
async create(data: { name: string; email: string; password: string }) {
return prisma.user.create({
data,
select: {
id: true,
email: true,
name: true,
},
});
}
async findById(id: number) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
name: true,
role: true,
},
});
}
}

View File

@@ -1,15 +1,38 @@
export class LoginUserDto {
private constructor(
public readonly email: string,
public readonly password: string,
) {}
/**
* login-user.dto.ts
* DTO para iniciar sesión.
* Define qué datos esperamos al hacer login.
*/
static create(props: { [key: string]: any }): [string | undefined, LoginUserDto?] {
const { email, password } = props;
export interface LoginUserDto {
email: string;
password: string;
}
if (!email) return ['email is required'];
if (!password) return ['password is required'];
export class LoginUserDtoValidator {
static validate(data: unknown): LoginUserDto {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid data");
}
return [undefined, new LoginUserDto(email, password)];
const obj = data as Record<string, unknown>;
if (typeof obj.email !== "string" || !this.isValidEmail(obj.email)) {
throw new Error("Email is required and must be valid");
}
if (typeof obj.password !== "string" || obj.password.length === 0) {
throw new Error("Password is required");
}
return {
email: obj.email.trim().toLowerCase(),
password: obj.password,
};
}
private static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}

View File

@@ -1,21 +1,44 @@
export class RegisterUserDto {
private constructor(
public readonly name: string,
public readonly email: string,
public readonly password: string,
) {}
/**
* register-user.dto.ts
* DTO para registrar un usuario.
* Define qué datos esperamos al registrar.
*/
static create(props: { [key: string]: any }): [string | undefined, RegisterUserDto?] {
const { name, email, password } = props;
export interface RegisterUserDto {
name: string;
email: string;
password: string;
}
if (!name) return ['name is required'];
if (!email) return ['email is required'];
if (!password) return ['password is required'];
if (password.length < 6) {
return ['password must be at least 6 characters'];
export class RegisterUserDtoValidator {
static validate(data: unknown): RegisterUserDto {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid data");
}
return [undefined, new RegisterUserDto(name, email, password)];
const obj = data as Record<string, unknown>;
if (typeof obj.name !== "string" || obj.name.trim().length === 0) {
throw new Error("Name is required and must be a non-empty string");
}
if (typeof obj.email !== "string" || !this.isValidEmail(obj.email)) {
throw new Error("Email is required and must be valid");
}
if (typeof obj.password !== "string" || obj.password.length < 6) {
throw new Error("Password is required and must be at least 6 characters");
}
return {
name: obj.name.trim(),
email: obj.email.trim().toLowerCase(),
password: obj.password,
};
}
private static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}

View File

@@ -0,0 +1,35 @@
/**
* custom.error.ts
* CustomError: clase base para errores personalizados.
* Facilita el manejo de errores en toda la aplicación.
*/
export class CustomError extends Error {
constructor(
public message: string,
public statusCode: number,
) {
super(message);
this.name = "CustomError";
}
static badRequest(message: string): CustomError {
return new CustomError(message, 400);
}
static unauthorized(message: string = "Unauthorized"): CustomError {
return new CustomError(message, 401);
}
static notFound(message: string): CustomError {
return new CustomError(message, 404);
}
static conflict(message: string): CustomError {
return new CustomError(message, 409);
}
static internalServer(message: string = "Internal Server Error"): CustomError {
return new CustomError(message, 500);
}
}

View File

@@ -0,0 +1,11 @@
/**
* auth.repository.ts
* AuthRepository (interfaz): define qué métodos debe tener el repositorio de auth.
* El use-case usa esta interfaz sin saber cómo se implementa.
*/
export interface AuthRepository {
findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string } | null>;
create(data: { name: string; email: string; password: string }): Promise<{ id: number; email: string; name: string }>;
findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>;
}

View File

@@ -0,0 +1,30 @@
/**
* get-me.use-case.ts
* GetMeUseCase: obtiene los datos del usuario autenticado.
* - Busca el usuario por ID (que viene del JWT)
* - Retorna los datos del usuario
*/
import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js";
export interface GetMeResponse {
id: number;
email: string;
name: string;
role: string;
}
export class GetMeUseCase {
constructor(private repository: AuthRepository) {}
async execute(userId: number): Promise<GetMeResponse> {
const user = await this.repository.findById(userId);
if (!user) {
throw CustomError.notFound("User not found");
}
return user as GetMeResponse;
}
}

View File

@@ -0,0 +1,59 @@
/**
* login-user.use-case.ts
* LoginUserUseCase: lógica para iniciar sesión.
* - Valida datos
* - Busca el usuario por email
* - Compara las contraseñas
* - Genera el JWT
*/
import { LoginUserDto, LoginUserDtoValidator } from "../../dtos/index.js";
import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js";
export interface LoginUserResponse {
user: {
id: number;
email: string;
name: string;
};
token: string;
}
export class LoginUserUseCase {
constructor(private repository: AuthRepository) {}
async execute(data: unknown): Promise<LoginUserResponse> {
// Validar DTO
const dto = LoginUserDtoValidator.validate(data);
// Buscar usuario por email
const user = await this.repository.findByEmail(dto.email);
if (!user) {
throw CustomError.unauthorized("Invalid credentials");
}
// Comparar contraseñas
const isPasswordValid = await BcryptAdapter.compare(dto.password, user.password);
if (!isPasswordValid) {
throw CustomError.unauthorized("Invalid credentials");
}
// Generar JWT
const token = JwtAdapter.generate({
id: user.id,
email: user.email,
});
return {
user: {
id: user.id,
email: user.email,
name: user.name,
},
token,
};
}
}

View File

@@ -0,0 +1,60 @@
/**
* register-user.use-case.ts
* RegisterUserUseCase: lógica para registrar un usuario.
* - Valida datos
* - Verifica si el email ya existe
* - Hashea la contraseña
* - Crea el usuario
* - Genera el JWT
*/
import { RegisterUserDto, RegisterUserDtoValidator } from "../../dtos/index.js";
import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js";
export interface RegisterUserResponse {
user: {
id: number;
email: string;
name: string;
};
token: string;
}
export class RegisterUserUseCase {
constructor(private repository: AuthRepository) {}
async execute(data: unknown): Promise<RegisterUserResponse> {
// Validar DTO
const dto = RegisterUserDtoValidator.validate(data);
// Verificar si el email ya existe
const existingUser = await this.repository.findByEmail(dto.email);
if (existingUser) {
throw CustomError.conflict("Email already in use");
}
// Hashear la contraseña
const hashedPassword = await BcryptAdapter.hash(dto.password);
// Crear el usuario
const user = await this.repository.create({
name: dto.name,
email: dto.email,
password: hashedPassword,
});
// Generar JWT
const token = JwtAdapter.generate({
id: user.id,
email: user.email,
});
return {
user,
token,
};
}
}

View File

@@ -1,137 +1,68 @@
import { Request, Response } from "express";
import bcrypt from "bcryptjs";
/**
* controller.ts
* AuthController: maneja las peticiones HTTP de autenticación.
* - Recibe los datos
* - Llama al use-case correspondiente
* - Responde al cliente
* - Maneja los errores
*/
import { prisma } from "../../data/postgres/index.js";
import { JwtAdapter } from "../../config/jwt.js";
import { LoginUserDto, RegisterUserDto } from "../../domain/dtos/index.js";
import { Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js";
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
import { RegisterUserUseCase } from "../../domain/use-cases/auth/register-user.use-case.js";
import { LoginUserUseCase } from "../../domain/use-cases/auth/login-user.use-case.js";
import { GetMeUseCase } from "../../domain/use-cases/auth/get-me.use-case.js";
export class AuthController {
private repository = new AuthRepositoryImpl();
private registerUseCase = new RegisterUserUseCase(this.repository);
private loginUseCase = new LoginUserUseCase(this.repository);
private getMeUseCase = new GetMeUseCase(this.repository);
// SIN metodos estaticos para hacer DI
constructor() {}
register = async (req: AuthRequest, res: Response) => {
try {
const result = await this.registerUseCase.execute(req.body);
res.status(201).json(result);
} catch (error) {
this.handleError(error, res);
}
};
public registerUser = async (req: Request, res: Response) => {
login = async (req: AuthRequest, res: Response) => {
try {
const result = await this.loginUseCase.execute(req.body);
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
const [error, registerUserDto] = RegisterUserDto.create(req.body);
getMe = async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
throw CustomError.unauthorized("User not authenticated");
}
if (error) {
return res.status(400).json({ error });
}
const result = await this.getMeUseCase.execute(req.user.id);
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
try {
const userExists = await prisma.user.findUnique({
where: {
email: registerUserDto!.email,
}
});
if (userExists) {
return res.status(400).json({
error: 'Email already exists'
});
}
const hashedPassword = bcrypt.hashSync(registerUserDto!.password, 10);
const user = await prisma.user.create({
data: {
name: registerUserDto!.name,
email: registerUserDto!.email,
password: hashedPassword,
}
});
const token = JwtAdapter.generateToken({
id: user.id,
email: user.email,
role: user.role,
});
return res.status(201).json({
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
token
});
} catch (error) {
console.log(error);
return res.status(500).json({
error: 'Internal server error'
});
}
private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
public loginUser = async (req: Request, res: Response) => {
const [error, loginUserDto] = LoginUserDto.create(req.body);
if (error) {
return res.status(400).json({ error });
}
try {
const user = await prisma.user.findUnique({
where: {
email: loginUserDto!.email,
}
});
if (!user) {
return res.status(400).json({
error: 'Invalid email or password'
});
}
const isPasswordValid = bcrypt.compareSync(
loginUserDto!.password,
user.password
);
if (!isPasswordValid) {
return res.status(400).json({
error: 'Invalid email or password'
});
}
const token = JwtAdapter.generateToken({
id: user.id,
email: user.email,
role: user.role,
});
return res.json({
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
token
});
} catch (error) {
console.log(error);
return res.status(500).json({
error: 'Internal server error'
});
}
if (error instanceof Error) {
res.status(400).json({ error: error.message });
return;
}
public getMe = async (req: AuthRequest, res: Response) => {
if (!req.user) {
return res.status(401).json({
error: 'User not authenticated'
});
}
return res.json({
user: req.user
});
}
res.status(500).json({ error: "Internal server error" });
}
}

View File

@@ -1,20 +1,22 @@
/**
* routes.ts
* Rutas de autenticación.
* Define los endpoints de register, login y getMe.
*/
import { Router } from "express";
import { AuthController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class AuthRoutes {
static get routes(): Router {
const router = Router();
const controller = new AuthController();
static get routes(): Router {
router.post("/register", controller.register);
router.post("/login", controller.login);
router.get("/me", AuthMiddleware.validate, controller.getMe);
const router = Router();
const controller = new AuthController();
router.post('/register', controller.registerUser);
router.post('/login', controller.loginUser);
// Ruta protegida para probar JWT
router.get('/me', AuthMiddleware.validateJwt, controller.getMe);
return router;
}
return router;
}
}

View File

@@ -1,46 +1,54 @@
import { Request, Response, NextFunction } from "express";
import { JwtAdapter } from "../../config/jwt.js";
/**
* auth.middleware.ts
* Middleware para validar el JWT.
* - Lee el token del header Authorization: Bearer TOKEN
* - Valida el token
* - Busca el usuario en base de datos
* - Agrega el usuario a req.user
*/
interface TokenPayload {
id: number;
email: string;
role: string;
}
import { Request, Response, NextFunction } from "express";
import { JwtAdapter, JwtPayload } from "../../config/jwt.js";
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
import { CustomError } from "../../domain/errors/custom.error.js";
export interface AuthRequest extends Request {
user?: TokenPayload;
user?: JwtPayload;
}
export class AuthMiddleware {
static async validate(req: AuthRequest, res: Response, next: NextFunction) {
try {
// Obtener el token del header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw CustomError.unauthorized("Missing or invalid Authorization header");
}
static validateJwt = (req: AuthRequest, res: Response, next: NextFunction) => {
const token = authHeader.substring(7); // Remover "Bearer "
const authorization = req.header('Authorization');
// Validar el token
const payload = JwtAdapter.validate(token);
if (!payload) {
throw CustomError.unauthorized("Invalid token");
}
if (!authorization) {
return res.status(401).json({
error: 'No token provided'
});
}
// Verificar que el usuario existe en BD
const repository = new AuthRepositoryImpl();
const user = await repository.findById(payload.id);
if (!user) {
throw CustomError.unauthorized("User not found");
}
if (!authorization.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Invalid Bearer token'
});
}
// Agregar el usuario al request
req.user = payload;
const token = authorization.split(' ').at(1) ?? '';
const payload = JwtAdapter.validateToken<TokenPayload>(token);
if (!payload) {
return res.status(401).json({
error: 'Invalid token'
});
}
req.user = payload;
next();
next();
} catch (error) {
if (error instanceof CustomError) {
return res.status(error.statusCode).json({ error: error.message });
}
res.status(500).json({ error: "Internal server error" });
}
}
}

View File

@@ -11,12 +11,45 @@ export default function Layout() {
tabBarInactiveTintColor: "#9CA3AF",
tabBarStyle: {
height: 70,
paddingBottom: 10,
paddingTop: 10,
position: "absolute",
bottom: 5,
left: 20,
right: 20,
height: 82,
borderRadius: 25,
backgroundColor: "#FFFFFF",
borderTopWidth: 0,
elevation: 8,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 10,
},
shadowOpacity: 0.08,
shadowRadius: 10,
elevation: 10,
paddingHorizontal: 10,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: "400",
marginBottom: 8,
},
tabBarItemStyle: {
paddingTop: 6,
paddingBottom: 4,
borderRadius: 18,
marginVertical: 6,
marginHorizontal: 4,
},
}}
>
@@ -24,8 +57,12 @@ export default function Layout() {
name="index"
options={{
title: "Inicio",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
tabBarIcon: ({ focused, color }) => (
<Ionicons
name={focused ? "home" : "home-outline"}
size={focused ? 28 : 24}
color={color}
/>
),
}}
/>
@@ -34,8 +71,12 @@ export default function Layout() {
name="alerts"
options={{
title: "Alertas",
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications-outline" size={size} color={color} />
tabBarIcon: ({ focused, color }) => (
<Ionicons
name={focused ? "notifications" : "notifications-outline"}
size={focused ? 28 : 24}
color={color}
/>
),
}}
/>
@@ -44,8 +85,12 @@ export default function Layout() {
name="guide"
options={{
title: "Guía",
tabBarIcon: ({ color, size }) => (
<Ionicons name="leaf-outline" size={size} color={color} />
tabBarIcon: ({ focused, color }) => (
<Ionicons
name={focused ? "leaf" : "leaf-outline"}
size={focused ? 28 : 24}
color={color}
/>
),
}}
/>
@@ -54,8 +99,12 @@ export default function Layout() {
name="profile"
options={{
title: "Perfil",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
tabBarIcon: ({ focused, color }) => (
<Ionicons
name={focused ? "person" : "person-outline"}
size={focused ? 28 : 24}
color={color}
/>
),
}}
/>

View File

@@ -8,7 +8,7 @@ export default function GuideScreen() {
justifyContent: "center",
alignItems: "center",
}}
>
>
<Text>Guía</Text>
</View>
);

View File

@@ -1,4 +1,4 @@
/*import { View, Text, ScrollView } from "react-native";
import { View, Text } from "react-native";
export default function HomeScreen() {
return (
@@ -12,91 +12,4 @@ export default function HomeScreen() {
<Text>Inicio</Text>
</View>
);
}*/
import { View, ScrollView } from 'react-native';
import EtaCard from '../components/EtaCard';
import QuickAction from '../components/QuickAction';
import SectionTitle from '../components/SectionTitle';
import AlertItem from '../components/Alertltem';
export default function Home() {
return (
<ScrollView
style={{ flex: 1, backgroundColor: '#ffffff' }}
showsVerticalScrollIndicator={false}
>
{/*ETA*/}
<EtaCard
minutes={12}
status="Camión en ruta 🚛"
/>
{/*ACCIONES RÁPIDAS*/}
<SectionTitle title="Acciones rápidas" />
<View
style={{
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 10,
marginTop: 10,
}}
>
<QuickAction title="Mapa" icon="🗺️" />
<QuickAction title="Reportar" icon="🚨" />
<QuickAction title="Horarios" icon="🕒" />
</View>
{/*IMPACTO SEMANAL*/}
<SectionTitle title="Impacto semanal" />
<View
style={{
marginHorizontal: 15,
backgroundColor: '#F0F0F3',
padding: 20,
borderRadius: 16,
}}
>
<AlertItem
message="🚛 5 recolecciones completadas"
type="success"
/>
<AlertItem
message="⏱ Sin retrasos esta semana"
type="success"
/>
<AlertItem
message="📍 Cobertura completa en tu zona"
type="success"
/>
</View>
{/* 🔔 ALERTAS RECIENTES */}
<SectionTitle title="Alertas recientes" />
<View style={{ marginBottom: 40 }}>
<AlertItem
message="🚛 Ruta iniciada"
type="success"
/>
<AlertItem
message="📍 Camión cerca de tu zona"
type="warning"
/>
<AlertItem
message="⏳ Retraso detectado"
type="danger"
/>
</View>
</ScrollView>
);
}

View File

@@ -4,12 +4,12 @@ export default function ProfileScreen() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Perfil</Text>
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Perfil</Text>
</View>
);
}
}