diff --git a/Pipfile b/Pipfile
index 4d377014ae..9ce9d4e386 100644
--- a/Pipfile
+++ b/Pipfile
@@ -11,15 +11,17 @@ flask-sqlalchemy = "*"
flask-migrate = "*"
flask-swagger = "*"
psycopg2-binary = "*"
-python-dotenv = "*"
-flask-cors = "*"
gunicorn = "*"
cloudinary = "*"
flask-admin = "==2.0.0"
typing-extensions = "*"
-flask-jwt-extended = "==4.6.0"
wtforms = "==3.1.2"
sqlalchemy = "*"
+flask-mail = "*"
+flask-jwt-extended = "*"
+stripe = "*"
+python-dotenv = "*"
+flask-cors = "*"
[requires]
python_version = "3.13"
diff --git a/Pipfile.lock b/Pipfile.lock
index d9e474e972..468f9d7bd9 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "ffbfb32d0afa5e4bcaba5c2d08c81381a97abd90f22284d2b76647365df5dc50"
+ "sha256": "07d2109e62459b2f49554989d7d56aa9d3a1230637990c73f7f7eea57de9a708"
},
"pipfile-spec": 6,
"requires": {
@@ -34,19 +34,138 @@
},
"certifi": {
"hashes": [
- "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de",
- "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"
+ "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b",
+ "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"
],
"markers": "python_version >= '3.7'",
- "version": "==2025.10.5"
+ "version": "==2025.11.12"
+ },
+ "charset-normalizer": {
+ "hashes": [
+ "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
+ "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
+ "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
+ "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
+ "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
+ "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
+ "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
+ "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
+ "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
+ "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
+ "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
+ "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
+ "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
+ "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
+ "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
+ "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
+ "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
+ "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
+ "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
+ "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
+ "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
+ "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
+ "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
+ "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
+ "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
+ "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
+ "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
+ "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
+ "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
+ "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
+ "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
+ "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
+ "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
+ "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
+ "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
+ "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
+ "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
+ "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
+ "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
+ "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
+ "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
+ "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
+ "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
+ "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
+ "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
+ "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
+ "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
+ "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
+ "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
+ "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
+ "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
+ "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
+ "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
+ "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
+ "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
+ "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
+ "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
+ "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
+ "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
+ "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
+ "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
+ "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
+ "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
+ "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
+ "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
+ "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
+ "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
+ "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
+ "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
+ "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
+ "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
+ "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
+ "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
+ "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
+ "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
+ "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
+ "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
+ "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
+ "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
+ "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
+ "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
+ "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
+ "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
+ "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
+ "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
+ "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
+ "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
+ "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
+ "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
+ "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
+ "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
+ "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
+ "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
+ "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
+ "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
+ "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
+ "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
+ "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
+ "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
+ "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
+ "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
+ "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
+ "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
+ "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
+ "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
+ "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
+ "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
+ "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
+ "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
+ "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
+ "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
+ "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
+ "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==3.4.4"
},
"click": {
"hashes": [
- "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc",
- "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"
+ "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
+ "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
],
"markers": "python_version >= '3.10'",
- "version": "==8.3.0"
+ "version": "==8.3.1"
},
"cloudinary": {
"hashes": [
@@ -85,12 +204,21 @@
},
"flask-jwt-extended": {
"hashes": [
- "sha256:63a28fc9731bcc6c4b8815b6f954b5904caa534fc2ae9b93b1d3ef12930dca95",
- "sha256:9215d05a9413d3855764bcd67035e75819d23af2fafb6b55197eb5a3313fdfb2"
+ "sha256:52f35bf0985354d7fb7b876e2eb0e0b141aaff865a22ff6cc33d9a18aa987978",
+ "sha256:8085d6757505b6f3291a2638c84d207e8f0ad0de662d1f46aa2f77e658a0c976"
],
"index": "pypi",
- "markers": "python_version >= '3.7' and python_version < '4'",
- "version": "==4.6.0"
+ "markers": "python_version >= '3.9' and python_version < '4'",
+ "version": "==4.7.1"
+ },
+ "flask-mail": {
+ "hashes": [
+ "sha256:44083e7b02bbcce792209c06252f8569dd5a325a7aaa76afe7330422bd97881d",
+ "sha256:a451e490931bb3441d9b11ebab6812a16bfa81855792ae1bf9c1e1e22c4e51e7"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==0.10.0"
},
"flask-migrate": {
"hashes": [
@@ -187,6 +315,14 @@
"markers": "python_version >= '3.7'",
"version": "==23.0.0"
},
+ "idna": {
+ "hashes": [
+ "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
+ "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
+ ],
+ "markers": "python_version >= '3.8'",
+ "version": "==3.11"
+ },
"itsdangerous": {
"hashes": [
"sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef",
@@ -484,6 +620,14 @@
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
},
+ "requests": {
+ "hashes": [
+ "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
+ "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==2.32.5"
+ },
"six": {
"hashes": [
"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274",
@@ -556,6 +700,15 @@
"markers": "python_version >= '3.7'",
"version": "==2.0.44"
},
+ "stripe": {
+ "hashes": [
+ "sha256:e7d18bd44bab1812bc4e9e75da4ceacedd587f71737bb9193955edf420605f88",
+ "sha256:ed6ad1c27725e1e32a336fa9d835cfa8f0bd6dafcc98b3ab7bc7e8791390453b"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.7'",
+ "version": "==13.2.0"
+ },
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
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/c878237aa65e_fix_table_structure.py b/migrations/versions/c878237aa65e_fix_table_structure.py
new file mode 100644
index 0000000000..a15ed957b9
--- /dev/null
+++ b/migrations/versions/c878237aa65e_fix_table_structure.py
@@ -0,0 +1,256 @@
+"""Fix table structure
+
+Revision ID: c878237aa65e
+Revises:
+Create Date: 2025-11-18 17:40:26.827531
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'c878237aa65e'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('experiences',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=200), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('price', sa.Float(), nullable=False),
+ sa.Column('max_capacity', sa.Integer(), nullable=False),
+ sa.Column('duration_hours', sa.Integer(), nullable=True),
+ sa.Column('image_url', sa.String(length=500), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('extras',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('price', sa.Float(), nullable=False),
+ sa.Column('type', sa.Enum('PER_BOOKING', 'PER_GUEST', name='extratype'), nullable=False),
+ sa.Column('image_url', sa.String(length=500), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('rooms',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('capacity', sa.Integer(), nullable=False),
+ sa.Column('price_per_night', sa.Float(), nullable=False),
+ sa.Column('image_url', sa.String(length=500), nullable=True),
+ sa.Column('amenities', sa.JSON(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('check_in_time', sa.Time(), nullable=False),
+ sa.Column('check_out_time', sa.Time(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('email', sa.String(length=120), nullable=False),
+ sa.Column('password', sa.String(length=255), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('role', sa.Enum('USER', 'ADMIN', name='userrole'), nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=True),
+ sa.Column('phone', sa.String(length=20), nullable=True),
+ sa.Column('email_verified', sa.Boolean(), nullable=False),
+ sa.Column('verification_token', sa.String(length=100), nullable=True),
+ sa.Column('verification_token_expires', sa.DateTime(), nullable=True),
+ sa.Column('password_reset_token', sa.String(length=100), nullable=True),
+ sa.Column('password_reset_expires', sa.DateTime(), nullable=True),
+ sa.Column('is_guest', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('last_login', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('password_reset_token'),
+ sa.UniqueConstraint('verification_token')
+ )
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
+
+ op.create_table('experience_availability',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('experience_id', sa.Integer(), nullable=False),
+ sa.Column('date', sa.Date(), nullable=False),
+ sa.Column('available_spots', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('experience_id', 'date', name='_experience_date_uc')
+ )
+ with op.batch_alter_table('experience_availability', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_experience_availability_date'), ['date'], unique=False)
+
+ op.create_table('experience_schedules',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('experience_id', sa.Integer(), nullable=False),
+ sa.Column('day_of_week', sa.Enum('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY', name='dayofweek'), nullable=False),
+ sa.Column('start_time', sa.Time(), nullable=False),
+ sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('packages',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=200), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('price', sa.Float(), nullable=False),
+ sa.Column('image_url', sa.String(length=500), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('room_id', sa.Integer(), nullable=True),
+ sa.Column('experience_id', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ),
+ sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('room_availability',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('room_id', sa.Integer(), nullable=False),
+ sa.Column('date', sa.Date(), nullable=False),
+ sa.Column('is_available', sa.Boolean(), nullable=False),
+ sa.Column('reason', sa.String(length=200), nullable=True),
+ sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('room_id', 'date', name='_room_date_uc')
+ )
+ with op.batch_alter_table('room_availability', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_room_availability_date'), ['date'], unique=False)
+
+ op.create_table('bookings',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('confirmation_number', sa.String(length=20), nullable=False),
+ sa.Column('experience_id', sa.Integer(), nullable=True),
+ sa.Column('package_id', sa.Integer(), nullable=True),
+ sa.Column('experience_date', sa.Date(), nullable=True),
+ sa.Column('experience_time', sa.Time(), nullable=True),
+ sa.Column('check_in', sa.Date(), nullable=True),
+ sa.Column('check_out', sa.Date(), nullable=True),
+ sa.Column('check_in_time', sa.Time(), nullable=True),
+ sa.Column('check_out_time', sa.Time(), nullable=True),
+ sa.Column('number_of_guests', sa.Integer(), nullable=False),
+ sa.Column('status', sa.Enum('CART', 'PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED', name='bookingstatus'), nullable=False),
+ sa.Column('total_price', sa.Float(), nullable=False),
+ sa.Column('stripe_payment_intent_id', sa.String(length=200), nullable=True),
+ sa.Column('stripe_payment_status', sa.String(length=50), nullable=True),
+ sa.Column('payment_status', sa.Enum('PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'REFUNDED', name='paymentstatus'), nullable=False),
+ sa.Column('special_requests', sa.Text(), nullable=True),
+ sa.Column('admin_notes', sa.Text(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.Column('cart_expires_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ),
+ sa.ForeignKeyConstraint(['package_id'], ['packages.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ with op.batch_alter_table('bookings', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_bookings_confirmation_number'), ['confirmation_number'], unique=True)
+
+ op.create_table('package_extras',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('package_id', sa.Integer(), nullable=False),
+ sa.Column('extra_id', sa.Integer(), nullable=False),
+ sa.Column('quantity', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['extra_id'], ['extras.id'], ),
+ sa.ForeignKeyConstraint(['package_id'], ['packages.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('booking_extras',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('booking_id', sa.Integer(), nullable=False),
+ sa.Column('extra_id', sa.Integer(), nullable=False),
+ sa.Column('quantity', sa.Integer(), nullable=False),
+ sa.Column('price', sa.Float(), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
+ sa.ForeignKeyConstraint(['extra_id'], ['extras.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('booking_items',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('booking_id', sa.Integer(), nullable=False),
+ sa.Column('item_type', sa.String(length=50), nullable=False),
+ sa.Column('experience_id', sa.Integer(), nullable=True),
+ sa.Column('room_id', sa.Integer(), nullable=True),
+ sa.Column('name', sa.String(length=200), nullable=False),
+ sa.Column('image_url', sa.String(length=500), nullable=True),
+ sa.Column('date', sa.Date(), nullable=True),
+ sa.Column('guests', sa.Integer(), nullable=True),
+ sa.Column('check_in', sa.Date(), nullable=True),
+ sa.Column('check_out', sa.Date(), nullable=True),
+ sa.Column('nights', sa.Integer(), nullable=True),
+ sa.Column('unit_price', sa.Float(), nullable=False),
+ sa.Column('subtotal', sa.Float(), nullable=False),
+ sa.Column('extras', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
+ sa.ForeignKeyConstraint(['experience_id'], ['experiences.id'], ),
+ sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('booking_rooms',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('booking_id', sa.Integer(), nullable=False),
+ sa.Column('room_id', sa.Integer(), nullable=False),
+ sa.Column('check_in', sa.Date(), nullable=False),
+ sa.Column('check_out', sa.Date(), nullable=False),
+ sa.Column('nights', sa.Integer(), nullable=False),
+ sa.Column('price', sa.Float(), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
+ sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('email_logs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('booking_id', sa.Integer(), nullable=False),
+ sa.Column('email_type', sa.String(length=50), nullable=False),
+ sa.Column('recipient_email', sa.String(length=120), nullable=False),
+ sa.Column('subject', sa.String(length=200), nullable=False),
+ sa.Column('status', sa.Enum('PENDING', 'SENT', 'FAILED', name='emailstatus'), nullable=False),
+ sa.Column('error_message', sa.Text(), nullable=True),
+ sa.Column('sent_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('email_logs')
+ op.drop_table('booking_rooms')
+ op.drop_table('booking_items')
+ op.drop_table('booking_extras')
+ op.drop_table('package_extras')
+ with op.batch_alter_table('bookings', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_bookings_confirmation_number'))
+
+ op.drop_table('bookings')
+ with op.batch_alter_table('room_availability', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_room_availability_date'))
+
+ op.drop_table('room_availability')
+ op.drop_table('packages')
+ op.drop_table('experience_schedules')
+ with op.batch_alter_table('experience_availability', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_experience_availability_date'))
+
+ op.drop_table('experience_availability')
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_users_email'))
+
+ op.drop_table('users')
+ op.drop_table('rooms')
+ op.drop_table('extras')
+ op.drop_table('experiences')
+ # ### end Alembic commands ###
diff --git a/package-lock.json b/package-lock.json
index 8d43d98ab7..44f1ece2dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -73,6 +73,7 @@
"integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -884,7 +885,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@@ -997,14 +997,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",
@@ -1018,6 +1010,7 @@
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1066,6 +1059,7 @@
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1294,6 +1288,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -1312,8 +1307,7 @@
"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
+ "optional": true
},
"node_modules/call-bind": {
"version": "1.0.8",
@@ -1797,6 +1791,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3486,6 +3481,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3498,6 +3494,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -3921,7 +3918,6 @@
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"optional": true,
- "peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -3933,7 +3929,6 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"optional": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4081,7 +4076,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
- "peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -4100,8 +4094,7 @@
"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
+ "optional": true
},
"node_modules/text-table": {
"version": "0.2.0",
@@ -4278,6 +4271,7 @@
"integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.18.10",
"postcss": "^8.4.27",
@@ -4500,6 +4494,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz",
"integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==",
"dev": true,
+ "peer": true,
"requires": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -4950,7 +4945,6 @@
"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"
@@ -5044,14 +5038,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",
@@ -5063,6 +5049,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"dev": true,
+ "peer": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -5098,7 +5085,8 @@
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"acorn-jsx": {
"version": "5.3.2",
@@ -5245,6 +5233,7 @@
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true,
+ "peer": true,
"requires": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -5257,8 +5246,7 @@
"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
+ "optional": true
},
"call-bind": {
"version": "1.0.8",
@@ -5600,6 +5588,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true,
+ "peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6702,6 +6691,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
@@ -6710,6 +6700,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "peer": true,
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6988,7 +6979,6 @@
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"optional": true,
- "peer": true,
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -6999,8 +6989,7 @@
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
- "optional": true,
- "peer": true
+ "optional": true
}
}
},
@@ -7100,7 +7089,6 @@
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
"dev": true,
"optional": true,
- "peer": true,
"requires": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -7113,8 +7101,7 @@
"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
+ "optional": true
}
}
},
@@ -7228,6 +7215,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.9.tgz",
"integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==",
"dev": true,
+ "peer": true,
"requires": {
"esbuild": "^0.18.10",
"fsevents": "~2.3.2",
diff --git a/requirements.txt b/requirements.txt
index 4eac45f4f8..886ee8f66c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,3 +24,4 @@ sqlalchemy==1.3.23
urllib3==1.26.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
werkzeug==1.0.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
wtforms==2.3.3
+Flask-Mail==0.10.0
diff --git a/src/api/commands.py b/src/api/commands.py
index 19806164d3..0df4cb90c1 100644
--- a/src/api/commands.py
+++ b/src/api/commands.py
@@ -1,4 +1,4 @@
-
+# src/api/commands.py
import click
from api.models import db, User
@@ -31,4 +31,11 @@ def insert_test_users(count):
@app.cli.command("insert-test-data")
def insert_test_data():
- pass
\ No newline at end of file
+ pass
+
+ # 👇 NUEVO COMANDO SEED
+ @app.cli.command("seed")
+ def seed_data():
+ """Poblar la base de datos con datos de CaliaFarm"""
+ from api.seed import seed_database
+ seed_database()
\ No newline at end of file
diff --git a/src/api/email_service.py b/src/api/email_service.py
new file mode 100644
index 0000000000..d37d621854
--- /dev/null
+++ b/src/api/email_service.py
@@ -0,0 +1,480 @@
+# src/api/email_service.py
+from flask_mail import Mail, Message
+from api.models import db, EmailLog, EmailStatus
+from datetime import datetime
+import os
+from jinja2 import Template
+
+mail = Mail()
+
+def init_mail(app):
+ """Inicializar Flask-Mail con la app"""
+ mail.init_app(app)
+
+def send_email(recipient, subject, html_body, booking_id=None, email_type='general'):
+ """
+ Enviar email usando Flask-Mail (SMTP)
+ """
+ try:
+ msg = Message(
+ subject=subject,
+ recipients=[recipient],
+ html=html_body
+ )
+
+ mail.send(msg)
+
+ # Registrar email enviado
+ if booking_id:
+ email_log = EmailLog(
+ booking_id=booking_id,
+ email_type=email_type,
+ recipient_email=recipient,
+ subject=subject,
+ status=EmailStatus.SENT,
+ sent_at=datetime.utcnow()
+ )
+ db.session.add(email_log)
+ db.session.commit()
+
+ print(f"✅ Email enviado a {recipient}")
+ return True
+
+ except Exception as e:
+ print(f"❌ Error sending email: {str(e)}")
+
+ # Registrar error
+ if booking_id:
+ email_log = EmailLog(
+ booking_id=booking_id,
+ email_type=email_type,
+ recipient_email=recipient,
+ subject=subject,
+ status=EmailStatus.FAILED,
+ error_message=str(e)
+ )
+ db.session.add(email_log)
+ db.session.commit()
+
+ return False
+
+def send_verification_email(user, token):
+ """Enviar email de verificación de cuenta"""
+ verification_url = f"{os.getenv('FRONTEND_URL')}/verify-email?token={token}"
+
+ html_template = """
+
+
+
+
+
+
+
+
+
+
+
Hola {{ name }} ,
+
Gracias por registrarte en celiafarm. Para completar tu registro, por favor verifica tu dirección de email haciendo clic en el botón de abajo:
+
+ Verificar mi email
+
+
O copia y pega este enlace en tu navegador:
+
{{ verification_url }}
+
Este enlace expirará en 24 horas.
+
Si no creaste esta cuenta, puedes ignorar este email.
+
+
+
+
+
+ """
+
+ template = Template(html_template)
+ html_body = template.render(
+ name=user.name or user.email.split('@')[0],
+ verification_url=verification_url
+ )
+
+ return send_email(
+ recipient=user.email,
+ subject="Verifica tu cuenta en celiafarm",
+ html_body=html_body,
+ email_type='email_verification'
+ )
+
+def send_password_reset_email(user, token):
+ """Enviar email de reset de contraseña"""
+ reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}"
+
+ html_template = """
+
+
+
+
+
+
+
+
+
+
+
Hola {{ name }} ,
+
Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva contraseña:
+
+ Restablecer contraseña
+
+
O copia y pega este enlace en tu navegador:
+
{{ reset_url }}
+
Este enlace expirará en 2 horas.
+
Si no solicitaste restablecer tu contraseña, puedes ignorar este email de forma segura.
+
+
+
+
+
+ """
+
+ template = Template(html_template)
+ html_body = template.render(
+ name=user.name or user.email.split('@')[0],
+ reset_url=reset_url
+ )
+
+ return send_email(
+ recipient=user.email,
+ subject="Restablecer tu contraseña - celiafarm",
+ html_body=html_body,
+ email_type='password_reset'
+ )
+
+def send_booking_confirmation_email(booking):
+ """Enviar email de confirmación de reserva"""
+ html_template = """
+
+
+
+
+
+
+
+
+
+
+
Hola {{ user_name }} ,
+
¡Gracias por tu reserva! Hemos recibido tu pago y tu reserva está confirmada.
+
+
+
📋 Detalles de tu reserva:
+
+ {% if experience %}
+
+ 🎯 Experiencia:
+ {{ experience.name }}
+
+
+ 📅 Fecha:
+ {{ experience_date }}
+
+ {% if experience_time %}
+
+ 🕐 Hora:
+ {{ experience_time }}
+
+ {% endif %}
+ {% endif %}
+
+ {% if rooms %}
+
+ 🏨 Alojamiento:
+ {{ check_in }} - {{ check_out }}
+
+ {% for room in rooms %}
+
+ 🛏️ Habitación:
+ {{ room.room.name }} ({{ room.nights }} noches)
+
+ {% endfor %}
+ {% endif %}
+
+
+ 👥 Huéspedes:
+ {{ number_of_guests }}
+
+
+ {% if extras %}
+
✨ Extras incluidos:
+ {% for extra in extras %}
+
+ {{ extra.extra.name }}:
+ x{{ extra.quantity }}
+
+ {% endfor %}
+ {% endif %}
+
+
+ 💰 TOTAL PAGADO: ${{ total_price }}
+
+
+
+ {% if special_requests %}
+
+
📝 Solicitudes especiales:
+
{{ special_requests }}
+
+ {% endif %}
+
+
¡Esperamos verte pronto! 💜
+
El equipo de celiafarm
+
+
+
+
+
+ """
+
+ template = Template(html_template)
+ html_body = template.render(
+ confirmation_number=booking.confirmation_number,
+ user_name=booking.user.name or booking.user.email.split('@')[0],
+ experience=booking.experience,
+ experience_date=booking.experience_date.strftime('%d/%m/%Y') if booking.experience_date else None,
+ experience_time=booking.experience_time.strftime('%H:%M') if booking.experience_time else None,
+ check_in=booking.check_in.strftime('%d/%m/%Y') if booking.check_in else None,
+ check_out=booking.check_out.strftime('%d/%m/%Y') if booking.check_out else None,
+ rooms=booking.rooms,
+ number_of_guests=booking.number_of_guests,
+ extras=booking.extras,
+ total_price=f"{booking.total_price:.2f}",
+ special_requests=booking.special_requests
+ )
+
+ return send_email(
+ recipient=booking.user.email,
+ subject=f"✓ Confirmación de Reserva #{booking.confirmation_number} - HerSafe",
+ html_body=html_body,
+ booking_id=booking.id,
+ email_type='booking_confirmation'
+ )
+
+def send_guest_checkout_email(booking, temporary_password=None):
+ """Enviar email a usuarios guest con contraseña temporal"""
+ html_template = """
+
+
+
+
+
+
+
+
+
+
+
Hola {{ user_name }} ,
+
¡Gracias por tu reserva! Tu pago ha sido confirmado.
+
+ {% if temporary_password %}
+
+
🔑 Hemos creado una cuenta para ti
+
Para que puedas revisar tu reserva, usa estos datos:
+
+ 📧 Email: {{ email }}
+
+
+ 🔐 Contraseña temporal: {{ temporary_password }}
+
+
+ ⚠️ Te recomendamos cambiar tu contraseña después de iniciar sesión.
+
+
+ {% endif %}
+
+
Número de confirmación: {{ confirmation_number }}
+
¡Esperamos verte pronto! 💜
+
+
+
+
+
+ """
+
+
+ template = Template(html_template)
+ html_body = template.render(
+ confirmation_number=booking.confirmation_number,
+ user_name=booking.user.name or booking.user.email.split('@')[0],
+ email=booking.user.email,
+ temporary_password=temporary_password
+ )
+
+ return send_email(
+ recipient=booking.user.email,
+ subject=f"Tu cuenta y reserva en celiafarm - {booking.confirmation_number}",
+ html_body=html_body,
+ booking_id=booking.id,
+ email_type='guest_checkout'
+ )
+
+def send_booking_confirmation_email(booking_data):
+ """
+ Envía email de confirmación de reserva
+ """
+ try:
+ subject = f"Booking Confirmation #{booking_data['booking_number']} - CaliaFarm"
+
+ # Preparar lista de items
+ items_html = ""
+ for item in booking_data.get('items', []):
+ items_html += f"""
+
+
+ {item['name']}
+
+
+ €{item['subtotal']:.2f}
+
+
+ """
+
+ html_body = f"""
+
+
+
+
+
+
+
+
+
+
+
+
Dear {booking_data['customer_name']},
+
+
We're delighted to confirm your booking at CaliaFarm. Your reservation has been successfully processed.
+
+
+
Booking Reference
+
#{booking_data['booking_number']}
+
+
+
Booking Details:
+
+ {items_html}
+
+ Total Paid:
+ €{booking_data['total_amount']:.2f}
+
+
+
+
Customer Information:
+
+ Name: {booking_data['customer_name']}
+ Email: {booking_data['customer_email']}
+ Phone: {booking_data['customer_phone']}
+ Payment Status: ✓ Paid
+
+
+
Important Information:
+
+ Please arrive 15 minutes before your scheduled time
+ Bring this confirmation email with you
+ For cancellations, contact us at least 48 hours in advance
+
+
+
+ Contact Us
+
+
+
+
+
+
+
+ """
+
+ msg = Message(
+ subject=subject,
+ recipients=[booking_data['customer_email']],
+ html=html_body
+ )
+
+ mail.send(msg)
+ print(f"✅ Confirmation email sent to {booking_data['customer_email']}")
+ return True
+
+ except Exception as e:
+ print(f"❌ Error sending confirmation email: {str(e)}")
+ raise e
\ No newline at end of file
diff --git a/src/api/models.py b/src/api/models.py
index da515f6a1a..9fc94755df 100644
--- a/src/api/models.py
+++ b/src/api/models.py
@@ -1,19 +1,616 @@
+# models.py (ACTUALIZACIONES)
from flask_sqlalchemy import SQLAlchemy
-from sqlalchemy import String, Boolean
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy import String, Boolean, Integer, Float, Text, Date, Time, DateTime, Enum as SQLEnum, JSON
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from datetime import datetime, time
+from enum import Enum
+from typing import List, Optional
+import secrets
db = SQLAlchemy()
+# ============= ENUMS =============
+
+
+class UserRole(Enum):
+ USER = "user"
+ ADMIN = "admin"
+
+
+class BookingStatus(Enum):
+ CART = "cart"
+ PENDING = "pending"
+ CONFIRMED = "confirmed"
+ CANCELLED = "cancelled"
+ COMPLETED = "completed"
+
+
+class DayOfWeek(Enum):
+ MONDAY = "monday"
+ TUESDAY = "tuesday"
+ WEDNESDAY = "wednesday"
+ THURSDAY = "thursday"
+ FRIDAY = "friday"
+ SATURDAY = "saturday"
+ SUNDAY = "sunday"
+
+
+class ExtraType(Enum):
+ PER_BOOKING = "per_booking"
+ PER_GUEST = "per_guest"
+
+
+class PaymentStatus(Enum):
+ PENDING = "pending"
+ PROCESSING = "processing"
+ SUCCEEDED = "succeeded"
+ FAILED = "failed"
+ REFUNDED = "refunded"
+
+
+class EmailStatus(Enum):
+ PENDING = "pending"
+ SENT = "sent"
+ FAILED = "failed"
+
+# ============= USUARIOS =============
+
+
class User(db.Model):
+ __tablename__ = 'users'
+
id: Mapped[int] = mapped_column(primary_key=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)
+ email: Mapped[str] = mapped_column(
+ String(120), unique=True, nullable=False, index=True)
+ password: Mapped[Optional[str]] = mapped_column(
+ String(255), nullable=True) # Nullable para guest checkout
+ is_active: Mapped[bool] = mapped_column(
+ Boolean(), default=True, nullable=False)
+
+ role: Mapped[UserRole] = mapped_column(
+ SQLEnum(UserRole), default=UserRole.USER, nullable=False)
+ name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
+ phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
+
+ # NUEVO: Sistema de verificación de email
+ email_verified: Mapped[bool] = mapped_column(
+ Boolean(), default=False, nullable=False)
+ verification_token: Mapped[Optional[str]] = mapped_column(
+ String(100), nullable=True, unique=True)
+ verification_token_expires: Mapped[Optional[datetime]] = mapped_column(
+ DateTime, nullable=True)
+
+ # NUEVO: Reset de contraseña
+ password_reset_token: Mapped[Optional[str]] = mapped_column(
+ String(100), nullable=True, unique=True)
+ password_reset_expires: Mapped[Optional[datetime]] = mapped_column(
+ DateTime, nullable=True)
+
+ # NUEVO: Para guest checkout (usuarios sin cuenta)
+ is_guest: Mapped[bool] = mapped_column(
+ Boolean(), default=False, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+ last_login: Mapped[Optional[datetime]] = mapped_column(
+ DateTime, nullable=True)
+
+ # Relaciones
+ bookings: Mapped[List["Booking"]] = relationship(
+ back_populates='user', lazy='dynamic')
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
+ "is_active": self.is_active,
+ "role": self.role.value,
+ "name": self.name,
+ "phone": self.phone,
+ "email_verified": self.email_verified,
+ "is_guest": self.is_guest,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "last_login": self.last_login.isoformat() if self.last_login else None
+ }
+
+ def is_admin(self):
+ return self.role == UserRole.ADMIN
+
+ def generate_verification_token(self):
+ """Generar token de verificación de email"""
+ self.verification_token = secrets.token_urlsafe(32)
+ self.verification_token_expires = datetime.utcnow() + timedelta(hours=24)
+ return self.verification_token
+
+ def generate_password_reset_token(self):
+ """Generar token de reset de contraseña"""
+ self.password_reset_token = secrets.token_urlsafe(32)
+ self.password_reset_expires = datetime.utcnow() + timedelta(hours=2)
+ return self.password_reset_token
+
+# ============= EXPERIENCIAS (sin cambios) =============
+
+
+class Experience(db.Model):
+ __tablename__ = 'experiences'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(200), nullable=False)
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ price: Mapped[float] = mapped_column(Float, nullable=False)
+ max_capacity: Mapped[int] = mapped_column(
+ Integer, default=20, nullable=False)
+ duration_hours: Mapped[Optional[int]] = mapped_column(
+ Integer, nullable=True)
+ image_url: Mapped[Optional[str]] = mapped_column(
+ String(500), nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean(), default=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+
+ schedules: Mapped[List["ExperienceSchedule"]] = relationship(
+ back_populates='experience', cascade='all, delete-orphan')
+ bookings: Mapped[List["Booking"]] = relationship(
+ back_populates='experience')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'description': self.description,
+ 'price': self.price,
+ 'max_capacity': self.max_capacity,
+ 'duration_hours': self.duration_hours,
+ 'image_url': self.image_url,
+ 'is_active': self.is_active,
+ 'schedules': [schedule.serialize() for schedule in self.schedules]
+ }
+
+
+class ExperienceSchedule(db.Model):
+ __tablename__ = 'experience_schedules'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ experience_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('experiences.id'), nullable=False)
+ day_of_week: Mapped[DayOfWeek] = mapped_column(
+ SQLEnum(DayOfWeek), nullable=False)
+ start_time: Mapped[time] = mapped_column(Time, nullable=False)
+
+ experience: Mapped["Experience"] = relationship(back_populates='schedules')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'day_of_week': self.day_of_week.value,
+ 'start_time': self.start_time.strftime('%H:%M') if self.start_time else None
+ }
+
+# ============= HABITACIONES (sin cambios) =============
+
+
+class Room(db.Model):
+ __tablename__ = 'rooms'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ capacity: Mapped[int] = mapped_column(Integer, nullable=False)
+ price_per_night: Mapped[float] = mapped_column(Float, nullable=False)
+ image_url: Mapped[Optional[str]] = mapped_column(
+ String(500), nullable=True)
+ amenities: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean(), default=True)
+ check_in_time: Mapped[time] = mapped_column(
+ Time, default=time(15, 0), nullable=False)
+ check_out_time: Mapped[time] = mapped_column(
+ Time, default=time(11, 0), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+
+ booking_rooms: Mapped[List["BookingRoom"]
+ ] = relationship(back_populates='room')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'description': self.description,
+ 'capacity': self.capacity,
+ 'price_per_night': self.price_per_night,
+ 'image_url': self.image_url,
+ 'amenities': self.amenities,
+ 'is_active': self.is_active,
+ 'check_in_time': self.check_in_time.strftime('%H:%M'),
+ 'check_out_time': self.check_out_time.strftime('%H:%M')
+ }
+
+# ============= EXTRAS (sin cambios) =============
+
+
+class Extra(db.Model):
+ __tablename__ = 'extras'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ price: Mapped[float] = mapped_column(Float, nullable=False)
+ type: Mapped[ExtraType] = mapped_column(SQLEnum(ExtraType), nullable=False)
+ image_url: Mapped[Optional[str]] = mapped_column(
+ String(500), nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean(), default=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+
+ booking_extras: Mapped[List["BookingExtra"]
+ ] = relationship(back_populates='extra')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'description': self.description,
+ 'price': self.price,
+ 'type': self.type.value,
+ 'image_url': self.image_url,
+ 'is_active': self.is_active
+ }
+
+# ============= PAQUETES (sin cambios) =============
+
+
+class Package(db.Model):
+ __tablename__ = 'packages'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(200), nullable=False)
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ price: Mapped[float] = mapped_column(Float, nullable=False)
+ image_url: Mapped[Optional[str]] = mapped_column(
+ String(500), nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean(), default=True)
+ room_id: Mapped[Optional[int]] = mapped_column(
+ Integer, db.ForeignKey('rooms.id'), nullable=True)
+ experience_id: Mapped[Optional[int]] = mapped_column(
+ Integer, db.ForeignKey('experiences.id'), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+
+ room: Mapped[Optional["Room"]] = relationship()
+ experience: Mapped[Optional["Experience"]] = relationship()
+ included_extras: Mapped[List["PackageExtra"]] = relationship(
+ back_populates='package', cascade='all, delete-orphan')
+ bookings: Mapped[List["Booking"]] = relationship(back_populates='package')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'description': self.description,
+ 'price': self.price,
+ 'image_url': self.image_url,
+ 'room': self.room.serialize() if self.room else None,
+ 'experience': self.experience.serialize() if self.experience else None,
+ 'included_extras': [pe.serialize() for pe in self.included_extras],
+ 'is_active': self.is_active
+ }
+
+
+class PackageExtra(db.Model):
+ __tablename__ = 'package_extras'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ package_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('packages.id'), nullable=False)
+ extra_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('extras.id'), nullable=False)
+ quantity: Mapped[int] = mapped_column(Integer, default=1)
+
+ package: Mapped["Package"] = relationship(back_populates='included_extras')
+ extra: Mapped["Extra"] = relationship()
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'extra': self.extra.serialize(),
+ 'quantity': self.quantity
+ }
+
+# ============= RESERVAS =============
+
+
+class Booking(db.Model):
+ __tablename__ = 'bookings'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ user_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('users.id'), nullable=False)
+
+ # NUEVO: Número de confirmación único
+ confirmation_number: Mapped[str] = mapped_column(
+ String(20), unique=True, nullable=False, index=True)
+
+ experience_id: Mapped[Optional[int]] = mapped_column(
+ Integer, db.ForeignKey('experiences.id'), nullable=True)
+ package_id: Mapped[Optional[int]] = mapped_column(
+ Integer, db.ForeignKey('packages.id'), nullable=True)
+
+ experience_date: Mapped[Optional[datetime]
+ ] = mapped_column(Date, nullable=True)
+ experience_time: Mapped[Optional[time]
+ ] = mapped_column(Time, nullable=True)
+
+ check_in: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True)
+ check_out: Mapped[Optional[datetime]] = mapped_column(Date, nullable=True)
+ check_in_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True)
+ check_out_time: Mapped[Optional[time]] = mapped_column(Time, nullable=True)
+
+ number_of_guests: Mapped[int] = mapped_column(Integer, nullable=False)
+
+ status: Mapped[BookingStatus] = mapped_column(
+ SQLEnum(BookingStatus), default=BookingStatus.CART)
+ total_price: Mapped[float] = mapped_column(Float, nullable=False)
+
+ stripe_payment_intent_id: Mapped[Optional[str]] = mapped_column(
+ String(200), nullable=True)
+ stripe_payment_status: Mapped[Optional[str]
+ ] = mapped_column(String(50), nullable=True)
+ payment_status: Mapped[PaymentStatus] = mapped_column(
+ SQLEnum(PaymentStatus), default=PaymentStatus.PENDING)
+
+ special_requests: Mapped[Optional[str]
+ ] = mapped_column(Text, nullable=True)
+ admin_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ cart_expires_at: Mapped[Optional[datetime]
+ ] = mapped_column(DateTime, nullable=True)
+
+ # Relaciones
+ user: Mapped["User"] = relationship(back_populates='bookings')
+ experience: Mapped[Optional["Experience"]
+ ] = relationship(back_populates='bookings')
+ package: Mapped[Optional["Package"]] = relationship(
+ back_populates='bookings')
+ rooms: Mapped[List["BookingRoom"]] = relationship(
+ back_populates='booking', cascade='all, delete-orphan')
+ extras: Mapped[List["BookingExtra"]] = relationship(
+ back_populates='booking', cascade='all, delete-orphan')
+ email_logs: Mapped[List["EmailLog"]] = relationship(
+ back_populates='booking', cascade='all, delete-orphan')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'confirmation_number': self.confirmation_number,
+ 'user': self.user.serialize(),
+ 'experience': self.experience.serialize() if self.experience else None,
+ 'package': self.package.serialize() if self.package else None,
+ 'experience_date': self.experience_date.isoformat() if self.experience_date else None,
+ 'experience_time': self.experience_time.strftime('%H:%M') if self.experience_time else None,
+ 'check_in': self.check_in.isoformat() if self.check_in else None,
+ 'check_out': self.check_out.isoformat() if self.check_out else None,
+ 'check_in_time': self.check_in_time.strftime('%H:%M') if self.check_in_time else None,
+ 'check_out_time': self.check_out_time.strftime('%H:%M') if self.check_out_time else None,
+ 'number_of_guests': self.number_of_guests,
+ 'status': self.status.value,
+ 'payment_status': self.payment_status.value,
+ 'total_price': self.total_price,
+ 'rooms': [br.serialize() for br in self.rooms],
+ 'extras': [be.serialize() for be in self.extras],
+ 'special_requests': self.special_requests,
+ 'admin_notes': self.admin_notes,
+ 'stripe_payment_intent_id': self.stripe_payment_intent_id,
+ 'created_at': self.created_at.isoformat(),
+ 'updated_at': self.updated_at.isoformat(),
+ 'cart_expires_at': self.cart_expires_at.isoformat() if self.cart_expires_at else None
+ }
+
+ def serialize_admin(self):
+ data = self.serialize()
+ data['stripe_details'] = {
+ 'payment_intent_id': self.stripe_payment_intent_id,
+ 'payment_status': self.stripe_payment_status,
+ 'payment_status_enum': self.payment_status.value
+ }
+ return data
+
+ @staticmethod
+ def generate_confirmation_number():
+ """Generar número de confirmación único (ej: BK20240115ABCD)"""
+ import random
+ import string
+ date_str = datetime.utcnow().strftime('%Y%m%d')
+ random_str = ''.join(random.choices(
+ string.ascii_uppercase + string.digits, k=4))
+ return f"BK{date_str}{random_str}"
+
+
+class BookingRoom(db.Model):
+ __tablename__ = 'booking_rooms'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ booking_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('bookings.id'), nullable=False)
+ room_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('rooms.id'), nullable=False)
+ check_in: Mapped[datetime] = mapped_column(Date, nullable=False)
+ check_out: Mapped[datetime] = mapped_column(Date, nullable=False)
+ nights: Mapped[int] = mapped_column(Integer, nullable=False)
+ price: Mapped[float] = mapped_column(Float, nullable=False)
+
+ booking: Mapped["Booking"] = relationship(back_populates='rooms')
+ room: Mapped["Room"] = relationship(back_populates='booking_rooms')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'room': self.room.serialize(),
+ 'check_in': self.check_in.isoformat(),
+ 'check_out': self.check_out.isoformat(),
+ 'nights': self.nights,
+ 'price': self.price
+ }
+
+
+class BookingExtra(db.Model):
+ __tablename__ = 'booking_extras'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ booking_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('bookings.id'), nullable=False)
+ extra_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('extras.id'), nullable=False)
+ quantity: Mapped[int] = mapped_column(Integer, default=1)
+ price: Mapped[float] = mapped_column(Float, nullable=False)
+
+ booking: Mapped["Booking"] = relationship(back_populates='extras')
+ extra: Mapped["Extra"] = relationship(back_populates='booking_extras')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'extra': self.extra.serialize(),
+ 'quantity': self.quantity,
+ 'price': self.price
+ }
+
+# ============= EMAIL LOG =============
+
+
+class EmailLog(db.Model):
+ __tablename__ = 'email_logs'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ booking_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('bookings.id'), nullable=False)
+ # 'booking_confirmation', 'payment_receipt', etc.
+ email_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ recipient_email: Mapped[str] = mapped_column(String(120), nullable=False)
+ subject: Mapped[str] = mapped_column(String(200), nullable=False)
+ status: Mapped[EmailStatus] = mapped_column(
+ SQLEnum(EmailStatus), default=EmailStatus.PENDING)
+ error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ sent_at: Mapped[Optional[datetime]] = mapped_column(
+ DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow)
+
+ booking: Mapped["Booking"] = relationship(back_populates='email_logs')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'booking_id': self.booking_id,
+ 'email_type': self.email_type,
+ 'recipient_email': self.recipient_email,
+ 'subject': self.subject,
+ 'status': self.status.value,
+ 'error_message': self.error_message,
+ 'sent_at': self.sent_at.isoformat() if self.sent_at else None,
+ 'created_at': self.created_at.isoformat()
+ }
+
+# ============= DISPONIBILIDAD (sin cambios) =============
+
+
+class RoomAvailability(db.Model):
+ __tablename__ = 'room_availability'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ room_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('rooms.id'), nullable=False)
+ date: Mapped[datetime] = mapped_column(Date, nullable=False, index=True)
+ is_available: Mapped[bool] = mapped_column(Boolean(), default=True)
+ reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
+
+ room: Mapped["Room"] = relationship()
+
+ __table_args__ = (
+ db.UniqueConstraint('room_id', 'date', name='_room_date_uc'),
+ )
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'room_id': self.room_id,
+ 'date': self.date.isoformat(),
+ 'is_available': self.is_available,
+ 'reason': self.reason
+ }
+
+
+class ExperienceAvailability(db.Model):
+ __tablename__ = 'experience_availability'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ experience_id: Mapped[int] = mapped_column(
+ Integer, db.ForeignKey('experiences.id'), nullable=False)
+ date: Mapped[datetime] = mapped_column(Date, nullable=False, index=True)
+ available_spots: Mapped[int] = mapped_column(Integer, nullable=False)
+
+ experience: Mapped["Experience"] = relationship()
+
+ __table_args__ = (
+ db.UniqueConstraint('experience_id', 'date', name='_experience_date_uc'),
+ )
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'experience_id': self.experience_id,
+ 'date': self.date.isoformat(),
+ 'available_spots': self.available_spots
+ }
+
+
+class BookingItem(db.Model):
+ __tablename__ = 'booking_items'
+
+ id = db.Column(db.Integer, primary_key=True)
+ booking_id = db.Column(db.Integer, db.ForeignKey('bookings.id'), nullable=False)
+
+ item_type = db.Column(db.String(50), nullable=False) # 'experience' o 'room'
+
+ experience_id = db.Column(db.Integer, db.ForeignKey('experiences.id'), nullable=True)
+ room_id = db.Column(db.Integer, db.ForeignKey('rooms.id'), nullable=True)
+
+ name = db.Column(db.String(200), nullable=False)
+ image_url = db.Column(db.String(500))
+
+ date = db.Column(db.Date, nullable=True)
+ guests = db.Column(db.Integer)
+
+ check_in = db.Column(db.Date, nullable=True)
+ check_out = db.Column(db.Date, nullable=True)
+ nights = db.Column(db.Integer)
+
+ unit_price = db.Column(db.Float, nullable=False)
+ subtotal = db.Column(db.Float, nullable=False)
+
+ extras = db.Column(db.JSON) # [{id, name, price}, ...]
+
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+ experience = db.relationship('Experience', backref='booking_items')
+ room = db.relationship('Room', backref='booking_items')
+
+ def serialize(self):
+ return {
+ 'id': self.id,
+ 'type': self.item_type,
+ 'name': self.name,
+ 'image_url': self.image_url,
+ 'date': self.date.isoformat() if self.date else None,
+ 'check_in': self.check_in.isoformat() if self.check_in else None,
+ 'check_out': self.check_out.isoformat() if self.check_out else None,
+ 'guests': self.guests,
+ 'nights': self.nights,
+ 'unit_price': self.unit_price,
+ 'subtotal': self.subtotal,
+ 'extras': self.extras,
+ 'experience_id': self.experience_id,
+ 'room_id': self.room_id
+ }
diff --git a/src/api/routes.py b/src/api/routes.py
index 029589a3a1..6ceb6b3327 100644
--- a/src/api/routes.py
+++ b/src/api/routes.py
@@ -2,21 +2,1636 @@
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.models import (
+ db, User, Experience, ExperienceSchedule, Room, Extra, Package, PackageExtra,
+ Booking, BookingRoom, BookingExtra, RoomAvailability, ExperienceAvailability,
+ BookingStatus, PaymentStatus, EmailStatus, EmailLog, UserRole, ExtraType, DayOfWeek
+)
from api.utils import generate_sitemap, APIException
+from api.email_service import (
+ send_verification_email,
+ send_password_reset_email,
+ send_booking_confirmation_email,
+ send_guest_checkout_email
+)
from flask_cors import CORS
+from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token
+from werkzeug.security import generate_password_hash, check_password_hash
+from datetime import datetime, timedelta, date, time
+from sqlalchemy import and_, or_, func
+import stripe
+import random
+import os
+import secrets
+import string
+from flask import Blueprint, request, jsonify
api = Blueprint('api', __name__)
-
-# Allow CORS requests to this API
CORS(api)
+stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
+
+# ============= DECORADOR PARA ADMIN =============
+def admin_required():
+ def wrapper(fn):
+ @jwt_required()
+ def decorator(*args, **kwargs):
+ user_id = get_jwt_identity()
+ user = User.query.get(user_id)
+ if not user or not user.is_admin():
+ return jsonify({"error": "Admin access required"}), 403
+ return fn(*args, **kwargs)
+ decorator.__name__ = fn.__name__
+ return decorator
+ return wrapper
+
+# ============= HELPER: LIMPIAR CARRITOS EXPIRADOS =============
+def clean_expired_carts():
+ """Eliminar carritos que hayan expirado (más de 30 minutos)"""
+ expired_bookings = Booking.query.filter(
+ Booking.status == BookingStatus.CART,
+ Booking.cart_expires_at < datetime.utcnow()
+ ).all()
+
+ for booking in expired_bookings:
+ db.session.delete(booking)
+
+ if expired_bookings:
+ db.session.commit()
+
+def generate_temporary_password(length=12):
+ """Generar contraseña temporal segura"""
+ characters = string.ascii_letters + string.digits + "!@#$%^&*()"
+ return ''.join(secrets.choice(characters) for _ in range(length))
+
+# ============= AUTENTICACIÓN COMPLETA =============
+@api.route('/register', methods=['POST'])
+def register():
+ """
+ Registro de usuario con verificación de email
+ Body: {
+ "email": "user@example.com",
+ "password": "securepassword",
+ "name": "John Doe",
+ "phone": "+1234567890"
+ }
+ """
+ data = request.get_json()
+
+ if not data.get('email') or not data.get('password'):
+ return jsonify({"error": "Email and password are required"}), 400
+
+ # Validar formato de email
+ import re
+ email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
+ if not re.match(email_regex, data['email']):
+ return jsonify({"error": "Invalid email format"}), 400
+
+ # Verificar si el email ya existe
+ if User.query.filter_by(email=data['email']).first():
+ return jsonify({"error": "Email already exists"}), 400
+
+ # Validar longitud de contraseña
+ if len(data['password']) < 8:
+ return jsonify({"error": "Password must be at least 8 characters long"}), 400
+
+ try:
+ user = User(
+ email=data['email'].lower(),
+ password=generate_password_hash(data['password']),
+ name=data.get('name'),
+ phone=data.get('phone'),
+ role=UserRole.USER,
+ is_active=True,
+ email_verified=False,
+ is_guest=False
+ )
+
+ # Generar token de verificación
+ token = user.generate_verification_token()
+
+ db.session.add(user)
+ db.session.commit()
+
+ # Enviar email de verificación
+ send_verification_email(user, token)
+
+ # Crear token JWT
+ access_token = create_access_token(identity=user.id)
+
+ return jsonify({
+ "message": "User registered successfully. Please check your email to verify your account.",
+ "user": user.serialize(),
+ "token": access_token,
+ "email_verification_required": True
+ }), 201
+
+ except Exception as e:
+ db.session.rollback()
+ return jsonify({"error": str(e)}), 500
+
+@api.route('/verify-email', methods=['POST'])
+def verify_email():
+ """
+ Verificar email con token
+ Body: { "token": "verification_token_here" }
+ """
+ data = request.get_json()
+
+ if not data.get('token'):
+ return jsonify({"error": "Token is required"}), 400
+
+ user = User.query.filter_by(verification_token=data['token']).first()
+
+ if not user:
+ return jsonify({"error": "Invalid verification token"}), 400
+
+ if user.verification_token_expires < datetime.utcnow():
+ return jsonify({"error": "Verification token has expired"}), 400
+
+ user.email_verified = True
+ user.verification_token = None
+ user.verification_token_expires = None
+
+ db.session.commit()
+
+ return jsonify({
+ "message": "Email verified successfully",
+ "user": user.serialize()
+ }), 200
+
+@api.route('/resend-verification', methods=['POST'])
+@jwt_required()
+def resend_verification():
+ """Reenviar email de verificación"""
+ user_id = get_jwt_identity()
+ user = User.query.get(user_id)
+
+ if not user:
+ return jsonify({"error": "User not found"}), 404
+
+ if user.email_verified:
+ return jsonify({"error": "Email already verified"}), 400
+
+ # Generar nuevo token
+ token = user.generate_verification_token()
+ db.session.commit()
+
+ # Enviar email
+ send_verification_email(user, token)
+
+ return jsonify({"message": "Verification email sent"}), 200
+
+@api.route('/login', methods=['POST'])
+def login():
+ """
+ Login de usuario
+ Body: {
+ "email": "user@example.com",
+ "password": "password"
+ }
+ """
+ data = request.get_json()
+
+ if not data.get('email') or not data.get('password'):
+ return jsonify({"error": "Email and password are required"}), 400
+
+ user = User.query.filter_by(email=data['email'].lower()).first()
+
+ if not user or not user.password:
+ return jsonify({"error": "Invalid credentials"}), 401
+
+ if not check_password_hash(user.password, data['password']):
+ return jsonify({"error": "Invalid credentials"}), 401
+
+ if not user.is_active:
+ return jsonify({"error": "Account is inactive"}), 401
+
+ # Actualizar último login
+ user.last_login = datetime.utcnow()
+ db.session.commit()
+
+ access_token = create_access_token(identity=user.id)
+
+ return jsonify({
+ "message": "Login successful",
+ "user": user.serialize(),
+ "token": access_token,
+ "email_verified": user.email_verified
+ }), 200
+
+@api.route('/forgot-password', methods=['POST'])
+def forgot_password():
+ """
+ Solicitar reset de contraseña
+ Body: { "email": "user@example.com" }
+ """
+ data = request.get_json()
+
+ if not data.get('email'):
+ return jsonify({"error": "Email is required"}), 400
+
+ user = User.query.filter_by(email=data['email'].lower()).first()
+
+ # Por seguridad, siempre devolver éxito aunque el email no exista
+ if user and not user.is_guest:
+ token = user.generate_password_reset_token()
+ db.session.commit()
+
+ send_password_reset_email(user, token)
+
+ return jsonify({
+ "message": "If that email exists, we've sent password reset instructions"
+ }), 200
+
+@api.route('/reset-password', methods=['POST'])
+def reset_password():
+ """
+ Resetear contraseña con token
+ Body: {
+ "token": "reset_token_here",
+ "new_password": "newpassword123"
+ }
+ """
+ data = request.get_json()
+
+ if not data.get('token') or not data.get('new_password'):
+ return jsonify({"error": "Token and new password are required"}), 400
+
+ if len(data['new_password']) < 8:
+ return jsonify({"error": "Password must be at least 8 characters long"}), 400
+
+ user = User.query.filter_by(password_reset_token=data['token']).first()
+
+ if not user:
+ return jsonify({"error": "Invalid reset token"}), 400
+
+ if user.password_reset_expires < datetime.utcnow():
+ return jsonify({"error": "Reset token has expired"}), 400
+
+ user.password = generate_password_hash(data['new_password'])
+ user.password_reset_token = None
+ user.password_reset_expires = None
+
+ db.session.commit()
+
+ return jsonify({"message": "Password reset successfully"}), 200
+
+@api.route('/change-password', methods=['POST'])
+@jwt_required()
+def change_password():
+ """
+ Cambiar contraseña (usuario logueado)
+ Body: {
+ "current_password": "oldpassword",
+ "new_password": "newpassword123"
+ }
+ """
+ user_id = get_jwt_identity()
+ data = request.get_json()
+
+ if not data.get('current_password') or not data.get('new_password'):
+ return jsonify({"error": "Current and new password are required"}), 400
+
+ user = User.query.get(user_id)
+
+ if not user or not user.password:
+ return jsonify({"error": "User not found"}), 404
+
+ if not check_password_hash(user.password, data['current_password']):
+ return jsonify({"error": "Current password is incorrect"}), 401
+
+ if len(data['new_password']) < 8:
+ return jsonify({"error": "New password must be at least 8 characters long"}), 400
+
+ user.password = generate_password_hash(data['new_password'])
+ db.session.commit()
+
+ return jsonify({"message": "Password changed successfully"}), 200
+
+@api.route('/me', methods=['GET'])
+@jwt_required()
+def get_current_user():
+ user_id = get_jwt_identity()
+ user = User.query.get(user_id)
+
+ if not user:
+ return jsonify({"error": "User not found"}), 404
+
+ return jsonify(user.serialize()), 200
+
+@api.route('/me', methods=['PUT'])
+@jwt_required()
+def update_profile():
+ """
+ Actualizar perfil de usuario
+ Body: {
+ "name": "New Name",
+ "phone": "+1234567890"
+ }
+ """
+ user_id = get_jwt_identity()
+ user = User.query.get(user_id)
+
+ if not user:
+ return jsonify({"error": "User not found"}), 404
+
+ data = request.get_json()
+
+ if data.get('name'):
+ user.name = data['name']
+
+ if data.get('phone'):
+ user.phone = data['phone']
+
+ db.session.commit()
+
+ return jsonify({
+ "message": "Profile updated successfully",
+ "user": user.serialize()
+ }), 200
+
+# ============= EXPERIENCIAS (sin cambios del código anterior) =============
+@api.route('/experiences', methods=['GET'])
+def get_experiences():
+ experiences = Experience.query.filter_by(is_active=True).all()
+ return jsonify([exp.serialize() for exp in experiences]), 200
+
+@api.route('/experiences/', methods=['GET'])
+def get_experience(experience_id):
+ experience = Experience.query.get(experience_id)
+ if not experience:
+ return jsonify({"error": "Experience not found"}), 404
+
+ return jsonify(experience.serialize()), 200
+
+@api.route('/experiences/available', methods=['POST'])
+def get_available_experiences():
+ data = request.get_json()
+
+ try:
+ start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
+ end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
+ guests = int(data.get('guests', 1))
+ except (KeyError, ValueError):
+ return jsonify({"error": "Invalid date format or missing parameters"}), 400
+
+ experiences = Experience.query.filter_by(is_active=True).all()
+ available_experiences = []
+
+ for experience in experiences:
+ schedule_days = [schedule.day_of_week for schedule in experience.schedules]
+ available_dates = []
+ current_date = start_date
+
+ while current_date <= end_date:
+ day_name = current_date.strftime('%A').upper()
+ try:
+ day_enum = DayOfWeek[day_name]
+ except KeyError:
+ current_date += timedelta(days=1)
+ continue
+
+ if day_enum in schedule_days:
+ confirmed_bookings = Booking.query.filter(
+ Booking.experience_id == experience.id,
+ Booking.experience_date == current_date,
+ Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING])
+ ).all()
+
+ booked_spots = sum([b.number_of_guests for b in confirmed_bookings])
+ available_spots = experience.max_capacity - booked_spots
+
+ if available_spots >= guests:
+ schedule = next((s for s in experience.schedules if s.day_of_week == day_enum), None)
+ available_dates.append({
+ 'date': current_date.isoformat(),
+ 'available_spots': available_spots,
+ 'day_of_week': day_name.lower(),
+ 'start_time': schedule.start_time.strftime('%H:%M') if schedule else None
+ })
+
+ current_date += timedelta(days=1)
+
+ if available_dates:
+ exp_data = experience.serialize()
+ exp_data['available_dates'] = available_dates
+ available_experiences.append(exp_data)
+
+ return jsonify(available_experiences), 200
+
+# ============= HABITACIONES (sin cambios) =============
+@api.route('/rooms', methods=['GET'])
+def get_rooms():
+ rooms = Room.query.filter_by(is_active=True).all()
+ return jsonify([room.serialize() for room in rooms]), 200
+
+@api.route('/rooms/', methods=['GET'])
+def get_room(room_id):
+ room = Room.query.get(room_id)
+ if not room:
+ return jsonify({"error": "Room not found"}), 404
+
+ return jsonify(room.serialize()), 200
+
+@api.route('/rooms/available', methods=['POST'])
+def get_available_rooms():
+ data = request.get_json()
+
+ try:
+ check_in = datetime.strptime(data['check_in'], '%Y-%m-%d').date()
+ check_out = datetime.strptime(data['check_out'], '%Y-%m-%d').date()
+ except (KeyError, ValueError):
+ return jsonify({"error": "Invalid date format or missing parameters"}), 400
+
+ if check_in >= check_out:
+ return jsonify({"error": "Check-out must be after check-in"}), 400
+
+ rooms = Room.query.filter_by(is_active=True).all()
+ available_rooms = []
+
+ for room in rooms:
+ is_available = True
+ current_date = check_in
+
+ while current_date < check_out:
+ existing_bookings = BookingRoom.query.join(Booking).filter(
+ BookingRoom.room_id == room.id,
+ BookingRoom.check_in <= current_date,
+ BookingRoom.check_out > current_date,
+ Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING])
+ ).first()
+
+ manual_block = RoomAvailability.query.filter_by(
+ room_id=room.id,
+ date=current_date,
+ is_available=False
+ ).first()
+
+ if existing_bookings or manual_block:
+ is_available = False
+ break
+
+ current_date += timedelta(days=1)
+
+ if is_available:
+ nights = (check_out - check_in).days
+ room_data = room.serialize()
+ room_data['nights'] = nights
+ room_data['total_price'] = room.price_per_night * nights
+ available_rooms.append(room_data)
+
+ return jsonify(available_rooms), 200
+
+# ============= EXTRAS =============
+@api.route('/extras', methods=['GET'])
+def get_extras():
+ extras = Extra.query.filter_by(is_active=True).all()
+ return jsonify([extra.serialize() for extra in extras]), 200
+
+# ============= PAQUETES =============
+@api.route('/packages', methods=['GET'])
+def get_packages():
+ packages = Package.query.filter_by(is_active=True).all()
+ return jsonify([package.serialize() for package in packages]), 200
+
+@api.route('/packages/', methods=['GET'])
+def get_package(package_id):
+ package = Package.query.get(package_id)
+ if not package:
+ return jsonify({"error": "Package not found"}), 404
+
+ return jsonify(package.serialize()), 200
+
+# ============= CARRITO DE COMPRAS =============
+@api.route('/cart', methods=['POST'])
+@jwt_required()
+def add_to_cart():
+ """Agregar item al carrito"""
+ user_id = get_jwt_identity()
+ clean_expired_carts()
+
+ data = request.get_json()
+
+ try:
+ number_of_guests = int(data['number_of_guests'])
+ total_price = 0
+
+ # Parsear fechas y horarios
+ experience_date = None
+ experience_time = None
+ if data.get('experience_date'):
+ experience_date = datetime.strptime(data['experience_date'], '%Y-%m-%d').date()
+ if data.get('experience_time'):
+ experience_time = datetime.strptime(data['experience_time'], '%H:%M').time()
+
+ check_in = None
+ check_out = None
+ check_in_time = None
+ check_out_time = None
+
+ if data.get('check_in') and data.get('check_out'):
+ check_in = datetime.strptime(data['check_in'], '%Y-%m-%d').date()
+ check_out = datetime.strptime(data['check_out'], '%Y-%m-%d').date()
+
+ if data.get('check_in_time'):
+ check_in_time = datetime.strptime(data['check_in_time'], '%H:%M').time()
+ if data.get('check_out_time'):
+ check_out_time = datetime.strptime(data['check_out_time'], '%H:%M').time()
+
+ # Validar experiencia
+ experience = None
+ if data.get('experience_id'):
+ experience = Experience.query.get(data['experience_id'])
+ if not experience or not experience.is_active:
+ return jsonify({"error": "Invalid experience"}), 400
+ total_price += experience.price * number_of_guests
+
+ # Validar paquete
+ package = None
+ if data.get('package_id'):
+ package = Package.query.get(data['package_id'])
+ if not package or not package.is_active:
+ return jsonify({"error": "Invalid package"}), 400
+ total_price += package.price
+
+ # Generar número de confirmación
+ confirmation_number = Booking.generate_confirmation_number()
+
+ # Crear booking en carrito
+ booking = Booking(
+ user_id=user_id,
+ confirmation_number=confirmation_number,
+ experience_id=data.get('experience_id'),
+ package_id=data.get('package_id'),
+ experience_date=experience_date,
+ experience_time=experience_time,
+ check_in=check_in,
+ check_out=check_out,
+ check_in_time=check_in_time,
+ check_out_time=check_out_time,
+ number_of_guests=number_of_guests,
+ status=BookingStatus.CART,
+ payment_status=PaymentStatus.PENDING,
+ total_price=0,
+ special_requests=data.get('special_requests'),
+ cart_expires_at=datetime.utcnow() + timedelta(minutes=30)
+ )
+
+ db.session.add(booking)
+ db.session.flush()
+
+ # Agregar habitaciones
+ if data.get('rooms') and check_in and check_out:
+ nights = (check_out - check_in).days
+
+ for room_data in data['rooms']:
+ room = Room.query.get(room_data['room_id'])
+ if not room or not room.is_active:
+ db.session.rollback()
+ return jsonify({"error": f"Invalid room {room_data['room_id']}"}), 400
+
+ room_price = room.price_per_night * nights
+ total_price += room_price
+
+ booking_room = BookingRoom(
+ booking_id=booking.id,
+ room_id=room.id,
+ check_in=check_in,
+ check_out=check_out,
+ nights=nights,
+ price=room_price
+ )
+ db.session.add(booking_room)
+
+ # Agregar extras
+ if data.get('extras'):
+ for extra_data in data['extras']:
+ extra = Extra.query.get(extra_data['extra_id'])
+ if not extra or not extra.is_active:
+ db.session.rollback()
+ return jsonify({"error": f"Invalid extra {extra_data['extra_id']}"}), 400
+
+ quantity = extra_data.get('quantity', 1)
+
+ if extra.type == ExtraType.PER_BOOKING:
+ extra_price = extra.price
+ else:
+ extra_price = extra.price * number_of_guests * quantity
+
+ total_price += extra_price
+
+ booking_extra = BookingExtra(
+ booking_id=booking.id,
+ extra_id=extra.id,
+ quantity=quantity,
+ price=extra_price
+ )
+ db.session.add(booking_extra)
+
+ booking.total_price = total_price
+ db.session.commit()
+
+ return jsonify({
+ "message": "Item added to cart",
+ "booking": booking.serialize()
+ }), 201
+
+ except Exception as e:
+ db.session.rollback()
+ return jsonify({"error": str(e)}), 500
+
+@api.route('/cart', methods=['GET'])
+@jwt_required()
+def get_cart():
+ user_id = get_jwt_identity()
+ clean_expired_carts()
+
+ cart_items = Booking.query.filter_by(
+ user_id=user_id,
+ status=BookingStatus.CART
+ ).all()
+
+ return jsonify([item.serialize() for item in cart_items]), 200
+
+@api.route('/cart/', methods=['PUT'])
+@jwt_required()
+def update_cart_item(booking_id):
+ user_id = get_jwt_identity()
+
+ booking = Booking.query.filter_by(
+ id=booking_id,
+ user_id=user_id,
+ status=BookingStatus.CART
+ ).first()
+
+ if not booking:
+ return jsonify({"error": "Cart item not found"}), 404
+
+ data = request.get_json()
+
+ try:
+ if data.get('number_of_guests'):
+ booking.number_of_guests = int(data['number_of_guests'])
+
+ if 'special_requests' in data:
+ booking.special_requests = data['special_requests']
+
+ if data.get('extras'):
+ BookingExtra.query.filter_by(booking_id=booking.id).delete()
+
+ total_price = 0
+
+ if booking.experience:
+ total_price += booking.experience.price * booking.number_of_guests
+
+ if booking.package:
+ total_price += booking.package.price
+
+ for booking_room in booking.rooms:
+ total_price += booking_room.price
+
+ for extra_data in data['extras']:
+ extra = Extra.query.get(extra_data['extra_id'])
+ if extra and extra.is_active:
+ quantity = extra_data.get('quantity', 1)
+
+ if extra.type == ExtraType.PER_BOOKING:
+ extra_price = extra.price
+ else:
+ extra_price = extra.price * booking.number_of_guests * quantity
+
+ total_price += extra_price
+
+ booking_extra = BookingExtra(
+ booking_id=booking.id,
+ extra_id=extra.id,
+ quantity=quantity,
+ price=extra_price
+ )
+ db.session.add(booking_extra)
+
+ booking.total_price = total_price
+
+ booking.cart_expires_at = datetime.utcnow() + timedelta(minutes=30)
+ booking.updated_at = datetime.utcnow()
+
+ db.session.commit()
+
+ return jsonify({
+ "message": "Cart item updated",
+ "booking": booking.serialize()
+ }), 200
+
+ except Exception as e:
+ db.session.rollback()
+ return jsonify({"error": str(e)}), 500
+
+@api.route('/cart/', methods=['DELETE'])
+@jwt_required()
+def remove_from_cart(booking_id):
+ user_id = get_jwt_identity()
+
+ booking = Booking.query.filter_by(
+ id=booking_id,
+ user_id=user_id,
+ status=BookingStatus.CART
+ ).first()
+
+ if not booking:
+ return jsonify({"error": "Cart item not found"}), 404
+
+ db.session.delete(booking)
+ db.session.commit()
+
+ return jsonify({"message": "Item removed from cart"}), 200
+
+@api.route('/cart/clear', methods=['DELETE'])
+@jwt_required()
+def clear_cart():
+ user_id = get_jwt_identity()
+
+ cart_items = Booking.query.filter_by(
+ user_id=user_id,
+ status=BookingStatus.CART
+ ).all()
+
+ for item in cart_items:
+ db.session.delete(item)
+
+ db.session.commit()
+
+ return jsonify({"message": "Cart cleared"}), 200
+
+# ============= GUEST CHECKOUT =============
+@api.route('/guest-checkout', methods=['POST'])
+def guest_checkout():
+ """
+ Checkout como invitado (sin registro previo)
+ Body: {
+ "email": "guest@example.com",
+ "name": "Guest User",
+ "phone": "+1234567890",
+ "booking_data": { ... datos del carrito ... }
+ }
+ """
+ data = request.get_json()
+
+ if not data.get('email'):
+ return jsonify({"error": "Email is required"}), 400
+
+ email = data['email'].lower()
+
+ try:
+ # Verificar si el usuario ya existe
+ user = User.query.filter_by(email=email).first()
+
+ if not user:
+ # Crear usuario guest con contraseña temporal
+ temp_password = generate_temporary_password()
+
+ user = User(
+ email=email,
+ password=generate_password_hash(temp_password),
+ name=data.get('name'),
+ phone=data.get('phone'),
+ role=UserRole.USER,
+ is_active=True,
+ email_verified=False,
+ is_guest=True
+ )
+
+ db.session.add(user)
+ db.session.flush()
+
+ user_created = True
+ else:
+ temp_password = None
+ user_created = False
+
+ # Procesar booking (similar a add_to_cart pero directo a PENDING)
+ booking_data = data.get('booking_data', {})
+
+ number_of_guests = int(booking_data['number_of_guests'])
+ total_price = 0
+
+ # Parsear fechas
+ experience_date = None
+ experience_time = None
+ if booking_data.get('experience_date'):
+ experience_date = datetime.strptime(booking_data['experience_date'], '%Y-%m-%d').date()
+ if booking_data.get('experience_time'):
+ experience_time = datetime.strptime(booking_data['experience_time'], '%H:%M').time()
+
+ check_in = None
+ check_out = None
+ check_in_time = None
+ check_out_time = None
+
+ if booking_data.get('check_in') and booking_data.get('check_out'):
+ check_in = datetime.strptime(booking_data['check_in'], '%Y-%m-%d').date()
+ check_out = datetime.strptime(booking_data['check_out'], '%Y-%m-%d').date()
+
+ if booking_data.get('check_in_time'):
+ check_in_time = datetime.strptime(booking_data['check_in_time'], '%H:%M').time()
+ if booking_data.get('check_out_time'):
+ check_out_time = datetime.strptime(booking_data['check_out_time'], '%H:%M').time()
+
+ # Validar experiencia
+ experience = None
+ if booking_data.get('experience_id'):
+ experience = Experience.query.get(booking_data['experience_id'])
+ if not experience or not experience.is_active:
+ db.session.rollback()
+ return jsonify({"error": "Invalid experience"}), 400
+ total_price += experience.price * number_of_guests
+
+ # Validar paquete
+ package = None
+ if booking_data.get('package_id'):
+ package = Package.query.get(booking_data['package_id'])
+ if not package or not package.is_active:
+ db.session.rollback()
+ return jsonify({"error": "Invalid package"}), 400
+ total_price += package.price
+
+ confirmation_number = Booking.generate_confirmation_number()
+
+ booking = Booking(
+ user_id=user.id,
+ confirmation_number=confirmation_number,
+ experience_id=booking_data.get('experience_id'),
+ package_id=booking_data.get('package_id'),
+ experience_date=experience_date,
+ experience_time=experience_time,
+ check_in=check_in,
+ check_out=check_out,
+ check_in_time=check_in_time,
+ check_out_time=check_out_time,
+ number_of_guests=number_of_guests,
+ status=BookingStatus.PENDING,
+ payment_status=PaymentStatus.PENDING,
+ total_price=0,
+ special_requests=booking_data.get('special_requests')
+ )
+
+ db.session.add(booking)
+ db.session.flush()
+
+ # Agregar habitaciones
+ if booking_data.get('rooms') and check_in and check_out:
+ nights = (check_out - check_in).days
+
+ for room_data in booking_data['rooms']:
+ room = Room.query.get(room_data['room_id'])
+ if not room or not room.is_active:
+ db.session.rollback()
+ return jsonify({"error": f"Invalid room {room_data['room_id']}"}), 400
+
+ room_price = room.price_per_night * nights
+ total_price += room_price
+
+ booking_room = BookingRoom(
+ booking_id=booking.id,
+ room_id=room.id,
+ check_in=check_in,
+ check_out=check_out,
+ nights=nights,
+ price=room_price
+ )
+ db.session.add(booking_room)
+
+ # Agregar extras
+ if booking_data.get('extras'):
+ for extra_data in booking_data['extras']:
+ extra = Extra.query.get(extra_data['extra_id'])
+ if not extra or not extra.is_active:
+ db.session.rollback()
+ return jsonify({"error": f"Invalid extra {extra_data['extra_id']}"}), 400
+
+ quantity = extra_data.get('quantity', 1)
+
+ if extra.type == ExtraType.PER_BOOKING:
+ extra_price = extra.price
+ else:
+ extra_price = extra.price * number_of_guests * quantity
+
+ total_price += extra_price
+
+ booking_extra = BookingExtra(
+ booking_id=booking.id,
+ extra_id=extra.id,
+ quantity=quantity,
+ price=extra_price
+ )
+ db.session.add(booking_extra)
+
+ booking.total_price = total_price
+
+ # Crear Payment Intent
+ intent = stripe.PaymentIntent.create(
+ amount=int(total_price * 100),
+ currency='usd',
+ metadata={
+ 'user_id': user.id,
+ 'booking_ids': str(booking.id),
+ 'is_guest': str(user_created)
+ }
+ )
+
+ booking.stripe_payment_intent_id = intent.id
+ booking.stripe_payment_status = intent.status
+ booking.payment_status = PaymentStatus.PROCESSING
+
+ db.session.commit()
+
+ # Si se creó usuario nuevo, enviar email con credenciales
+ if user_created and temp_password:
+ send_guest_checkout_email(booking, temp_password)
+
+ return jsonify({
+ "message": "Guest checkout initiated",
+ "client_secret": intent.client_secret,
+ "payment_intent_id": intent.id,
+ "booking": booking.serialize(),
+ "user_created": user_created,
+ "temporary_password": temp_password if user_created else None
+ }), 200
+
+ except Exception as e:
+ db.session.rollback()
+ return jsonify({"error": str(e)}), 500
+
+# ============= CHECKOUT (USUARIO REGISTRADO) =============
+@api.route('/checkout', methods=['POST'])
+@jwt_required()
+def checkout():
+ """Procesar checkout - convierte items del carrito a PENDING"""
+ user_id = get_jwt_identity()
+ data = request.get_json()
+
+ if not data.get('booking_ids'):
+ return jsonify({"error": "No booking IDs provided"}), 400
+
+ booking_ids = data['booking_ids']
+
+ bookings = Booking.query.filter(
+ Booking.id.in_(booking_ids),
+ Booking.user_id == user_id,
+ Booking.status == BookingStatus.CART
+ ).all()
+
+ if not bookings:
+ return jsonify({"error": "No valid cart items found"}), 404
+
+ # Validar disponibilidad
+ for booking in bookings:
+ if booking.experience_id and booking.experience_date:
+ confirmed_bookings = Booking.query.filter(
+ Booking.experience_id == booking.experience_id,
+ Booking.experience_date == booking.experience_date,
+ Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING]),
+ Booking.id != booking.id
+ ).all()
+
+ booked_spots = sum([b.number_of_guests for b in confirmed_bookings])
+ available_spots = booking.experience.max_capacity - booked_spots
+
+ if available_spots < booking.number_of_guests:
+ return jsonify({
+ "error": f"Experience '{booking.experience.name}' no longer has enough spots available"
+ }), 400
+
+ for booking_room in booking.rooms:
+ current_date = booking_room.check_in
+ while current_date < booking_room.check_out:
+ existing = BookingRoom.query.join(Booking).filter(
+ BookingRoom.room_id == booking_room.room_id,
+ BookingRoom.check_in <= current_date,
+ BookingRoom.check_out > current_date,
+ Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.PENDING]),
+ Booking.id != booking.id
+ ).first()
+
+ if existing:
+ return jsonify({
+ "error": f"Room '{booking_room.room.name}' is no longer available for selected dates"
+ }), 400
+
+ current_date += timedelta(days=1)
+
+ total_amount = sum([b.total_price for b in bookings])
+
+ try:
+ intent = stripe.PaymentIntent.create(
+ amount=int(total_amount * 100),
+ currency='usd',
+ metadata={
+ 'user_id': user_id,
+ 'booking_ids': ','.join([str(b.id) for b in bookings])
+ }
+ )
+
+ for booking in bookings:
+ booking.status = BookingStatus.PENDING
+ booking.payment_status = PaymentStatus.PROCESSING
+ booking.stripe_payment_intent_id = intent.id
+ booking.stripe_payment_status = intent.status
+ booking.cart_expires_at = None
+
+ db.session.commit()
+
+ return jsonify({
+ "message": "Checkout initiated",
+ "client_secret": intent.client_secret,
+ "payment_intent_id": intent.id,
+ "total_amount": total_amount,
+ "bookings": [b.serialize() for b in bookings]
+ }), 200
+
+ except Exception as e:
+ db.session.rollback()
+ return jsonify({"error": str(e)}), 500
+
+# ============= WEBHOOK STRIPE CON ENVÍO DE EMAILS =============
+@api.route('/webhooks/stripe', methods=['POST'])
+def stripe_webhook():
+ payload = request.get_data()
+ sig_header = request.headers.get('Stripe-Signature')
+
+ try:
+ event = stripe.Webhook.construct_event(
+ payload, sig_header, os.getenv('STRIPE_WEBHOOK_SECRET')
+ )
+ except ValueError:
+ return jsonify({"error": "Invalid payload"}), 400
+ except stripe.error.SignatureVerificationError:
+ return jsonify({"error": "Invalid signature"}), 400
+
+ if event['type'] == 'payment_intent.succeeded':
+ payment_intent = event['data']['object']
+ booking_ids = payment_intent['metadata']['booking_ids'].split(',')
+
+ for booking_id in booking_ids:
+ booking = Booking.query.get(int(booking_id))
+ if booking:
+ booking.payment_status = PaymentStatus.SUCCEEDED
+ booking.status = BookingStatus.CONFIRMED
+ booking.stripe_payment_status = payment_intent['status']
+
+ # ENVIAR EMAIL DE CONFIRMACIÓN
+ try:
+ send_booking_confirmation_email(booking)
+ except Exception as e:
+ print(f"Error sending confirmation email: {str(e)}")
+
+ db.session.commit()
+
+ elif event['type'] == 'payment_intent.payment_failed':
+ payment_intent = event['data']['object']
+ booking_ids = payment_intent['metadata']['booking_ids'].split(',')
+
+ for booking_id in booking_ids:
+ booking = Booking.query.get(int(booking_id))
+ if booking:
+ booking.payment_status = PaymentStatus.FAILED
+ booking.stripe_payment_status = payment_intent['status']
+
+ db.session.commit()
+
+ return jsonify({"success": True}), 200
+
+# ============= BUSCAR RESERVA POR NÚMERO DE CONFIRMACIÓN =============
+@api.route('/bookings/search/', methods=['GET'])
+def search_booking_by_confirmation(confirmation_number):
+ """
+ Buscar reserva por número de confirmación (público)
+ Query params: ?email=user@example.com
+ """
+ email = request.args.get('email')
+
+ if not email:
+ return jsonify({"error": "Email is required"}), 400
+
+ booking = Booking.query.join(User).filter(
+ Booking.confirmation_number == confirmation_number.upper(),
+ User.email == email.lower(),
+ Booking.status != BookingStatus.CART
+ ).first()
+
+ if not booking:
+ return jsonify({"error": "Booking not found"}), 404
+
+ return jsonify(booking.serialize()), 200
+
+# ============= RESERVAS (USUARIO LOGUEADO) =============
+@api.route('/bookings/', methods=['GET'])
+@jwt_required()
+def get_booking(booking_id):
+ user_id = get_jwt_identity()
+ user = User.query.get(user_id)
+
+ booking = Booking.query.get(booking_id)
+ if not booking:
+ return jsonify({"error": "Booking not found"}), 404
+
+ if booking.user_id != user_id and not user.is_admin():
+ return jsonify({"error": "Unauthorized"}), 403
+
+ return jsonify(booking.serialize()), 200
+
+@api.route('/bookings/my-bookings', methods=['GET'])
+@jwt_required()
+def get_my_bookings():
+ user_id = get_jwt_identity()
+
+ bookings = Booking.query.filter(
+ Booking.user_id == user_id,
+ Booking.status != BookingStatus.CART
+ ).order_by(Booking.created_at.desc()).all()
+
+ return jsonify([booking.serialize() for booking in bookings]), 200
+
+# ============= ADMIN ROUTES =============
+@api.route('/admin/bookings', methods=['GET'])
+@admin_required()
+def admin_get_all_bookings():
+ status = request.args.get('status')
+ payment_status = request.args.get('payment_status')
+ start_date = request.args.get('start_date')
+ end_date = request.args.get('end_date')
+
+ query = Booking.query.filter(Booking.status != BookingStatus.CART)
+
+ if status:
+ query = query.filter_by(status=BookingStatus[status.upper()])
+
+ if payment_status:
+ query = query.filter_by(payment_status=PaymentStatus[payment_status.upper()])
+
+ if start_date:
+ start = datetime.strptime(start_date, '%Y-%m-%d').date()
+ query = query.filter(Booking.created_at >= start)
+
+ if end_date:
+ end = datetime.strptime(end_date, '%Y-%m-%d').date()
+ query = query.filter(Booking.created_at <= end)
+
+ bookings = query.order_by(Booking.created_at.desc()).all()
+
+ return jsonify([booking.serialize_admin() for booking in bookings]), 200
+
+@api.route('/admin/bookings/', methods=['PUT'])
+@admin_required()
+def admin_update_booking(booking_id):
+ booking = Booking.query.get(booking_id)
+ if not booking:
+ return jsonify({"error": "Booking not found"}), 404
+
+ data = request.get_json()
+
+ if data.get('status'):
+ booking.status = BookingStatus[data['status'].upper()]
+
+ if data.get('payment_status'):
+ booking.payment_status = PaymentStatus[data['payment_status'].upper()]
+
+ if data.get('admin_notes'):
+ booking.admin_notes = data['admin_notes']
+
+ db.session.commit()
+
+ return jsonify({
+ "message": "Booking updated successfully",
+ "booking": booking.serialize_admin()
+ }), 200
+
+@api.route('/admin/stats', methods=['GET'])
+@admin_required()
+def admin_get_stats():
+ total_bookings = Booking.query.filter(Booking.status != BookingStatus.CART).count()
+ confirmed_bookings = Booking.query.filter_by(status=BookingStatus.CONFIRMED).count()
+ pending_bookings = Booking.query.filter_by(status=BookingStatus.PENDING).count()
+
+ total_revenue = db.session.query(func.sum(Booking.total_price)).filter(
+ Booking.payment_status == PaymentStatus.SUCCEEDED
+ ).scalar() or 0
+
+ return jsonify({
+ "total_bookings": total_bookings,
+ "confirmed_bookings": confirmed_bookings,
+ "pending_bookings": pending_bookings,
+ "total_revenue": float(total_revenue)
+ }), 200
@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"
+ "message": "Hello! I'm a message that came from the backend"
}
-
return jsonify(response_body), 200
+
+
+@api.route('/test-email', methods=['POST'])
+def test_email():
+ """Endpoint para probar que el email funciona"""
+ data = request.get_json()
+ email = data.get('email')
+
+ if not email:
+ return jsonify({"error": "Email is required"}), 400
+
+ html = """
+
+
+ 🎉 Test Email Exitoso
+ Si ves este email, ¡tu configuración SMTP funciona perfectamente!
+ Tu sistema de emails de Celiafarm está listo.
+
+
+ """
+
+ try:
+ from api.email_service import send_email
+ success = send_email(
+ recipient=email,
+ subject="✅ Test Email - Celiafarm",
+ html_body=html
+ )
+
+ if success:
+ return jsonify({"message": f"Email enviado exitosamente a {email}"}), 200
+ else:
+ return jsonify({"error": "Error al enviar email"}), 500
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+# Configurar Stripe
+stripe.api_key = os.getenv('STRIPE_SECRET_KEY')
+
+print(f"🔑 STRIPE KEY: {stripe.api_key[:20]}...")
+
+@api.route('/create-checkout-session', methods=['POST', 'OPTIONS'])
+def create_checkout_session():
+ # Manejar preflight request
+ if request.method == 'OPTIONS':
+ return '', 204
+
+ try:
+ data = request.json
+
+ # Crear line items para Stripe
+ line_items = []
+
+ for item in data['items']:
+ line_items.append({
+ 'price_data': {
+ 'currency': 'eur',
+ 'product_data': {
+ 'name': item['name'],
+ 'description': f"{item['type'].capitalize()} - CaliaFarm Booking",
+ },
+ 'unit_amount': int(item['subtotal'] * 100),
+ },
+ 'quantity': 1,
+ })
+
+ # Obtener URL del frontend
+ frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000')
+
+ # Crear sesión de Stripe Checkout
+ checkout_session = stripe.checkout.Session.create(
+ payment_method_types=['card'],
+ line_items=line_items,
+ mode='payment',
+ success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}",
+ cancel_url=f"{frontend_url}/cart",
+ customer_email=data.get('customer_email'),
+ metadata={
+ 'customer_name': data.get('customer_name', ''),
+ 'customer_phone': data.get('customer_phone', ''),
+ 'total_items': str(len(data['items']))
+ }
+ )
+
+ return jsonify({
+ 'checkout_url': checkout_session.url,
+ 'session_id': checkout_session.id
+ }), 200
+
+ except stripe.StripeError as e:
+ print(f"Stripe error: {str(e)}")
+ return jsonify({'error': f'Stripe error: {str(e)}'}), 400
+ except Exception as e:
+ print(f"Error: {str(e)}")
+ return jsonify({'error': str(e)}), 400
+
+
+@api.route('/verify-payment', methods=['POST'])
+def verify_payment():
+ try:
+ data = request.json
+ session_id = data.get('session_id')
+
+ if not session_id:
+ return jsonify({'error': 'No session ID provided'}), 400
+
+ # Verificar si ya existe una reserva con este session_id
+ existing_booking = Booking.query.filter_by(stripe_payment_intent_id=session_id).first()
+ if existing_booking:
+ return jsonify(existing_booking.serialize()), 200
+
+ # Recuperar la sesión de Stripe
+ session = stripe.checkout.Session.retrieve(session_id)
+
+ if session.payment_status != 'paid':
+ return jsonify({'error': 'Payment not completed'}), 400
+
+ # Generar número de confirmación
+ confirmation_number = Booking.generate_confirmation_number()
+
+ # Recuperar metadata
+ metadata = session.metadata
+
+ # Obtener o crear usuario guest
+ customer_email = session.customer_details.email if hasattr(session, 'customer_details') else session.customer_email
+ user = User.query.filter_by(email=customer_email).first()
+
+ if not user:
+ # Crear usuario guest
+ user = User(
+ email=customer_email,
+ name=metadata.get('customer_name', 'Guest'),
+ phone=metadata.get('customer_phone'),
+ is_guest=True,
+ email_verified=False,
+ password=None # Guest users don't have password
+ )
+ db.session.add(user)
+ db.session.flush() # Para obtener el user_id
+
+ # Crear la reserva con los campos correctos de tu modelo
+ booking = Booking(
+ confirmation_number=confirmation_number,
+ user_id=user.id, # REQUERIDO según tu modelo
+ number_of_guests=int(metadata.get('guests', 1)),
+ total_price=session.amount_total / 100,
+ payment_status=PaymentStatus.SUCCEEDED,
+ stripe_payment_intent_id=session_id, # Campo correcto
+ stripe_payment_status='paid',
+ status=BookingStatus.CONFIRMED,
+ special_requests=metadata.get('special_requests')
+ )
+
+ db.session.add(booking)
+ db.session.commit()
+
+ # Preparar datos para el email
+ booking_data = {
+ 'booking_number': confirmation_number,
+ 'customer_email': user.email,
+ 'customer_name': user.name,
+ 'customer_phone': user.phone,
+ 'total_amount': booking.total_price,
+ 'payment_status': 'paid',
+ 'created_at': booking.created_at.isoformat(),
+ 'items': []
+ }
+
+ # Enviar email de confirmación
+ try:
+ send_booking_confirmation_email(booking_data)
+
+ # Registrar el envío del email
+ email_log = EmailLog(
+ booking_id=booking.id,
+ email_type='booking_confirmation',
+ recipient_email=user.email,
+ subject=f'Confirmación de Reserva {confirmation_number}',
+ status=EmailStatus.SENT,
+ sent_at=datetime.utcnow()
+ )
+ db.session.add(email_log)
+ db.session.commit()
+ except Exception as e:
+ print(f"Error sending confirmation email: {str(e)}")
+ # Registrar el error del email
+ email_log = EmailLog(
+ booking_id=booking.id,
+ email_type='booking_confirmation',
+ recipient_email=user.email,
+ subject=f'Confirmación de Reserva {confirmation_number}',
+ status=EmailStatus.FAILED,
+ error_message=str(e)
+ )
+ db.session.add(email_log)
+ db.session.commit()
+
+ return jsonify(booking.serialize()), 200
+
+ except stripe.StripeError as e:
+ db.session.rollback()
+ print(f"Stripe error: {str(e)}")
+ return jsonify({'error': f'Stripe error: {str(e)}'}), 400
+ except Exception as e:
+ db.session.rollback()
+ print(f"Error verifying payment: {str(e)}")
+ return jsonify({'error': str(e)}), 400
+
+ # Manejar preflight request
+ if request.method == 'OPTIONS':
+ return '', 204
+
+ try:
+ data = request.json
+
+ # Crear line items para Stripe
+ line_items = []
+
+ for item in data['items']:
+ line_items.append({
+ 'price_data': {
+ 'currency': 'eur',
+ 'product_data': {
+ 'name': item['name'],
+ 'description': f"{item['type'].capitalize()} - CaliaFarm Booking",
+ },
+ 'unit_amount': int(item['subtotal'] * 100),
+ },
+ 'quantity': 1,
+ })
+
+ # Obtener URL del frontend
+ frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000')
+
+ # Crear sesión de Stripe Checkout
+ checkout_session = stripe.checkout.Session.create(
+ payment_method_types=['card'],
+ line_items=line_items,
+ mode='payment',
+ success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}",
+ cancel_url=f"{frontend_url}/cart",
+ customer_email=data.get('customer_email'),
+ metadata={
+ 'customer_name': data.get('customer_name', ''),
+ 'customer_phone': data.get('customer_phone', ''),
+ 'total_items': str(len(data['items']))
+ }
+ )
+
+ return jsonify({
+ 'checkout_url': checkout_session.url,
+ 'session_id': checkout_session.id
+ }), 200
+
+ except stripe.StripeError as e:
+ print(f"Stripe error: {str(e)}")
+ return jsonify({'error': f'Stripe error: {str(e)}'}), 400
+ except Exception as e:
+ print(f"Error: {str(e)}")
+ return jsonify({'error': str(e)}), 400
+ if request.method == 'OPTIONS':
+ return '', 204
+
+ try:
+ data = request.json
+
+ line_items = []
+
+ for item in data['items']:
+ line_items.append({
+ 'price_data': {
+ 'currency': 'eur',
+ 'product_data': {
+ 'name': item['name'],
+ 'description': f"{item['type'].capitalize()} - CaliaFarm Booking",
+ },
+ 'unit_amount': int(item['subtotal'] * 100),
+ },
+ 'quantity': 1,
+ })
+
+ frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000')
+
+ checkout_session = stripe.checkout.Session.create(
+ payment_method_types=['card'],
+ line_items=line_items,
+ mode='payment',
+ success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}",
+ cancel_url=f"{frontend_url}/cart",
+ customer_email=data.get('customer_email'),
+ metadata={
+ 'customer_name': data.get('customer_name', ''),
+ 'customer_phone': data.get('customer_phone', ''),
+ 'total_items': str(len(data['items']))
+ }
+ )
+
+ return jsonify({
+ 'checkout_url': checkout_session.url,
+ 'session_id': checkout_session.id
+ }), 200
+
+ except stripe.StripeError as e: # 👈 CAMBIAR AQUÍ
+ print(f"Stripe error: {str(e)}")
+ return jsonify({'error': f'Stripe error: {str(e)}'}), 400
+ except Exception as e:
+ print(f"Error: {str(e)}")
+ return jsonify({'error': str(e)}), 400
+ if request.method == 'OPTIONS':
+ return '', 204
+
+ try:
+ data = request.json
+
+ line_items = []
+
+ for item in data['items']:
+ line_items.append({
+ 'price_data': {
+ 'currency': 'eur',
+ 'product_data': {
+ 'name': item['name'],
+ 'description': f"{item['type'].capitalize()} - CaliaFarm Booking",
+ },
+ 'unit_amount': int(item['subtotal'] * 100),
+ },
+ 'quantity': 1,
+ })
+
+ frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000')
+
+ checkout_session = stripe.checkout.Session.create(
+ payment_method_types=['card'],
+ line_items=line_items,
+ mode='payment',
+ success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}",
+ cancel_url=f"{frontend_url}/cart",
+ customer_email=data.get('customer_email'),
+ metadata={
+ 'customer_name': data.get('customer_name', ''),
+ 'customer_phone': data.get('customer_phone', ''),
+ 'total_items': str(len(data['items']))
+ }
+ )
+
+ return jsonify({
+ 'checkout_url': checkout_session.url,
+ 'session_id': checkout_session.id
+ }), 200
+
+ except stripe.StripeError as e: # 👈 CAMBIAR AQUÍ
+ print(f"Stripe error: {str(e)}")
+ return jsonify({'error': f'Stripe error: {str(e)}'}), 400
+ except Exception as e:
+ print(f"Error: {str(e)}")
+ return jsonify({'error': str(e)}), 400
+ try:
+ data = request.json
+
+ # Crear line items para Stripe
+ line_items = []
+
+ for item in data['items']:
+ line_items.append({
+ 'price_data': {
+ 'currency': 'eur',
+ 'product_data': {
+ 'name': item['name'],
+ 'description': f"{item['type'].capitalize()} - CaliaFarm Booking",
+ },
+ 'unit_amount': int(item['subtotal'] * 100), # Stripe usa centavos
+ },
+ 'quantity': 1,
+ })
+
+ # Obtener URL del frontend
+ frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:3000')
+
+ # Crear sesión de Stripe Checkout
+ checkout_session = stripe.checkout.Session.create(
+ payment_method_types=['card'],
+ line_items=line_items,
+ mode='payment',
+ success_url=f"{frontend_url}/confirmation?session_id={{CHECKOUT_SESSION_ID}}",
+ cancel_url=f"{frontend_url}/cart",
+ customer_email=data.get('customer_email'),
+ metadata={
+ 'customer_name': data.get('customer_name', ''),
+ 'customer_phone': data.get('customer_phone', ''),
+ 'total_items': str(len(data['items']))
+ }
+ )
+
+ return jsonify({
+ 'checkout_url': checkout_session.url,
+ 'session_id': checkout_session.id
+ }), 200
+
+ except stripe.error.StripeError as e:
+ print(f"Stripe error: {str(e)}")
+ return jsonify({'error': f'Stripe error: {str(e)}'}), 400
+ except Exception as e:
+ print(f"Error: {str(e)}")
+ return jsonify({'error': str(e)}), 400
\ No newline at end of file
diff --git a/src/api/seed.py b/src/api/seed.py
new file mode 100644
index 0000000000..a8e5fdf663
--- /dev/null
+++ b/src/api/seed.py
@@ -0,0 +1,215 @@
+# src/api/seed.py
+"""
+Seed script para poblar la base de datos con datos reales de CaliaFarm
+"""
+from api.models import (
+ db, User, Experience, ExperienceSchedule, Room, Extra, Package, PackageExtra,
+ UserRole, DayOfWeek, ExtraType
+)
+from werkzeug.security import generate_password_hash
+from datetime import time
+
+def seed_database():
+ """Poblar la base de datos con datos iniciales de CaliaFarm"""
+
+ print("🌱 Iniciando seed de CaliaFarm...")
+
+ # ============= CREAR USUARIO ADMIN =============
+ print("👤 Creando usuario admin...")
+ admin = User.query.filter_by(email='admin@caliafarm.com').first()
+ if not admin:
+ admin = User(
+ email='admin@caliafarm.com',
+ password=generate_password_hash('admin123'),
+ name='CaliaFarm Admin',
+ phone='+39 123 456 7890',
+ role=UserRole.ADMIN,
+ is_active=True,
+ email_verified=True,
+ is_guest=False
+ )
+ db.session.add(admin)
+
+ # ============= CREAR EXPERIENCIAS =============
+ print("🍷 Creando experiencias...")
+
+ # 1. Full Experience
+ full_exp = Experience.query.filter_by(name='Full Experience: Cooking & Dinner + Wine Tasting').first()
+ if not full_exp:
+ full_exp = Experience(
+ name='Full Experience: Cooking & Dinner + Wine Tasting',
+ description='Private tours to Sicily\'s finest wineries with exclusive tastings. Learn authentic Sicilian recipes in hands-on cooking classes.',
+ price=85.0,
+ max_capacity=8,
+ duration_hours=6,
+ image_url='https://images.unsplash.com/photo-1510812431401-41d2bd2722f3?w=800',
+ is_active=True
+ )
+ db.session.add(full_exp)
+ db.session.flush()
+
+ for day in DayOfWeek:
+ schedule = ExperienceSchedule(
+ experience_id=full_exp.id,
+ day_of_week=day,
+ start_time=time(10, 0)
+ )
+ db.session.add(schedule)
+
+ # 2. Cooking & Dinner
+ cooking_exp = Experience.query.filter_by(name='Cooking & Dinner Experience').first()
+ if not cooking_exp:
+ cooking_exp = Experience(
+ name='Cooking & Dinner Experience',
+ description='Visit century-old olive groves and traditional mills to discover Sicily\'s liquid gold.',
+ price=65.0,
+ max_capacity=8,
+ duration_hours=4,
+ image_url='https://images.unsplash.com/photo-1556910103-1c02745aae4d?w=800',
+ is_active=True
+ )
+ db.session.add(cooking_exp)
+ db.session.flush()
+
+ for day in DayOfWeek:
+ schedule = ExperienceSchedule(
+ experience_id=cooking_exp.id,
+ day_of_week=day,
+ start_time=time(14, 0)
+ )
+ db.session.add(schedule)
+
+ # 3. Wine Tasting
+ wine_exp = Experience.query.filter_by(name='Sicilian Wine Tasting').first()
+ if not wine_exp:
+ wine_exp = Experience(
+ name='Sicilian Wine Tasting',
+ description='Learn authentic Sicilian recipes in hands-on cooking classes with local chefs.',
+ price=75.0,
+ max_capacity=8,
+ duration_hours=3,
+ image_url='https://images.unsplash.com/photo-1506377247377-2a5b3b417ebb?w=800',
+ is_active=True
+ )
+ db.session.add(wine_exp)
+ db.session.flush()
+
+ for day in [DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY]:
+ schedule = ExperienceSchedule(
+ experience_id=wine_exp.id,
+ day_of_week=day,
+ start_time=time(11, 0)
+ )
+ db.session.add(schedule)
+
+ # ============= CREAR HABITACIONES =============
+ print("🏨 Creando habitaciones...")
+
+ fico = Room.query.filter_by(name='Fico d\'India').first()
+ if not fico:
+ fico = Room(
+ name='Fico d\'India',
+ description='Elegant room with panoramic views and private jacuzzi.',
+ capacity=2,
+ price_per_night=150.0,
+ image_url='https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800',
+ amenities={'jacuzzi': True, 'vineyard_view': True},
+ check_in_time=time(15, 0),
+ check_out_time=time(10, 0),
+ is_active=True
+ )
+ db.session.add(fico)
+
+ ulivo = Room.query.filter_by(name='Ulivo').first()
+ if not ulivo:
+ ulivo = Room(
+ name='Ulivo',
+ description='Cozy double room with traditional Sicilian decor.',
+ capacity=2,
+ price_per_night=100.0,
+ image_url='https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=800',
+ amenities={'olive_grove_view': True},
+ check_in_time=time(15, 0),
+ check_out_time=time(10, 0),
+ is_active=True
+ )
+ db.session.add(ulivo)
+
+ mandorla = Room.query.filter_by(name='Mandorla').first()
+ if not mandorla:
+ mandorla = Room(
+ name='Mandorla',
+ description='Bright and spacious double room with garden views.',
+ capacity=2,
+ price_per_night=100.0,
+ image_url='https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800',
+ amenities={'garden_view': True},
+ check_in_time=time(15, 0),
+ check_out_time=time(10, 0),
+ is_active=True
+ )
+ db.session.add(mandorla)
+
+ vigna = Room.query.filter_by(name='Vigna').first()
+ if not vigna:
+ vigna = Room(
+ name='Vigna',
+ description='Spacious triple room perfect for families.',
+ capacity=3,
+ price_per_night=140.0,
+ image_url='https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=800',
+ amenities={'vineyard_view': True, 'family_friendly': True},
+ check_in_time=time(15, 0),
+ check_out_time=time(10, 0),
+ is_active=True
+ )
+ db.session.add(vigna)
+
+ # ============= CREAR EXTRAS =============
+ print("✨ Creando extras...")
+
+ transfer = Extra.query.filter_by(name='Airport Transfer').first()
+ if not transfer:
+ transfer = Extra(
+ name='Airport Transfer',
+ description='Private transfer from/to airport',
+ price=80.0,
+ type=ExtraType.PER_BOOKING,
+ is_active=True
+ )
+ db.session.add(transfer)
+
+ breakfast = Extra.query.filter_by(name='Sicilian Breakfast').first()
+ if not breakfast:
+ breakfast = Extra(
+ name='Sicilian Breakfast',
+ description='Traditional Sicilian breakfast',
+ price=15.0,
+ type=ExtraType.PER_GUEST,
+ is_active=True
+ )
+ db.session.add(breakfast)
+
+ dinner = Extra.query.filter_by(name='Gourmet Dinner').first()
+ if not dinner:
+ dinner = Extra(
+ name='Gourmet Dinner',
+ description='5-course Sicilian dinner with wine',
+ price=60.0,
+ type=ExtraType.PER_GUEST,
+ is_active=True
+ )
+ db.session.add(dinner)
+
+ db.session.commit()
+
+ print("✅ Seed completado!")
+ print(f"Experiencias: {Experience.query.count()}")
+ print(f"Habitaciones: {Room.query.count()}")
+ print(f"Extras: {Extra.query.count()}")
+ print("\nAdmin: admin@caliafarm.com / admin123")
+
+if __name__ == '__main__':
+ from app import app
+ with app.app_context():
+ seed_database()
\ No newline at end of file
diff --git a/src/app.py b/src/app.py
index 1b3340c0fa..1504005a6e 100644
--- a/src/app.py
+++ b/src/app.py
@@ -5,13 +5,14 @@
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
+from flask_cors import CORS # 👈 AGREGAR IMPORT
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 models import Person
+from api.email_service import init_mail
ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production"
static_file_dir = os.path.join(os.path.dirname(
@@ -19,7 +20,16 @@
app = Flask(__name__)
app.url_map.strict_slashes = False
-# database condiguration
+# 👇 CONFIGURAR CORS (AGREGAR ESTO AQUÍ)
+CORS(app, resources={
+ r"/api/*": {
+ "origins": "*",
+ "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ "allow_headers": ["Content-Type", "Authorization"]
+ }
+})
+
+# database configuration
db_url = os.getenv("DATABASE_URL")
if db_url is not None:
app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace(
@@ -28,28 +38,40 @@
app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:////tmp/test.db"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
+
+# JWT CONFIGURATION
+app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'dev_secret_key_change_in_production')
+
+# EMAIL CONFIGURATION (Flask-Mail)
+app.config['MAIL_SERVER'] = os.getenv('MAIL_SERVER', 'smtp.gmail.com')
+app.config['MAIL_PORT'] = int(os.getenv('MAIL_PORT', 587))
+app.config['MAIL_USE_TLS'] = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true'
+app.config['MAIL_USE_SSL'] = os.getenv('MAIL_USE_SSL', 'false').lower() == 'true'
+app.config['MAIL_USERNAME'] = os.getenv('MAIL_USERNAME')
+app.config['MAIL_PASSWORD'] = os.getenv('MAIL_PASSWORD')
+app.config['MAIL_DEFAULT_SENDER'] = os.getenv('MAIL_DEFAULT_SENDER', os.getenv('MAIL_USERNAME'))
+
+# Initialize extensions
MIGRATE = Migrate(app, db, compare_type=True)
db.init_app(app)
+jwt = JWTManager(app)
+init_mail(app)
# add the admin
setup_admin(app)
-# add the admin
+# add the commands
setup_commands(app)
-# Add all endpoints form the API with a "api" prefix
+# Add all endpoints from the API with a "api" prefix
app.register_blueprint(api, url_prefix='/api')
# Handle/serialize errors like a JSON object
-
-
@app.errorhandler(APIException)
def handle_invalid_usage(error):
return jsonify(error.to_dict()), error.status_code
# generate sitemap with all your endpoints
-
-
@app.route('/')
def sitemap():
if ENV == "development":
@@ -65,8 +87,7 @@ def serve_any_other_file(path):
response.cache_control.max_age = 0 # avoid cache memory
return response
-
-# this only runs if `$ python src/main.py` is executed
+# this only runs if `$ python src/app.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/components/Footer.jsx b/src/front/components/Footer.jsx
index f06302dbd2..9c963235fb 100644
--- a/src/front/components/Footer.jsx
+++ b/src/front/components/Footer.jsx
@@ -1,11 +1,288 @@
-export const Footer = () => (
-
-);
+// src/front/js/component/Footer.jsx
+import React from "react";
+import { Link } from "react-router-dom";
+
+export const Footer = () => {
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx
index 30d43a2636..8220bc1043 100644
--- a/src/front/components/Navbar.jsx
+++ b/src/front/components/Navbar.jsx
@@ -1,19 +1,161 @@
-import { Link } from "react-router-dom";
+// src/front/js/component/Navbar.jsx
+import React, { useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; // 👈 TU HOOK
export const Navbar = () => {
+ const { store, dispatch } = useGlobalReducer(); // 👈 USA TU HOOK
+ const navigate = useNavigate();
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
- return (
-
-
-
-
React Boilerplate
-
-
-
- Check the Context in action
-
-
-
-
- );
+ const handleLogout = () => {
+ dispatch({ type: "logout" });
+ navigate('/');
+ };
+
+ return (
+
+
+ {/* Logo */}
+
+
✨
+
+ CALIAFARM
+
+
+
+ {/* Mobile Menu Toggle */}
+
setIsMenuOpen(!isMenuOpen)}
+ >
+
+
+
+ {/* Navigation Links */}
+
+
+
+ );
};
\ No newline at end of file
diff --git a/src/front/components/SearchBar.jsx b/src/front/components/SearchBar.jsx
new file mode 100644
index 0000000000..8c0d4982e1
--- /dev/null
+++ b/src/front/components/SearchBar.jsx
@@ -0,0 +1,226 @@
+// src/front/js/component/SearchBar.jsx
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+export const SearchBar = ({ variant = "hero" }) => {
+ const navigate = useNavigate();
+ const [searchData, setSearchData] = useState({
+ checkIn: "",
+ checkOut: "",
+ adults: 2,
+ children: 0
+ });
+ const [showGuestDropdown, setShowGuestDropdown] = useState(false);
+
+ const handleSearch = (e) => {
+ e.preventDefault();
+
+ // Validaciones
+ if (!searchData.checkIn || !searchData.checkOut) {
+ alert("Please select check-in and check-out dates");
+ return;
+ }
+
+ const checkInDate = new Date(searchData.checkIn);
+ const checkOutDate = new Date(searchData.checkOut);
+
+ if (checkInDate >= checkOutDate) {
+ alert("Check-out date must be after check-in date");
+ return;
+ }
+
+ // Navegar a resultados con query params
+ const params = new URLSearchParams({
+ check_in: searchData.checkIn,
+ check_out: searchData.checkOut,
+ guests: searchData.adults + searchData.children
+ });
+
+ navigate(`/search?${params.toString()}`);
+ };
+
+ const incrementGuests = (type) => {
+ setSearchData(prev => ({
+ ...prev,
+ [type]: prev[type] + 1
+ }));
+ };
+
+ const decrementGuests = (type) => {
+ if (searchData[type] > 0) {
+ setSearchData(prev => ({
+ ...prev,
+ [type]: prev[type] - 1
+ }));
+ }
+ };
+
+ const totalGuests = searchData.adults + searchData.children;
+
+ // Estilos según variante
+ const isHero = variant === "hero";
+ const containerClass = isHero
+ ? "shadow-lg p-4 rounded-4 bg-white"
+ : "shadow-sm p-3 rounded-3 bg-white border";
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/hooks/useGlobalReducer.jsx b/src/front/hooks/useGlobalReducer.jsx
index 6aeb9d768e..6275411d63 100644
--- a/src/front/hooks/useGlobalReducer.jsx
+++ b/src/front/hooks/useGlobalReducer.jsx
@@ -1,24 +1,26 @@
-// Import necessary hooks and functions from React.
+// src/front/hooks/useGlobalReducer.jsx
import { useContext, useReducer, createContext } from "react";
-import storeReducer, { initialStore } from "../store" // Import the reducer and the initial state.
+import storeReducer, { initialStore } from "../store.js" // 👈 SIN carpeta store/
-// Create a context to hold the global state of the application
-// We will call this global state the "store" to avoid confusion while using local states
const StoreContext = createContext()
-// Define a provider component that encapsulates the store and warps it in a context provider to
-// broadcast the information throught all the app pages and components.
export function StoreProvider({ children }) {
- // Initialize reducer with the initial state.
const [store, dispatch] = useReducer(storeReducer, initialStore())
- // Provide the store and dispatch method to all child components.
- return
- {children}
-
+
+ return (
+
+ {children}
+
+ )
}
-// Custom hook to access the global state and dispatch function.
export default function useGlobalReducer() {
- const { dispatch, store } = useContext(StoreContext)
+ const context = useContext(StoreContext)
+
+ if (!context) {
+ throw new Error('useGlobalReducer must be used within a StoreProvider')
+ }
+
+ const { dispatch, store } = context
return { dispatch, store };
}
\ No newline at end of file
diff --git a/src/front/pages/Cart.jsx b/src/front/pages/Cart.jsx
new file mode 100644
index 0000000000..6e57691b33
--- /dev/null
+++ b/src/front/pages/Cart.jsx
@@ -0,0 +1,223 @@
+// src/front/pages/Cart.jsx
+import React from "react";
+import { Link, useNavigate } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const Cart = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const navigate = useNavigate();
+
+ const removeItem = (index) => {
+ if (window.confirm("Are you sure you want to remove this item?")) {
+ dispatch({ type: 'remove_from_cart', payload: index });
+ }
+ };
+
+ const calculateGrandTotal = () => {
+ return store.cart.reduce((total, item) => total + item.subtotal, 0).toFixed(2);
+ };
+
+ const proceedToCheckout = () => {
+ if (store.cart.length === 0) {
+ alert("Your cart is empty");
+ return;
+ }
+ navigate("/checkout");
+ };
+
+ if (store.cart.length === 0) {
+ return (
+
+
+
+
+
Your cart is empty
+
+ Looks like you haven't added anything to your cart yet.
+
+
+ Start Exploring
+
+
+
+
+ );
+ }
+
+ return (
+
+
Your Cart
+
+
+ {/* Cart Items */}
+
+ {store.cart.map((item, index) => (
+
+
+
+ {/* Image */}
+
+
+
+
+ {/* Details */}
+
+
{item.name}
+
+ {/* Experience Details */}
+ {item.type === 'experience' && (
+ <>
+
+
+ Date: {new Date(item.date).toLocaleDateString()}
+
+
+
+ Guests: {item.guests}
+
+
+ Price: €{item.price}/person
+
+ >
+ )}
+
+ {/* Room Details */}
+ {item.type === 'room' && (
+ <>
+
+
+ Check-in: {new Date(item.check_in).toLocaleDateString()}
+
+
+
+ Check-out: {new Date(item.check_out).toLocaleDateString()}
+
+
+
+ Nights: {item.nights}
+
+
+ Price: €{item.price}/night
+
+ >
+ )}
+
+ {/* Extras */}
+ {item.extras && item.extras.length > 0 && (
+
+
+ Extras:
+
+
+ {item.extras.map((extra, idx) => (
+ {extra.name} - €{extra.price}
+ ))}
+
+
+ )}
+
+
+ {/* Price and Remove */}
+
+
+ €{item.subtotal.toFixed(2)}
+
+ removeItem(index)}
+ >
+
+ Remove
+
+
+
+
+
+ ))}
+
+
+ {/* Order Summary */}
+
+
+
+
Order Summary
+
+ {/* Items breakdown */}
+
+ {store.cart.map((item, index) => (
+
+ {item.name}
+ €{item.subtotal.toFixed(2)}
+
+ ))}
+
+
+
+
+ {/* Total Items */}
+
+ Total Items:
+ {store.cart.length}
+
+
+
+
+ {/* Grand Total */}
+
+
Grand Total:
+
+ €{calculateGrandTotal()}
+
+
+
+ {/* Checkout Button */}
+
+ Proceed to Checkout
+
+
+ {/* Continue Shopping */}
+
+ Continue Shopping
+
+
+ {/* Security badges */}
+
+
+
+ Secure Checkout
+
+
+
+ Your payment information is safe
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Checkout.jsx b/src/front/pages/Checkout.jsx
new file mode 100644
index 0000000000..9f60fa404f
--- /dev/null
+++ b/src/front/pages/Checkout.jsx
@@ -0,0 +1,361 @@
+// src/front/pages/Checkout.jsx
+import React, { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const Checkout = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const navigate = useNavigate();
+
+ const [loading, setLoading] = useState(false);
+ const [formData, setFormData] = useState({
+ email: "",
+ name: "",
+ phone: "",
+ country: "Italy",
+ address: "",
+ city: "",
+ postal_code: "",
+ special_requests: ""
+ });
+
+ useEffect(() => {
+ // Si el carrito está vacío, redirigir
+ if (store.cart.length === 0) {
+ navigate("/cart");
+ }
+ }, [store.cart, navigate]);
+
+ const handleInputChange = (e) => {
+ const { name, value } = e.target;
+ setFormData({
+ ...formData,
+ [name]: value
+ });
+ };
+
+ const calculateGrandTotal = () => {
+ return store.cart.reduce((total, item) => total + item.subtotal, 0).toFixed(2);
+ };
+
+ const validateForm = () => {
+ if (!formData.email || !formData.name || !formData.phone) {
+ alert("Please fill in all required fields");
+ return false;
+ }
+
+ // Validar email
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(formData.email)) {
+ alert("Please enter a valid email address");
+ return false;
+ }
+
+ return true;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!validateForm()) return;
+
+ setLoading(true);
+
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ // Preparar datos de la reserva
+ const bookingData = {
+ customer_email: formData.email,
+ customer_name: formData.name,
+ customer_phone: formData.phone,
+ country: formData.country,
+ address: formData.address,
+ city: formData.city,
+ postal_code: formData.postal_code,
+ special_requests: formData.special_requests,
+ items: store.cart.map(item => ({
+ type: item.type,
+ id: item.id,
+ name: item.name,
+ date: item.date || null,
+ check_in: item.check_in || null,
+ check_out: item.check_out || null,
+ guests: item.guests || item.capacity,
+ nights: item.nights || null,
+ price: item.price,
+ extras: item.extras,
+ subtotal: item.subtotal
+ })),
+ total_amount: parseFloat(calculateGrandTotal())
+ };
+
+ // Crear sesión de checkout con Stripe
+ const response = await fetch(`${backendUrl}/api/create-checkout-session`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(bookingData)
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ // Redirigir a Stripe Checkout
+ window.location.href = data.checkout_url;
+ } else {
+ alert(data.error || "Error creating checkout session");
+ setLoading(false);
+ }
+ } catch (error) {
+ console.error("Error:", error);
+ alert("An error occurred. Please try again.");
+ setLoading(false);
+ }
+ };
+
+ if (store.cart.length === 0) {
+ return null;
+ }
+
+ return (
+
+
Checkout
+
+
+ {/* Left Column - Form */}
+
+
+ {/* Right Column - Order Summary */}
+
+
+
+
Order Summary
+
+ {/* Cart Items */}
+
+ {store.cart.map((item, index) => (
+
+
+
+
+
{item.name}
+
+ {item.type === 'experience' && (
+ <>
+ {new Date(item.date).toLocaleDateString()} · {item.guests} guests
+ >
+ )}
+ {item.type === 'room' && (
+ <>
+ {item.nights} nights · {new Date(item.check_in).toLocaleDateString()}
+ >
+ )}
+
+
+
+ €{item.subtotal.toFixed(2)}
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* Total */}
+
+
Total:
+
+ €{calculateGrandTotal()}
+
+
+
+ {/* Payment Button */}
+
+ {loading ? (
+ <>
+
+ Processing...
+ >
+ ) : (
+ <>
+
+ Pay with Stripe
+ >
+ )}
+
+
+ {/* Security Info */}
+
+
+
+ Secure payment powered by Stripe
+
+
+
+ Your payment information is encrypted
+
+
+
+ {/* Cancellation Policy */}
+
+
Cancellation Policy
+
+ Free cancellation up to 48 hours before your booking.
+ After that, cancellations are subject to a 50% fee.
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Confirmation.jsx b/src/front/pages/Confirmation.jsx
new file mode 100644
index 0000000000..b774b24030
--- /dev/null
+++ b/src/front/pages/Confirmation.jsx
@@ -0,0 +1,270 @@
+// src/front/pages/Confirmation.jsx
+import React, { useState, useEffect } from "react";
+import { useSearchParams, Link } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const Confirmation = () => {
+ const [searchParams] = useSearchParams();
+ const { dispatch } = useGlobalReducer();
+ const [loading, setLoading] = useState(true);
+ const [booking, setBooking] = useState(null);
+ const [error, setError] = useState(null);
+
+ const sessionId = searchParams.get('session_id');
+
+ useEffect(() => {
+ if (sessionId) {
+ verifyPayment();
+ } else {
+ setError("No session ID found");
+ setLoading(false);
+ }
+ }, [sessionId]);
+
+ const verifyPayment = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+ const response = await fetch(`${backendUrl}/api/verify-payment`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ session_id: sessionId })
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ setBooking(data);
+ // Limpiar carrito después del pago exitoso
+ dispatch({ type: 'clear_cart' });
+ } else {
+ setError(data.error || "Payment verification failed");
+ }
+
+ setLoading(false);
+ } catch (error) {
+ console.error("Error verifying payment:", error);
+ setError("An error occurred while verifying your payment");
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+ Loading...
+
+
Verifying your payment...
+
Please wait while we confirm your booking.
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
Payment Verification Failed
+
{error}
+
+ Return to Home
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Success Header */}
+
+
+
+
+
Booking Confirmed!
+
+ Thank you for your booking. A confirmation email has been sent to{' '}
+ {booking?.customer_email}
+
+
+
+ {/* Booking Details Card */}
+
+
+
+
+
Booking Reference
+
+ #{booking?.booking_number}
+
+
+
+ Booking Date
+ {new Date(booking?.created_at).toLocaleDateString()}
+
+
+
+ {/* Customer Information */}
+
+
Customer Information
+
+
+ Name
+ {booking?.customer_name}
+
+
+ Email
+ {booking?.customer_email}
+
+
+ Phone
+ {booking?.customer_phone}
+
+
+ Payment Status
+ Paid
+
+
+
+
+ {/* Booking Items */}
+
+
Booking Details
+ {booking?.items?.map((item, index) => (
+
+
+
+
+
{item.name}
+
+ {item.type === 'experience' && (
+ <>
+
+
+ Date: {new Date(item.date).toLocaleDateString()}
+
+
+
+ Guests: {item.guests}
+
+ >
+ )}
+ {item.type === 'room' && (
+ <>
+
+
+ Check-in: {new Date(item.check_in).toLocaleDateString()}
+
+
+
+ Check-out: {new Date(item.check_out).toLocaleDateString()}
+
+
+
+ Nights: {item.nights}
+
+ >
+ )}
+
+ {item.extras && item.extras.length > 0 && (
+
+
+ Extras: {item.extras.map(e => e.name).join(', ')}
+
+
+ )}
+
+
+
+ €{item.subtotal.toFixed(2)}
+
+
+
+
+ ))}
+
+
+ {/* Total */}
+
+
Total Paid
+
+ €{booking?.total_amount?.toFixed(2)}
+
+
+
+
+
+ {/* Important Information */}
+
+
+
+
+ Important Information
+
+
+
+ A confirmation email with your booking details has been sent to your email address.
+
+
+ Please arrive 15 minutes before your scheduled time.
+
+
+ For any changes or cancellations, please contact us at least 48 hours in advance.
+
+
+ If you have any questions, contact us at:
+ info@caliafarm.com
+
+
+
+
+
+ {/* Actions */}
+
+
+ Return to Home
+
+ window.print()}
+ >
+
+ Print Receipt
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/ExperienceDetail.jsx b/src/front/pages/ExperienceDetail.jsx
new file mode 100644
index 0000000000..409b65037d
--- /dev/null
+++ b/src/front/pages/ExperienceDetail.jsx
@@ -0,0 +1,298 @@
+// src/front/pages/ExperienceDetail.jsx
+import React, { useState, useEffect } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const ExperienceDetail = () => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const { store, dispatch } = useGlobalReducer();
+
+ const [experience, setExperience] = useState(null);
+ const [extras, setExtras] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Formulario
+ const [selectedDate, setSelectedDate] = useState("");
+ const [guests, setGuests] = useState(2);
+ const [selectedExtras, setSelectedExtras] = useState([]);
+
+ useEffect(() => {
+ loadExperience();
+ loadExtras();
+ }, [id]);
+
+ const loadExperience = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+ const response = await fetch(`${backendUrl}/api/experiences/${id}`);
+ const data = await response.json();
+
+ if (response.ok) {
+ setExperience(data);
+ } else {
+ alert("Experience not found");
+ navigate("/");
+ }
+ setLoading(false);
+ } catch (error) {
+ console.error("Error loading experience:", error);
+ setLoading(false);
+ }
+ };
+
+ const loadExtras = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+ const response = await fetch(`${backendUrl}/api/extras`);
+ const data = await response.json();
+ if (response.ok) setExtras(data);
+ } catch (error) {
+ console.error("Error loading extras:", error);
+ }
+ };
+
+ const toggleExtra = (extra) => {
+ const exists = selectedExtras.find(e => e.id === extra.id);
+ if (exists) {
+ setSelectedExtras(selectedExtras.filter(e => e.id !== extra.id));
+ } else {
+ setSelectedExtras([...selectedExtras, extra]);
+ }
+ };
+
+ const calculateTotal = () => {
+ if (!experience) return 0;
+
+ let total = experience.price * guests;
+
+ selectedExtras.forEach(extra => {
+ if (extra.type === 'per_guest') {
+ total += extra.price * guests;
+ } else {
+ total += extra.price;
+ }
+ });
+
+ return total.toFixed(2);
+ };
+
+ const addToCart = () => {
+ if (!selectedDate) {
+ alert("Please select a date");
+ return;
+ }
+
+ const cartItem = {
+ type: 'experience',
+ id: experience.id,
+ name: experience.name,
+ date: selectedDate,
+ guests: guests,
+ price: experience.price,
+ image_url: experience.image_url,
+ extras: selectedExtras,
+ subtotal: parseFloat(calculateTotal())
+ };
+
+ dispatch({ type: 'add_to_cart', payload: cartItem });
+ alert("Added to cart!");
+ navigate("/cart");
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!experience) {
+ return (
+
+
Experience not found
+
+ );
+ }
+
+ return (
+
+
+ {/* Left Column - Image and Description */}
+
+
+
+
{experience.name}
+
+
+
+
+ {experience.duration_hours} hours
+
+
+
+ Max {experience.max_capacity} guests
+
+
+
+ 5.0 (120 reviews)
+
+
+
+
+
About this experience
+
+ {experience.description}
+
+
+
+
+
What's included
+
+
+
+ Expert guide
+
+
+
+ All materials and equipment
+
+
+
+ Light refreshments
+
+
+
+ Transportation during activity
+
+
+
+
+
+ {/* Right Column - Booking Form */}
+
+
+
+
+
+
+ €{experience.price}
+
+ per person
+
+
+
+ {/* Date Selection */}
+
+ Select Date
+ setSelectedDate(e.target.value)}
+ min={new Date().toISOString().split('T')[0]}
+ />
+
+
+ {/* Guests Selection */}
+
+
Number of Guests
+
+ setGuests(Math.max(1, guests - 1))}
+ disabled={guests <= 1}
+ >
+
+
+ {guests}
+ setGuests(Math.min(experience.max_capacity, guests + 1))}
+ disabled={guests >= experience.max_capacity}
+ >
+
+
+
+
+
+ {/* Extras */}
+ {extras.length > 0 && (
+
+
Add Extras (Optional)
+ {extras.map(extra => (
+
+ e.id === extra.id)}
+ onChange={() => toggleExtra(extra)}
+ />
+
+
+ {extra.name}
+
+ €{extra.price}
+ {extra.type === 'per_guest' && ' /person'}
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Price Breakdown */}
+
+
+ €{experience.price} x {guests} guests
+ €{(experience.price * guests).toFixed(2)}
+
+ {selectedExtras.map(extra => (
+
+ {extra.name}
+
+ €{extra.type === 'per_guest'
+ ? (extra.price * guests).toFixed(2)
+ : extra.price.toFixed(2)
+ }
+
+
+ ))}
+
+ Total
+ €{calculateTotal()}
+
+
+
+ {/* Add to Cart Button */}
+
+ Add to Cart
+
+
+
+ You won't be charged yet
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx
index 341ed21768..a95a7107be 100644
--- a/src/front/pages/Home.jsx
+++ b/src/front/pages/Home.jsx
@@ -1,52 +1,223 @@
-import React, { useEffect } from "react"
-import rigoImageUrl from "../assets/img/rigo-baby.jpg";
+// src/front/pages/Home.jsx
+import React, { useState, useEffect } from "react";
+import { Link } from "react-router-dom";
import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+import { SearchBar } from "../components/SearchBar.jsx";
export const Home = () => {
+ const { store, dispatch } = useGlobalReducer();
+ const [loading, setLoading] = useState(true);
- const { store, dispatch } = useGlobalReducer()
-
- const loadMessage = async () => {
- try {
- const backendUrl = import.meta.env.VITE_BACKEND_URL
-
- if (!backendUrl) throw new Error("VITE_BACKEND_URL is not defined in .env file")
-
- const response = await fetch(backendUrl + "/api/hello")
- const data = await response.json()
-
- if (response.ok) dispatch({ type: "set_hello", payload: data.message })
-
- return data
-
- } catch (error) {
- if (error.message) throw new Error(
- `Could not fetch the message from the backend.
- Please check if the backend is running and the backend port is public.`
- );
- }
-
- }
-
- useEffect(() => {
- loadMessage()
- }, [])
-
- return (
-
-
Hello Rigo!!
-
-
-
-
- {store.message ? (
- {store.message}
- ) : (
-
- Loading message from the backend (make sure your python 🐍 backend is running)...
-
- )}
-
-
- );
-};
\ No newline at end of file
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const loadData = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ // Cargar experiencias
+ const expResponse = await fetch(`${backendUrl}/api/experiences`);
+ const expData = await expResponse.json();
+ if (expResponse.ok) {
+ dispatch({ type: "set_experiences", payload: expData });
+ }
+
+ // Cargar habitaciones
+ const roomResponse = await fetch(`${backendUrl}/api/rooms`);
+ const roomData = await roomResponse.json();
+ if (roomResponse.ok) {
+ dispatch({ type: "set_rooms", payload: roomData });
+ }
+
+ setLoading(false);
+ } catch (error) {
+ console.error("Error loading data:", error);
+ setLoading(false);
+ }
+ };
+
+ const experiences = store.experiences.slice(0, 3);
+
+ return (
+ <>
+ {/* HERO SECTION */}
+
+
+
+
+
+ ✨
+
+
+
+ CALIAFARM
+
+
+
+
+
+ Authentic Sicilian Agritourism
+
+
+
+ Experience the true essence of Sicily through exclusive wine tours,
+ olive oil tastings, and luxury accommodation in the heart of the countryside
+
+
+
+
+
+ Between Palermo & Trapani
+
+
+
+ Small Groups · Max 8 People
+
+
+
+ 5-Star Rated Experiences
+
+
+
+
+
+
+
+
+
+
+
+ {/* STATS SECTION */}
+
+
+
+
+
+
+
15
+
Years Experience
+
+
+
100%
+
Sicilian Authentic
+
+
+
+
+
+ {/* EXPERIENCES SECTION */}
+
+
+
+
+ What We Offer
+
+
+ Sicilian Experiences
+
+
+ Immerse yourself in authentic Sicilian culture through our curated experiences
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {experiences.length > 0 ? (
+ experiences.map((exp) => (
+
+
e.currentTarget.style.transform = 'translateY(-10px)'}
+ onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'}
+ >
+
+
+
+ €{exp.price}/person
+
+
+
+
+ {exp.name}
+
+
+ {exp.description.substring(0, 120)}...
+
+
+
+
+ {exp.duration_hours}h
+
+
+
+ Max {exp.max_capacity}
+
+
+
+ View Details
+
+
+
+
+ ))
+ ) : (
+
+
No experiences available at the moment.
+
+ )}
+
+ )}
+
+
+ >
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/RoomDetail.jsx b/src/front/pages/RoomDetail.jsx
new file mode 100644
index 0000000000..12bca145a6
--- /dev/null
+++ b/src/front/pages/RoomDetail.jsx
@@ -0,0 +1,341 @@
+// src/front/pages/RoomDetail.jsx
+import React, { useState, useEffect } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const RoomDetail = () => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const { store, dispatch } = useGlobalReducer();
+
+ const [room, setRoom] = useState(null);
+ const [extras, setExtras] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Formulario
+ const [checkIn, setCheckIn] = useState("");
+ const [checkOut, setCheckOut] = useState("");
+ const [selectedExtras, setSelectedExtras] = useState([]);
+
+ useEffect(() => {
+ loadRoom();
+ loadExtras();
+ }, [id]);
+
+ const loadRoom = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+ const response = await fetch(`${backendUrl}/api/rooms/${id}`);
+ const data = await response.json();
+
+ if (response.ok) {
+ setRoom(data);
+ } else {
+ alert("Room not found");
+ navigate("/");
+ }
+ setLoading(false);
+ } catch (error) {
+ console.error("Error loading room:", error);
+ setLoading(false);
+ }
+ };
+
+ const loadExtras = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+ const response = await fetch(`${backendUrl}/api/extras`);
+ const data = await response.json();
+ if (response.ok) setExtras(data);
+ } catch (error) {
+ console.error("Error loading extras:", error);
+ }
+ };
+
+ const toggleExtra = (extra) => {
+ const exists = selectedExtras.find(e => e.id === extra.id);
+ if (exists) {
+ setSelectedExtras(selectedExtras.filter(e => e.id !== extra.id));
+ } else {
+ setSelectedExtras([...selectedExtras, extra]);
+ }
+ };
+
+ const calculateNights = () => {
+ if (!checkIn || !checkOut) return 0;
+ const start = new Date(checkIn);
+ const end = new Date(checkOut);
+ const diffTime = Math.abs(end - start);
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+ return diffDays;
+ };
+
+ const calculateTotal = () => {
+ if (!room) return 0;
+
+ const nights = calculateNights();
+ let total = room.price_per_night * nights;
+
+ selectedExtras.forEach(extra => {
+ if (extra.type === 'per_guest') {
+ total += extra.price * room.capacity * nights;
+ } else {
+ total += extra.price;
+ }
+ });
+
+ return total.toFixed(2);
+ };
+
+ const addToCart = () => {
+ if (!checkIn || !checkOut) {
+ alert("Please select check-in and check-out dates");
+ return;
+ }
+
+ if (new Date(checkIn) >= new Date(checkOut)) {
+ alert("Check-out date must be after check-in date");
+ return;
+ }
+
+ const nights = calculateNights();
+
+ const cartItem = {
+ type: 'room',
+ id: room.id,
+ name: room.name,
+ check_in: checkIn,
+ check_out: checkOut,
+ nights: nights,
+ guests: room.capacity,
+ price: room.price_per_night,
+ image_url: room.image_url,
+ extras: selectedExtras,
+ subtotal: parseFloat(calculateTotal())
+ };
+
+ dispatch({ type: 'add_to_cart', payload: cartItem });
+ alert("Added to cart!");
+ navigate("/cart");
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!room) {
+ return (
+
+
Room not found
+
+ );
+ }
+
+ const nights = calculateNights();
+
+ return (
+
+
+ {/* Left Column - Image and Description */}
+
+
+
+
{room.name}
+
+
+
+
+ Up to {room.capacity} guests
+
+
+
+ Luxury room
+
+
+
+ 5.0 (85 reviews)
+
+
+
+
+
About this room
+
+ {room.description}
+
+
+
+ {/* Amenities */}
+
+
Amenities
+
+ {room.amenities && Object.entries(room.amenities).map(([key, value]) => (
+ value && (
+
+
+
+
+ {key.replace(/_/g, ' ')}
+
+
+
+ )
+ ))}
+
+
+
+ {/* Check-in/out times */}
+
+
House Rules
+
+
+
+
+
+ Check-in:
+ After 3:00 PM
+
+
+
+
+
+
+
+ Check-out:
+ Before 10:00 AM
+
+
+
+
+
+
+
+ {/* Right Column - Booking Form */}
+
+
+
+
+
+
+ €{room.price_per_night}
+
+ per night
+
+
+
+ {/* Check-in Date */}
+
+ Check-in
+ setCheckIn(e.target.value)}
+ min={new Date().toISOString().split('T')[0]}
+ />
+
+
+ {/* Check-out Date */}
+
+ Check-out
+ setCheckOut(e.target.value)}
+ min={checkIn || new Date().toISOString().split('T')[0]}
+ />
+
+
+ {/* Show nights if dates selected */}
+ {nights > 0 && (
+
+
+ {nights} {nights === 1 ? 'night' : 'nights'}
+
+ )}
+
+ {/* Extras */}
+ {extras.length > 0 && (
+
+
Add Extras (Optional)
+ {extras.map(extra => (
+
+ e.id === extra.id)}
+ onChange={() => toggleExtra(extra)}
+ />
+
+
+ {extra.name}
+
+ €{extra.price}
+ {extra.type === 'per_guest' && ' /person/night'}
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Price Breakdown */}
+ {nights > 0 && (
+
+
+ €{room.price_per_night} x {nights} nights
+ €{(room.price_per_night * nights).toFixed(2)}
+
+ {selectedExtras.map(extra => (
+
+ {extra.name}
+
+ €{extra.type === 'per_guest'
+ ? (extra.price * room.capacity * nights).toFixed(2)
+ : extra.price.toFixed(2)
+ }
+
+
+ ))}
+
+ Total
+ €{calculateTotal()}
+
+
+ )}
+
+ {/* Add to Cart Button */}
+
+ Add to Cart
+
+
+
+ You won't be charged yet
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/SearchResults.jsx b/src/front/pages/SearchResults.jsx
new file mode 100644
index 0000000000..b31e5cc4b4
--- /dev/null
+++ b/src/front/pages/SearchResults.jsx
@@ -0,0 +1,166 @@
+// src/front/pages/SearchResults.jsx
+import React, { useState, useEffect } from "react";
+import { useSearchParams, Link } from "react-router-dom";
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+
+export const SearchResults = () => {
+ const [searchParams] = useSearchParams();
+ const { store } = useGlobalReducer();
+ const [loading, setLoading] = useState(true);
+ const [results, setResults] = useState({ experiences: [], rooms: [] });
+
+ // Obtener parámetros de búsqueda
+ const checkIn = searchParams.get('check_in');
+ const checkOut = searchParams.get('check_out');
+ const guests = searchParams.get('guests');
+
+ useEffect(() => {
+ searchAvailability();
+ }, [checkIn, checkOut, guests]);
+
+ const searchAvailability = async () => {
+ try {
+ const backendUrl = import.meta.env.VITE_BACKEND_URL;
+
+ // Por ahora mostramos todo (luego implementaremos filtrado por disponibilidad)
+ const expResponse = await fetch(`${backendUrl}/api/experiences`);
+ const expData = await expResponse.json();
+
+ const roomResponse = await fetch(`${backendUrl}/api/rooms`);
+ const roomData = await roomResponse.json();
+
+ if (expResponse.ok && roomResponse.ok) {
+ setResults({
+ experiences: expData,
+ rooms: roomData
+ });
+ }
+
+ setLoading(false);
+ } catch (error) {
+ console.error("Error searching:", error);
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {/* SEARCH SUMMARY */}
+
+
+
Search Results
+
+
+
Check-in:
+
{checkIn || 'Not selected'}
+
+
+
Check-out:
+
{checkOut || 'Not selected'}
+
+
+
Guests:
+
{guests || '0'}
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+ <>
+ {/* EXPERIENCES */}
+
+ Experiences ({results.experiences.length})
+
+ {results.experiences.map((exp) => (
+
+
+
+
+
{exp.name}
+
+ {exp.description.substring(0, 100)}...
+
+
+ €{exp.price}/person
+
+
+ {exp.duration_hours}h
+
+
+
+ View Details
+
+
+
+
+ ))}
+
+
+
+ {/* ROOMS */}
+
+ Accommodation ({results.rooms.length})
+
+ {results.rooms.map((room) => (
+
+
+
+
+
{room.name}
+
+ {room.description.substring(0, 100)}...
+
+
+ €{room.price_per_night}/night
+
+
+ {room.capacity} guests
+
+
+
+ View Details
+
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/routes.jsx b/src/front/routes.jsx
index 0557df6141..c17f015b78 100644
--- a/src/front/routes.jsx
+++ b/src/front/routes.jsx
@@ -1,5 +1,4 @@
-// Import necessary components and functions from react-router-dom.
-
+// src/front/routes.jsx
import {
createBrowserRouter,
createRoutesFromElements,
@@ -9,22 +8,39 @@ import { Layout } from "./pages/Layout";
import { Home } from "./pages/Home";
import { Single } from "./pages/Single";
import { Demo } from "./pages/Demo";
+import { SearchResults } from "./pages/SearchResults";
+import { ExperienceDetail } from "./pages/ExperienceDetail";
+import { RoomDetail } from "./pages/RoomDetail";
+import { Cart } from "./pages/Cart";
+import { Checkout } from "./pages/Checkout";
+import { Confirmation } from "./pages/Confirmation"; // 👈 NUEVO
export const router = createBrowserRouter(
createRoutesFromElements(
- // CreateRoutesFromElements function allows you to build route elements declaratively.
- // Create your routes here, if you want to keep the Navbar and Footer in all views, add your new routes inside the containing Route.
- // Root, on the contrary, create a sister Route, if you have doubts, try it!
- // Note: keep in mind that errorElement will be the default page when you don't get a route, customize that page to make your project more attractive.
- // 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! } >
-
- {/* Nested Routes: Defines sub-routes within the BaseHome component. */}
- } />
- } /> {/* Dynamic route for single items */}
- } />
-
- )
+ } errorElement={Not found! } >
+ {/* Main Routes */}
+ } />
+ } />
+
+ {/* Detail Pages */}
+ } />
+ } />
+
+ {/* Booking Flow */}
+ } />
+ } />
+ } /> {/* 👈 NUEVO */}
+
+ {/* Other Pages */}
+ } />
+ } />
+
+ ),
+ {
+ basename: import.meta.env.VITE_BASENAME || "",
+ future: {
+ v7_startTransition: true,
+ v7_relativeSplatPath: true
+ }
+ }
);
\ No newline at end of file
diff --git a/src/front/store.js b/src/front/store.js
index 3062cd222d..590840431e 100644
--- a/src/front/store.js
+++ b/src/front/store.js
@@ -1,38 +1,120 @@
-export const initialStore=()=>{
- return{
- message: null,
- todos: [
- {
- id: 1,
- title: "Make the bed",
- background: null,
- },
- {
- id: 2,
- title: "Do my homework",
- background: null,
- }
- ]
- }
+// src/front/js/store/store.js
+
+export const initialStore = () => {
+ return {
+ // Mensaje de ejemplo (puedes mantenerlo o quitarlo)
+ message: null,
+
+ // Usuario autenticado
+ user: null,
+
+ // Carrito de compras
+ cart: JSON.parse(localStorage.getItem('cart')) || [],
+
+ // Datos de la aplicación
+ experiences: [],
+ rooms: [],
+ packages: [],
+
+ // Reserva en proceso
+ currentBooking: null
+ }
}
export default function storeReducer(store, action = {}) {
- switch(action.type){
- case 'set_hello':
- return {
- ...store,
- message: action.payload
- };
-
- case 'add_task':
-
- const { id, color } = action.payload
-
- return {
- ...store,
- todos: store.todos.map((todo) => (todo.id === id ? { ...todo, background: color } : todo))
- };
- default:
- throw Error('Unknown action.');
- }
-}
+ switch (action.type) {
+ // ============= MENSAJE DE EJEMPLO =============
+ case 'set_hello':
+ return {
+ ...store,
+ message: action.payload
+ };
+
+ // ============= EXPERIENCIAS =============
+ case 'set_experiences':
+ return {
+ ...store,
+ experiences: action.payload
+ };
+
+ // ============= HABITACIONES =============
+ case 'set_rooms':
+ return {
+ ...store,
+ rooms: action.payload
+ };
+
+ // ============= PAQUETES =============
+ case 'set_packages':
+ return {
+ ...store,
+ packages: action.payload
+ };
+
+ // ============= USUARIO =============
+ case 'set_user':
+ return {
+ ...store,
+ user: action.payload
+ };
+
+ case 'logout':
+ localStorage.removeItem('token');
+ localStorage.removeItem('cart');
+ return {
+ ...store,
+ user: null,
+ cart: []
+ };
+
+ // ============= CARRITO =============
+ case 'add_to_cart':
+ const newCart = [...store.cart, action.payload];
+ localStorage.setItem('cart', JSON.stringify(newCart));
+ return {
+ ...store,
+ cart: newCart
+ };
+
+ case 'remove_from_cart':
+ const filteredCart = store.cart.filter((_, index) => index !== action.payload);
+ localStorage.setItem('cart', JSON.stringify(filteredCart));
+ return {
+ ...store,
+ cart: filteredCart
+ };
+
+ case 'update_cart_item':
+ const updatedCart = store.cart.map((item, index) =>
+ index === action.payload.index ? action.payload.item : item
+ );
+ localStorage.setItem('cart', JSON.stringify(updatedCart));
+ return {
+ ...store,
+ cart: updatedCart
+ };
+
+ case 'clear_cart':
+ localStorage.removeItem('cart');
+ return {
+ ...store,
+ cart: []
+ };
+
+ // ============= RESERVA ACTUAL =============
+ case 'set_current_booking':
+ return {
+ ...store,
+ currentBooking: action.payload
+ };
+
+ case 'clear_current_booking':
+ return {
+ ...store,
+ currentBooking: null
+ };
+
+ default:
+ return store;
+ }
+}
\ No newline at end of file