diff --git a/Pipfile b/Pipfile
index 4d377014ae..4085ff4201 100644
--- a/Pipfile
+++ b/Pipfile
@@ -12,7 +12,6 @@ flask-migrate = "*"
flask-swagger = "*"
psycopg2-binary = "*"
python-dotenv = "*"
-flask-cors = "*"
gunicorn = "*"
cloudinary = "*"
flask-admin = "==2.0.0"
@@ -20,6 +19,7 @@ typing-extensions = "*"
flask-jwt-extended = "==4.6.0"
wtforms = "==3.1.2"
sqlalchemy = "*"
+flask-cors = "*"
[requires]
python_version = "3.13"
diff --git a/Pipfile.lock b/Pipfile.lock
index d9e474e972..dc7d8b9015 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -42,11 +42,11 @@
},
"click": {
"hashes": [
- "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc",
- "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"
+ "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5",
+ "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"
],
"markers": "python_version >= '3.10'",
- "version": "==8.3.0"
+ "version": "==8.3.2"
},
"cloudinary": {
"hashes": [
@@ -58,12 +58,12 @@
},
"flask": {
"hashes": [
- "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87",
- "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"
+ "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb",
+ "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==3.1.2"
+ "version": "==3.1.3"
},
"flask-admin": {
"hashes": [
@@ -76,12 +76,12 @@
},
"flask-cors": {
"hashes": [
- "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c",
- "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db"
+ "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423",
+ "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a"
],
"index": "pypi",
"markers": "python_version >= '3.9' and python_version < '4.0'",
- "version": "==6.0.1"
+ "version": "==6.0.2"
},
"flask-jwt-extended": {
"hashes": [
@@ -575,11 +575,11 @@
},
"werkzeug": {
"hashes": [
- "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e",
- "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"
+ "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50",
+ "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44"
],
"markers": "python_version >= '3.9'",
- "version": "==3.1.3"
+ "version": "==3.1.8"
},
"wtforms": {
"hashes": [
diff --git a/index.html b/index.html
index 27a99f796e..e9194468ee 100644
--- a/index.html
+++ b/index.html
@@ -5,8 +5,12 @@
+
+
+
+
-
Hello Rigo
+ Expedition
diff --git a/migrations/versions/0763d677d453_.py b/migrations/versions/0763d677d453_.py
deleted file mode 100644
index 88964176f1..0000000000
--- a/migrations/versions/0763d677d453_.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""empty message
-
-Revision ID: 0763d677d453
-Revises:
-Create Date: 2025-02-25 14:47:16.337069
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '0763d677d453'
-down_revision = None
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.create_table('user',
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('email', sa.String(length=120), nullable=False),
- sa.Column('password', sa.String(), nullable=False),
- sa.Column('is_active', sa.Boolean(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('email')
- )
- # ### end Alembic commands ###
-
-
-def downgrade():
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_table('user')
- # ### end Alembic commands ###
diff --git a/migrations/versions/e26fa83162c6_.py b/migrations/versions/e26fa83162c6_.py
new file mode 100644
index 0000000000..d870fd0c0d
--- /dev/null
+++ b/migrations/versions/e26fa83162c6_.py
@@ -0,0 +1,48 @@
+"""empty message
+
+Revision ID: e26fa83162c6
+Revises:
+Create Date: 2026-04-20 16:17:52.714014
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e26fa83162c6'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('itinerary', schema=None) as batch_op:
+ batch_op.alter_column('notes',
+ existing_type=sa.VARCHAR(length=150),
+ nullable=True)
+
+ with op.batch_alter_table('trip', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('image_url', sa.String(length=500), nullable=True))
+ batch_op.alter_column('notes',
+ existing_type=sa.VARCHAR(length=150),
+ nullable=True)
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('trip', schema=None) as batch_op:
+ batch_op.alter_column('notes',
+ existing_type=sa.VARCHAR(length=150),
+ nullable=False)
+ batch_op.drop_column('image_url')
+
+ with op.batch_alter_table('itinerary', schema=None) as batch_op:
+ batch_op.alter_column('notes',
+ existing_type=sa.VARCHAR(length=150),
+ nullable=False)
+
+ # ### end Alembic commands ###
diff --git a/package-lock.json b/package-lock.json
index 8d43d98ab7..398b3c017c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -877,19 +877,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/source-map": {
- "version": "0.3.6",
- "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
- "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25"
- }
- },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -997,14 +984,6 @@
"@babel/types": "^7.20.7"
}
},
- "node_modules/@types/node": {
- "version": "16.11.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
- "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -1307,14 +1286,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -3915,29 +3886,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
- "node_modules/source-map-support/node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -4074,35 +4022,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/terser": {
- "version": "5.38.1",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz",
- "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@jridgewell/source-map": "^0.3.3",
- "acorn": "^8.8.2",
- "commander": "^2.20.0",
- "source-map-support": "~0.5.20"
- },
- "bin": {
- "terser": "bin/terser"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/terser/node_modules/commander": {
- "version": "2.20.3",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -4944,18 +4863,6 @@
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true
},
- "@jridgewell/source-map": {
- "version": "0.3.6",
- "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
- "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25"
- }
- },
"@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -5044,14 +4951,6 @@
"@babel/types": "^7.20.7"
}
},
- "@types/node": {
- "version": "16.11.12",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
- "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -5252,14 +5151,6 @@
"update-browserslist-db": "^1.1.1"
}
},
- "buffer-from": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6982,28 +6873,6 @@
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true
},
- "source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- },
- "dependencies": {
- "source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
- "optional": true,
- "peer": true
- }
- }
- },
"string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -7094,30 +6963,6 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true
},
- "terser": {
- "version": "5.38.1",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz",
- "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "@jridgewell/source-map": "^0.3.3",
- "acorn": "^8.8.2",
- "commander": "^2.20.0",
- "source-map-support": "~0.5.20"
- },
- "dependencies": {
- "commander": {
- "version": "2.20.3",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true,
- "optional": true,
- "peer": true
- }
- }
- },
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
diff --git a/src/api/models.py b/src/api/models.py
index da515f6a1a..21e4664c35 100644
--- a/src/api/models.py
+++ b/src/api/models.py
@@ -1,19 +1,264 @@
+import enum
from flask_sqlalchemy import SQLAlchemy
-from sqlalchemy import String, Boolean
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy import String, Boolean, Enum, Date, Integer, ForeignKey, Float, Time, DateTime
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from datetime import date, time, datetime
+from werkzeug.security import check_password_hash, generate_password_hash
db = SQLAlchemy()
+# --- ENUMS ---
+
+
+class StateTypes(enum.Enum):
+ FINISHED = "finished"
+ ONGOING = "ongoing"
+ PLANNING = "planning"
+
+
+class CategoryTypes(enum.Enum):
+ TRANSPORT = "transport"
+ LODGING = "lodging"
+ FOOD = "food"
+ ACTIVITIES = "activities"
+ OTHERS = "others"
+
+# --- MODELS ---
+
+
class User(db.Model):
+ __tablename__ = 'user'
id: Mapped[int] = mapped_column(primary_key=True)
- email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
+ name: Mapped[str] = mapped_column(String(20), nullable=False)
+ last_name: Mapped[str] = mapped_column(String(50), nullable=True)
+ email: Mapped[str] = mapped_column(
+ String(120), unique=True, nullable=False)
password: Mapped[str] = mapped_column(nullable=False)
- is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False)
+ # Relationships
+ travelers = relationship("Traveler", back_populates="users")
+ expenses_paid = relationship("Expense", back_populates="payers")
+ messages = relationship("Message", back_populates="authors")
+ debts_owed = relationship(
+ "Debt", foreign_keys="[Debt.debtor_id]", back_populates="debtors")
+ debts_to_receive = relationship(
+ "Debt", foreign_keys="[Debt.creditor_id]", back_populates="creditors")
+
+ def set_password(self, password: str) -> None:
+ self.password = generate_password_hash(password)
+
+ def check_password(self, password: str) -> bool:
+ return check_password_hash(self.password, password)
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "name": self.name,
+ "last_name": self.last_name,
+ "email": self.email
+ }
+
+ def serialize_name(self):
+ return {
+ "name": self.name
+ }
+
+
+class Trip(db.Model):
+ __tablename__ = 'trip'
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(30), nullable=False)
+ destination: Mapped[str] = mapped_column(String(50), nullable=False)
+ state: Mapped[StateTypes] = mapped_column(
+ Enum(StateTypes), nullable=False)
+ starting_date: Mapped[date] = mapped_column(Date(), nullable=False)
+ ending_date: Mapped[date] = mapped_column(Date(), nullable=False)
+ budget: Mapped[float] = mapped_column(Float, nullable=False)
+ notes: Mapped[str] = mapped_column(String(150), nullable=True)
+
+ # 📸 NUEVO CAMPO: Para guardar la foto de portada
+ image_url: Mapped[str] = mapped_column(String(500), nullable=True)
+
+ # Relationships
+ travelers = relationship("Traveler", back_populates="trips")
+ itineraries = relationship("Itinerary", back_populates="trips")
+ expenses = relationship("Expense", back_populates="trips")
+ documents = relationship("Document", back_populates="trips")
+ chats = relationship("Chat", back_populates="trips", uselist=False)
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "title": self.title,
+ "destination": self.destination,
+ "state": self.state.value,
+ "starting_date": str(self.starting_date),
+ "ending_date": str(self.ending_date),
+ "budget": self.budget,
+ "notes": self.notes,
+ "image_url": self.image_url # 📸 Incluido en serialización
+ }
+
+ def serialize_common_trips(self):
+ return {
+ "id": self.id,
+ "title": self.title,
+ "destination": self.destination, # Añadido para ayudar con imágenes genéricas si hace falta
+ "state": self.state.value,
+ "starting_date": str(self.starting_date),
+ "ending_date": str(self.ending_date),
+ "image_url": self.image_url # 📸 Incluido en serialización reducida
+ }
+
+
+class Traveler(db.Model):
+ __tablename__ = 'traveler'
+ user_id: Mapped[int] = mapped_column(ForeignKey(
+ "user.id", ondelete="CASCADE"), primary_key=True)
+ trip_id: Mapped[int] = mapped_column(ForeignKey(
+ "trip.id", ondelete="CASCADE"), primary_key=True)
+
+ users = relationship("User", back_populates="travelers")
+ trips = relationship("Trip", back_populates="travelers")
+
+ def serialize(self):
+ return {
+ "user_id": self.user_id,
+ "trip_id": self.trip_id
+ }
+
+ def serialize_trip(self):
+ return {
+ "trip_id": self.trip_id
+ }
+
+ def serialize_user(self):
+ return {
+ "user_id": self.user_id
+ }
+
+
+class Itinerary(db.Model):
+ __tablename__ = 'itinerary'
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(30), nullable=False)
+ destination: Mapped[str] = mapped_column(String(50), nullable=False)
+ hour: Mapped[time] = mapped_column(Time, nullable=False)
+ starting_date: Mapped[date] = mapped_column(Date(), nullable=False)
+ notes: Mapped[str] = mapped_column(String(150), nullable=True)
+ trip_id: Mapped[int] = mapped_column(
+ ForeignKey("trip.id", ondelete="CASCADE"))
+
+ trips = relationship("Trip", back_populates="itineraries")
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "title": self.title,
+ "hour": str(self.hour),
+ "starting_date": str(self.starting_date),
+ "trip_id": self.trip_id
+ }
+
+
+class Expense(db.Model):
+ __tablename__ = 'expense'
+ id: Mapped[int] = mapped_column(primary_key=True)
+ amount: Mapped[float] = mapped_column(Float, nullable=False)
+ description: Mapped[str] = mapped_column(String(100), nullable=False)
+ trip_id: Mapped[int] = mapped_column(
+ ForeignKey("trip.id", ondelete="CASCADE"))
+ payer_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
+
+ trips = relationship("Trip", back_populates="expenses")
+ payers = relationship("User", back_populates="expenses_paid")
+ debts = relationship("Debt", back_populates="expenses")
+
+ def serialize(self):
+ return {"id": self.id, "amount": self.amount, "description": self.description, "payer_id": self.payer_id}
+
+
+class Debt(db.Model):
+ __tablename__ = 'debt'
+ id: Mapped[int] = mapped_column(primary_key=True)
+ amount: Mapped[float] = mapped_column(Float, nullable=False)
+ debtor_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
+ creditor_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
+ expense_id: Mapped[int] = mapped_column(ForeignKey("expense.id"))
+
+ expenses = relationship("Expense", back_populates="debts")
+ debtors = relationship("User", foreign_keys=[
+ debtor_id], back_populates="debts_owed")
+ creditors = relationship("User", foreign_keys=[
+ creditor_id], back_populates="debts_to_receive")
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "amount": self.amount,
+ "debtor_id": self.debtor_id,
+ "creditor_id": self.creditor_id,
+ "expense_id": self.expense_id
+ }
+
+
+class Document(db.Model):
+ __tablename__ = 'document'
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(50), nullable=False)
+ url: Mapped[str] = mapped_column(String(250), nullable=False)
+ trip_id: Mapped[int] = mapped_column(
+ ForeignKey("trip.id", ondelete="CASCADE"))
+
+ trips = relationship("Trip", back_populates="documents")
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "title": self.title,
+ "url": self.url,
+ "trip_id": self.trip_id
+ }
+
+
+class Chat(db.Model):
+ __tablename__ = 'chat'
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(
+ String(50), nullable=True) # <-- Título añadido
+ trip_id: Mapped[int] = mapped_column(
+ ForeignKey("trip.id", ondelete="CASCADE"))
+
+ trips = relationship("Trip", back_populates="chats")
+ messages = relationship("Message", back_populates="chats")
+
+ def serialize(self):
+ return {
+ "id": self.id,
+ "title": self.amount,
+ "trip_id": self.trip_id
+ }
+
+
+class Message(db.Model):
+ __tablename__ = 'message'
+ id: Mapped[int] = mapped_column(primary_key=True)
+ content: Mapped[str] = mapped_column(String(500), nullable=False)
+ date_time: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+ chat_id: Mapped[int] = mapped_column(
+ ForeignKey("chat.id", ondelete="CASCADE"))
+ user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
+
+ chats = relationship("Chat", back_populates="messages")
+ authors = relationship("User", back_populates="messages")
def serialize(self):
return {
"id": self.id,
- "email": self.email,
- # do not serialize the password, its a security breach
- }
\ No newline at end of file
+ "content": self.content,
+ "date_time": str(self.date_time),
+ "chat_id": self.chat_id,
+ "user_id": self.user_id
+ }
+
\ No newline at end of file
diff --git a/src/api/routes.py b/src/api/routes.py
index 029589a3a1..05771b353b 100644
--- a/src/api/routes.py
+++ b/src/api/routes.py
@@ -1,22 +1,541 @@
"""
This module takes care of starting the API Server, Loading the DB and Adding the endpoints
"""
-from flask import Flask, request, jsonify, url_for, Blueprint
-from api.models import db, User
-from api.utils import generate_sitemap, APIException
-from flask_cors import CORS
+from flask import Blueprint, jsonify, request
+from flask_jwt_extended import (
+ create_access_token,
+ get_jwt_identity,
+ jwt_required
+)
-api = Blueprint('api', __name__)
+import enum
+from sqlalchemy import func
+from collections import defaultdict
+from api.models import db, User, Trip, Traveler, Itinerary, Expense, Debt, Document, Chat, Message, StateTypes, CategoryTypes
+from api.utils import APIException
-# Allow CORS requests to this API
-CORS(api)
+api = Blueprint("api", __name__)
-@api.route('/hello', methods=['POST', 'GET'])
-def handle_hello():
- response_body = {
- "message": "Hello! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request"
- }
+def get_json_payload():
+ return request.get_json(silent=True) or {}
- return jsonify(response_body), 200
+
+def build_auth_response(user, status_code, message):
+ access_token = create_access_token(identity=str(user.id))
+ return jsonify({
+ "message": message,
+ "access_token": access_token,
+ "user": user.serialize()
+ }), status_code
+
+
+def get_current_user():
+ identity = get_jwt_identity()
+ if identity is None:
+ raise APIException("Missing user identity in token", status_code=401)
+
+ try:
+ user_id = int(identity)
+ except (TypeError, ValueError) as error:
+ raise APIException("Invalid token identity",
+ status_code=401) from error
+
+ user = db.session.get(User, user_id)
+ if user is None:
+ raise APIException("Authenticated user was not found", status_code=404)
+
+ return user
+
+
+def validate_credentials(payload, require_name=False):
+ name = payload.get("name", "").strip()
+ email = payload.get("email", "").strip().lower()
+ password = payload.get("password", "")
+
+ if require_name and len(name) < 2:
+ raise APIException(
+ "Name must contain at least 2 characters", status_code=400)
+
+ if "@" not in email:
+ raise APIException(
+ "Please provide a valid email address", status_code=400)
+
+ if len(password) < 6:
+ raise APIException(
+ "Password must contain at least 6 characters", status_code=400)
+
+ return name, email, password
+
+
+def validate_new_trip(payload):
+
+ title = payload.get("title").strip()
+ destination = payload.get("destination").strip()
+ state = payload.get("state").strip()
+ starting_date = payload.get("starting_date").strip()
+ ending_date = payload.get("ending_date").strip()
+ budget = payload.get("budget").strip()
+ notes = payload.get("notes").strip()
+
+ # 📸 NUEVO: Capturamos la URL (puede venir vacía)
+ image_url = payload.get("image_url", "").strip()
+
+ if title is None:
+ raise APIException(
+ "El viaje debe contener titulo", status_code=400)
+
+ if destination is None:
+ raise APIException(
+ "El viaje debe contener destino", status_code=400)
+
+ if state is None:
+ raise APIException(
+ "El viaje debe contener estado", status_code=400)
+
+ if starting_date is None:
+ raise APIException(
+ "El viaje debe contener fecha de inicio", status_code=400)
+
+ if ending_date is None:
+ raise APIException(
+ "El viaje debe contener fecha de fin", status_code=400)
+
+ if budget is None:
+ raise APIException(
+ "El viaje debe contener un presupuesto", status_code=400)
+
+ trip = Trip(
+ title=title,
+ destination=destination,
+ state=state,
+ starting_date=starting_date,
+ ending_date=ending_date,
+ budget=budget,
+ notes=notes,
+ image_url=image_url if image_url else None # 📸 Guardamos la URL o None
+ )
+
+ return trip
+
+
+def validate_new_itinerary(payload):
+
+ title = payload.get("title").strip()
+ destination = payload.get("destination").strip()
+ hour = payload.get("hour").strip()
+ starting_date = payload.get("starting_date").strip()
+ notes = payload.get("notes","").strip()
+
+ if title is None:
+ raise APIException(
+ "La actividad debe contener titulo", status_code=400)
+
+ if destination is None:
+ raise APIException(
+ "La actividad debe contener destino", status_code=400)
+
+ if hour is None:
+ raise APIException(
+ "La actividad debe contener hora", status_code=400)
+
+ if starting_date is None:
+ raise APIException(
+ "La actividad debe contener fecha", status_code=400)
+
+ itinerary = Itinerary(
+ title=title,
+ destination=destination,
+ hour=hour,
+ starting_date=starting_date,
+ notes=notes
+ )
+
+ return itinerary
+
+def validate_new_expense(payload):
+ amount = payload.get("amount")
+ description = payload.get("description")
+ payer_id = payload.get("payer_id")
+
+ if amount is None:
+ raise APIException(
+ "El gasto debe contener una cantidad", status_code=400)
+
+ if description is None or str(description).strip() == "":
+ raise APIException(
+ "El gasto debe contener descripcion", status_code=400)
+
+ if payer_id is None:
+ raise APIException(
+ "El gasto debe contener un pagador", status_code=400)
+
+ expense = Expense(
+ amount = float(amount),
+ description = str(description).strip(),
+ payer_id = int(payer_id)
+ )
+
+ return expense
+
+def validate_user_trip(user, trip_id):
+
+ applicant = Traveler.query.filter(
+ Traveler.user_id == user.id, Traveler.trip_id == trip_id).one_or_none()
+ if applicant is None:
+ raise APIException(
+ "No estás incluido en este viaje", status_code=401)
+
+ return True
+
+@api.route("/login", methods=["POST"])
+@api.route("/signin", methods=["POST"])
+def sign_in():
+ data = get_json_payload()
+ _, email, password = validate_credentials(data)
+
+ user = User.query.filter_by(email=email).one_or_none()
+ if user is None or not user.check_password(password):
+ raise APIException("Email o contraseña incorrecta", status_code=401)
+
+ return build_auth_response(user, 200, "Login correcto")
+
+
+@api.route("/sign-up", methods=["POST"])
+@api.route("/signup", methods=["POST"])
+@api.route("/register", methods=["POST"])
+def sign_up():
+ data = get_json_payload()
+ name, email, password = validate_credentials(data, require_name=True)
+
+ existing_user = User.query.filter_by(email=email).one_or_none()
+ if existing_user is not None:
+ raise APIException(
+ "Ya existe un usuario con registrado con este correo", status_code=409)
+
+ new_user = User(
+ email=email,
+ name=name
+ )
+ new_user.set_password(password)
+
+ db.session.add(new_user)
+ db.session.commit()
+
+ return build_auth_response(new_user, 201, "Usuario creado correctamente")
+
+
+@api.route("/travels", methods=["GET"])
+@api.route("/trips", methods=["GET"])
+@jwt_required()
+def travels():
+ data = get_json_payload()
+ state_param = data.get("state")
+
+ user = get_current_user()
+ trips_by_traveler = Traveler.query.filter_by(user_id=user.id).all()
+ trip_ids = [t.trip_id for t in trips_by_traveler]
+
+ filters = [Trip.id.in_(trip_ids)]
+
+ if state_param:
+ try:
+ state_enum = StateTypes(state_param)
+ filters.append(Trip.state == state_enum)
+ except ValueError:
+ raise ValueError(f"Invalid state: {state_param}", status_code=400)
+
+ trips = Trip.query.filter(*filters)
+
+ return jsonify({
+ "viajes": [trip.serialize_common_trips() for trip in trips]
+ }), 200
+
+
+@api.route("/profile", methods=["GET"])
+@api.route("/me", methods=["GET"])
+@jwt_required()
+def me():
+ user = get_current_user()
+ return jsonify({"user": user.serialize()}), 200
+
+
+@api.route("/new_trip", methods=["POST"])
+@api.route("/newtrip", methods=["POST"])
+@jwt_required()
+def new_trip():
+
+ user = get_current_user()
+ data = get_json_payload()
+
+ payload_users = data.get("users", [])
+ users = User.query.filter(User.email.in_(payload_users)).all()
+ travelers_ids = [u.id for u in users]
+ travelers_ids.append(user.id)
+
+ trip = validate_new_trip(data)
+
+ db.session.add(trip)
+ db.session.commit()
+ db.session.refresh(trip)
+
+ for traveler_id in travelers_ids:
+ traveler = Traveler(
+ user_id=traveler_id,
+ trip_id=trip.id
+ )
+
+ db.session.add(traveler)
+ db.session.commit()
+
+ chat = Chat(
+ title=trip.title,
+ trip_id=trip.id
+ )
+
+ db.session.add(chat)
+ db.session.commit()
+
+ # CORREO INFORMATIVO PENDIENTE
+
+ return jsonify({
+ "message": "Viaje creado correctamente",
+ "trip": trip.serialize()
+ }), 201
+
+
+@api.route("/trip-detail/", methods=["GET"])
+@jwt_required()
+def trip_detail(trip_id):
+
+ user = get_current_user()
+
+ validate_user_trip(user, trip_id)
+
+ trip = db.session.get(Trip, trip_id)
+ if not trip:
+ raise APIException("Viaje no encontrado", status_code=404)
+
+ travelers_links = Traveler.query.filter_by(trip_id=trip_id).all()
+ users_confirmed = [t.users.serialize() for t in travelers_links]
+
+ itineraries = Itinerary.query.filter_by(trip_id=trip_id).order_by(Itinerary.starting_date.asc(), Itinerary.hour.asc()).all()
+ expenses = Expense.query.filter_by(trip_id=trip_id).all()
+ documents = Document.query.filter_by(trip_id=trip_id).all()
+
+ chat = Chat.query.filter_by(trip_id=trip_id).first()
+ messages_list = []
+
+ if chat:
+ messages = Message.query.filter_by(chat_id=chat.id).order_by(Message.date_time.asc()).all()
+ for msg in messages:
+ messages_list.append({
+ "id": msg.id,
+ "content": msg.content,
+ "date_time": msg.date_time.isoformat(),
+ "user_id": msg.user_id,
+ "user_name": msg.authors.name
+ })
+
+ return jsonify({
+ "travelers": users_confirmed,
+ "trip": trip.serialize(),
+ "itinerary": [i.serialize() for i in itineraries],
+ "expense": [e.serialize() for e in expenses],
+ "document": [d.serialize() for d in documents],
+ "messages": messages_list,
+ }), 200
+
+
+@api.route("/new-activity/", methods=["POST"])
+@jwt_required()
+def new_activity(trip_id):
+
+ user = get_current_user()
+ data = get_json_payload()
+
+ validate_user_trip(user, trip_id)
+
+ itinerary = validate_new_itinerary(data)
+ itinerary.trip_id = trip_id
+
+ db.session.add(itinerary)
+ db.session.commit()
+ db.session.refresh(itinerary)
+
+ # CORREO INFORMATIVO PENDIENTE
+
+ return jsonify({
+ "message": "Actividad añadida correctamente",
+ "itinerary": itinerary.serialize()
+ }), 201
+
+
+# --- CORRECCIÓN 2: NEW_EXPENSE (Bucle doble eliminado) ---
+@api.route("/new-expense/", methods=["POST"])
+@jwt_required()
+def new_expense(trip_id):
+
+ user = get_current_user()
+ data = get_json_payload()
+
+ validate_user_trip(user, trip_id)
+
+ expense = validate_new_expense(data)
+ expense.trip_id = trip_id
+
+ db.session.add(expense)
+ db.session.commit()
+ db.session.refresh(expense)
+
+ # Añadir una nueva deuda por cada usuario no pagador
+ debtors = data.get("debtors", [])
+
+ # 1. Extraemos los IDs convirtiéndolos a números
+ debtors_ids = [int(debtor.get("id")) for debtor in debtors]
+
+ # 2. Aseguramos que el payer_id también sea un número
+ payer_id_int = int(expense.payer_id)
+
+ # 3. Calculamos la cantidad (protección por si acaso la lista de deudores viene vacía)
+ if len(debtors_ids) > 0:
+ amount = float(expense.amount) / len(debtors_ids)
+ else:
+ amount = float(expense.amount)
+
+ # 4. Quitamos al pagador de la lista de deudores (solo si está en la lista)
+ if payer_id_int in debtors_ids:
+ debtors_ids.remove(payer_id_int)
+
+ for debtor_id in debtors_ids:
+ debt = Debt(
+ amount = amount,
+ debtor_id = debtor_id,
+ creditor_id = payer_id_int,
+ expense_id = expense.id
+ )
+ db.session.add(debt)
+
+ db.session.commit()
+
+ # CORREO INFORMATIVO PENDIENTE
+
+ return jsonify({
+ "message": "Gasto añadida correctamente",
+ "expense": expense.serialize()
+ }), 201
+
+
+@api.route("/all-activity/", methods=["GET"])
+@jwt_required()
+def all_activity(trip_id):
+
+ user = get_current_user()
+
+ validate_user_trip(user, trip_id)
+
+ itineraries = Itinerary.query.filter_by(Itinerary.trip_id == trip_id).order_by(Itinerary.starting_date.asc()).all()
+
+ return jsonify({
+ "itinerary": [itinerary.serialize() for itinerary in itineraries]
+ }), 200
+
+
+@api.route("/new-message/", methods=["POST"])
+@jwt_required()
+def new_message(trip_id):
+ user = get_current_user()
+ validate_user_trip(user, trip_id)
+
+ data = get_json_payload()
+ content = data.get("content", "").strip()
+
+ if not content:
+ raise APIException("El mensaje no puede estar vacío", status_code=400)
+
+ chat = Chat.query.filter_by(trip_id=trip_id).first()
+ if not chat:
+ chat = Chat(title="Chat del Viaje", trip_id=trip_id)
+ db.session.add(chat)
+ db.session.commit()
+
+ new_msg = Message(
+ content=content,
+ chat_id=chat.id,
+ user_id=user.id
+ )
+
+ db.session.add(new_msg)
+ db.session.commit()
+
+ return jsonify({
+ "message": "Mensaje enviado",
+ "data": {
+ "id": new_msg.id,
+ "content": new_msg.content,
+ "date_time": new_msg.date_time.isoformat(),
+ "user_id": new_msg.user_id,
+ "user_name": user.name
+ }
+ }), 201
+
+
+@api.route("/all-expense/", methods=["GET"])
+@jwt_required()
+def all_expense(trip_id):
+
+ user = get_current_user()
+
+ validate_user_trip(user, trip_id)
+
+ expenses = Expense.query.filter_by(Expense.trip_id == trip_id).order_by(Expense.id.desc()).all()
+ expenses_ids = [expense.id for expense in expenses]
+
+ debts = Debt.query.filter(Debt.expense_id.in_(expenses_ids)).order_by(Debt.expense_id.desc()).all()
+
+ #DEUDAS SIMPLIFICADAS PARA EL FUTURO
+
+ return jsonify({
+ "expenses": [expense.serialize() for expense in expenses],
+ "debts": [debt.serialize() for debt in debts]
+ }), 200
+
+@api.route("/update-trip-image/", methods=["PUT"])
+@jwt_required()
+def update_trip_image(trip_id):
+ user = get_current_user()
+
+ # Verificamos que el usuario pertenezca a este viaje
+ validate_user_trip(user, trip_id)
+
+ data = get_json_payload()
+ new_image_url = data.get("image_url", "").strip()
+
+ trip = db.session.get(Trip, trip_id)
+ if not trip:
+ raise APIException("Viaje no encontrado", status_code=404)
+
+ # Actualizamos la URL y guardamos
+ trip.image_url = new_image_url
+ db.session.commit()
+
+ return jsonify({
+ "message": "Imagen de portada actualizada correctamente",
+ "image_url": trip.image_url
+ }), 200
+
+# ENDPOINT QUE MODIFICA LOS DATOS DEL USUARIO
+# 1º: recibe el JWT y saca el usuario
+
+# 2º: debe recibir el tipo de modificacion (perfil o contraseña) y los datos a modificar
+
+# 3º: modifica los datos necesarios
+
+# 4º: devuelve los datos actualizados del usuario
+
+
+# ENDPOINT QUE CREA EL PDF DEL ITIENERARIO COMPLETO
+
+
+# ENDPOINT QUE CREA EL PDF DE LOS GASTOS COMPLETOS
\ No newline at end of file
diff --git a/src/app.py b/src/app.py
index 1b3340c0fa..60bacdd33e 100644
--- a/src/app.py
+++ b/src/app.py
@@ -5,20 +5,29 @@
from flask import Flask, request, jsonify, url_for, send_from_directory
from flask_migrate import Migrate
from flask_swagger import swagger
+from flask_jwt_extended import JWTManager # <--- IMPORTACIÓN AÑADIDA
from api.utils import APIException, generate_sitemap
from api.models import db
from api.routes import api
from api.admin import setup_admin
from api.commands import setup_commands
+from flask_cors import CORS # <--- 1. AÑADE ESTA LÍNEA AQUÍ
# from models import Person
ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production"
static_file_dir = os.path.join(os.path.dirname(
os.path.realpath(__file__)), '../dist/')
+
app = Flask(__name__)
+CORS(app) # <--- 2. AÑADE ESTA LÍNEA JUSTO AQUÍ
app.url_map.strict_slashes = False
+# --- CONFIGURACIÓN DE JWT AÑADIDA ---
+app.config["JWT_SECRET_KEY"] = "super-secreta-cambiar-luego"
+jwt = JWTManager(app)
+# ------------------------------------
+
# database condiguration
db_url = os.getenv("DATABASE_URL")
if db_url is not None:
@@ -69,4 +78,4 @@ def serve_any_other_file(path):
# this only runs if `$ python src/main.py` is executed
if __name__ == '__main__':
PORT = int(os.environ.get('PORT', 3001))
- app.run(host='0.0.0.0', port=PORT, debug=True)
+ app.run(host='0.0.0.0', port=PORT, debug=True)
\ No newline at end of file
diff --git a/src/front/assets/img/EXPEDITION-LOGO.png b/src/front/assets/img/EXPEDITION-LOGO.png
new file mode 100644
index 0000000000..0de4fdf1a4
Binary files /dev/null and b/src/front/assets/img/EXPEDITION-LOGO.png differ
diff --git a/src/front/assets/img/fondo-landing.jpg b/src/front/assets/img/fondo-landing.jpg
new file mode 100644
index 0000000000..324f356358
Binary files /dev/null and b/src/front/assets/img/fondo-landing.jpg differ
diff --git a/src/front/assets/img/fondo-login.jpg b/src/front/assets/img/fondo-login.jpg
new file mode 100644
index 0000000000..e4474e9483
Binary files /dev/null and b/src/front/assets/img/fondo-login.jpg differ
diff --git a/src/front/assets/img/fondo-mytrips.jpg b/src/front/assets/img/fondo-mytrips.jpg
new file mode 100644
index 0000000000..99b8997493
Binary files /dev/null and b/src/front/assets/img/fondo-mytrips.jpg differ
diff --git a/src/front/components/ChatTab.jsx b/src/front/components/ChatTab.jsx
new file mode 100644
index 0000000000..89a821e4c1
--- /dev/null
+++ b/src/front/components/ChatTab.jsx
@@ -0,0 +1,132 @@
+import React, { useState, useEffect, useRef } from "react";
+import { useParams } from "react-router-dom";
+import "../styles/ChatTab.css";
+
+export const ChatTab = () => {
+ // 1. Obtenemos el ID del viaje de la URL
+ const { id } = useParams();
+
+ const [messages, setMessages] = useState([]);
+ const [newMessage, setNewMessage] = useState("");
+ const messagesEndRef = useRef(null);
+
+ // Leemos quién es el usuario logueado desde el localStorage (para saber qué burbujas son tuyas)
+ const currentUserString = localStorage.getItem("user");
+ const currentUser = currentUserString ? JSON.parse(currentUserString) : null;
+
+ // Auto-scroll al último mensaje
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages]);
+
+ // 2. FUNCIÓN: Leer mensajes de la base de datos
+ const fetchMessages = async () => {
+ const token = localStorage.getItem("token");
+ if (!token) return;
+
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/trip-detail/${id}`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ }
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.messages) {
+ setMessages(data.messages);
+ }
+ }
+ } catch (error) {
+ console.error("Error al cargar el historial del chat:", error);
+ }
+ };
+
+ // 3. EFECTO: Cargar al entrar y refrescar (Polling)
+ useEffect(() => {
+ fetchMessages();
+ // Pregunta a Rigo cada 5 segundos si hay mensajes nuevos
+ const interval = setInterval(fetchMessages, 5000);
+ return () => clearInterval(interval);
+ }, [id]);
+
+ // 4. FUNCIÓN: Enviar a la base de datos
+ const handleSendMessage = async (e) => {
+ e.preventDefault();
+ if (!newMessage.trim()) return;
+
+ const token = localStorage.getItem("token");
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/new-message/${id}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ },
+ // 🛠️ EL FIX ESTÁ AQUÍ: Rigo exige "content"
+ body: JSON.stringify({ content: newMessage })
+ });
+
+ if (response.ok) {
+ setNewMessage(""); // Limpiamos el input si se envió con éxito
+ fetchMessages(); // Pedimos la lista actualizada
+ } else {
+ const errorData = await response.json();
+ console.error("Rigo rechazó el mensaje:", errorData);
+ }
+ } catch (error) {
+ console.error("Error al enviar el mensaje:", error);
+ }
+ };
+
+ return (
+
+
+ {messages.length > 0 ? (
+ messages.map((msg, index) => {
+ // 🛠️ IDENTIDAD: Comparamos el user_id del mensaje con tu ID
+ const isMe = currentUser && msg.user_id === currentUser.id;
+
+ return (
+
+ {!isMe &&
{msg.user_name || "Viajero"} }
+
+ {/* 🛠️ EL OTRO FIX: Renderizamos msg.content en lugar de msg.text */}
+
{msg.content}
+
+ {msg.date_time
+ ? new Date(msg.date_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ : "Ahora"}
+
+
+
+ );
+ })
+ ) : (
+
+ Aún no hay mensajes. ¡Escribe el primero!
+
+ )}
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/ExpensesTab.jsx b/src/front/components/ExpensesTab.jsx
new file mode 100644
index 0000000000..02fd6b6a6d
--- /dev/null
+++ b/src/front/components/ExpensesTab.jsx
@@ -0,0 +1,345 @@
+import React, { useState } from "react";
+import { useParams } from "react-router-dom"; // <-- Necesario para obtener el ID del viaje
+import "../styles/ExpensesTab.css";
+
+// CAMBIO 1: Recibimos 'travelers' (la lista de objetos de la base de datos) en lugar de solo nombres
+export const ExpensesTab = ({ expensesList, setExpensesList, travelers, allParticipants }) => {
+ const { id } = useParams(); // ID del viaje de la URL
+
+ const [showExpenseModal, setShowExpenseModal] = useState(false);
+ const [isEditingExpense, setIsEditingExpense] = useState(false);
+ const [selectedExpense, setSelectedExpense] = useState(null);
+ const [loading, setLoading] = useState(false); // Para el botón de enviar
+
+ const [expenseData, setExpenseData] = useState({
+ id: null,
+ description: "",
+ amount: "",
+ category: "Comida",
+ paidBy: allParticipants[0] || "Yo",
+ splitMethod: "equally",
+ splitWith: [],
+ settledWith: []
+ });
+
+ const handleExpenseChange = (e) => {
+ setExpenseData({ ...expenseData, [e.target.name]: e.target.value });
+ };
+
+ const handleCheckboxChange = (participant) => {
+ setExpenseData(prev => ({
+ ...prev,
+ splitWith: prev.splitWith.includes(participant)
+ ? prev.splitWith.filter(p => p !== participant)
+ : [...prev.splitWith, participant]
+ }));
+ };
+
+ // --- LA FUNCIÓN MÁGICA CONECTADA AL BACKEND ---
+ const handleExpenseSubmit = async (e) => {
+ e.preventDefault();
+ const parsedAmount = parseFloat(expenseData.amount);
+
+ if (isEditingExpense) {
+ // (La edición al backend la dejaremos para el siguiente paso, por ahora se queda en local)
+ setExpensesList(expensesList.map(exp =>
+ exp.id === expenseData.id ? { ...expenseData, amount: parsedAmount } : exp
+ ));
+ cerrarModal();
+ } else {
+ // 1. TRADUCTOR DE NOMBRE A ID
+ // Buscamos el ID de quien pagó
+ const payerObj = travelers.find(t => t.name === expenseData.paidBy);
+ const payerId = payerObj ? payerObj.id : null;
+
+ // Buscamos los IDs de con quién se divide
+ const debtorsList = expenseData.splitWith.map(name => {
+ const t = travelers.find(t => t.name === name);
+ return t ? { id: t.id } : null;
+ }).filter(d => d !== null);
+
+ // 2. PAQUETE PARA EL BACKEND
+ const newExpensePayload = {
+ amount: parsedAmount,
+ description: expenseData.description,
+ payer_id: payerId,
+ debtors: debtorsList
+ };
+
+ setLoading(true);
+ try {
+ // 3. ENVIAMOS AL SERVIDOR
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/new-expense/${id}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${localStorage.getItem("token")}`
+ },
+ body: JSON.stringify(newExpensePayload)
+ });
+
+ if (response.ok) {
+ const responseData = await response.json();
+
+ // 4. GUARDAMOS EN REACT (Mezclando datos del Back con la estructura del Front)
+ setExpensesList([...expensesList, {
+ id: responseData.expense.id, // ID real de PostgreSQL
+ ...expenseData,
+ amount: parsedAmount,
+ date: "Hoy",
+ settledWith: []
+ }]);
+
+ cerrarModal();
+ } else {
+ const errorData = await response.json();
+ alert("Error al guardar en el servidor: " + (errorData.message || ""));
+ }
+ } catch (error) {
+ console.error("Error de conexión:", error);
+ alert("Error de conexión con el backend");
+ } finally {
+ setLoading(false);
+ }
+ }
+ };
+
+ const cerrarModal = () => {
+ setShowExpenseModal(false);
+ setIsEditingExpense(false);
+ setExpenseData({ id: null, description: "", amount: "", category: "Comida", paidBy: allParticipants[0] || "Yo", splitMethod: "equally", splitWith: [], settledWith: [] });
+ };
+
+ const handleEditExpenseClick = () => {
+ setExpenseData(selectedExpense);
+ setIsEditingExpense(true);
+ setSelectedExpense(null);
+ setShowExpenseModal(true);
+ };
+
+ const handleDeleteExpense = (id) => {
+ const confirmDelete = window.confirm("¿Estás seguro de que quieres borrar este gasto? Esta acción actualizará los balances.");
+ if (confirmDelete) {
+ setExpensesList(expensesList.filter(exp => exp.id !== id));
+ setSelectedExpense(null);
+ }
+ };
+
+ const toggleSettleDebt = (expenseId, personName) => {
+ const updatedExpenses = expensesList.map(exp => {
+ if (exp.id === expenseId) {
+ const isAlreadySettled = exp.settledWith?.includes(personName);
+ const newSettledWith = isAlreadySettled
+ ? exp.settledWith.filter(p => p !== personName)
+ : [...(exp.settledWith || []), personName];
+
+ const updatedExp = { ...exp, settledWith: newSettledWith };
+
+ if (selectedExpense && selectedExpense.id === expenseId) {
+ setSelectedExpense(updatedExp);
+ }
+ return updatedExp;
+ }
+ return exp;
+ });
+ setExpensesList(updatedExpenses);
+ };
+
+ const getCategoryIcon = (category) => {
+ switch(category) {
+ case "Comida": return "fa-solid fa-utensils";
+ case "Transporte": return "fa-solid fa-taxi";
+ default: return "fa-solid fa-receipt";
+ }
+ };
+
+ return (
+ <>
+
+
+
Control de Gastos
+ {
+ setIsEditingExpense(false);
+ setExpenseData({ description: "", amount: "", category: "Comida", paidBy: allParticipants[0] || "Yo", splitMethod: "equally", splitWith: allParticipants, settledWith: [] });
+ setShowExpenseModal(true);
+ }}
+ >
+ Añadir
+
+
+
+ {expensesList.length > 0 ? (
+
+ {expensesList.map((expense) => (
+
setSelectedExpense(expense)}>
+
+
+
+
+
{expense.description}
+ {expense.paidBy} pagó por {expense.splitWith ? expense.splitWith.length : 0}
+
+
+
{expense.amount} €
+ {expense.date}
+
+
+ ))}
+
+ ) : (
+
+
+
Aún no hay gastos registrados
+ setShowExpenseModal(true)}>Añadir Gasto
+
+ )}
+
+
+ {/* --- MODAL CREAR/EDITAR --- */}
+ {showExpenseModal && (
+
+
e.stopPropagation()}>
+
+
{isEditingExpense ? "Editar Gasto" : "Añadir Nuevo Gasto"}
+ ×
+
+
+
+
+ )}
+
+ {/* --- MODAL DESGLOSE --- */}
+ {selectedExpense && (
+ setSelectedExpense(null)}>
+
e.stopPropagation()}>
+
+
+ {selectedExpense.category}
+
+ setSelectedExpense(null)}>×
+
+
+
+
{selectedExpense.description}
+
{selectedExpense.amount} €
+
Pagado por {selectedExpense.paidBy} el {selectedExpense.date}
+
+
+
+
+
+
División del gasto ({selectedExpense.splitWith ? selectedExpense.splitWith.length : 0} personas)
+
+ {selectedExpense.splitWith && selectedExpense.splitWith.map((person, index) => {
+ const amountPerPerson = (selectedExpense.amount / selectedExpense.splitWith.length).toFixed(2);
+ const isPayer = person === selectedExpense.paidBy;
+ const isSettled = selectedExpense.settledWith?.includes(person);
+
+ return (
+
+
+
{person.charAt(0)}
+
{person}
+
+
+ {isPayer ? (
+
Pagó la cuenta
+ ) : (
+
+
+ {isSettled ? "Saldado" : `Debe ${amountPerPerson} €`}
+
+ {
+ e.stopPropagation();
+ toggleSettleDebt(selectedExpense.id, person);
+ }}
+ >
+ {isSettled ? : "Saldar"}
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ handleDeleteExpense(selectedExpense.id)}>
+ Borrar
+
+
+ Editar
+
+ setSelectedExpense(null)}>
+ Cerrar
+
+
+
+
+ )}
+ >
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/Features.jsx b/src/front/components/Features.jsx
new file mode 100644
index 0000000000..e966b960aa
--- /dev/null
+++ b/src/front/components/Features.jsx
@@ -0,0 +1,24 @@
+import React from "react";
+
+export const Features = () => {
+ return (
+
+ Todo lo que necesitas
+
+
💰
+
Gestión de Gastos
+
Divide cuentas y liquida deudas fácilmente (Model: Expense/Debt).
+
+
+
📍
+
Itinerarios Vivos
+
Planifica cada parada con tu grupo en tiempo real (Model: Itinerary).
+
+
+
💬
+
Chat Grupal
+
Toda la comunicación en un solo lugar (Model: Chat/Message).
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/Hero.jsx b/src/front/components/Hero.jsx
new file mode 100644
index 0000000000..3588dd8803
--- /dev/null
+++ b/src/front/components/Hero.jsx
@@ -0,0 +1,32 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import { Stats } from "./Stats";
+
+export const Hero = () => {
+ const navigate = useNavigate();
+
+ return (
+
+
+
Viaja en grupo sin complicaciones
+
+ {/* Texto actualizado al diseño */}
+
+ Organiza destinos, gastos y decisiones con tus amigos en un solo lugar.
+
+
+
+ {/* Botones actualizados */}
+ navigate("/login", { state: { tab: "register" } })}>
+ Comienza GRATIS
+
+ navigate("/demo")}>
+ Explorar cómo funciona
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/ItineraryTab.jsx b/src/front/components/ItineraryTab.jsx
new file mode 100644
index 0000000000..f14afdc66e
--- /dev/null
+++ b/src/front/components/ItineraryTab.jsx
@@ -0,0 +1,202 @@
+import React, { useState } from "react";
+import { useParams } from "react-router-dom"; // <-- Necesario para obtener el ID del viaje
+import "../styles/ItineraryTab.css";
+
+export const ItineraryTab = ({ tripItinerary, setTripItinerary }) => {
+ const { id } = useParams(); // Obtenemos el ID de la URL
+
+ // Estados internos solo para esta pestaña
+ const [selectedActivity, setSelectedActivity] = useState(null);
+ const [isEditingActivity, setIsEditingActivity] = useState(false);
+ const [tempActivityData, setTempActivityData] = useState(null);
+ const [showAddActivityModal, setShowAddActivityModal] = useState(false);
+ const [loading, setLoading] = useState(false); // Para mostrar "Guardando..."
+
+ const today = new Date().toISOString().split('T')[0];
+
+ // ALINEADO CON LA BASE DE DATOS: title, destination, hour, starting_date, notes
+ const [newActivity, setNewActivity] = useState({
+ starting_date: today,
+ hour: "12:00",
+ title: "",
+ destination: "",
+ notes: ""
+ });
+
+ // Función de formateo de fecha
+ const formatDateDisplay = (dateStr) => {
+ if (!dateStr) return "";
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short', year: 'numeric' });
+ };
+
+ // Funciones de control
+ const openActivityModal = (activity) => {
+ setSelectedActivity(activity);
+ setTempActivityData({ ...activity });
+ setIsEditingActivity(false);
+ };
+
+ const handleActivityChange = (e) => {
+ setTempActivityData({ ...tempActivityData, [e.target.name]: e.target.value });
+ };
+
+ const saveActivityChanges = () => {
+ // (Nota: La edición al backend la dejaremos para el siguiente paso, por ahora se queda en local)
+ setTripItinerary(tripItinerary.map(item => item.id === selectedActivity.id ? tempActivityData : item));
+ setSelectedActivity(tempActivityData);
+ setIsEditingActivity(false);
+ };
+
+ const handleNewActivityChange = (e) => {
+ setNewActivity({ ...newActivity, [e.target.name]: e.target.value });
+ };
+
+ // --- LA FUNCIÓN MÁGICA CONECTADA AL BACKEND ---
+ const handleAddActivitySubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ const token = localStorage.getItem("token");
+
+ try {
+ // Enviamos el paquete exacto que espera routes.py
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/new-activity/${id}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ },
+ body: JSON.stringify(newActivity)
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+
+ // Añadimos la actividad devuelta por el servidor a la lista y ordenamos por fecha
+ const updatedItinerary = [...tripItinerary, data.itinerary].sort((a, b) => new Date(a.starting_date) - new Date(b.starting_date));
+
+ setTripItinerary(updatedItinerary);
+ setShowAddActivityModal(false);
+
+ // Reseteamos el formulario
+ setNewActivity({ starting_date: today, hour: "12:00", title: "", destination: "", notes: "" });
+ } else {
+ const errorData = await response.json();
+ alert("Error al guardar: " + (errorData.message || "Error desconocido"));
+ }
+ } catch (error) {
+ console.error("Error de conexión:", error);
+ alert("No se pudo conectar con el servidor.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+
Plan de Viaje
+ {tripItinerary.length > 0 ? (
+
+ {tripItinerary.map((item, index) => (
+
openActivityModal(item)}>
+
+
+
{formatDateDisplay(item.starting_date)}
+
{item.hour}
+
{item.title}
+
{item.notes}
+
+
+ ))}
+
+ ) : (
+
Aún no hay actividades planeadas.
+ )}
+
setShowAddActivityModal(true)}>
+ Añadir actividad
+
+
+
+ {/* MODAL DETALLE/EDICIÓN ITINERARIO */}
+ {selectedActivity && (
+ setSelectedActivity(null)}>
+
e.stopPropagation()}>
+
+ {formatDateDisplay(tempActivityData.starting_date)}
+ setSelectedActivity(null)}>×
+
+ {isEditingActivity ? (
+
+
Título
+
+
Ubicación
+
Descripción
+
+ setIsEditingActivity(false)}>Cancelar
+ Guardar
+
+
+ ) : (
+ <>
+
+
{selectedActivity.title}
+
{selectedActivity.hour}
+
{selectedActivity.destination}
+
+
Descripción {selectedActivity.notes}
+
+ setIsEditingActivity(true)}>Editar
+ setSelectedActivity(null)}>Cerrar
+
+ >
+ )}
+
+
+ )}
+
+ {/* MODAL NUEVA ACTIVIDAD */}
+ {showAddActivityModal && (
+ setShowAddActivityModal(false)}>
+
e.stopPropagation()}>
+
Nueva Actividad setShowAddActivityModal(false)}>×
+
+
+
+ )}
+ >
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx
index 30d43a2636..3c9c4e933c 100644
--- a/src/front/components/Navbar.jsx
+++ b/src/front/components/Navbar.jsx
@@ -1,19 +1,125 @@
-import { Link } from "react-router-dom";
+import React, { useState } from "react";
+import { Link, useNavigate, useLocation } from "react-router-dom";
+import "../styles/Navbar.css";
+
+// 1. IMPORTAMOS TU LOGO AQUÍ
+import logoExpedition from "../assets/img/EXPEDITION-LOGO.png";
export const Navbar = () => {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ // Detectamos si está en el Login
+ const isLoginPage = location.pathname === "/login";
+
+ // 🧠 MAGIA NUEVA: Verificamos si el usuario tiene un token guardado.
+ // Si hay token = Está logueado. Si no hay = Es un visitante.
+ const isAuthenticated = !!localStorage.getItem("token");
+
+ // Función para cerrar sesión correctamente
+ const handleLogout = () => {
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ setMenuOpen(false);
+ navigate("/login");
+ };
+
+ return (
+
+
+ {/* 1. IZQUIERDA: Menú Móvil y Logo */}
+
+
setMenuOpen(!menuOpen)}>
+
+
+
+
+
+
+
+
+ {/* 2. CENTRO: Menú de navegación */}
+
+
+ Inicio
+
+
+ {/* 🔒 RUTAS PROTEGIDAS: Solo se muestran si el usuario está logueado */}
+ {isAuthenticated && (
+ <>
+
+ Mis viajes
+
+
+ Perfil
+
+ >
+ )}
+
+
+ {/* 3. DERECHA: Autenticación y Avatar */}
+
+ {isAuthenticated ? (
+ // ✅ SI ESTÁ LOGUEADO: Mostramos Cerrar Sesión y el Avatar
+ <>
+
+ Cerrar sesión
+
+
navigate("/profile")}
+ style={{ cursor: "pointer" }}
+ >
+
+
+ >
+ ) : (
+ // ❌ SI NO ESTÁ LOGUEADO: Escondemos el Avatar y mostramos "Iniciar sesión"
+ // (Pero lo ocultamos si ya estamos en la propia pantalla de login)
+ !isLoginPage && (
+
navigate("/login")}
+ style={{ cursor: "pointer", fontWeight: "600", color: "var(--brand-navy)" }}
+ >
+ Iniciar sesión
+
+ )
+ )}
+
+
- return (
-
-
-
-
React Boilerplate
-
-
-
- Check the Context in action
-
-
-
-
- );
+ {/* Menú desplegable Móvil */}
+ {menuOpen && (
+
+
setMenuOpen(false)}>Inicio
+
+ {/* 🔒 RUTAS PROTEGIDAS EN MÓVIL */}
+ {isAuthenticated && (
+ <>
+
setMenuOpen(false)}>Mis viajes
+
setMenuOpen(false)}>Perfil
+ >
+ )}
+
+
+
+ {/* Botón de sesión dinámico en el móvil */}
+ {isAuthenticated ? (
+
+ Cerrar sesión
+
+ ) : (
+
setMenuOpen(false)} style={{ color: "var(--brand-navy)", fontWeight: "bold" }}>
+ Iniciar sesión
+
+ )}
+
+ )}
+
+ );
};
\ No newline at end of file
diff --git a/src/front/components/Stats.jsx b/src/front/components/Stats.jsx
new file mode 100644
index 0000000000..5a80fe5d9b
--- /dev/null
+++ b/src/front/components/Stats.jsx
@@ -0,0 +1,22 @@
+import React from "react";
+
+export const Stats = () => {
+ return (
+
+
+
+ 1M+
+ Viajeros
+
+
+ 500K
+ Destinos
+
+
+ {/* El texto extra que aparece abajo en tu diseño */}
+
+ Expande tus horizontes, nosotros hacemos el resto
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/index.css b/src/front/index.css
index e69de29bb2..a6bc2543e8 100644
--- a/src/front/index.css
+++ b/src/front/index.css
@@ -0,0 +1,11 @@
+:root {
+ --brand-orange: #FF7A59;
+ --brand-navy: #1E3A5F;
+ --brand-teal: #2EC4B6;
+ --bg-light: #F8FAFC;
+}
+
+body {
+ font-family: 'Poppins', sans-serif;
+ color: var(--brand-navy);
+}
\ No newline at end of file
diff --git a/src/front/main.jsx b/src/front/main.jsx
index a5a3c781dc..433d93defb 100644
--- a/src/front/main.jsx
+++ b/src/front/main.jsx
@@ -1,29 +1,24 @@
+// src/front/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
-import './index.css' // Global styles for your application
-import { RouterProvider } from "react-router-dom"; // Import RouterProvider to use the router
-import { router } from "./routes"; // Import the router configuration
-import { StoreProvider } from './hooks/useGlobalReducer'; // Import the StoreProvider for global state management
+import './index.css'
+import { RouterProvider } from "react-router-dom";
+import { router } from "./routes";
+import { StoreProvider } from './hooks/useGlobalReducer';
import { BackendURL } from './components/BackendURL';
const Main = () => {
-
- if(! import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL == "") return (
-
-
-
- );
+ // Si no hay URL de backend, mostramos el aviso
+ if(!import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL == "") {
+ return ;
+ }
+
return (
-
- {/* Provide global state to all components */}
-
- {/* Set up routing for the application */}
-
-
-
-
+
+ {/* RouterProvider DEBE ser el único que maneje la vista */}
+
+
);
}
-// Render the Main component into the root DOM element.
-ReactDOM.createRoot(document.getElementById('root')).render( )
+ReactDOM.createRoot(document.getElementById('root')).render( )
\ No newline at end of file
diff --git a/src/front/pages/AuthPage.jsx b/src/front/pages/AuthPage.jsx
new file mode 100644
index 0000000000..9cca198e8e
--- /dev/null
+++ b/src/front/pages/AuthPage.jsx
@@ -0,0 +1,213 @@
+import React, { useState, useEffect } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import "../styles/AuthPage.css";
+
+export const AuthPage = () => {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const [isLogin, setIsLogin] = useState(true);
+
+ // --- 1. ESTADOS PARA EL FORMULARIO ---
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [errorMsg, setErrorMsg] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ // Sincronizar la pestaña con la navegación
+ useEffect(() => {
+ if (location.state?.tab === "register") {
+ setIsLogin(false);
+ } else if (location.state?.tab === "login") {
+ setIsLogin(true);
+ }
+ // Limpiar errores al cambiar de pestaña
+ setErrorMsg("");
+ }, [location]);
+
+ // --- 2. FUNCIÓN DE ENVÍO (HANDLESUBMIT) ---
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setErrorMsg("");
+ setLoading(true);
+
+ // Definimos la ruta dependiendo de si es login o registro
+ const endpoint = isLogin ? "/api/login" : "/api/sign-up";
+
+ // Preparamos el cuerpo de la petición
+ const bodyData = {
+ email: email.trim().toLowerCase(),
+ password: password
+ };
+
+ // Si es registro, añadimos el nombre
+ if (!isLogin) {
+ bodyData.name = name.trim();
+ }
+
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}${endpoint}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(bodyData)
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ // Guardamos el token en localStorage
+ localStorage.setItem("token", data.access_token);
+ // Opcional: guardar datos del usuario
+ localStorage.setItem("user", JSON.stringify(data.user));
+
+ // Redirigimos al panel principal de viajes del usuario
+ navigate("/my-trips");
+ } else {
+ // Manejo de errores del backend
+ setErrorMsg(data.message || "Ocurrió un error inesperado");
+ }
+ } catch (error) {
+ console.error("Error de red:", error);
+ setErrorMsg("No se pudo conectar con el servidor. Verifica tu conexión.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ { setIsLogin(true); setErrorMsg(""); }}
+ >
+ Inicia sesión
+
+ { setIsLogin(false); setErrorMsg(""); }}
+ >
+ Regístrate
+
+
+
+
+
{isLogin ? "Bienvenido de nuevo" : "Crea tu cuenta"}
+
+ {isLogin
+ ? "Gestiona tus aventuras compartidas con facilidad."
+ : "Únete a Expedition y empieza a planificar con tus amigos."}
+
+
+
+
+ {/* Mensaje de Error dinámico */}
+ {errorMsg && (
+
+
+ {errorMsg}
+
+ )}
+
+ {/* Campo Nombre (Solo en Registro) */}
+ {!isLogin && (
+
+
NOMBRE COMPLETO
+
+
+ setName(e.target.value)}
+ required={!isLogin}
+ />
+
+
+ )}
+
+ {/* Campo Email */}
+
+
CORREO ELECTRÓNICO
+
+
+ setEmail(e.target.value)}
+ required
+ />
+
+
+
+ {/* Campo Password */}
+
+
CONTRASEÑA
+
+
+ setPassword(e.target.value)}
+ required
+ />
+
+
+
+ {isLogin && (
+
+ )}
+
+
+ {loading ? "Cargando..." : (isLogin ? "Entrar" : "Registrarse")}
+
+
+
+
+ O CONTINÚA CON
+
+
+
+
+ Google
+
+
+ Facebook
+
+
+
+
+
+
+ {isLogin ? "¿Aún no tienes cuenta? " : "¿Ya tienes una cuenta? "}
+ setIsLogin(!isLogin)}>
+ {isLogin ? "Únete a la comunidad" : "Inicia sesión"}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/LandingPage.jsx b/src/front/pages/LandingPage.jsx
new file mode 100644
index 0000000000..0f75822b71
--- /dev/null
+++ b/src/front/pages/LandingPage.jsx
@@ -0,0 +1,21 @@
+import React from "react";
+import { Hero } from "../components/Hero";
+import { Stats } from "../components/Stats";
+import { Features } from "../components/Features";
+import "../styles/LandingPage.css"; //
+
+export const LandingPage = () => {
+ return (
+
+ {/* Aquí llamamos a los componentes */}
+
+
+
+ {/* CALL TO ACTION FINAL (Este es pequeñito, lo podemos dejar aquí o separarlo luego) */}
+
+ ¿Listo para tu próxima expedición?
+ Crear mi viaje ahora
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Layout.jsx b/src/front/pages/Layout.jsx
index 9bfa31325c..300915cb2c 100644
--- a/src/front/pages/Layout.jsx
+++ b/src/front/pages/Layout.jsx
@@ -1,15 +1,17 @@
-import { Outlet } from "react-router-dom/dist"
-import ScrollToTop from "../components/ScrollToTop"
-import { Navbar } from "../components/Navbar"
-import { Footer } from "../components/Footer"
+import React from "react";
+import { Outlet } from "react-router-dom"; // Outlet es donde se cargan las páginas como LandingPage o Home
+import { Navbar } from "../components/Navbar"; // Verifica que la "N" coincida con tu archivo
+import { Footer } from "../components/Footer";
+import ScrollToTop from "../components/ScrollToTop";
-// Base component that maintains the navbar and footer throughout the page and the scroll to top functionality.
export const Layout = () => {
return (
-
-
-
-
-
- )
-}
\ No newline at end of file
+
+
+
+ {/* AQUÍ se inyectan las páginas */}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/MyTrips.jsx b/src/front/pages/MyTrips.jsx
new file mode 100644
index 0000000000..e57ae7b8cb
--- /dev/null
+++ b/src/front/pages/MyTrips.jsx
@@ -0,0 +1,191 @@
+import React, { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import "../styles/MyTrips.css";
+
+export const MyTrips = () => {
+ // 1. HERRAMIENTAS DE REACT
+ const navigate = useNavigate();
+ const [activeFilter, setActiveFilter] = useState("Todos");
+
+ // Estados para la Base de Datos Real
+ const [trips, setTrips] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // 2. CONEXIÓN AL BACKEND
+ useEffect(() => {
+ const fetchMyTrips = async () => {
+ const token = localStorage.getItem("token");
+
+ // Si no hay llave, a iniciar sesión
+ if (!token) {
+ navigate("/login");
+ return;
+ }
+
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/trips`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ }
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ setTrips(data.viajes || []);
+ } else if (response.status === 401) {
+ navigate("/login");
+ }
+ } catch (error) {
+ console.error("Error al cargar los viajes:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchMyTrips();
+ }, [navigate]);
+
+ // 3. TRADUCTOR DE ESTADOS
+ const translateStatus = (status) => {
+ if (!status) return "Planificando";
+ const s = status.toUpperCase();
+ if (s === "FINISHED") return "Pasados";
+ if (s === "ONGOING") return "En curso";
+ if (s === "PLANNING") return "Planificando";
+ return status;
+ };
+
+ // 4. DATOS DE SUGERENCIAS (Estáticos por ahora)
+ const suggestions = [
+ { title: "Misterios de la India", image: "https://images.unsplash.com/photo-1524492412937-b28074a5d7da?auto=format&fit=crop&w=400&q=80" },
+ { title: "Islas Griegas", image: "https://images.unsplash.com/photo-1533105079780-92b9be482077?auto=format&fit=crop&w=400&q=80" },
+ { title: "Tokio Moderno", image: "https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?auto=format&fit=crop&w=400&q=80" },
+ { title: "Dubai Futurista", image: "https://images.unsplash.com/photo-1512453979798-5ea266f8880c?auto=format&fit=crop&w=400&q=80" }
+ ];
+
+ // 5. LA MAGIA DEL FILTRADO
+ const filteredTrips = activeFilter === "Todos"
+ ? trips
+ : trips.filter(trip => translateStatus(trip.state) === activeFilter);
+
+ // 6. PANTALLA DE CARGA
+ if (loading) {
+ return (
+
+
Cargando tus aventuras... 🌍
+
+ );
+ }
+
+ // 7. RENDERIZADO VISUAL
+ return (
+
+
+
+
+
+
Mis Viajes
+
Gestiona tus próximas aventuras y recuerdos pasados.
+
+
navigate("/new-trip")}>
+ Crear nuevo viaje
+
+
+
+
+ {["Todos", "En curso", "Planificando", "Pasados"].map(filter => (
+ setActiveFilter(filter)}
+ >
+ {filter}
+
+ ))}
+
+
+
+ {filteredTrips.length === 0 ? (
+
+ No tienes viajes en esta categoría.
+
+ ) : (
+ filteredTrips.map((trip) => {
+ const statusEsp = translateStatus(trip.state);
+
+ // 📸 LÓGICA DE LA IMAGEN:
+ // 1. Usa la image_url de la DB si existe y no es nula.
+ // 2. Si no hay imagen, busca una aleatoria en Unsplash basada en el destino.
+ const tripImage = trip.image_url && trip.image_url.trim() !== ""
+ ? trip.image_url
+ : `https://source.unsplash.com/500x300/?${encodeURIComponent(trip.destination || 'travel')}`;
+
+ return (
+
+
+
{ e.target.src = "https://images.unsplash.com/photo-1488646953014-85cb44e25828?auto=format&fit=crop&w=500&q=80" }}
+ />
+
+ {statusEsp}
+
+
+
+
+
{trip.title}
+
{trip.starting_date} al {trip.ending_date}
+
+
+ {statusEsp === "En curso" && (
+
+ )}
+
+
navigate(`/trip-details/${trip.id}`)}>
+ Ver detalles
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
¿Sin ideas?
+
Explora destinos seleccionados por nuestra comunidad.
+
Explorar destinos
+
+
+
+
+
+
+
Sugerencias para ti
+ Ver todas
+
+
+ {suggestions.map((sug, index) => (
+
+
+
+
{sug.title}
+
+
+ ))}
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/NewTrip.jsx b/src/front/pages/NewTrip.jsx
new file mode 100644
index 0000000000..a6f3536f86
--- /dev/null
+++ b/src/front/pages/NewTrip.jsx
@@ -0,0 +1,219 @@
+import React, { useState } from "react";
+import { useNavigate, Link } from "react-router-dom";
+import "../styles/NewTrip.css";
+
+export const NewTrip = () => {
+ const navigate = useNavigate();
+
+ const [trip, setTrip] = useState({
+ title: "",
+ destination: "",
+ starting_date: "",
+ ending_date: "",
+ state: "PLANNING",
+ budget: "",
+ notes: "",
+ users: "",
+ image_url: "" // 📸 NUEVO ESTADO PARA LA IMAGEN
+ });
+
+ const handleChange = (e) => {
+ setTrip({ ...trip, [e.target.name]: e.target.value });
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ const token = localStorage.getItem("token");
+ if (!token) {
+ navigate("/login");
+ return;
+ }
+
+ const emailsArray = trip.users
+ .split(",")
+ .map(email => email.trim())
+ .filter(email => email !== "");
+
+ const payload = {
+ title: String(trip.title),
+ destination: String(trip.destination),
+ state: String(trip.state),
+ starting_date: String(trip.starting_date),
+ ending_date: String(trip.ending_date),
+ budget: String(trip.budget),
+ notes: String(trip.notes),
+ users: emailsArray,
+ image_url: String(trip.image_url) // 📸 ENVIAMOS LA IMAGEN
+ };
+
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/new_trip`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ },
+ body: JSON.stringify(payload)
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ navigate(`/trip-details/${data.trip.id}`);
+ } else {
+ const errorData = await response.json();
+ alert(`Rigo dice: ${errorData.message}`);
+ }
+ } catch (error) {
+ console.error("Error de conexión:", error);
+ }
+ };
+
+ return (
+
+
+
+ {/* 🛠️ FIX: Botón de volver a prueba de balas usando Link y zIndex máximo */}
+
+
Volver a Mis Viajes
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Profile.jsx b/src/front/pages/Profile.jsx
new file mode 100644
index 0000000000..dabfab693b
--- /dev/null
+++ b/src/front/pages/Profile.jsx
@@ -0,0 +1,220 @@
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import "../styles/Profile.css";
+
+export const Profile = () => {
+ const navigate = useNavigate();
+
+ const [user, setUser] = useState({
+ firstName: "Alex",
+ lastName: "Thompson",
+ email: "alex.thompson@traveler.com",
+ phone: "+34 600 000 000",
+ location: "Madrid, España",
+ bio: "Amante de la fotografía, las montañas y descubrir nuevas culturas."
+ });
+
+ const [notifications, setNotifications] = useState({
+ itinerary: true,
+ expenses: true,
+ offers: false
+ });
+
+ // === NUEVOS ESTADOS PARA LOS MODALES ===
+ const [showPasswordModal, setShowPasswordModal] = useState(false);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [passwordData, setPasswordData] = useState({ current: "", new: "", confirm: "" });
+
+ const handleChange = (e) => {
+ setUser({ ...user, [e.target.name]: e.target.value });
+ };
+
+ const handlePasswordChange = (e) => {
+ setPasswordData({ ...passwordData, [e.target.name]: e.target.value });
+ };
+
+ const handleToggle = (setting) => {
+ setNotifications({ ...notifications, [setting]: !notifications[setting] });
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ alert("¡Cambios guardados correctamente!");
+ };
+
+ // Funciones para manejar los modales
+ const submitPasswordChange = (e) => {
+ e.preventDefault();
+ if(passwordData.new !== passwordData.confirm) {
+ alert("Las contraseñas nuevas no coinciden.");
+ return;
+ }
+ alert("¡Contraseña actualizada con éxito!");
+ setShowPasswordModal(false);
+ setPasswordData({ current: "", new: "", confirm: "" }); // Limpiar formulario
+ };
+
+ const confirmDeleteAccount = () => {
+ alert("Tu cuenta ha sido eliminada. Lamentamos verte partir.");
+ setShowDeleteModal(false);
+ navigate("/"); // Lo mandamos de vuelta al inicio
+ };
+
+ return (
+
+
+
+
navigate("/my-trips")}>
+ Volver a Mis Viajes
+
+
+
+ {/* CABECERA */}
+
+
+
+ {user.firstName.charAt(0)}{user.lastName.charAt(0)}
+
+
+
+
+
+
+
{user.firstName} {user.lastName}
+
Explorador • Premium
+
+
+
+
+ {/* 1. INFORMACIÓN PERSONAL */}
+
+
Información Personal
+
+
+
+
+ {/* 2. CONFIGURADOR DE NOTIFICACIONES */}
+
+
Configuración de notificaciones
+
+
+
+
+
+
Actualizaciones de itinerario
+
Avisos sobre cambios en actividades y horarios.
+
+
+ handleToggle('itinerary')} />
+
+
+
+
+
+
Gastos compartidos
+
Alertas sobre nuevas facturas o pagos pendientes.
+
+
+ handleToggle('expenses')} />
+
+
+
+
+
+ {/* 3. SEGURIDAD */}
+
+
Seguridad de la cuenta
+
+
+
+ {/* Al hacer clic, activamos los modales */}
+ setShowPasswordModal(true)}>
+ Cambiar Contraseña
+
+ setShowDeleteModal(true)}>
+ Eliminar Cuenta
+
+
+
+
+
+ Guardar Cambios
+
+
+
+
+
+
+ {/* =========================================
+ MODAL: CAMBIAR CONTRASEÑA
+ ========================================= */}
+ {showPasswordModal && (
+
+
+
Cambiar Contraseña
+
Introduce tu contraseña actual y la nueva que deseas utilizar.
+
+
+ Contraseña Actual
+
+
+
+ Nueva Contraseña
+
+
+
+ Confirmar Nueva Contraseña
+
+
+
+ setShowPasswordModal(false)}>Cancelar
+ Actualizar
+
+
+
+
+ )}
+
+ {/* =========================================
+ MODAL: ELIMINAR CUENTA
+ ========================================= */}
+ {showDeleteModal && (
+
+
+
+
+
+
¿Eliminar cuenta definitivamente?
+
Esta acción no se puede deshacer . Todos tus viajes, gastos, e información personal serán eliminados de nuestros servidores para siempre.
+
+ setShowDeleteModal(false)}>Cancelar
+ Sí, eliminar mi cuenta
+
+
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/TripDetails.jsx b/src/front/pages/TripDetails.jsx
new file mode 100644
index 0000000000..b4b4297ff8
--- /dev/null
+++ b/src/front/pages/TripDetails.jsx
@@ -0,0 +1,293 @@
+import React, { useState, useEffect } from "react";
+import { useParams, useNavigate, Link } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer";
+import "../styles/TripDetails.css";
+
+import { ItineraryTab } from "../components/ItineraryTab";
+import { ExpensesTab } from "../components/ExpensesTab";
+import { ChatTab } from "../components/ChatTab";
+
+export const TripDetails = () => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const { store, dispatch } = useGlobalReducer();
+
+ const [activeTab, setActiveTab] = useState("itinerario");
+ const [tripItinerary, setTripItinerary] = useState([]);
+ const [expensesList, setExpensesList] = useState([]);
+
+ // 📸 ESTADOS PARA LA EDICIÓN DE IMAGEN
+ const [isEditingImage, setIsEditingImage] = useState(false);
+ const [newImageUrl, setNewImageUrl] = useState("");
+ const [isUpdatingImage, setIsUpdatingImage] = useState(false);
+
+ const stateTranslations = {
+ "PLANNING": { text: "Planificando", color: "#3498db" },
+ "ONGOING": { text: "En curso", color: "#2ecc71" },
+ "FINISHED": { text: "Finalizado", color: "#95a5a6" }
+ };
+
+ // Extraemos el fetch a una función para poder reutilizarlo al actualizar la imagen
+ const fetchTripDetails = async () => {
+ const token = localStorage.getItem("token");
+ if (!token) {
+ navigate("/login");
+ return;
+ }
+
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/trip-detail/${id}`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ }
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ dispatch({ type: "load_trip_details", payload: data });
+ setTripItinerary(data.itinerary || []);
+ setExpensesList(data.expense || []);
+ } else {
+ if (response.status === 401) navigate("/login");
+ }
+ } catch (error) {
+ console.error("Error de conexión con el backend:", error);
+ }
+ };
+
+ useEffect(() => {
+ if (id) fetchTripDetails();
+ }, [id, navigate, dispatch]);
+
+ // 📸 FUNCIÓN PARA ACTUALIZAR LA IMAGEN EN EL BACKEND
+ const handleImageUpdate = async () => {
+ setIsUpdatingImage(true);
+ const token = localStorage.getItem("token");
+
+ try {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/update-trip-image/${id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`
+ },
+ body: JSON.stringify({ image_url: newImageUrl })
+ });
+
+ if (response.ok) {
+ setIsEditingImage(false);
+ setNewImageUrl("");
+ fetchTripDetails(); // Recargamos los datos para que se vea la foto nueva
+ } else {
+ alert("Hubo un error al actualizar la imagen.");
+ }
+ } catch (error) {
+ console.error("Error:", error);
+ } finally {
+ setIsUpdatingImage(false);
+ }
+ };
+
+ if (!store.currentTrip) {
+ return (
+
+
+
Cargando información del viaje...
+
+ );
+ }
+
+ const trip = store.currentTrip;
+ const formattedDates = `${trip.starting_date} al ${trip.ending_date}`;
+
+ const safeState = trip.state ? trip.state.toUpperCase() : "PLANNING";
+ const currentState = stateTranslations[safeState] || { text: trip.state, color: "var(--brand-teal)" };
+
+ const allParticipants = store.travelers && store.travelers.length > 0
+ ? store.travelers.map(t => t.name)
+ : ["Usuario"];
+
+ const calculateBalances = () => {
+ let balances = {};
+ allParticipants.forEach(p => balances[p] = 0);
+
+ expensesList.forEach(exp => {
+ const amount = parseFloat(exp.amount) || 0;
+ const splitArray = exp.splitWith || allParticipants;
+ const splitAmount = amount / splitArray.length;
+ const payerName = exp.payer_name || allParticipants[0];
+
+ if (balances[payerName] !== undefined) balances[payerName] += amount;
+
+ splitArray.forEach(person => {
+ if (balances[person] !== undefined) balances[person] -= splitAmount;
+ });
+ });
+ return balances;
+ };
+
+ const participantBalances = calculateBalances();
+ const totalSpent = expensesList.reduce((acc, curr) => acc + (parseFloat(curr.amount) || 0), 0);
+
+ // 📸 LÓGICA DE FONDO: Usa la imagen del viaje o una por defecto
+ const heroImage = trip.image_url && trip.image_url.trim() !== ""
+ ? trip.image_url
+ : `https://source.unsplash.com/1200x400/?${encodeURIComponent(trip.destination || 'travel')}`;
+
+ return (
+
+ {/* HERO SECTION */}
+
+
+
+ {/* 🛠️ FIX: Botón de volver a prueba de balas usando Link */}
+
+
Volver
+
+
+
+ {currentState.text}
+
+
+
{trip.title}
+
{formattedDates}
+
+ {/* 📸 INTERFAZ DE EDICIÓN DE IMAGEN */}
+
+ {!isEditingImage ? (
+
{ setIsEditingImage(true); setNewImageUrl(trip.image_url || ""); }}
+ style={{ background: "rgba(255,255,255,0.2)", border: "1px solid white", color: "white", padding: "8px 12px", borderRadius: "8px", cursor: "pointer", backdropFilter: "blur(4px)" }}
+ >
+ Cambiar Foto
+
+ ) : (
+
+ setNewImageUrl(e.target.value)}
+ style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc", width: "250px" }}
+ />
+
+ {isUpdatingImage ? "..." : "Guardar"}
+
+ setIsEditingImage(false)}
+ style={{ background: "#e2e8f0", color: "#334155", border: "none", padding: "8px 10px", borderRadius: "4px", cursor: "pointer" }}
+ >
+
+
+
+ )}
+
+
+
+
+
+ {/* CONTENEDOR PRINCIPAL */}
+
+
+
+
+ {/* ÁREA DE PESTAÑAS */}
+
+
+ setActiveTab("itinerario")}>Itinerario
+ setActiveTab("gastos")}>Gastos
+ setActiveTab("documentos")}>Documentos
+
+
+ {activeTab === "itinerario" && (
+
+ )}
+
+ {activeTab === "gastos" && (
+
+ )}
+
+ {activeTab === "documentos" && (
+
+
+
Aún no hay documentos
+
Sube aquí tus reservas de hotel o vuelos.
+
+ )}
+
+
+ {/* CHAT PERSISTENTE */}
+
+
+
+
+
+
+
+ {/* BARRA LATERAL / SIDEBAR */}
+
+
+
Presupuesto
+
+
Gastado
{totalSpent.toFixed(2)}€
+
Total
{trip.budget || 0}€
+
+
+
0 ? Math.min((totalSpent / trip.budget) * 100, 100) : 0}%`,
+ backgroundColor: totalSpent > trip.budget ? "#e74c3c" : "#2ecc71"
+ }}>
+
+
+
+
+
Viajeros y Balances
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/routes.jsx b/src/front/routes.jsx
index 0557df6141..18149ad523 100644
--- a/src/front/routes.jsx
+++ b/src/front/routes.jsx
@@ -1,5 +1,4 @@
// Import necessary components and functions from react-router-dom.
-
import {
createBrowserRouter,
createRoutesFromElements,
@@ -9,6 +8,12 @@ import { Layout } from "./pages/Layout";
import { Home } from "./pages/Home";
import { Single } from "./pages/Single";
import { Demo } from "./pages/Demo";
+import { LandingPage } from "./pages/LandingPage";
+import { AuthPage } from "./pages/AuthPage"; // <-- Importamos la nueva página de Login/Registro
+import { MyTrips } from "./pages/MyTrips";
+import { NewTrip } from "./pages/NewTrip";
+import { TripDetails } from "./pages/TripDetails";
+import { Profile } from "./pages/Profile";
export const router = createBrowserRouter(
createRoutesFromElements(
@@ -19,12 +24,32 @@ export const router = createBrowserRouter(
// Note: The child paths of the Layout element replace the Outlet component with the elements contained in the "element" attribute of these child paths.
// Root Route: All navigation will start from here.
- } errorElement={Not found! } >
+ } errorElement={Not found! }>
+
+ {/* Landing Page es la vista por defecto al entrar a la web */}
+ } />
+
+ {/* Ruta para el sistema de Autenticación (Login/Registro) */}
+ } />
+
+ {/* Ruta para el sistema de Autenticación (mytrips) */}
+ } path="/my-trips" />
+
+ {/* Ruta para el sistema de Autenticación (newtrip) */}
+ } />
+
+ {/* ¡AQUÍ ESTÁ LA CORRECCIÓN! Cambiamos /trip/ por /trip-details/ */}
+ } />
+
+ } />
- {/* Nested Routes: Defines sub-routes within the BaseHome component. */}
- } />
- } /> {/* Dynamic route for single items */}
- } />
-
+ {/* Cambiamos el path de Home a "/home" para que no choque con la Landing Page */}
+ } />
+
+ {/* Rutas dinámicas y de demostración */}
+ } />
+ } />
+
+
)
);
\ No newline at end of file
diff --git a/src/front/store.js b/src/front/store.js
index 3062cd222d..d200fffdd7 100644
--- a/src/front/store.js
+++ b/src/front/store.js
@@ -1,38 +1,82 @@
-export const initialStore=()=>{
- return{
+export const initialStore = () => {
+ return {
message: null,
- todos: [
- {
- id: 1,
- title: "Make the bed",
- background: null,
- },
- {
- id: 2,
- title: "Do my homework",
- background: null,
- }
- ]
+ // --- ESTADOS PARA EL BACKEND ---
+ currentTrip: null, // Aquí guardaremos el viaje actual
+ itinerary: [], // Actividades del viaje
+ expenses: [], // Gastos del viaje
+ messages: [], // Mensajes del chat
+ travelers: [], // Compañeros de viaje
+ loading: false // Para saber si estamos cargando datos
}
}
export default function storeReducer(store, action = {}) {
- switch(action.type){
+ switch (action.type) {
case 'set_hello':
+ return { ...store, message: action.payload };
+
+ // --- NUEVOS CASES PARA LA CONEXIÓN ---
+
+ // Carga todos los detalles del viaje
+ case 'load_trip_details':
return {
...store,
- message: action.payload
+ currentTrip: action.payload.trip,
+ itinerary: action.payload.itinerary,
+ expenses: action.payload.expense,
+ travelers: action.payload.travelers,
+ messages: action.payload.messages
};
-
- case 'add_task':
-
- const { id, color } = action.payload
+ // Añadir un mensaje nuevo al chat en tiempo real
+ case 'add_message':
return {
...store,
- todos: store.todos.map((todo) => (todo.id === id ? { ...todo, background: color } : todo))
+ messages: [...store.messages, action.payload]
};
+
+ case 'set_loading':
+ return { ...store, loading: action.payload };
+
default:
- throw Error('Unknown action.');
- }
+ return store;
+ }
}
+
+// --- ACTIONS (Las funciones que llamarán a tu Backend) ---
+export const getActions = ({ getStore, getActions, setStore }) => {
+ return {
+ loadTripData: async (tripId) => {
+ const token = localStorage.getItem("token"); // Asegúrate de que el token se guarde con este nombre al hacer Login
+
+ try {
+ const response = await fetch(`${process.env.BACKEND_URL}/api/trip-detail/${tripId}`, {
+ method: "GET",
+ headers: {
+ "Authorization": `Bearer ${token}`,
+ "Content-Type": "application/json"
+ }
+ });
+
+ if (!response.ok) throw new Error("No se pudo cargar el viaje");
+
+ const data = await response.json();
+
+ // ¡LA MAGIA OCURRE AQUÍ! Guardamos los datos recibidos en el estado global
+ setStore({
+ currentTrip: data.trip,
+ itinerary: data.itinerary,
+ expenses: data.expense,
+ travelers: data.travelers,
+ messages: data.messages
+ });
+
+ return data;
+
+ } catch (error) {
+ console.error("Error cargando viaje:", error);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/front/styles/AuthPage.css b/src/front/styles/AuthPage.css
new file mode 100644
index 0000000000..c21c1a3937
--- /dev/null
+++ b/src/front/styles/AuthPage.css
@@ -0,0 +1,263 @@
+/* Contenedor principal corregido */
+.auth-wrapper {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ /* Fondo idéntico al de la Landing Page */
+ background: linear-gradient(rgba(46, 196, 182, 0.8), rgba(46, 196, 182, 0.6)),
+ url('../assets/img/fondo-login.jpg');
+ background-size: cover;
+ background-position: center;
+ background-attachment: fixed;
+ /* Aumentamos el padding superior a 100px para librar el Navbar (que mide 90px) */
+ padding: 100px 20px 40px 20px;
+ box-sizing: border-box;
+ font-family: 'Poppins', sans-serif;
+}
+
+/* La tarjeta blanca */
+.auth-card {
+ background: rgba(255, 255, 255, 0.98);
+ backdrop-filter: blur(5px);
+ width: 100%;
+ max-width: 450px;
+ border-radius: 16px;
+ box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+ position: relative;
+ z-index: 2000; /* Esto rompe el "escudo invisible" del Navbar */
+}
+
+/* Pestañas */
+.auth-tabs {
+ display: flex;
+ border-bottom: 1px solid #eaeaea;
+}
+
+.tab-btn {
+ flex: 1;
+ padding: 20px;
+ background: none;
+ border: none;
+ font-weight: 600;
+ font-size: 1rem;
+ color: #6c757d;
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.tab-btn.active {
+ color: var(--brand-orange, #FF7A59); /* Naranja */
+ border-bottom: 3px solid var(--brand-orange, #FF7A59);
+}
+
+/* Cuerpo de la tarjeta */
+.auth-body {
+ padding: 30px 40px;
+ text-align: center;
+}
+
+.auth-body h2 {
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: var(--brand-navy, #1E3A5F); /* Azul Marino */
+ margin-bottom: 10px;
+}
+
+.auth-subtitle {
+ color: #6c757d;
+ font-size: 0.9rem;
+ margin-bottom: 30px;
+}
+
+/* Formularios e Inputs */
+.auth-form {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.input-group {
+ margin-bottom: 20px;
+ width: 100%;
+ text-align: left;
+}
+
+.input-group label {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--brand-navy, #1E3A5F); /* Azul Marino */
+ margin-bottom: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.input-with-icon {
+ position: relative;
+ width: 100%;
+}
+
+.input-with-icon i {
+ position: absolute;
+ left: 15px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #adb5bd;
+ z-index: 10;
+ width: 25px; /* Un poco más de caja para el icono */
+ text-align: center;
+ font-size: 1.1rem;
+}
+
+.input-with-icon input {
+ width: 100%;
+ box-sizing: border-box;
+ /* 🚨 EL FIX: 55px con !important para aplastar cualquier otra regla */
+ padding: 12px 15px 12px 55px !important;
+ border: 1px solid #ced4da;
+ border-radius: 10px;
+ font-size: 0.95rem;
+ font-family: 'Poppins', sans-serif;
+ outline: none;
+ transition: all 0.3s;
+}
+
+.input-with-icon input:focus {
+ border-color: var(--brand-teal, #2EC4B6); /* Borde Turquesa al escribir */
+ box-shadow: 0 0 0 0.2rem rgba(46, 196, 182, 0.15); /* Sombra Turquesa suave */
+}
+
+/* Enlace de contraseña y botón principal */
+.forgot-password {
+ text-align: right;
+ margin-top: -10px;
+ margin-bottom: 20px;
+}
+
+.forgot-password a {
+ font-size: 0.85rem;
+ color: var(--brand-teal, #2EC4B6); /* Turquesa */
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.btn-submit {
+ width: 100%;
+ background-color: var(--brand-orange, #FF7A59); /* Naranja */
+ color: white;
+ padding: 14px;
+ border: none;
+ border-radius: 10px;
+ font-weight: 700;
+ font-size: 1.05rem;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.btn-submit:hover {
+ background-color: #e56a4d;
+}
+
+/* Separador "O continúa con" */
+.auth-divider {
+ margin: 30px 0;
+ position: relative;
+ text-align: center;
+}
+
+.auth-divider::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 50%;
+ width: 100%;
+ height: 1px;
+ background-color: #eaeaea;
+ z-index: 1;
+}
+
+.auth-divider span {
+ background: rgba(255, 255, 255, 0.98);
+ padding: 0 15px;
+ color: #adb5bd;
+ font-size: 0.8rem;
+ font-weight: 500;
+ position: relative;
+ z-index: 2;
+}
+
+/* Botones sociales */
+.social-login {
+ display: flex;
+ gap: 15px;
+}
+
+.btn-social {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 12px;
+ background: white;
+ border: 1px solid #ced4da;
+ border-radius: 10px;
+ font-weight: 600;
+ color: var(--brand-navy, #1E3A5F);
+ cursor: pointer;
+ font-family: 'Poppins', sans-serif;
+ transition: background-color 0.3s;
+}
+
+.btn-social:hover {
+ background-color: #f8fafc;
+}
+
+/* Texto de abajo (Fuera de la tarjeta) */
+.auth-footer-text {
+ margin-top: 25px;
+ font-size: 0.95rem;
+ color: var(--brand-navy, #1E3A5F); /* Azul Marino */
+ font-weight: 500;
+}
+
+.toggle-link {
+ color: var(--brand-orange, #FF7A59); /* Naranja para resaltar la acción de cambiar */
+ font-weight: 700;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+/* =========================================
+ ADAPTACIÓN PARA MÓVILES (Responsive)
+ ========================================= */
+@media (max-width: 500px) {
+ /* Reducimos el relleno interno de la tarjeta para que respire */
+ .auth-body {
+ padding: 30px 20px;
+ }
+
+ /* Hacemos las pestañas un pelín más pequeñas */
+ .tab-btn {
+ padding: 15px 10px;
+ font-size: 0.95rem;
+ }
+
+ /* 🛠️ FIX: Aseguramos que en móvil el padding también sea 55px */
+ .input-with-icon input {
+ padding-left: 55px !important;
+ }
+
+ /* El botón y los textos sociales un poco más compactos */
+ .btn-submit {
+ padding: 12px;
+ font-size: 1rem;
+ }
+
+ .btn-social {
+ font-size: 0.9rem;
+ }
+}
\ No newline at end of file
diff --git a/src/front/styles/ChatTab.css b/src/front/styles/ChatTab.css
new file mode 100644
index 0000000000..85919a1fc4
--- /dev/null
+++ b/src/front/styles/ChatTab.css
@@ -0,0 +1,129 @@
+.chat-section {
+ display: flex;
+ flex-direction: column;
+ height: 400px;
+ background: #fdfdfd;
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid #e2e8f0;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+@media (min-width: 901px) {
+ .chat-section {
+ height: 600px;
+ box-shadow: 0 4px 15px rgba(0,0,0,0.05);
+ }
+}
+
+.chat-messages-container {
+ flex: 1;
+ padding: 20px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.message-wrapper {
+ display: flex;
+ flex-direction: column;
+ max-width: 85%; /* Ampliado un poco para móviles muy pequeños */
+}
+
+.message-wrapper.me {
+ align-self: flex-end;
+ align-items: flex-end;
+}
+
+.message-wrapper.others {
+ align-self: flex-start;
+ align-items: flex-start;
+}
+
+.message-user {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--brand-navy);
+ margin-bottom: 4px;
+ margin-left: 5px;
+}
+
+.message-bubble {
+ padding: 10px 15px;
+ border-radius: 18px;
+ position: relative;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
+ /* FIX ANCHO MÓVIL: Fuerza a que textos largos (como URLs) salten de línea */
+ word-wrap: break-word;
+ word-break: break-word;
+}
+
+.me .message-bubble {
+ background: var(--brand-teal);
+ color: white;
+ border-bottom-right-radius: 4px;
+}
+
+.others .message-bubble {
+ background: #e2e8f0;
+ color: var(--brand-navy);
+ border-bottom-left-radius: 4px;
+}
+
+.message-bubble p {
+ margin: 0;
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+
+.message-time {
+ font-size: 0.65rem;
+ display: block;
+ margin-top: 4px;
+ opacity: 0.7;
+ text-align: right;
+}
+
+.chat-input-area {
+ padding: 15px;
+ background: white;
+ border-top: 1px solid #e2e8f0;
+ display: flex;
+ gap: 10px;
+ align-items: center; /* Alinea botón e input perfectamente */
+}
+
+.chat-input-area input {
+ flex: 1;
+ padding: 12px 15px;
+ border: 1px solid #ced4da;
+ border-radius: 25px;
+ outline: none;
+ font-family: 'Poppins', sans-serif;
+ background: #f8fafc;
+ min-width: 0;
+ box-sizing: border-box;
+}
+
+.btn-send-chat {
+ width: 45px;
+ height: 45px;
+ background: var(--brand-navy);
+ color: white;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: transform 0.2s;
+ /* FIX ANCHO MÓVIL: Prohíbe terminantemente que el botón se aplaste en pantallas de 320px */
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.btn-send-chat:hover {
+ transform: scale(1.05);
+ background: var(--brand-teal);
+}
\ No newline at end of file
diff --git a/src/front/styles/ExpensesTab.css b/src/front/styles/ExpensesTab.css
new file mode 100644
index 0000000000..e7a1ec70ff
--- /dev/null
+++ b/src/front/styles/ExpensesTab.css
@@ -0,0 +1,298 @@
+/* =========================================
+ GASTOS: TARJETAS
+ ========================================= */
+.expenses-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.expenses-header h2 {
+ margin: 0;
+ color: var(--brand-navy);
+}
+
+.btn-add-expense-small {
+ background: #f1f5f9;
+ color: var(--brand-navy);
+ border: none;
+ padding: 8px 15px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-add-expense-small:hover {
+ background: #e2e8f0;
+}
+
+.expenses-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.expense-card {
+ display: flex;
+ align-items: center;
+ background: white;
+ padding: 15px 20px;
+ border-radius: 12px;
+ border: 1px solid #e2e8f0;
+ transition: all 0.2s ease;
+}
+
+.expense-card.clickable:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.05);
+ border-color: var(--brand-teal);
+ cursor: pointer;
+}
+
+.expense-card-icon {
+ width: 45px;
+ height: 45px;
+ background: #f0fdfa;
+ color: var(--brand-teal);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.2rem;
+ margin-right: 15px;
+}
+
+.expense-card-info {
+ flex: 1;
+}
+
+.expense-card-info h4 {
+ margin: 0 0 5px 0;
+ color: var(--brand-navy);
+ font-size: 1rem;
+}
+
+.expense-payer {
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+.expense-card-amount {
+ text-align: right;
+}
+
+.expense-card-amount h4 {
+ margin: 0 0 5px 0;
+ color: var(--brand-navy);
+ font-size: 1.1rem;
+}
+
+.expense-card-amount span {
+ font-size: 0.8rem;
+ color: #94a3b8;
+}
+
+/* =========================================
+ GASTOS: CHECKBOXES PERSONALIZADOS
+ ========================================= */
+.custom-split-container {
+ background: #f1f5f9;
+ padding: 15px;
+ border-radius: 10px;
+ border: 1px solid #e2e8f0;
+}
+
+.checkbox-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ font-size: 0.95rem;
+ color: #475569;
+ gap: 10px;
+}
+
+.checkbox-label input {
+ display: none;
+}
+
+.custom-checkbox {
+ height: 20px;
+ width: 20px;
+ background-color: white;
+ border: 2px solid #cbd5e1;
+ border-radius: 6px;
+ position: relative;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.checkbox-label input:checked ~ .custom-checkbox {
+ background-color: var(--brand-teal);
+ border-color: var(--brand-teal);
+}
+
+.custom-checkbox::after {
+ content: "\f00c";
+ font-family: "Font Awesome 6 Free";
+ font-weight: 900;
+ color: white;
+ font-size: 11px;
+ display: none;
+}
+
+.checkbox-label input:checked ~ .custom-checkbox::after {
+ display: block;
+}
+
+/* =========================================
+ GASTOS: MODAL DE DESGLOSE
+ ========================================= */
+.expense-breakdown-modal {
+ max-width: 400px;
+}
+
+.breakdown-header {
+ text-align: center;
+ margin: 20px 0;
+}
+
+.breakdown-header h2 {
+ margin: 0 0 10px 0;
+ color: #64748b;
+ font-size: 1.1rem;
+ font-weight: 500;
+}
+
+.breakdown-total {
+ font-size: 3rem;
+ margin: 0 0 10px 0;
+ color: var(--brand-navy);
+}
+
+.breakdown-subtitle {
+ font-size: 0.9rem;
+ color: #64748b;
+}
+
+.breakdown-list h4 {
+ color: var(--brand-navy);
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ margin-bottom: 15px;
+}
+
+.breakdown-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.breakdown-row:last-child {
+ border-bottom: none;
+}
+
+.breakdown-person {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-weight: 600;
+ color: var(--brand-navy);
+}
+
+.small-avatar {
+ width: 30px;
+ height: 30px;
+ font-size: 0.75rem;
+}
+
+.text-debit {
+ color: var(--brand-orange);
+ font-weight: 600;
+}
+
+.text-credit {
+ color: #94a3b8;
+ font-style: italic;
+}
+
+/* =========================================
+ GASTOS: BOTONES INTERACTIVOS (SALDAR/BORRAR)
+ ========================================= */
+.breakdown-amount-interactive {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.text-neutral {
+ color: #94a3b8;
+ font-size: 0.9rem;
+ font-weight: 500;
+ text-decoration: line-through;
+}
+
+.btn-settle {
+ background: #f1f5f9;
+ color: var(--brand-navy);
+ border: none;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.btn-settle:hover {
+ background: #e2e8f0;
+}
+
+.btn-settle.settled {
+ background: #dcfce7;
+ color: #166534;
+ padding: 6px 10px;
+}
+
+.btn-settle.settled:hover {
+ background: #f87171;
+ color: white;
+}
+
+.btn-settle.settled:hover::after {
+ content: " Deshacer";
+}
+.btn-settle.settled:hover i {
+ display: none;
+}
+
+.btn-delete-expense {
+ flex: 1;
+ background: transparent;
+ border: 1px solid #ef4444;
+ color: #ef4444;
+ padding: 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.btn-delete-expense:hover {
+ background: #fef2f2;
+}
\ No newline at end of file
diff --git a/src/front/styles/ItineraryTab.css b/src/front/styles/ItineraryTab.css
new file mode 100644
index 0000000000..9bbd3628aa
--- /dev/null
+++ b/src/front/styles/ItineraryTab.css
@@ -0,0 +1,135 @@
+/* =========================================
+ ITINERARIO: LÍNEA DE TIEMPO
+ ========================================= */
+.timeline {
+ border-left: 2px solid #e2e8f0;
+ margin-left: 10px;
+ padding-left: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 30px;
+}
+
+.timeline-item {
+ position: relative;
+}
+
+.timeline-item.clickable {
+ cursor: pointer;
+ transition: transform 0.2s ease, background 0.2s ease;
+ padding: 15px;
+ border-radius: 12px;
+}
+
+.timeline-item.clickable:hover {
+ background: #f8fafc;
+ transform: translateX(5px);
+}
+
+.timeline-dot {
+ width: 14px;
+ height: 14px;
+ background: var(--brand-teal);
+ border-radius: 50%;
+ position: absolute;
+ left: -28px;
+ top: 20px;
+ box-shadow: 0 0 0 4px white;
+}
+
+.day-badge {
+ font-size: 0.8rem;
+ font-weight: 700;
+ color: var(--brand-orange);
+ text-transform: uppercase;
+}
+
+.time-tag {
+ display: inline-block;
+ background: #e2e8f0;
+ color: #475569;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 700;
+ margin-left: 10px;
+}
+
+.timeline-content h3 {
+ color: var(--brand-navy);
+ margin: 5px 0;
+ font-size: 1.2rem;
+}
+
+.timeline-content p {
+ color: #64748b;
+ font-size: 0.95rem;
+ margin: 0;
+}
+
+/* =========================================
+ ITINERARIO: MODALES
+ ========================================= */
+.activity-detail-modal {
+ max-width: 480px;
+}
+
+.activity-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.btn-close-modal {
+ background: none; border: none; font-size: 1.8rem; color: #94a3b8; cursor: pointer;
+}
+
+.activity-main-info h2 {
+ color: var(--brand-navy);
+ font-size: 1.8rem;
+ margin-bottom: 15px;
+}
+
+.info-row {
+ display: flex; align-items: center; gap: 10px; color: #64748b; margin-bottom: 8px;
+}
+
+.info-row i { color: var(--brand-teal); width: 20px; text-align: center; }
+
+.modal-divider { border: 0; border-top: 1px solid #f1f5f9; margin: 20px 0; }
+
+.notes-box {
+ background: #fffbeb; border-left: 4px solid #fcd34d; padding: 15px;
+ color: #92400e; font-style: italic; border-radius: 4px;
+}
+
+.modal-actions-itinerary {
+ display: flex;
+ gap: 10px;
+ margin-top: 25px;
+}
+
+.btn-modal-cancel {
+ flex: 1; background: transparent; border: 1px solid #cbd5e1; color: #64748b;
+ padding: 12px; border-radius: 8px; cursor: pointer; font-weight: 600;
+}
+
+.btn-modal-confirm {
+ flex: 1; background: var(--brand-teal); color: white; border: none;
+ padding: 12px; border-radius: 8px; cursor: pointer; font-weight: 600;
+}
+
+.btn-edit-activity {
+ flex: 1; background: #f1f5f9; color: #475569; border: none;
+ padding: 12px; border-radius: 8px; cursor: pointer; font-weight: 600;
+}
+
+.activity-edit-form textarea {
+ resize: none;
+}
+
+.expense-row {
+ display: flex;
+ gap: 15px;
+}
\ No newline at end of file
diff --git a/src/front/styles/LandingPage.css b/src/front/styles/LandingPage.css
new file mode 100644
index 0000000000..82273d6580
--- /dev/null
+++ b/src/front/styles/LandingPage.css
@@ -0,0 +1,310 @@
+/* Contenedor principal */
+.landing-container {
+ width: 100%;
+ overflow-x: hidden;
+ background-color: var(--bg-light, #F8FAFC);
+ font-family: 'Poppins', sans-serif;
+}
+
+/* =========================================
+ 1. HERO SECTION (Mobile First)
+ ========================================= */
+.hero-section {
+ background: linear-gradient(rgba(46, 196, 182, 0.8), rgba(46, 196, 182, 0.6)),
+ url('../assets/img/fondo-landing.jpg');
+ background-size: cover;
+ background-position: center;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 100px 20px 40px 20px;
+ text-align: left;
+ box-sizing: border-box;
+}
+
+.hero-content {
+ width: 100%;
+}
+
+.hero-section h1 {
+ font-size: 2.3rem;
+ font-weight: 800;
+ line-height: 1.1;
+ margin-bottom: 16px;
+ color: white;
+ word-wrap: break-word;
+}
+
+.text-highlight {
+ color: var(--brand-orange, #FF7A59);
+}
+
+.hero-section p {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--brand-navy, #1E3A5F);
+ margin-bottom: 30px;
+ max-width: 100%;
+ padding: 0;
+}
+
+.hero-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: 100%;
+}
+
+.btn-start, .btn-demo {
+ width: 100%;
+ padding: 14px 20px;
+ border-radius: 30px;
+ font-weight: 700;
+ font-size: 1.05rem;
+ text-align: center;
+ border: none;
+ transition: all 0.3s ease;
+}
+
+.btn-start {
+ background-color: var(--brand-navy, #1E3A5F);
+ color: white;
+}
+
+.btn-start:hover {
+ background-color: #132742;
+}
+
+.btn-demo {
+ background-color: white;
+ color: var(--brand-navy, #1E3A5F);
+}
+
+.btn-demo:hover {
+ background-color: #f1f5f9;
+}
+
+/* =========================================
+ 2. STATS SECTION
+ ========================================= */
+.stats-wrapper {
+ margin-top: 40px;
+ width: 100%;
+ position: relative;
+ z-index: 10;
+}
+
+.stats-container {
+ display: flex;
+ justify-content: flex-start;
+ gap: 30px;
+ margin-bottom: 15px;
+}
+
+.stat-item {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+}
+
+.stat-number {
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: white;
+}
+
+.stat-label {
+ font-size: 0.9rem;
+ color: var(--brand-navy, #1E3A5F);
+ font-weight: 600;
+}
+
+.hero-bottom-text {
+ color: white;
+ font-size: 1rem;
+ font-weight: 600;
+ font-style: italic;
+}
+
+/* =========================================
+ 3. FEATURES (Tarjetas)
+ ========================================= */
+.features-section {
+ padding: 60px 20px;
+ /* Limitamos el ancho y centramos toda la sección */
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.features-section h2 {
+ text-align: center;
+ font-size: 1.8rem;
+ font-weight: 800;
+ margin-bottom: 40px;
+ color: var(--brand-navy, #1E3A5F);
+}
+
+/* NUEVO: Contenedor para alinear las tarjetas */
+.features-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.feature-card {
+ background: white;
+ padding: 30px;
+ border-radius: 16px;
+ border: 1px solid #e2e8f0;
+ box-shadow: 0 10px 30px rgba(0,0,0,0.05); /* Sombra más moderna */
+ transition: transform 0.3s ease;
+ /* Centramos el texto de las tarjetas para que se vea más limpio */
+ text-align: center;
+}
+
+.feature-card:hover {
+ transform: translateY(-5px); /* Efecto hover elegante */
+}
+
+.feature-card .icon {
+ font-size: 2.5rem;
+ margin-bottom: 15px;
+ display: block; /* Asegura el centrado */
+}
+
+.feature-card h3 {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin-bottom: 10px;
+ color: var(--brand-navy, #1E3A5F);
+}
+
+.feature-card p {
+ color: #475569;
+ font-size: 1rem;
+ line-height: 1.6;
+}
+
+/* =========================================
+ 4. FOOTER / CALL TO ACTION
+ ========================================= */
+.cta-footer {
+ background-color: var(--brand-navy, #1E3A5F);
+ color: white;
+ padding: 60px 20px;
+ text-align: center;
+}
+
+.cta-footer h3 {
+ font-size: 1.8rem;
+ font-weight: 800;
+ margin-bottom: 24px;
+}
+
+.btn-final {
+ background-color: var(--brand-orange, #FF7A59);
+ color: white;
+ width: 100%;
+ max-width: 300px; /* Evita que el botón sea exageradamente ancho en PC */
+ margin: 0 auto;
+ display: block;
+ padding: 16px;
+ border-radius: 14px;
+ font-weight: 700;
+ border: none;
+ font-size: 1.1rem;
+ transition: background-color 0.3s ease;
+ cursor: pointer;
+}
+
+.btn-final:hover {
+ background-color: #e56a4d;
+}
+
+/* =========================================
+ ADAPTACIÓN PARA ESCRITORIO (PC/Tablets)
+ ========================================= */
+@media (min-width: 768px) {
+
+ .hero-section {
+ background: linear-gradient(to right, rgba(46, 196, 182, 0.95) 0%, rgba(46, 196, 182, 0.6) 50%, rgba(46, 196, 182, 0.1) 100%),
+ url('../assets/img/fondo-landing.jpg');
+ background-size: cover;
+ background-position: center;
+ min-height: 100vh;
+ padding: 160px 0 60px 0;
+ align-items: center;
+ }
+
+ .hero-content, .stats-wrapper {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 20px;
+ }
+
+ .stats-wrapper {
+ margin-top: 50px;
+ }
+
+ .hero-section h1 {
+ font-size: 5rem;
+ font-weight: 800;
+ letter-spacing: -2px;
+ line-height: 1.05;
+ max-width: 850px;
+ }
+
+ .hero-section p {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-top: 20px;
+ margin-bottom: 40px;
+ max-width: 650px;
+ }
+
+ .hero-buttons {
+ flex-direction: row;
+ max-width: 600px;
+ gap: 20px;
+ }
+
+ .btn-start, .btn-demo {
+ width: auto;
+ padding: 14px 35px;
+ font-size: 1.15rem;
+ }
+
+ .stat-number {
+ font-size: 2.5rem;
+ }
+
+ .stat-label {
+ font-size: 1.3rem;
+ }
+
+ .hero-bottom-text {
+ font-size: 1.2rem;
+ max-width: 500px;
+ margin-top: 15px;
+ }
+
+ /* NUEVO: Las Features se convierten en cuadrícula en PC */
+ .features-section h2 {
+ font-size: 2.5rem;
+ margin-bottom: 50px;
+ }
+
+ .features-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr); /* Crea 3 columnas iguales */
+ gap: 30px; /* Separación entre las tarjetas */
+ }
+
+ .cta-footer h3 {
+ font-size: 2.5rem;
+ margin-bottom: 30px;
+ }
+}
\ No newline at end of file
diff --git a/src/front/styles/MyTrips.css b/src/front/styles/MyTrips.css
new file mode 100644
index 0000000000..44944766dc
--- /dev/null
+++ b/src/front/styles/MyTrips.css
@@ -0,0 +1,353 @@
+/* =========================================
+ MAIN CONTAINER Y FONDO TURQUESA
+ ========================================= */
+.dashboard-wrapper {
+ min-height: 100vh;
+ font-family: 'Poppins', sans-serif;
+
+ /* ¡AQUÍ ESTÁ TU FONDO EXACTO DEL LOGIN Y LANDING! */
+ background: linear-gradient(rgba(46, 196, 182, 0.8), rgba(46, 196, 182, 0.6)),
+ url('../assets/img/fondo-mytrips.jpg');
+ background-size: cover;
+ background-position: center;
+ background-attachment: fixed;
+
+ /* Texto general en blanco para contrastar con el turquesa */
+ color: #ffffff;
+}
+
+.dashboard-container {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 140px 20px 60px 20px;
+}
+
+/* =========================================
+ HEADER
+ ========================================= */
+.dashboard-header {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.dashboard-header h1 {
+ font-size: 2.2rem;
+ font-weight: 800;
+ margin-bottom: 5px;
+ letter-spacing: -1px;
+ color: #ffffff; /* Título blanco */
+}
+
+.dashboard-header p {
+ color: rgba(255, 255, 255, 0.9); /* Subtítulo blanco suave */
+ font-size: 0.95rem;
+ font-weight: 500;
+}
+
+/* Botón Blanco para que destaque */
+.btn-new-trip {
+ background-color: #ffffff;
+ color: var(--brand-teal, #2EC4B6);
+ padding: 14px 24px;
+ border-radius: 12px;
+ font-weight: 700;
+ font-size: 1rem;
+ border: none;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ box-shadow: 0 4px 10px rgba(0,0,0,0.1);
+}
+
+.btn-new-trip:hover {
+ background-color: var(--brand-navy, #1E3A5F);
+ color: white;
+}
+
+/* =========================================
+ FILTERS (TABS)
+ ========================================= */
+.trip-filters {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 30px;
+ overflow-x: auto;
+ padding-bottom: 5px;
+}
+
+/* Filtros transparentes con borde blanco */
+.btn-filter {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.5);
+ padding: 10px 20px;
+ border-radius: 30px;
+ color: #ffffff;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ white-space: nowrap;
+}
+
+/* Filtro activo se rellena de blanco */
+.btn-filter.active {
+ background: #ffffff;
+ color: var(--brand-teal, #2EC4B6);
+ border-color: #ffffff;
+}
+
+/* =========================================
+ TRIPS GRID (Las tarjetas de viaje)
+ ========================================= */
+.trips-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 24px;
+ margin-bottom: 60px;
+}
+
+.trip-card {
+ background: white; /* La tarjeta se queda blanca */
+ color: var(--brand-navy, #1E3A5F); /* Texto oscuro por dentro */
+ border-radius: 16px;
+ overflow: hidden;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+ border: none;
+ display: flex;
+ flex-direction: column;
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+}
+
+.trip-card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 15px 30px rgba(0, 0, 0, 0.2);
+}
+
+.trip-img-container {
+ position: relative;
+ height: 200px;
+}
+
+.trip-img-container img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.status-badge {
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: white;
+ letter-spacing: 0.5px;
+}
+
+/* Colores condicionales */
+.status-badge.en-curso { background-color: var(--brand-teal, #2EC4B6); }
+.status-badge.planificando { background-color: var(--brand-orange, #FF7A59); }
+.status-badge.pasados { background-color: #94a3b8; }
+
+.trip-info {
+ padding: 24px;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.trip-info h3 {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin-bottom: 10px;
+}
+
+.trip-info p {
+ color: #64748b;
+ font-size: 0.95rem;
+ font-weight: 500;
+}
+
+.trip-progress {
+ margin-top: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 20px;
+ border-top: 1px solid #f1f5f9;
+}
+
+.progress-bar {
+ flex-grow: 1;
+ height: 8px;
+ background: #f1f5f9;
+ border-radius: 4px;
+ margin-right: 15px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ width: 60%;
+ height: 100%;
+ background: var(--brand-teal, #2EC4B6);
+ border-radius: 4px;
+}
+
+.link-details {
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: var(--brand-teal, #2EC4B6);
+ cursor: pointer;
+ transition: color 0.3s;
+}
+
+.link-details:hover {
+ color: var(--brand-orange, #FF7A59);
+}
+
+/* Explore Card adaptada al fondo turquesa */
+.explore-card {
+ border: 2px dashed rgba(255, 255, 255, 0.5);
+ background: transparent;
+ color: white;
+ box-shadow: none;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ padding: 40px 20px;
+}
+
+.explore-card:hover {
+ border-color: white;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.compass-icon {
+ width: 65px;
+ height: 65px;
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.8rem;
+ margin: 0 auto 15px auto;
+}
+
+.btn-explore-link {
+ background: none;
+ border: none;
+ color: white;
+ font-weight: 700;
+ margin-top: 15px;
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+/* =========================================
+ SUGGESTIONS SECTION
+ ========================================= */
+.suggestions-section {
+ margin-top: 40px;
+}
+
+.suggestions-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.suggestions-header h2 {
+ font-size: 1.5rem;
+ font-weight: 800;
+ color: white;
+}
+
+.link-view-all {
+ color: white;
+ font-weight: 700;
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+.suggestions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 20px;
+}
+
+.suggestion-card {
+ position: relative;
+ border-radius: 12px;
+ overflow: hidden;
+ height: 300px;
+ cursor: pointer;
+ box-shadow: 0 10px 20px rgba(0,0,0,0.15);
+}
+
+.suggestion-card img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.5s;
+}
+
+.suggestion-card:hover img {
+ transform: scale(1.05);
+}
+
+.suggestion-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 30px 20px 20px 20px;
+ background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
+ color: white;
+}
+
+.suggestion-overlay h4 {
+ font-size: 1.1rem;
+ font-weight: 700;
+}
+
+/* =========================================
+ DESKTOP ADAPTATION
+ ========================================= */
+@media (min-width: 768px) {
+ .dashboard-header {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: flex-end;
+ }
+
+ .dashboard-header h1 {
+ font-size: 2.8rem;
+ }
+
+ .btn-new-trip {
+ width: auto;
+ }
+
+ .trips-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .featured-card {
+ grid-column: span 2;
+ }
+
+ .featured-card .trip-img-container {
+ height: 280px;
+ }
+}
\ No newline at end of file
diff --git a/src/front/styles/Navbar.css b/src/front/styles/Navbar.css
new file mode 100644
index 0000000000..5feb436ce3
--- /dev/null
+++ b/src/front/styles/Navbar.css
@@ -0,0 +1,203 @@
+.navbar-expedition {
+ width: 100%;
+ height: 90px;
+ background-color: transparent;
+ position: absolute;
+ top: 0;
+ /* ¡NUEVO! Le damos el número más alto posible para que gane a la tarjeta de login */
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ font-family: 'Poppins', sans-serif;
+}
+
+.nav-container {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: relative; /* Importante para el centrado */
+}
+
+/* =========================================
+ IZQUIERDA Y LOGO RESTAURADO A TU MEDIDA
+ ========================================= */
+.nav-left {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+
+.logo-img {
+ height: 200px; /* <--- TU MEDIDA ORIGINAL RESTAURADA */
+ width: auto;
+ max-width: 250px;
+ display: block;
+ object-fit: contain;
+}
+
+.nav-logo {
+ text-decoration: none;
+}
+
+.menu-btn {
+ background: none;
+ border: none;
+ font-size: 1.3rem;
+ color: var(--brand-navy, #1E3A5F);
+ cursor: pointer;
+}
+
+/* =========================================
+ CENTRO (Enlaces)
+ ========================================= */
+.nav-center {
+ display: flex;
+ gap: 30px;
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%); /* Centrado matemático perfecto */
+}
+
+.nav-link {
+ text-decoration: none;
+ /* APLICAMOS EL AZUL DEL LOGO Y NEGRITA */
+ color: var(--brand-navy, #1E3A5F);
+ font-size: 1rem;
+ font-weight: 700; /* NEGRITA */
+ padding-bottom: 5px;
+ border-bottom: 2px solid transparent;
+ transition: all 0.3s ease;
+}
+
+.nav-link:hover {
+ color: var(--brand-orange, #FF7A59); /* Cambia a naranja al pasar el mouse */
+}
+
+.nav-link.active {
+ color: var(--brand-navy, #1E3A5F);
+ font-weight: 800; /* Extra negrita si estás en esa página */
+ border-bottom: 2px solid var(--brand-navy, #1E3A5F);
+}
+
+/* =========================================
+ DERECHA (Logout y Perfil)
+ ========================================= */
+.nav-right {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+.logout-text {
+ font-size: 0.95rem;
+ /* APLICAMOS EL AZUL DEL LOGO Y NEGRITA */
+ color: var(--brand-navy, #1E3A5F);
+ font-weight: 700; /* NEGRITA */
+ cursor: pointer;
+ transition: color 0.3s;
+}
+
+.logout-text:hover {
+ color: var(--brand-orange, #FF7A59); /* Cambia a naranja al pasar el mouse */
+}
+
+.profile-icon {
+ width: 42px;
+ height: 42px;
+ background-color: white;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--brand-navy, #1E3A5F);
+ border: 2px solid var(--brand-teal, #2EC4B6);
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
+ transition: all 0.3s;
+}
+
+.profile-icon:hover {
+ background-color: var(--brand-teal, #2EC4B6);
+ color: white;
+}
+
+/* =========================================
+ MENÚ DESPLEGABLE MÓVIL
+ ========================================= */
+.dropdown-menu-mobile {
+ position: absolute;
+ top: 75px;
+ left: 20px;
+ right: 20px;
+ background: white;
+ border-radius: 12px;
+ padding: 15px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ box-shadow: 0 10px 15px rgba(0,0,0,0.1);
+ z-index: 9999;
+}
+
+.dropdown-menu-mobile a {
+ text-decoration: none;
+ color: var(--brand-navy, #1E3A5F);
+ font-weight: 600;
+ padding: 10px;
+ border-radius: 8px;
+ transition: background-color 0.3s;
+}
+
+.dropdown-menu-mobile a:hover {
+ background-color: #f1f5f9;
+ color: var(--brand-orange, #FF7A59);
+}
+
+/* =========================================
+ ADAPTACIÓN RESPONSIVA
+ ========================================= */
+.desktop-only {
+ display: none;
+}
+
+@media (min-width: 900px) {
+ .desktop-only {
+ display: flex;
+ }
+
+ .menu-btn {
+ display: none;
+ }
+}
+
+/* =========================================
+ MODO LOGIN (Aseguramos Azul y Negrita también aquí)
+ ========================================= */
+.navbar-login-mode .nav-link,
+.navbar-login-mode .logout-text {
+ /* APLICAMOS EL AZUL DEL LOGO Y NEGRITA PARA EL LOGIN */
+ color: var(--brand-navy, #1E3A5F);
+ font-weight: 700; /* NEGRITA */
+ text-shadow: none; /* Quitamos la sombra para que el azul se vea súper nítido */
+ opacity: 1;
+}
+
+.navbar-login-mode .nav-link:hover,
+.navbar-login-mode .logout-text:hover {
+ color: var(--brand-orange, #FF7A59); /* Naranja al pasar el ratón */
+}
+
+.navbar-login-mode .nav-link.active {
+ color: var(--brand-navy, #1E3A5F);
+ font-weight: 800; /* Extra negrita */
+ border-bottom: 2px solid var(--brand-navy, #1E3A5F); /* Línea azul debajo */
+}
+
+/* El botón de menú móvil lo mantenemos blanco en el login para que resalte sobre el fondo */
+.navbar-login-mode .menu-btn {
+ color: #ffffff;
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
+}
\ No newline at end of file
diff --git a/src/front/styles/NewTrip.css b/src/front/styles/NewTrip.css
new file mode 100644
index 0000000000..4d3645c8b9
--- /dev/null
+++ b/src/front/styles/NewTrip.css
@@ -0,0 +1,139 @@
+/* Heredamos el fondo turquesa que ya usamos en MyTrips y Login */
+.new-trip-wrapper {
+ min-height: 100vh;
+ font-family: 'Poppins', sans-serif;
+ background: linear-gradient(rgba(46, 196, 182, 0.8), rgba(46, 196, 182, 0.6)),
+ url('../assets/img/fondo-login.jpg');
+ background-size: cover;
+ background-position: center;
+ background-attachment: fixed;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 120px 20px 40px 20px; /* Espacio superior para el Navbar */
+}
+
+.new-trip-container {
+ width: 100%;
+ max-width: 600px; /* Más estrecho que MyTrips porque es un formulario */
+}
+
+.btn-back {
+ background: transparent;
+ border: none;
+ color: white;
+ font-weight: 600;
+ font-size: 1rem;
+ cursor: pointer;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transition: opacity 0.3s;
+}
+
+.btn-back:hover {
+ opacity: 0.8;
+}
+
+/* Tarjeta Blanca (Estilo Glassmorphism como el login) */
+.new-trip-card {
+ background: rgba(255, 255, 255, 0.98);
+ backdrop-filter: blur(5px);
+ border-radius: 16px;
+ box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
+ padding: 40px;
+}
+
+.card-header h2 {
+ color: var(--brand-navy, #1E3A5F);
+ font-weight: 800;
+ margin-bottom: 5px;
+ font-size: 1.8rem;
+}
+
+.card-header p {
+ color: #64748b;
+ font-size: 0.95rem;
+ margin-bottom: 30px;
+}
+
+/* Formularios */
+.new-trip-form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.dates-row {
+ display: flex;
+ gap: 20px;
+}
+
+.dates-row .input-group {
+ flex: 1;
+}
+
+.input-group label {
+ display: block;
+ font-size: 0.8rem;
+ font-weight: 700;
+ color: var(--brand-navy, #1E3A5F);
+ margin-bottom: 8px;
+ text-transform: uppercase;
+}
+
+.input-group input,
+.input-group select {
+ width: 100%;
+ padding: 12px 15px;
+ border: 1px solid #ced4da;
+ border-radius: 10px;
+ font-family: 'Poppins', sans-serif;
+ font-size: 0.95rem;
+ box-sizing: border-box;
+ transition: all 0.3s;
+}
+
+.input-group input:focus,
+.input-group select:focus {
+ outline: none;
+ border-color: var(--brand-teal, #2EC4B6);
+ box-shadow: 0 0 0 0.2rem rgba(46, 196, 182, 0.15);
+}
+
+.input-group small {
+ display: block;
+ color: #94a3b8;
+ margin-top: 5px;
+ font-size: 0.8rem;
+}
+
+.btn-create-trip {
+ background-color: var(--brand-orange, #FF7A59);
+ color: white;
+ padding: 16px;
+ border: none;
+ border-radius: 10px;
+ font-weight: 700;
+ font-size: 1.1rem;
+ cursor: pointer;
+ margin-top: 10px;
+ transition: background-color 0.3s;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+}
+
+.btn-create-trip:hover {
+ background-color: #e56a4d;
+}
+
+/* Responsive */
+@media (max-width: 600px) {
+ .dates-row {
+ flex-direction: column;
+ gap: 20px;
+ }
+}
\ No newline at end of file
diff --git a/src/front/styles/Profile.css b/src/front/styles/Profile.css
new file mode 100644
index 0000000000..964ef5fc0d
--- /dev/null
+++ b/src/front/styles/Profile.css
@@ -0,0 +1,518 @@
+/* =========================================
+ 1. CONTENEDOR PRINCIPAL Y FONDO
+ ========================================= */
+.profile-wrapper {
+ min-height: 100vh;
+ font-family: 'Poppins', sans-serif;
+ /* Fondo inmersivo heredado del login/landing */
+ background: linear-gradient(rgba(46, 196, 182, 0.8), rgba(46, 196, 182, 0.6)),
+ url('../assets/img/fondo-login.jpg');
+ background-size: cover;
+ background-position: center;
+ background-attachment: fixed;
+ display: flex;
+ justify-content: center;
+ padding: 120px 20px 60px 20px; /* Margen superior para no chocar con el Navbar */
+}
+
+.profile-container {
+ width: 100%;
+ max-width: 800px; /* Tamaño ideal para un formulario de perfil */
+}
+
+/* Botón de retroceso */
+.btn-back-profile {
+ background: transparent;
+ border: none;
+ color: white;
+ font-weight: 600;
+ font-size: 1rem;
+ cursor: pointer;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ transition: opacity 0.3s;
+}
+
+.btn-back-profile:hover {
+ opacity: 0.8;
+}
+
+/* =========================================
+ 2. LA TARJETA PRINCIPAL (Glassmorphism)
+ ========================================= */
+.profile-card {
+ background: rgba(255, 255, 255, 0.98);
+ backdrop-filter: blur(5px);
+ border-radius: 16px;
+ box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
+ padding: 40px;
+ color: var(--brand-navy, #1E3A5F);
+}
+
+/* =========================================
+ 3. CABECERA (Foto de perfil y Nivel)
+ ========================================= */
+.profile-header {
+ display: flex;
+ align-items: center;
+ gap: 25px;
+ margin-bottom: 40px;
+}
+
+.profile-avatar-container {
+ position: relative;
+}
+
+.profile-avatar {
+ width: 100px;
+ height: 100px;
+ background: var(--brand-teal, #2EC4B6);
+ color: white;
+ font-size: 2.5rem;
+ font-weight: 700;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 4px solid white;
+ box-shadow: 0 5px 15px rgba(0,0,0,0.1);
+ text-transform: uppercase;
+}
+
+/* El pequeño botón naranja con el lápiz para editar la foto */
+.btn-edit-avatar {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ background: var(--brand-orange, #FF7A59);
+ color: white;
+ border: none;
+ width: 35px;
+ height: 35px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ transition: transform 0.2s;
+}
+
+.btn-edit-avatar:hover {
+ transform: scale(1.1);
+}
+
+.profile-title h2 {
+ margin: 0 0 5px 0;
+ font-size: 1.8rem;
+ font-weight: 800;
+}
+
+.profile-title p {
+ margin: 0;
+ color: var(--brand-orange, #FF7A59);
+ font-weight: 600;
+ font-size: 0.95rem;
+}
+
+/* =========================================
+ 4. TÍTULOS DE SECCIÓN Y FORMULARIO
+ ========================================= */
+.form-section-title {
+ margin-bottom: 25px;
+}
+
+/* Para darle espacio a las nuevas secciones respecto a las anteriores */
+.section-margin-top {
+ margin-top: 40px;
+}
+
+.form-section-title h3 {
+ font-size: 1.2rem;
+ margin: 0 0 10px 0;
+ color: var(--brand-navy, #1E3A5F);
+ display: flex;
+ align-items: center;
+}
+
+.form-section-title h3 i {
+ color: var(--brand-teal, #2EC4B6);
+ margin-right: 8px;
+}
+
+.form-section-title hr {
+ border: 0;
+ height: 1px;
+ background: #e2e8f0;
+}
+
+/* El sistema de cuadrícula (Grid) para Nombre, Email, Teléfono... */
+.profile-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr; /* 2 columnas idénticas */
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.full-width {
+ grid-column: span 2; /* Hace que un input ocupe toda la fila */
+}
+
+.profile-grid .input-group label {
+ display: block;
+ font-size: 0.8rem;
+ font-weight: 700;
+ color: var(--brand-navy, #1E3A5F);
+ margin-bottom: 8px;
+ text-transform: uppercase;
+}
+
+.profile-grid .input-group input,
+.profile-grid .input-group textarea {
+ width: 100%;
+ padding: 12px 15px;
+ border: 1px solid #ced4da;
+ border-radius: 10px;
+ font-family: 'Poppins', sans-serif;
+ font-size: 0.95rem;
+ box-sizing: border-box;
+ transition: all 0.3s;
+ background: #f8fafc;
+}
+
+.profile-grid .input-group input:focus,
+.profile-grid .input-group textarea:focus {
+ outline: none;
+ border-color: var(--brand-teal, #2EC4B6);
+ background: white;
+ box-shadow: 0 0 0 0.2rem rgba(46, 196, 182, 0.15);
+}
+
+/* =========================================
+ 5. NOTIFICACIONES (Los Interruptores / Toggles)
+ ========================================= */
+.notification-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.notification-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ background: #f8fafc;
+ border-radius: 12px;
+ border: 1px solid #e2e8f0;
+ transition: background 0.3s;
+}
+
+.notification-item:hover {
+ background: #f1f5f9;
+}
+
+.noti-text h4 {
+ margin: 0 0 5px 0;
+ font-size: 1rem;
+ color: var(--brand-navy, #1E3A5F);
+}
+
+.noti-text p {
+ margin: 0;
+ font-size: 0.85rem;
+ color: #64748b;
+}
+
+/* El mecanismo visual del switch */
+.custom-switch {
+ position: relative;
+ display: inline-block;
+ width: 50px;
+ height: 26px;
+ flex-shrink: 0;
+}
+
+.custom-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.switch-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background-color: #cbd5e1;
+ transition: .4s;
+ border-radius: 34px;
+}
+
+.switch-slider:before {
+ position: absolute;
+ content: "";
+ height: 18px;
+ width: 18px;
+ left: 4px;
+ bottom: 4px;
+ background-color: white;
+ transition: .4s;
+ border-radius: 50%;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
+}
+
+input:checked + .switch-slider {
+ background-color: var(--brand-navy, #1E3A5F);
+}
+
+input:checked + .switch-slider:before {
+ transform: translateX(24px); /* Mueve la bolita blanca a la derecha */
+}
+
+/* =========================================
+ 6. SEGURIDAD (Botones de cuenta)
+ ========================================= */
+.account-actions-row {
+ display: flex;
+ gap: 15px;
+ margin-bottom: 40px;
+}
+
+.btn-change-password {
+ flex: 1;
+ padding: 14px;
+ background: #f1f5f9;
+ border: 1px solid #cbd5e1;
+ border-radius: 10px;
+ font-weight: 600;
+ color: var(--brand-navy, #1E3A5F);
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.btn-change-password:hover {
+ background: #e2e8f0;
+}
+
+.btn-delete-account {
+ flex: 1;
+ padding: 14px;
+ background: #fef2f2; /* Fondo rojizo claro */
+ border: 1px solid #fca5a5;
+ border-radius: 10px;
+ font-weight: 700;
+ color: #ef4444; /* Rojo alerta */
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.btn-delete-account:hover {
+ background: #fee2e2;
+ border-color: #ef4444;
+}
+
+/* =========================================
+ 7. BOTÓN PRINCIPAL DE GUARDAR
+ ========================================= */
+.profile-actions-main {
+ display: flex;
+ justify-content: flex-end;
+ border-top: 1px solid #e2e8f0; /* Línea separadora final */
+ padding-top: 25px;
+}
+
+.btn-save {
+ background: var(--brand-teal, #2EC4B6);
+ color: white;
+ border: none;
+ padding: 14px 30px;
+ border-radius: 10px;
+ font-weight: 600;
+ font-size: 1.05rem;
+ cursor: pointer;
+ transition: background 0.3s;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn-save:hover {
+ background: #25a195;
+}
+
+/* =========================================
+ 8. ADAPTACIÓN A MÓVILES (¡Siempre al final!)
+ ========================================= */
+@media (max-width: 600px) {
+ /* La tarjeta reduce su padding para aprovechar la pantalla pequeña */
+ .profile-card {
+ padding: 25px 20px;
+ }
+
+ /* Centramos la cabecera del avatar */
+ .profile-header {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ /* El grid de 2 columnas pasa a 1 sola columna */
+ .profile-grid {
+ grid-template-columns: 1fr;
+ }
+ .full-width {
+ grid-column: span 1;
+ }
+
+ /* Las notificaciones empujan el texto arriba y el switch abajo */
+ .notification-item {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 15px;
+ }
+
+ /* Los botones de seguridad se ponen uno encima del otro */
+ .account-actions-row {
+ flex-direction: column;
+ }
+
+ /* El botón de guardar ocupa el 100% del ancho */
+ .profile-actions-main {
+ justify-content: center;
+ }
+ .btn-save {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+/* =========================================
+ 9. MODALES (Ventanas Emergentes)
+ ========================================= */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(15, 23, 42, 0.7); /* Azul muy oscuro semitransparente */
+ backdrop-filter: blur(4px); /* Difumina el fondo */
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999; /* Asegura que esté por encima de todo, incluido el Navbar */
+ animation: fadeIn 0.3s ease;
+}
+
+.modal-content {
+ background: white;
+ width: 90%;
+ max-width: 450px;
+ border-radius: 16px;
+ padding: 35px;
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
+ animation: slideUp 0.3s ease;
+}
+
+.modal-content h3 {
+ margin: 0 0 10px 0;
+ font-size: 1.4rem;
+ color: var(--brand-navy, #1E3A5F);
+}
+
+.modal-content p {
+ color: #64748b;
+ font-size: 0.95rem;
+ margin-bottom: 25px;
+ line-height: 1.5;
+}
+
+.modal-content .input-group {
+ margin-bottom: 15px;
+}
+
+.modal-content .input-group input {
+ width: 100%;
+ padding: 12px 15px;
+ border: 1px solid #ced4da;
+ border-radius: 10px;
+ font-family: 'Poppins', sans-serif;
+}
+
+/* Botones dentro del modal */
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 30px;
+}
+
+.btn-modal-cancel {
+ background: transparent;
+ border: 1px solid #cbd5e1;
+ padding: 10px 20px;
+ border-radius: 8px;
+ font-weight: 600;
+ color: #64748b;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-modal-cancel:hover {
+ background: #f1f5f9;
+ color: var(--brand-navy, #1E3A5F);
+}
+
+.btn-modal-confirm {
+ background: var(--brand-teal, #2EC4B6);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-modal-confirm:hover {
+ background: #25a195;
+}
+
+/* Modal de Peligro (Eliminar) */
+.danger-modal {
+ text-align: center;
+}
+
+.danger-icon {
+ font-size: 3.5rem;
+ color: #ef4444;
+ margin-bottom: 15px;
+}
+
+.btn-modal-danger {
+ background: #ef4444;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-modal-danger:hover {
+ background: #dc2626;
+}
+
+/* Animaciones suaves */
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from { transform: translateY(30px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
\ No newline at end of file
diff --git a/src/front/styles/TripDetails.css b/src/front/styles/TripDetails.css
new file mode 100644
index 0000000000..9bb6098726
--- /dev/null
+++ b/src/front/styles/TripDetails.css
@@ -0,0 +1,343 @@
+/* =========================================
+ 1. VARIABLES Y RESET MÓVIL
+ ========================================= */
+:root {
+ --brand-navy: #1E3A5F;
+ --brand-teal: #2EC4B6;
+ --brand-orange: #FF7A59;
+}
+
+.trip-details-wrapper, .trip-details-wrapper * {
+ box-sizing: border-box;
+}
+
+.trip-details-wrapper {
+ min-height: 100vh;
+ font-family: 'Poppins', sans-serif;
+ background-color: #f8fafc;
+ overflow-x: hidden;
+ width: 100%;
+}
+
+/* =========================================
+ 2. CABECERA (HERO SECTION)
+ ========================================= */
+.trip-hero {
+ height: 400px;
+ background-size: cover;
+ background-position: center;
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ padding-bottom: 50px;
+}
+
+.hero-content {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 20px;
+ color: white;
+}
+
+.btn-back-light {
+ position: absolute;
+ top: 100px;
+ left: 20px;
+ background: rgba(255, 255, 255, 0.2);
+ backdrop-filter: blur(5px);
+ border: none;
+ color: white;
+ padding: 10px 15px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: background 0.3s;
+}
+
+.btn-back-light:hover {
+ background: rgba(255, 255, 255, 0.4);
+}
+
+.hero-badge {
+ background: var(--brand-teal);
+ color: white;
+ padding: 5px 12px;
+ border-radius: 20px;
+ font-size: 0.8rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ display: inline-block;
+ margin-bottom: 10px;
+}
+
+.hero-content h1 {
+ font-size: 3rem;
+ font-weight: 800;
+ margin: 0 0 10px 0;
+ text-shadow: 0 2px 10px rgba(0,0,0,0.3);
+}
+
+.hero-content p {
+ font-size: 1.1rem;
+ font-weight: 500;
+}
+
+/* =========================================
+ 3. ESTRUCTURA DE DOS COLUMNAS (LAYOUT)
+ ========================================= */
+.trip-content-container {
+ max-width: 1200px;
+ margin: -60px auto 50px auto;
+ padding: 0 20px;
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 30px;
+ position: relative;
+ z-index: 10;
+}
+
+.main-column {
+ background: white;
+ border-radius: 16px;
+ padding: 30px;
+ box-shadow: 0 10px 30px rgba(0,0,0,0.05);
+ max-width: 100%;
+ min-width: 0;
+}
+
+/* --- MAGIA HÍBRIDA MÓVIL/PC (Mobile First) --- */
+.main-layout-wrapper {
+ display: flex;
+ flex-direction: column-reverse; /* En móvil, el chat sube por encima de las pestañas */
+ gap: 30px;
+ min-width: 0;
+ width: 100%;
+}
+
+.persistent-chat, .tabs-content-area {
+ min-width: 0;
+ width: 100%;
+}
+
+/* 🪄 LA SOLUCIÓN: Hacemos que la caja sea visible SIEMPRE */
+.chat-desktop-view {
+ display: block;
+}
+
+/* =========================================
+ PESTAÑAS CON SCROLL MÓVIL
+ ========================================= */
+.content-tabs {
+ display: flex;
+ gap: 20px;
+ border-bottom: 2px solid #f1f5f9;
+ margin-bottom: 30px;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ padding-bottom: 5px;
+ width: 100%;
+}
+
+.content-tabs::-webkit-scrollbar {
+ display: none;
+}
+
+.content-tabs button {
+ background: none;
+ border: none;
+ padding: 10px 0;
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: #64748b;
+ cursor: pointer;
+ position: relative;
+ white-space: nowrap;
+ flex: 0 0 auto;
+}
+
+.content-tabs button.active {
+ color: var(--brand-navy);
+}
+
+.content-tabs button.active::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ background: var(--brand-orange);
+ border-radius: 3px 3px 0 0;
+}
+
+/* =========================================
+ 4. COLUMNA DERECHA (WIDGETS Y CARDS)
+ ========================================= */
+.side-column {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ min-width: 0;
+}
+
+.summary-card {
+ background: white;
+ border-radius: 16px;
+ padding: 25px;
+ box-shadow: 0 10px 30px rgba(0,0,0,0.05);
+}
+
+.summary-card h3 {
+ color: var(--brand-navy);
+ margin: 0 0 20px 0;
+ font-size: 1.1rem;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.summary-card h3 i { color: var(--brand-teal); }
+
+.budget-numbers {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 15px;
+}
+
+.budget-numbers span { font-size: 0.85rem; color: #64748b; }
+.budget-numbers h4 { font-size: 1.5rem; color: var(--brand-navy); margin: 0; }
+
+.progress-bar-bg {
+ width: 100%;
+ height: 8px;
+ background: #f1f5f9;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: var(--brand-orange);
+ transition: width 0.5s ease;
+}
+
+.friends-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 20px 0;
+}
+
+.friends-list li {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+ font-weight: 500;
+ color: var(--brand-navy);
+ width: 100%;
+}
+
+.avatar {
+ width: 35px;
+ height: 35px;
+ background: var(--brand-teal);
+ color: white;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.8rem;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.friend-avatar { background: #cbd5e1; }
+
+.balance-badge {
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 3px 8px;
+ border-radius: 12px;
+ margin-left: auto;
+ white-space: nowrap;
+}
+
+.balance-positive { background: #dcfce7; color: #166534; }
+.balance-negative { background: #fee2e2; color: #991b1b; }
+.balance-neutral { color: #94a3b8; }
+
+/* =========================================
+ 5. BOTONES GENERALES
+ ========================================= */
+.btn-action, .btn-invite, .btn-add-day {
+ width: 100%;
+ padding: 12px;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.btn-action, .btn-add-day {
+ background: #f1f5f9;
+ color: var(--brand-navy);
+ margin-top: 20px;
+}
+
+.btn-invite {
+ background: transparent;
+ border: 1px dashed #cbd5e1;
+ color: var(--brand-teal);
+}
+
+.btn-action:hover, .btn-invite:hover, .btn-add-day:hover { background: #e2e8f0; }
+
+/* =========================================
+ 8. RESPONSIVE DESIGN (PC FIRST y MOBILE)
+ ========================================= */
+@media (min-width: 901px) {
+ .main-layout-wrapper {
+ display: grid;
+ grid-template-columns: 1fr 350px;
+ gap: 30px;
+ align-items: start;
+ }
+
+ .persistent-chat {
+ position: sticky;
+ top: 20px;
+ }
+}
+
+@media (max-width: 900px) {
+ .trip-content-container {
+ grid-template-columns: 1fr;
+ margin-top: 20px;
+ }
+ .hero-content h1 {
+ font-size: 2.2rem;
+ }
+}
+
+@media (max-width: 500px) {
+ .trip-hero {
+ height: 320px;
+ padding-bottom: 20px;
+ }
+
+ .btn-back-light {
+ top: 75px;
+ padding: 8px 12px;
+ font-size: 0.85rem;
+ }
+
+ .main-column, .summary-card { padding: 20px 15px; }
+ .hero-content h1 { font-size: 1.8rem; }
+
+ .content-tabs { gap: 15px; }
+ .content-tabs button { font-size: 0.95rem; }
+}
+
+@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
\ No newline at end of file