From 59d785c2489b5715b7e57e33ecb9d88b682930ce Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 28 Sep 2025 18:48:44 +0200 Subject: [PATCH] Optimize frontend with performance improvements and design consistency --- .../factories/PlannableItemFactory.php | 62 ++++ backend/tests/Feature/CalendarSlotTest.php | 230 ++++++++++++++ backend/tests/Feature/PlannableItemTest.php | 188 +++++++++++ frontend/index.html | 2 +- frontend/package-lock.json | 56 +++- frontend/package.json | 3 +- frontend/src/App.css | 59 ++-- frontend/src/App.jsx | 21 +- frontend/src/components/TripCard.jsx | 44 +-- frontend/src/components/TripDetail.css | 190 +++++++++++ frontend/src/components/TripDetail.jsx | 95 ++++++ .../src/components/common/ConfirmDialog.css | 118 +++++++ .../src/components/common/ConfirmDialog.jsx | 68 ++++ .../components/common/ModalErrorDisplay.css | 65 ++++ .../components/common/ModalErrorDisplay.jsx | 27 ++ frontend/src/components/common/Toast.css | 126 ++++++++ frontend/src/components/common/Toast.jsx | 69 ++++ .../src/components/common/ToastContainer.css | 23 ++ .../src/components/common/ToastContainer.jsx | 69 ++++ .../components/plannables/PlannableForm.css | 198 ++++++++++++ .../components/plannables/PlannableForm.jsx | 203 ++++++++++++ .../components/plannables/PlannableItem.css | 99 ++++++ .../components/plannables/PlannableItem.jsx | 90 ++++++ .../components/plannables/PlannablesList.css | 115 +++++++ .../components/plannables/PlannablesList.jsx | 250 +++++++++++++++ frontend/src/hooks/useApiCall.js | 35 +++ frontend/src/hooks/useAuthToken.js | 9 + frontend/src/hooks/usePlannables.js | 84 +++++ frontend/src/hooks/useTrip.js | 35 +++ frontend/src/styles/variables.css | 120 +++++++ frontend/src/utils/dateFormatter.js | 78 +++++ .../specs/integration/plannable-items.test.js | 296 ++++++++++++++++++ 32 files changed, 3063 insertions(+), 64 deletions(-) create mode 100644 backend/database/factories/PlannableItemFactory.php create mode 100644 backend/tests/Feature/CalendarSlotTest.php create mode 100644 backend/tests/Feature/PlannableItemTest.php create mode 100644 frontend/src/components/TripDetail.css create mode 100644 frontend/src/components/TripDetail.jsx create mode 100644 frontend/src/components/common/ConfirmDialog.css create mode 100644 frontend/src/components/common/ConfirmDialog.jsx create mode 100644 frontend/src/components/common/ModalErrorDisplay.css create mode 100644 frontend/src/components/common/ModalErrorDisplay.jsx create mode 100644 frontend/src/components/common/Toast.css create mode 100644 frontend/src/components/common/Toast.jsx create mode 100644 frontend/src/components/common/ToastContainer.css create mode 100644 frontend/src/components/common/ToastContainer.jsx create mode 100644 frontend/src/components/plannables/PlannableForm.css create mode 100644 frontend/src/components/plannables/PlannableForm.jsx create mode 100644 frontend/src/components/plannables/PlannableItem.css create mode 100644 frontend/src/components/plannables/PlannableItem.jsx create mode 100644 frontend/src/components/plannables/PlannablesList.css create mode 100644 frontend/src/components/plannables/PlannablesList.jsx create mode 100644 frontend/src/hooks/useApiCall.js create mode 100644 frontend/src/hooks/useAuthToken.js create mode 100644 frontend/src/hooks/usePlannables.js create mode 100644 frontend/src/hooks/useTrip.js create mode 100644 frontend/src/styles/variables.css create mode 100644 frontend/src/utils/dateFormatter.js create mode 100644 tests/specs/integration/plannable-items.test.js diff --git a/backend/database/factories/PlannableItemFactory.php b/backend/database/factories/PlannableItemFactory.php new file mode 100644 index 0000000..8a83d1e --- /dev/null +++ b/backend/database/factories/PlannableItemFactory.php @@ -0,0 +1,62 @@ +faker->randomElement($types); + + return [ + 'trip_id' => Trip::factory(), + 'name' => $this->faker->company(), + 'type' => $type, + 'address' => $this->faker->address(), + 'notes' => $this->faker->sentence(), + 'metadata' => $this->getMetadataForType($type), + ]; + } + + private function getMetadataForType($type) + { + switch ($type) { + case 'hotel': + return [ + 'checkin_time' => '15:00', + 'checkout_time' => '11:00', + 'confirmation_number' => $this->faker->uuid() + ]; + case 'restaurant': + return [ + 'reservation_time' => '19:00', + 'party_size' => $this->faker->numberBetween(2, 6) + ]; + case 'transport': + return [ + 'departure_time' => '10:00', + 'arrival_time' => '14:00', + 'transport_type' => $this->faker->randomElement(['flight', 'train', 'bus']) + ]; + case 'attraction': + return [ + 'opening_hours' => '9:00 AM - 5:00 PM', + 'ticket_price' => '$' . $this->faker->numberBetween(10, 50) + ]; + case 'activity': + return [ + 'duration' => $this->faker->numberBetween(1, 4) . ' hours', + 'meeting_point' => $this->faker->streetAddress() + ]; + default: + return []; + } + } +} \ No newline at end of file diff --git a/backend/tests/Feature/CalendarSlotTest.php b/backend/tests/Feature/CalendarSlotTest.php new file mode 100644 index 0000000..e1b7668 --- /dev/null +++ b/backend/tests/Feature/CalendarSlotTest.php @@ -0,0 +1,230 @@ +user = User::factory()->create(); + $this->token = $this->user->createToken('test-token')->plainTextToken; + } + + public function test_calendar_slots_are_auto_created_when_trip_is_created() + { + $tripData = [ + 'name' => 'Test Trip', + 'description' => 'Test Description', + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/trips', $tripData); + + $response->assertStatus(201); + $tripId = $response->json('data.id'); + + // Check that 3 calendar slots were created (Jan 1, 2, 3) + $this->assertDatabaseCount('calendar_slots', 3); + + $slots = CalendarSlot::where('trip_id', $tripId) + ->orderBy('slot_order') + ->get(); + + $this->assertEquals('Day 1', $slots[0]->name); + $this->assertEquals('2024-01-01', $slots[0]->slot_date->format('Y-m-d')); + $this->assertEquals(1, $slots[0]->slot_order); + + $this->assertEquals('Day 2', $slots[1]->name); + $this->assertEquals('2024-01-02', $slots[1]->slot_date->format('Y-m-d')); + $this->assertEquals(2, $slots[1]->slot_order); + + $this->assertEquals('Day 3', $slots[2]->name); + $this->assertEquals('2024-01-03', $slots[2]->slot_date->format('Y-m-d')); + $this->assertEquals(3, $slots[2]->slot_order); + } + + public function test_calendar_slots_are_updated_when_trip_dates_change() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + // Initially should have 2 slots + $this->assertCount(2, $trip->calendarSlots); + + // Update trip dates + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/trips/{$trip->id}", [ + 'name' => $trip->name, + 'start_date' => '2024-02-01', + 'end_date' => '2024-02-04' + ]); + + $response->assertStatus(200); + + // Should now have 4 slots with new dates + $trip->refresh(); + $slots = $trip->calendarSlots()->orderBy('slot_order')->get(); + + $this->assertCount(4, $slots); + $this->assertEquals('2024-02-01', $slots[0]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-02', $slots[1]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-03', $slots[2]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-04', $slots[3]->slot_date->format('Y-m-d')); + } + + public function test_can_list_calendar_slots_for_trip() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$trip->id}/calendar-slots"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.name', 'Day 1') + ->assertJsonPath('data.1.name', 'Day 2') + ->assertJsonPath('data.2.name', 'Day 3'); + } + + public function test_can_update_calendar_slot_name() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + $slot = $trip->calendarSlots()->first(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/calendar-slots/{$slot->id}", [ + 'name' => 'Arrival Day' + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Arrival Day'); + + $this->assertDatabaseHas('calendar_slots', [ + 'id' => $slot->id, + 'name' => 'Arrival Day' + ]); + } + + public function test_cannot_access_calendar_slots_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$otherTrip->id}/calendar-slots"); + + $response->assertStatus(403); + } + + public function test_cannot_update_calendar_slot_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + $slot = $otherTrip->calendarSlots()->first(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/calendar-slots/{$slot->id}", [ + 'name' => 'Hacked Name' + ]); + + $response->assertStatus(403); + } + + public function test_no_slots_created_for_trip_without_dates() + { + $tripData = [ + 'name' => 'Trip without dates', + 'description' => 'No dates set' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/trips', $tripData); + + $response->assertStatus(201); + $tripId = $response->json('data.id'); + + // No slots should be created + $this->assertDatabaseCount('calendar_slots', 0); + + $slots = CalendarSlot::where('trip_id', $tripId)->get(); + $this->assertCount(0, $slots); + } + + public function test_slots_created_when_dates_added_to_trip() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => null, + 'end_date' => null + ]); + + // Initially no slots + $this->assertCount(0, $trip->calendarSlots); + + // Add dates to trip + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/trips/{$trip->id}", [ + 'name' => $trip->name, + 'start_date' => '2024-03-01', + 'end_date' => '2024-03-02' + ]); + + $response->assertStatus(200); + + // Now should have 2 slots + $trip->refresh(); + $this->assertCount(2, $trip->calendarSlots); + } +} \ No newline at end of file diff --git a/backend/tests/Feature/PlannableItemTest.php b/backend/tests/Feature/PlannableItemTest.php new file mode 100644 index 0000000..6300edf --- /dev/null +++ b/backend/tests/Feature/PlannableItemTest.php @@ -0,0 +1,188 @@ +user = User::factory()->create(); + $this->trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]); + + $this->token = $this->user->createToken('test-token')->plainTextToken; + } + + public function test_can_create_plannable_item() + { + $data = [ + 'name' => 'Eiffel Tower', + 'type' => 'attraction', + 'address' => 'Champ de Mars, Paris', + 'notes' => 'Visit in the morning', + 'metadata' => [ + 'opening_hours' => '9:00 AM - 11:00 PM', + 'ticket_price' => '25 EUR' + ] + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", $data); + + $response->assertStatus(201) + ->assertJsonPath('data.name', 'Eiffel Tower') + ->assertJsonPath('data.type', 'attraction') + ->assertJsonPath('data.address', 'Champ de Mars, Paris') + ->assertJsonPath('data.metadata.opening_hours', '9:00 AM - 11:00 PM'); + + $this->assertDatabaseHas('plannable_items', [ + 'trip_id' => $this->trip->id, + 'name' => 'Eiffel Tower', + 'type' => 'attraction' + ]); + } + + public function test_can_list_plannable_items_for_trip() + { + PlannableItem::factory()->count(3)->create([ + 'trip_id' => $this->trip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$this->trip->id}/plannables"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data'); + } + + public function test_can_update_plannable_item() + { + $item = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Old Name' + ]); + + $updateData = [ + 'name' => 'Updated Name', + 'type' => 'restaurant', + 'notes' => 'Updated notes' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/plannables/{$item->id}", $updateData); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Updated Name') + ->assertJsonPath('data.type', 'restaurant'); + + $this->assertDatabaseHas('plannable_items', [ + 'id' => $item->id, + 'name' => 'Updated Name', + 'type' => 'restaurant' + ]); + } + + public function test_can_delete_plannable_item() + { + $item = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->deleteJson("/api/plannables/{$item->id}"); + + $response->assertStatus(204); + + $this->assertDatabaseMissing('plannable_items', [ + 'id' => $item->id + ]); + } + + public function test_cannot_access_plannable_items_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$otherTrip->id}/plannables"); + + $response->assertStatus(403); + } + + public function test_cannot_update_plannable_item_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id + ]); + $item = PlannableItem::factory()->create([ + 'trip_id' => $otherTrip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/plannables/{$item->id}", [ + 'name' => 'Hacked Name' + ]); + + $response->assertStatus(403); + } + + public function test_validates_required_fields_when_creating_plannable_item() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'type']); + } + + public function test_validates_type_enum_values() + { + $data = [ + 'name' => 'Test Item', + 'type' => 'invalid_type' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", $data); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['type']); + } +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index c20fbd3..467839b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + TripPlanner
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c07d20d..f7c639a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "@heroicons/react": "^2.2.0", "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -1678,6 +1679,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2790,6 +2800,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", + "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz", + "integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2858,6 +2906,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f549e6f..fdb911e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "@heroicons/react": "^2.2.0", "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/frontend/src/App.css b/frontend/src/App.css index 1321eb6..4b38420 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,4 +1,5 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap'); +@import './styles/variables.css'; :root { /* Color Palette from Coolors.co */ @@ -74,8 +75,8 @@ body { .login-form { background: #f8f9fa; padding: 2rem; - border-radius: var(--radius-md); - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); width: 100%; max-width: 400px; margin: 0 auto; @@ -99,7 +100,7 @@ body { color: var(--color-dark-red); background: rgba(255, 244, 230, 0.3); padding: 0.25rem 0.5rem; - border-radius: var(--radius-sm); + border-radius: var(--border-radius-sm); display: inline-block; } @@ -107,7 +108,7 @@ body { width: 100%; padding: 0.75rem; border: 1px solid var(--primary-color); - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); font-size: 1rem; box-sizing: border-box; background: #fff4e6; @@ -134,7 +135,7 @@ body { .alert { padding: 0.75rem 1rem; margin-bottom: 1rem; - border-radius: var(--radius-sm); + border-radius: var(--border-radius-sm); } .alert-success { @@ -155,10 +156,10 @@ button[type="submit"] { background-color: var(--primary-color); color: white; border: none; - border-radius: var(--radius-sm); + border-radius: var(--border-radius-sm); font-size: 1rem; cursor: pointer; - transition: background-color 0.2s; + transition: background-color var(--transition-normal); } button[type="submit"]:hover:not(:disabled) { @@ -190,7 +191,7 @@ button[type="submit"]:disabled { .auth-toggle { display: flex; margin-bottom: 2rem; - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); overflow: hidden; border: 1px solid #ddd; } @@ -201,7 +202,7 @@ button[type="submit"]:disabled { background: #f8f9fa; border: none; cursor: pointer; - transition: all 0.2s; + transition: all var(--transition-normal); } .auth-toggle button.active { @@ -292,7 +293,7 @@ button[type="submit"]:disabled { cursor: pointer; padding: 0.5rem 0.75rem; border-radius: 20px; - transition: all 0.2s; + transition: all var(--transition-normal); display: flex; align-items: center; gap: 0.5rem; @@ -326,8 +327,8 @@ button[type="submit"]:disabled { right: 0; background: var(--bg-card); border: 1px solid rgba(228, 93, 4, 0.2); - border-radius: var(--radius-md); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-lg); z-index: 1000; min-width: 120px; margin-top: 0.5rem; @@ -356,7 +357,7 @@ button[type="submit"]:disabled { cursor: pointer; font-weight: 500; font-size: 0.875rem; - transition: all 0.2s; + transition: all var(--transition-normal); margin-top: 0.2rem; } @@ -385,8 +386,8 @@ button[type="submit"]:disabled { .feature-card { background: #f8f9fa; padding: 2rem; - border-radius: var(--radius-md); - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); text-align: center; } @@ -417,7 +418,7 @@ button[type="submit"]:disabled { text-align: center; padding: 3rem; background: #f8f9fa; - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); margin: 2rem 0; } @@ -575,8 +576,8 @@ button[type="submit"]:disabled { cursor: pointer; padding: 0.25rem; color: #666; - border-radius: var(--radius-sm); - transition: background-color 0.2s; + border-radius: var(--border-radius-sm); + transition: background-color var(--transition-normal); } .trip-menu-trigger:hover { @@ -589,8 +590,8 @@ button[type="submit"]:disabled { right: 0; background: white; border: 1px solid #ddd; - border-radius: var(--radius-sm); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: var(--border-radius-sm); + box-shadow: var(--shadow-lg); z-index: 1000; min-width: 120px; } @@ -603,7 +604,7 @@ button[type="submit"]:disabled { background: none; text-align: left; cursor: pointer; - transition: background-color 0.2s; + transition: background-color var(--transition-normal); color: #333; } @@ -725,7 +726,7 @@ button[type="submit"]:disabled { .modal-content { background: var(--bg-card); - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); width: 100%; max-width: 500px; max-height: 90vh; @@ -807,7 +808,7 @@ button[type="submit"]:disabled { width: 100%; padding: 0.75rem; border: 1px solid var(--primary-color); - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); font-size: 1rem; font-family: inherit; resize: vertical; @@ -850,10 +851,10 @@ button[type="submit"]:disabled { background: var(--primary-color); color: white; border: none; - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); cursor: pointer; font-weight: 500; - transition: background-color 0.2s; + transition: background-color var(--transition-normal); } .btn-primary:hover:not(:disabled) { @@ -870,10 +871,10 @@ button[type="submit"]:disabled { background: transparent; color: var(--secondary-color); border: 2px solid var(--secondary-color); - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); cursor: pointer; font-weight: 500; - transition: all 0.2s; + transition: all var(--transition-normal); } .btn-secondary:hover:not(:disabled) { @@ -893,10 +894,10 @@ button[type="submit"]:disabled { background: var(--danger-color); color: white; border: none; - border-radius: var(--radius-md); + border-radius: var(--border-radius-md); cursor: pointer; font-weight: 500; - transition: background-color 0.2s; + transition: background-color var(--transition-normal); } .btn-danger:hover:not(:disabled) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3e17bf1..9ce837a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,27 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider } from './contexts/AuthContext' +import { ToastProvider } from './components/common/ToastContainer' import AuthGuard from './components/auth/AuthGuard' import Dashboard from './components/Dashboard' +import TripDetail from './components/TripDetail' import './App.css' function App() { return ( -
- - - -
+ + +
+ + + } /> + } /> + } /> + + +
+
+
) } diff --git a/frontend/src/components/TripCard.jsx b/frontend/src/components/TripCard.jsx index d77e8fd..164ba0c 100644 --- a/frontend/src/components/TripCard.jsx +++ b/frontend/src/components/TripCard.jsx @@ -1,6 +1,9 @@ import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { formatDateShort, getDuration } from '../utils/dateFormatter'; const TripCard = ({ trip, onEdit, onDelete }) => { + const navigate = useNavigate(); const [showDropdown, setShowDropdown] = useState(false); const dropdownRef = useRef(null); @@ -17,15 +20,7 @@ const TripCard = ({ trip, onEdit, onDelete }) => { }; }, []); - const formatDate = (dateString) => { - if (!dateString) return null; - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }); - }; + // Removed - using shared utility const handleEdit = () => { setShowDropdown(false); @@ -37,19 +32,18 @@ const TripCard = ({ trip, onEdit, onDelete }) => { onDelete(trip); }; - const getDuration = () => { - if (!trip.start_date || !trip.end_date) return null; + // Removed - using shared utility - const start = new Date(trip.start_date); - const end = new Date(trip.end_date); - const diffTime = Math.abs(end - start); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; - - return diffDays === 1 ? '1 day' : `${diffDays} days`; + const handleCardClick = (e) => { + // Don't navigate if clicking on menu buttons + if (e.target.closest('.trip-card-menu')) { + return; + } + navigate(`/trip/${trip.id}`); }; return ( -
+

{trip.name}

@@ -81,19 +75,19 @@ const TripCard = ({ trip, onEdit, onDelete }) => { {trip.start_date && trip.end_date ? (
- {formatDate(trip.start_date)} - {formatDate(trip.end_date)} + {formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)} - {getDuration()} + {getDuration(trip.start_date, trip.end_date)}
) : trip.start_date ? ( - Starts: {formatDate(trip.start_date)} + Starts: {formatDateShort(trip.start_date)} ) : trip.end_date ? ( - Ends: {formatDate(trip.end_date)} + Ends: {formatDateShort(trip.end_date)} ) : ( @@ -104,11 +98,7 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
- Created {new Date(trip.created_at).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - })} + Created {formatDateShort(trip.created_at)}
diff --git a/frontend/src/components/TripDetail.css b/frontend/src/components/TripDetail.css new file mode 100644 index 0000000..df4cbea --- /dev/null +++ b/frontend/src/components/TripDetail.css @@ -0,0 +1,190 @@ +.trip-detail { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-secondary); +} + +/* Header */ +.trip-detail-header { + background: var(--color-bg-primary); + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-md) var(--spacing-xl) var(--spacing-lg); + box-shadow: var(--shadow-sm); +} + +.header-nav { + margin-bottom: 1rem; +} + +.btn-back { + color: var(--color-text-secondary); + text-decoration: none; + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + transition: color var(--transition-normal); +} + +.btn-back:hover { + color: var(--color-text-primary); +} + +.header-content h1 { + margin: 0 0 0.5rem 0; + font-size: 2rem; + color: var(--color-text-primary); +} + +.trip-description { + color: var(--color-text-secondary); + margin: 0 0 1rem 0; + font-size: 1.1rem; +} + +.trip-dates { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 0.95rem; +} + +.date-label { + color: var(--color-text-muted); + font-weight: 500; +} + +.date-value { + color: var(--color-text-primary); +} + +.date-separator { + color: var(--color-text-light); + margin: 0 0.5rem; +} + +/* Content Layout */ +.trip-detail-content { + flex: 1; + display: flex; + gap: 0; + height: calc(100vh - var(--header-height)); +} + +.trip-detail-sidebar { + width: var(--sidebar-width); + background: var(--color-bg-primary); + border-right: 1px solid var(--color-border); + overflow-y: auto; +} + +.trip-detail-main { + flex: 1; + padding: var(--spacing-xl); + overflow-y: auto; +} + +/* Calendar Placeholder */ +.calendar-placeholder { + background: var(--color-bg-primary); + border-radius: var(--border-radius-lg); + padding: var(--spacing-xl); + text-align: center; + box-shadow: var(--shadow-sm); +} + +.calendar-placeholder h2 { + color: var(--color-text-primary); + margin: 0 0 1rem 0; +} + +.calendar-placeholder p { + color: var(--color-text-muted); +} + +/* Loading State */ +.trip-detail-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + gap: var(--spacing-md); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #f3f3f3; + border-top: 3px solid #333; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Error State */ +.trip-detail-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + text-align: center; + gap: var(--spacing-md); +} + +.trip-detail-error h2 { + color: var(--color-danger); + margin: 0; +} + +.trip-detail-error p { + color: var(--color-text-secondary); + margin: 0; +} + +.trip-detail-error .btn-back { + margin-top: 1rem; + padding: var(--spacing-sm) var(--spacing-md); + background: #333; + color: white; + text-decoration: none; + border-radius: var(--border-radius-sm); + transition: background var(--transition-normal); +} + +.trip-detail-error .btn-back:hover { + background: #555; +} + +/* Responsive */ +@media (max-width: 768px) { + .trip-detail-content { + flex-direction: column; + height: auto; + } + + .trip-detail-sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--color-border); + min-height: 400px; + } + + .trip-detail-main { + padding: var(--spacing-md); + } + + .trip-detail-header { + padding: var(--spacing-md); + } + + .header-content h1 { + font-size: 1.5rem; + } +} \ No newline at end of file diff --git a/frontend/src/components/TripDetail.jsx b/frontend/src/components/TripDetail.jsx new file mode 100644 index 0000000..7183be1 --- /dev/null +++ b/frontend/src/components/TripDetail.jsx @@ -0,0 +1,95 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { formatDate } from '../utils/dateFormatter'; +import { useTrip } from '../hooks/useTrip'; +import PlannablesList from './plannables/PlannablesList'; +import './TripDetail.css'; + +const TripDetail = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const [trip, setTrip] = useState(null); + const { fetchTrip, loading, error } = useTrip(); + + useEffect(() => { + const loadTrip = async () => { + try { + const tripData = await fetchTrip(id); + setTrip(tripData); + } catch (err) { + console.error('Error loading trip:', err); + } + }; + + loadTrip(); + }, [id, fetchTrip]); + + // Memoize trip dates display to prevent unnecessary re-renders + const tripDatesDisplay = useMemo(() => { + if (!trip) return null; + + return ( +
+ Start: + {formatDate(trip.start_date)} + + End: + {formatDate(trip.end_date)} +
+ ); + }, [trip]); + + if (loading) { + return ( +
+
+

Loading trip details...

+
+ ); + } + + if (error) { + return ( +
+

Error

+

{error}

+ Back to Dashboard +
+ ); + } + + if (!trip) { + return null; + } + + return ( +
+
+
+ ← Back to Dashboard +
+
+

{trip.name}

+ {trip.description && ( +

{trip.description}

+ )} + {tripDatesDisplay} +
+
+ +
+
+ +
+
+
+

Calendar View

+

Calendar view will be implemented here in the future

+
+
+
+
+ ); +}; + +export default TripDetail; \ No newline at end of file diff --git a/frontend/src/components/common/ConfirmDialog.css b/frontend/src/components/common/ConfirmDialog.css new file mode 100644 index 0000000..1ef310b --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.css @@ -0,0 +1,118 @@ +.confirm-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--color-bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-toast); + animation: fadeIn var(--transition-normal); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.confirm-dialog-modal { + background: var(--color-bg-primary); + border-radius: var(--border-radius-lg); + width: 90%; + max-width: 400px; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: slideUp var(--transition-slow); + box-shadow: var(--shadow-lg); +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.confirm-dialog-header { + padding: var(--spacing-lg) var(--spacing-lg) 0; +} + +.confirm-dialog-header h3 { + margin: 0; + font-size: 1.25rem; + color: var(--color-text-primary); + font-weight: 600; +} + +.confirm-dialog-body { + padding: var(--spacing-md) var(--spacing-lg); + flex: 1; +} + +.confirm-dialog-body p { + margin: 0; + color: var(--color-text-secondary); + line-height: 1.5; +} + +.confirm-dialog-footer { + padding: var(--spacing-md) var(--spacing-lg) var(--spacing-lg); + display: flex; + gap: var(--spacing-md); + justify-content: flex-end; +} + +.confirm-dialog-footer .btn-secondary, +.confirm-dialog-footer .btn-primary { + padding: var(--spacing-sm) var(--spacing-lg); + border: none; + border-radius: var(--border-radius-sm); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-normal); + min-width: 80px; +} + +.confirm-dialog-footer .btn-secondary { + background: var(--color-bg-secondary); + color: var(--color-text-secondary); +} + +.confirm-dialog-footer .btn-secondary:hover { + background: var(--color-border); +} + +.confirm-dialog-footer .btn-primary { + background: var(--color-primary); + color: white; +} + +.confirm-dialog-footer .btn-primary:hover { + background: var(--color-primary-hover); +} + +.confirm-dialog-footer .btn-primary.btn-danger { + background: var(--color-danger); +} + +.confirm-dialog-footer .btn-primary.btn-danger:hover { + background: var(--color-danger-hover); +} + +.confirm-dialog-footer .btn-primary:focus, +.confirm-dialog-footer .btn-secondary:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.confirm-dialog-footer .btn-primary.btn-danger:focus { + outline-color: var(--color-danger); +} \ No newline at end of file diff --git a/frontend/src/components/common/ConfirmDialog.jsx b/frontend/src/components/common/ConfirmDialog.jsx new file mode 100644 index 0000000..47776d7 --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.jsx @@ -0,0 +1,68 @@ +import './ConfirmDialog.css'; + +const ConfirmDialog = ({ + isOpen, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + onConfirm, + onCancel, + variant = 'default' // 'default', 'danger' +}) => { + if (!isOpen) return null; + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onCancel(); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onCancel(); + } + }; + + return ( +
+
+
+

{title}

+
+ +
+

{message}

+
+ +
+ + +
+
+
+ ); +}; + +export default ConfirmDialog; \ No newline at end of file diff --git a/frontend/src/components/common/ModalErrorDisplay.css b/frontend/src/components/common/ModalErrorDisplay.css new file mode 100644 index 0000000..f921be5 --- /dev/null +++ b/frontend/src/components/common/ModalErrorDisplay.css @@ -0,0 +1,65 @@ +.modal-error-display { + margin-bottom: var(--spacing-md); + animation: slideDown var(--transition-normal) ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-error-content { + background: var(--color-danger-light); + border: 1px solid var(--color-danger); + border-radius: var(--border-radius-md); + padding: var(--spacing-md); + display: flex; + align-items: center; + gap: var(--spacing-sm); + position: relative; +} + +.modal-error-icon { + font-size: var(--font-size-lg); + color: var(--color-danger); + flex-shrink: 0; +} + +.modal-error-message { + color: var(--color-danger); + font-size: var(--font-size-sm); + flex: 1; + line-height: var(--line-height-normal); +} + +.modal-error-dismiss { + background: none; + border: none; + color: var(--color-danger); + font-size: var(--font-size-lg); + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-sm); + transition: background-color var(--transition-fast); + flex-shrink: 0; +} + +.modal-error-dismiss:hover { + background: rgba(244, 67, 54, 0.1); +} + +.modal-error-dismiss:focus { + outline: 2px solid var(--color-danger); + outline-offset: 2px; +} \ No newline at end of file diff --git a/frontend/src/components/common/ModalErrorDisplay.jsx b/frontend/src/components/common/ModalErrorDisplay.jsx new file mode 100644 index 0000000..e5d4a3c --- /dev/null +++ b/frontend/src/components/common/ModalErrorDisplay.jsx @@ -0,0 +1,27 @@ +import './ModalErrorDisplay.css'; + +const ModalErrorDisplay = ({ error, onDismiss }) => { + if (!error) return null; + + return ( +
+
+
⚠️
+
+ {typeof error === 'string' ? error : error.message || 'An error occurred'} +
+ {onDismiss && ( + + )} +
+
+ ); +}; + +export default ModalErrorDisplay; \ No newline at end of file diff --git a/frontend/src/components/common/Toast.css b/frontend/src/components/common/Toast.css new file mode 100644 index 0000000..8ba7f8d --- /dev/null +++ b/frontend/src/components/common/Toast.css @@ -0,0 +1,126 @@ +.toast { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem; + margin-bottom: 0.5rem; + background: white; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-left: 4px solid; + max-width: 400px; + animation: slideInRight 0.3s ease-out; + position: relative; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.toast.toast-exit { + animation: slideOutRight 0.3s ease-in; +} + +@keyframes slideOutRight { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +.toast-success { + border-left-color: #4CAF50; +} + +.toast-error { + border-left-color: #f44336; +} + +.toast-warning { + border-left-color: #ff9800; +} + +.toast-info { + border-left-color: #2196F3; +} + +.toast-icon { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: bold; + color: white; + flex-shrink: 0; + margin-top: 0.125rem; +} + +.toast-success .toast-icon { + background: #4CAF50; +} + +.toast-error .toast-icon { + background: #f44336; +} + +.toast-warning .toast-icon { + background: #ff9800; +} + +.toast-info .toast-icon { + background: #2196F3; +} + +.toast-content { + flex: 1; + min-width: 0; +} + +.toast-content p { + margin: 0; + color: #333; + font-size: 0.9rem; + line-height: 1.4; + word-wrap: break-word; +} + +.toast-close { + background: transparent; + border: none; + font-size: 1.25rem; + color: #999; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + transition: all 0.2s; + flex-shrink: 0; +} + +.toast-close:hover { + background: #f5f5f5; + color: #666; +} + +.toast-close:focus { + outline: 2px solid #4CAF50; + outline-offset: 1px; +} \ No newline at end of file diff --git a/frontend/src/components/common/Toast.jsx b/frontend/src/components/common/Toast.jsx new file mode 100644 index 0000000..7b2ed8f --- /dev/null +++ b/frontend/src/components/common/Toast.jsx @@ -0,0 +1,69 @@ +import { useState, useEffect } from 'react'; +import './Toast.css'; + +const Toast = ({ + message, + type = 'info', // 'success', 'error', 'warning', 'info' + duration = 4000, + onClose +}) => { + const [isVisible, setIsVisible] = useState(true); + const [isExiting, setIsExiting] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setIsExiting(true); + setTimeout(() => { + setIsVisible(false); + onClose?.(); + }, 300); // Animation duration + }, duration); + + return () => clearTimeout(timer); + }, [duration, onClose]); + + const handleClose = () => { + setIsExiting(true); + setTimeout(() => { + setIsVisible(false); + onClose?.(); + }, 300); + }; + + if (!isVisible) return null; + + const getIcon = () => { + switch (type) { + case 'success': return '✓'; + case 'error': return '✕'; + case 'warning': return '⚠'; + case 'info': + default: return 'ℹ'; + } + }; + + return ( +
+
+ {getIcon()} +
+
+

{message}

+
+ +
+ ); +}; + +export default Toast; \ No newline at end of file diff --git a/frontend/src/components/common/ToastContainer.css b/frontend/src/components/common/ToastContainer.css new file mode 100644 index 0000000..93ba786 --- /dev/null +++ b/frontend/src/components/common/ToastContainer.css @@ -0,0 +1,23 @@ +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + pointer-events: none; +} + +.toast-container .toast { + pointer-events: auto; +} + +@media (max-width: 768px) { + .toast-container { + top: 1rem; + left: 1rem; + right: 1rem; + } + + .toast-container .toast { + max-width: none; + } +} \ No newline at end of file diff --git a/frontend/src/components/common/ToastContainer.jsx b/frontend/src/components/common/ToastContainer.jsx new file mode 100644 index 0000000..7c3b79f --- /dev/null +++ b/frontend/src/components/common/ToastContainer.jsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useState, useCallback } from 'react'; +import Toast from './Toast'; +import './ToastContainer.css'; + +const ToastContext = createContext(); + +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; + +export const ToastProvider = ({ children }) => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message, type = 'info', duration = 4000) => { + const id = Date.now() + Math.random(); + const toast = { id, message, type, duration }; + + setToasts(prev => [...prev, toast]); + + return id; + }, []); + + const removeToast = useCallback((id) => { + setToasts(prev => prev.filter(toast => toast.id !== id)); + }, []); + + const showSuccess = useCallback((message, duration) => addToast(message, 'success', duration), [addToast]); + const showError = useCallback((message, duration) => addToast(message, 'error', duration), [addToast]); + const showWarning = useCallback((message, duration) => addToast(message, 'warning', duration), [addToast]); + const showInfo = useCallback((message, duration) => addToast(message, 'info', duration), [addToast]); + + const value = { + addToast, + removeToast, + showSuccess, + showError, + showWarning, + showInfo + }; + + return ( + + {children} + + + ); +}; + +const ToastContainer = ({ toasts, onRemove }) => { + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map(toast => ( + onRemove(toast.id)} + /> + ))} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/plannables/PlannableForm.css b/frontend/src/components/plannables/PlannableForm.css new file mode 100644 index 0000000..f42db03 --- /dev/null +++ b/frontend/src/components/plannables/PlannableForm.css @@ -0,0 +1,198 @@ +.plannable-form-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--color-bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + animation: fadeIn var(--transition-normal); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.plannable-form-modal { + background: var(--color-bg-primary); + border-radius: var(--border-radius-lg); + width: 90%; + max-width: 600px; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: slideUp var(--transition-slow); +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.form-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.form-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--color-text-primary); +} + +.btn-close { + background: transparent; + border: none; + font-size: 2rem; + cursor: pointer; + color: var(--color-text-secondary); + line-height: 1; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-sm); + transition: all var(--transition-normal); +} + +.btn-close:hover { + background: var(--color-bg-secondary); + color: var(--color-text-primary); +} + +.plannable-form { + padding: var(--spacing-lg); + overflow-y: auto; + flex: 1; +} + +.form-group { + margin-bottom: var(--spacing-lg); +} + +.form-group label { + display: block; + margin-bottom: var(--spacing-sm); + font-weight: 500; + color: var(--color-text-secondary); + font-size: 0.9rem; +} + +.form-control { + width: 100%; + padding: var(--spacing-sm); + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-sm); + font-size: 0.95rem; + transition: border-color var(--transition-normal); +} + +.form-control:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); +} + +.form-control.error { + border-color: var(--color-danger); +} + +.form-control.error:focus { + box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.1); +} + +textarea.form-control { + resize: vertical; + font-family: inherit; +} + +select.form-control { + cursor: pointer; +} + +.error-message { + color: var(--color-danger); + font-size: 0.85rem; + margin-top: var(--spacing-xs); + display: block; +} + +.form-actions { + padding: var(--spacing-lg); + border-top: 1px solid var(--color-border); + display: flex; + gap: var(--spacing-md); + justify-content: flex-end; +} + +.btn-primary, +.btn-secondary { + padding: var(--spacing-sm) var(--spacing-lg); + border: none; + border-radius: var(--border-radius-sm); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-normal); +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-secondary { + background: var(--color-bg-secondary); + color: var(--color-text-secondary); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-border); +} + +.btn-secondary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Responsive */ +@media (max-width: 600px) { + .plannable-form-modal { + width: 100%; + height: 100%; + max-height: 100vh; + border-radius: 0; + } + + .plannable-form { + padding: var(--spacing-md); + } + + .form-actions { + padding: var(--spacing-md); + } +} \ No newline at end of file diff --git a/frontend/src/components/plannables/PlannableForm.jsx b/frontend/src/components/plannables/PlannableForm.jsx new file mode 100644 index 0000000..b7de455 --- /dev/null +++ b/frontend/src/components/plannables/PlannableForm.jsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from 'react'; +import ModalErrorDisplay from '../common/ModalErrorDisplay'; +import './PlannableForm.css'; + +const PlannableForm = ({ item, tripId, calendarSlots, onSubmit, onCancel }) => { + const [formData, setFormData] = useState({ + name: '', + type: 'attraction', + address: '', + notes: '', + calendar_slot_id: null + }); + + const [errors, setErrors] = useState({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + useEffect(() => { + if (item) { + setFormData({ + name: item.name || '', + type: item.type || 'attraction', + address: item.address || '', + notes: item.notes || '', + calendar_slot_id: item.calendar_slot_id || null + }); + } + }, [item]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value === '' ? null : value + })); + + // Clear error for this field + if (errors[name]) { + setErrors(prev => ({ + ...prev, + [name]: null + })); + } + + // Clear submit error when user starts typing + if (submitError) { + setSubmitError(null); + } + }; + + const validate = () => { + const newErrors = {}; + + if (!formData.name || formData.name.trim() === '') { + newErrors.name = 'Name is required'; + } + + if (!formData.type) { + newErrors.type = 'Type is required'; + } + + return newErrors; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + const newErrors = validate(); + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setSubmitting(true); + setSubmitError(null); + try { + await onSubmit(formData); + } catch (err) { + console.error('Form submission error:', err); + setSubmitError(err.message || 'Failed to save item. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

{item ? 'Edit Item' : 'Add New Item'}

+ +
+ +
+ setSubmitError(null)} + /> + +
+ + + {errors.name && {errors.name}} +
+ +
+ + + {errors.type && {errors.type}} +
+ +
+ + +
+ +
+ + +
+ +
+ +