release/v0.1.0 #24
32 changed files with 3063 additions and 64 deletions
62
backend/database/factories/PlannableItemFactory.php
Normal file
62
backend/database/factories/PlannableItemFactory.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class PlannableItemFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = PlannableItem::class;
|
||||||
|
|
||||||
|
public function definition()
|
||||||
|
{
|
||||||
|
$types = ['hotel', 'restaurant', 'attraction', 'transport', 'activity'];
|
||||||
|
$type = $this->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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
backend/tests/Feature/CalendarSlotTest.php
Normal file
230
backend/tests/Feature/CalendarSlotTest.php
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class CalendarSlotTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private $user;
|
||||||
|
private $token;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
backend/tests/Feature/PlannableItemTest.php
Normal file
188
backend/tests/Feature/PlannableItemTest.php
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class PlannableItemTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private $user;
|
||||||
|
private $trip;
|
||||||
|
private $token;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>TripPlanner</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
56
frontend/package-lock.json
generated
56
frontend/package-lock.json
generated
|
|
@ -11,7 +11,8 @@
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|
@ -1678,6 +1679,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -2790,6 +2800,44 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
|
@ -2858,6 +2906,12 @@
|
||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|
|
||||||
|
|
@ -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 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 {
|
:root {
|
||||||
/* Color Palette from Coolors.co */
|
/* Color Palette from Coolors.co */
|
||||||
|
|
@ -74,8 +75,8 @@ body {
|
||||||
.login-form {
|
.login-form {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
@ -99,7 +100,7 @@ body {
|
||||||
color: var(--color-dark-red);
|
color: var(--color-dark-red);
|
||||||
background: rgba(255, 244, 230, 0.3);
|
background: rgba(255, 244, 230, 0.3);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +108,7 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--primary-color);
|
border: 1px solid var(--primary-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #fff4e6;
|
background: #fff4e6;
|
||||||
|
|
@ -134,7 +135,7 @@ body {
|
||||||
.alert {
|
.alert {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-success {
|
.alert-success {
|
||||||
|
|
@ -155,10 +156,10 @@ button[type="submit"] {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type="submit"]:hover:not(:disabled) {
|
button[type="submit"]:hover:not(:disabled) {
|
||||||
|
|
@ -190,7 +191,7 @@ button[type="submit"]:disabled {
|
||||||
.auth-toggle {
|
.auth-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +202,7 @@ button[type="submit"]:disabled {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-toggle button.active {
|
.auth-toggle button.active {
|
||||||
|
|
@ -292,7 +293,7 @@ button[type="submit"]:disabled {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-normal);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
@ -326,8 +327,8 @@ button[type="submit"]:disabled {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(228, 93, 4, 0.2);
|
border: 1px solid rgba(228, 93, 4, 0.2);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-lg);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|
@ -356,7 +357,7 @@ button[type="submit"]:disabled {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-normal);
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,8 +386,8 @@ button[type="submit"]:disabled {
|
||||||
.feature-card {
|
.feature-card {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,7 +418,7 @@ button[type="submit"]:disabled {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -575,8 +576,8 @@ button[type="submit"]:disabled {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
transition: background-color 0.2s;
|
transition: background-color var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trip-menu-trigger:hover {
|
.trip-menu-trigger:hover {
|
||||||
|
|
@ -589,8 +590,8 @@ button[type="submit"]:disabled {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-lg);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
@ -603,7 +604,7 @@ button[type="submit"]:disabled {
|
||||||
background: none;
|
background: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color var(--transition-normal);
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -725,7 +726,7 @@ button[type="submit"]:disabled {
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
|
@ -807,7 +808,7 @@ button[type="submit"]:disabled {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--primary-color);
|
border: 1px solid var(--primary-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
|
@ -850,10 +851,10 @@ button[type="submit"]:disabled {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: background-color 0.2s;
|
transition: background-color var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
|
@ -870,10 +871,10 @@ button[type="submit"]:disabled {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
border: 2px solid var(--secondary-color);
|
border: 2px solid var(--secondary-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
|
@ -893,10 +894,10 @@ button[type="submit"]:disabled {
|
||||||
background: var(--danger-color);
|
background: var(--danger-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: background-color 0.2s;
|
transition: background-color var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import { ToastProvider } from './components/common/ToastContainer'
|
||||||
import AuthGuard from './components/auth/AuthGuard'
|
import AuthGuard from './components/auth/AuthGuard'
|
||||||
import Dashboard from './components/Dashboard'
|
import Dashboard from './components/Dashboard'
|
||||||
|
import TripDetail from './components/TripDetail'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<div className="App">
|
<ToastProvider>
|
||||||
<AuthGuard>
|
<BrowserRouter>
|
||||||
<Dashboard />
|
<div className="App">
|
||||||
</AuthGuard>
|
<AuthGuard>
|
||||||
</div>
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/trip/:id" element={<TripDetail />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthGuard>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { formatDateShort, getDuration } from '../utils/dateFormatter';
|
||||||
|
|
||||||
const TripCard = ({ trip, onEdit, onDelete }) => {
|
const TripCard = ({ trip, onEdit, onDelete }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const dropdownRef = useRef(null);
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -17,15 +20,7 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
// Removed - using shared utility
|
||||||
if (!dateString) return null;
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
|
|
@ -37,19 +32,18 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
|
||||||
onDelete(trip);
|
onDelete(trip);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDuration = () => {
|
// Removed - using shared utility
|
||||||
if (!trip.start_date || !trip.end_date) return null;
|
|
||||||
|
|
||||||
const start = new Date(trip.start_date);
|
const handleCardClick = (e) => {
|
||||||
const end = new Date(trip.end_date);
|
// Don't navigate if clicking on menu buttons
|
||||||
const diffTime = Math.abs(end - start);
|
if (e.target.closest('.trip-card-menu')) {
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
return;
|
||||||
|
}
|
||||||
return diffDays === 1 ? '1 day' : `${diffDays} days`;
|
navigate(`/trip/${trip.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="trip-card">
|
<div className="trip-card" onClick={handleCardClick} style={{ cursor: 'pointer' }}>
|
||||||
<div className="trip-card-header">
|
<div className="trip-card-header">
|
||||||
<h3 className="trip-card-title">{trip.name}</h3>
|
<h3 className="trip-card-title">{trip.name}</h3>
|
||||||
<div className="trip-card-menu" ref={dropdownRef}>
|
<div className="trip-card-menu" ref={dropdownRef}>
|
||||||
|
|
@ -81,19 +75,19 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
|
||||||
{trip.start_date && trip.end_date ? (
|
{trip.start_date && trip.end_date ? (
|
||||||
<div className="trip-date-range">
|
<div className="trip-date-range">
|
||||||
<span className="trip-dates">
|
<span className="trip-dates">
|
||||||
{formatDate(trip.start_date)} - {formatDate(trip.end_date)}
|
{formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)}
|
||||||
</span>
|
</span>
|
||||||
<span className="trip-duration">
|
<span className="trip-duration">
|
||||||
{getDuration()}
|
{getDuration(trip.start_date, trip.end_date)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : trip.start_date ? (
|
) : trip.start_date ? (
|
||||||
<span className="trip-dates">
|
<span className="trip-dates">
|
||||||
Starts: {formatDate(trip.start_date)}
|
Starts: {formatDateShort(trip.start_date)}
|
||||||
</span>
|
</span>
|
||||||
) : trip.end_date ? (
|
) : trip.end_date ? (
|
||||||
<span className="trip-dates">
|
<span className="trip-dates">
|
||||||
Ends: {formatDate(trip.end_date)}
|
Ends: {formatDateShort(trip.end_date)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="trip-dates trip-dates-placeholder">
|
<span className="trip-dates trip-dates-placeholder">
|
||||||
|
|
@ -104,11 +98,7 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
|
||||||
|
|
||||||
<div className="trip-card-footer">
|
<div className="trip-card-footer">
|
||||||
<span className="trip-created">
|
<span className="trip-created">
|
||||||
Created {new Date(trip.created_at).toLocaleDateString('en-US', {
|
Created {formatDateShort(trip.created_at)}
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
190
frontend/src/components/TripDetail.css
Normal file
190
frontend/src/components/TripDetail.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
frontend/src/components/TripDetail.jsx
Normal file
95
frontend/src/components/TripDetail.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="trip-dates">
|
||||||
|
<span className="date-label">Start:</span>
|
||||||
|
<span className="date-value">{formatDate(trip.start_date)}</span>
|
||||||
|
<span className="date-separator">•</span>
|
||||||
|
<span className="date-label">End:</span>
|
||||||
|
<span className="date-value">{formatDate(trip.end_date)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [trip]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="trip-detail-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading trip details...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="trip-detail-error">
|
||||||
|
<h2>Error</h2>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Link to="/" className="btn-back">Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trip-detail">
|
||||||
|
<header className="trip-detail-header">
|
||||||
|
<div className="header-nav">
|
||||||
|
<Link to="/" className="btn-back">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
<div className="header-content">
|
||||||
|
<h1>{trip.name}</h1>
|
||||||
|
{trip.description && (
|
||||||
|
<p className="trip-description">{trip.description}</p>
|
||||||
|
)}
|
||||||
|
{tripDatesDisplay}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="trip-detail-content">
|
||||||
|
<div className="trip-detail-sidebar">
|
||||||
|
<PlannablesList tripId={trip.id} />
|
||||||
|
</div>
|
||||||
|
<div className="trip-detail-main">
|
||||||
|
<div className="calendar-placeholder">
|
||||||
|
<h2>Calendar View</h2>
|
||||||
|
<p>Calendar view will be implemented here in the future</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripDetail;
|
||||||
118
frontend/src/components/common/ConfirmDialog.css
Normal file
118
frontend/src/components/common/ConfirmDialog.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
68
frontend/src/components/common/ConfirmDialog.jsx
Normal file
68
frontend/src/components/common/ConfirmDialog.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className="confirm-dialog-overlay"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
aria-describedby="confirm-dialog-message"
|
||||||
|
>
|
||||||
|
<div className="confirm-dialog-modal">
|
||||||
|
<div className="confirm-dialog-header">
|
||||||
|
<h3 id="confirm-dialog-title">{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confirm-dialog-body">
|
||||||
|
<p id="confirm-dialog-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confirm-dialog-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn-primary ${variant === 'danger' ? 'btn-danger' : ''}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
65
frontend/src/components/common/ModalErrorDisplay.css
Normal file
65
frontend/src/components/common/ModalErrorDisplay.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
27
frontend/src/components/common/ModalErrorDisplay.jsx
Normal file
27
frontend/src/components/common/ModalErrorDisplay.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import './ModalErrorDisplay.css';
|
||||||
|
|
||||||
|
const ModalErrorDisplay = ({ error, onDismiss }) => {
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-error-display">
|
||||||
|
<div className="modal-error-content">
|
||||||
|
<div className="modal-error-icon">⚠️</div>
|
||||||
|
<div className="modal-error-message">
|
||||||
|
{typeof error === 'string' ? error : error.message || 'An error occurred'}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
className="modal-error-dismiss"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalErrorDisplay;
|
||||||
126
frontend/src/components/common/Toast.css
Normal file
126
frontend/src/components/common/Toast.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
69
frontend/src/components/common/Toast.jsx
Normal file
69
frontend/src/components/common/Toast.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={`toast toast-${type} ${isExiting ? 'toast-exit' : ''}`}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="toast-icon">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div className="toast-content">
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toast-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toast;
|
||||||
23
frontend/src/components/common/ToastContainer.css
Normal file
23
frontend/src/components/common/ToastContainer.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/src/components/common/ToastContainer.jsx
Normal file
69
frontend/src/components/common/ToastContainer.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastContainer = ({ toasts, onRemove }) => {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
duration={toast.duration}
|
||||||
|
onClose={() => onRemove(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
198
frontend/src/components/plannables/PlannableForm.css
Normal file
198
frontend/src/components/plannables/PlannableForm.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
frontend/src/components/plannables/PlannableForm.jsx
Normal file
203
frontend/src/components/plannables/PlannableForm.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="plannable-form-overlay">
|
||||||
|
<div className="plannable-form-modal">
|
||||||
|
<div className="form-header">
|
||||||
|
<h2>{item ? 'Edit Item' : 'Add New Item'}</h2>
|
||||||
|
<button className="btn-close" onClick={onCancel}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="plannable-form">
|
||||||
|
<ModalErrorDisplay
|
||||||
|
error={submitError}
|
||||||
|
onDismiss={() => setSubmitError(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`form-control ${errors.name ? 'error' : ''}`}
|
||||||
|
placeholder="Enter item name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.name && <span className="error-message">{errors.name}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Type *</label>
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`form-control ${errors.type ? 'error' : ''}`}
|
||||||
|
>
|
||||||
|
<option value="hotel">🏨 Hotel</option>
|
||||||
|
<option value="restaurant">🍽️ Restaurant</option>
|
||||||
|
<option value="attraction">🎯 Attraction</option>
|
||||||
|
<option value="transport">✈️ Transport</option>
|
||||||
|
<option value="activity">🎭 Activity</option>
|
||||||
|
</select>
|
||||||
|
{errors.type && <span className="error-message">{errors.type}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Enter address (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Assign to Day</label>
|
||||||
|
<select
|
||||||
|
name="calendar_slot_id"
|
||||||
|
value={formData.calendar_slot_id || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-control"
|
||||||
|
>
|
||||||
|
<option value="">Unplanned</option>
|
||||||
|
{calendarSlots.map(slot => {
|
||||||
|
const slotDate = new Date(slot.slot_date);
|
||||||
|
const dateStr = slotDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<option key={slot.id} value={slot.id}>
|
||||||
|
{slot.name} - {dateStr}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-control"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Add any additional notes (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving...' : (item ? 'Update' : 'Add')} Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlannableForm;
|
||||||
99
frontend/src/components/plannables/PlannableItem.css
Normal file
99
frontend/src/components/plannables/PlannableItem.css
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
.plannable-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: move;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannable-item:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-address {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-notes {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-type {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: white;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
90
frontend/src/components/plannables/PlannableItem.jsx
Normal file
90
frontend/src/components/plannables/PlannableItem.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useState, useCallback, useMemo, memo } from 'react';
|
||||||
|
import './PlannableItem.css';
|
||||||
|
|
||||||
|
const PlannableItem = memo(({ item, onEdit, onDelete }) => {
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
|
||||||
|
const getTypeIcon = (type) => {
|
||||||
|
const icons = {
|
||||||
|
hotel: '🏨',
|
||||||
|
restaurant: '🍽️',
|
||||||
|
attraction: '🎯',
|
||||||
|
transport: '✈️',
|
||||||
|
activity: '🎭'
|
||||||
|
};
|
||||||
|
return icons[type] || '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type) => {
|
||||||
|
const colors = {
|
||||||
|
hotel: '#1976d2',
|
||||||
|
restaurant: '#f57c00',
|
||||||
|
attraction: '#388e3c',
|
||||||
|
transport: '#7b1fa2',
|
||||||
|
activity: '#d32f2f'
|
||||||
|
};
|
||||||
|
return colors[type] || '#666';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize handlers to prevent unnecessary re-renders
|
||||||
|
const handleEdit = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(item);
|
||||||
|
}, [onEdit, item]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item.id);
|
||||||
|
}, [onDelete, item.id]);
|
||||||
|
|
||||||
|
// Memoize style objects to prevent unnecessary re-renders
|
||||||
|
const typeStyle = useMemo(() => ({
|
||||||
|
color: getTypeColor(item.type)
|
||||||
|
}), [item.type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="plannable-item"
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => setShowActions(false)}
|
||||||
|
>
|
||||||
|
<div className="item-icon" style={typeStyle}>
|
||||||
|
{getTypeIcon(item.type)}
|
||||||
|
</div>
|
||||||
|
<div className="item-content">
|
||||||
|
<h4 className="item-name">{item.name}</h4>
|
||||||
|
{item.address && (
|
||||||
|
<p className="item-address">{item.address}</p>
|
||||||
|
)}
|
||||||
|
{item.notes && (
|
||||||
|
<p className="item-notes">{item.notes}</p>
|
||||||
|
)}
|
||||||
|
<span className="item-type" style={typeStyle}>
|
||||||
|
{item.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showActions && (
|
||||||
|
<div className="item-actions">
|
||||||
|
<button
|
||||||
|
className="btn-action btn-edit"
|
||||||
|
onClick={handleEdit}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-action btn-delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PlannableItem.displayName = 'PlannableItem';
|
||||||
|
|
||||||
|
export default PlannableItem;
|
||||||
115
frontend/src/components/plannables/PlannablesList.css
Normal file
115
frontend/src/components/plannables/PlannablesList.css
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
.plannables-list {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-header {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-item {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-item:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-error {
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
color: var(--color-danger);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin: 1rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-sections {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-section {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-date {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #666;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-items {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
250
frontend/src/components/plannables/PlannablesList.jsx
Normal file
250
frontend/src/components/plannables/PlannablesList.jsx
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useToast } from '../common/ToastContainer';
|
||||||
|
import { usePlannables } from '../../hooks/usePlannables';
|
||||||
|
import PlannableItem from './PlannableItem';
|
||||||
|
import PlannableForm from './PlannableForm';
|
||||||
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
|
import './PlannablesList.css';
|
||||||
|
|
||||||
|
const PlannablesList = ({ tripId }) => {
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const {
|
||||||
|
fetchBothData,
|
||||||
|
createPlannable,
|
||||||
|
updatePlannable,
|
||||||
|
deletePlannable,
|
||||||
|
loading: apiLoading
|
||||||
|
} = usePlannables();
|
||||||
|
|
||||||
|
const [plannables, setPlannables] = useState([]);
|
||||||
|
const [calendarSlots, setCalendarSlots] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { plannables, calendarSlots, errors } = await fetchBothData(tripId);
|
||||||
|
|
||||||
|
setPlannables(plannables);
|
||||||
|
setCalendarSlots(calendarSlots);
|
||||||
|
|
||||||
|
if (errors.plannables) {
|
||||||
|
console.error('Failed to fetch plannables:', errors.plannables);
|
||||||
|
showError('Failed to load plannable items');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.calendarSlots) {
|
||||||
|
console.error('Failed to fetch calendar slots:', errors.calendarSlots);
|
||||||
|
showError('Failed to load calendar slots');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load data');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [tripId, fetchBothData, showError]);
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditItem = (item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = (itemId) => {
|
||||||
|
const item = plannables.find(p => p.id === itemId);
|
||||||
|
|
||||||
|
setConfirmDialog({
|
||||||
|
isOpen: true,
|
||||||
|
title: 'Delete Item',
|
||||||
|
message: `Are you sure you want to delete "${item?.name}"? This action cannot be undone.`,
|
||||||
|
onConfirm: () => performDelete(itemId)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const performDelete = async (itemId) => {
|
||||||
|
try {
|
||||||
|
await deletePlannable(itemId);
|
||||||
|
setPlannables(plannables.filter(item => item.id !== itemId));
|
||||||
|
showSuccess('Item deleted successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting item:', err);
|
||||||
|
showError('Failed to delete item. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setConfirmDialog({ isOpen: false, title: '', message: '', onConfirm: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async (formData) => {
|
||||||
|
try {
|
||||||
|
const isEditing = !!editingItem;
|
||||||
|
let savedItem;
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
savedItem = await updatePlannable(editingItem.id, formData);
|
||||||
|
setPlannables(plannables.map(item =>
|
||||||
|
item.id === editingItem.id ? savedItem : item
|
||||||
|
));
|
||||||
|
showSuccess('Item updated successfully');
|
||||||
|
} else {
|
||||||
|
savedItem = await createPlannable(tripId, formData);
|
||||||
|
setPlannables([...plannables, savedItem]);
|
||||||
|
showSuccess('Item added successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving item:', err);
|
||||||
|
showError('Failed to save item. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormCancel = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize expensive grouping computation to prevent recalculation on every render
|
||||||
|
const { unplannedItems, plannedItemsBySlot } = useMemo(() => {
|
||||||
|
const unplanned = plannables.filter(item => !item.calendar_slot_id);
|
||||||
|
const planned = {};
|
||||||
|
|
||||||
|
plannables.forEach(item => {
|
||||||
|
if (item.calendar_slot_id) {
|
||||||
|
if (!planned[item.calendar_slot_id]) {
|
||||||
|
planned[item.calendar_slot_id] = [];
|
||||||
|
}
|
||||||
|
planned[item.calendar_slot_id].push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { unplannedItems: unplanned, plannedItemsBySlot: planned };
|
||||||
|
}, [plannables]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="plannables-loading">
|
||||||
|
<div className="spinner-small"></div>
|
||||||
|
<p>Loading items...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plannables-list">
|
||||||
|
<div className="plannables-header">
|
||||||
|
<h2>Itinerary Items</h2>
|
||||||
|
<button className="btn-add-item" onClick={handleAddItem}>
|
||||||
|
+ Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="plannables-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="plannables-sections">
|
||||||
|
{/* Unplanned Items Section */}
|
||||||
|
<div className="plannables-section">
|
||||||
|
<h3 className="section-title">
|
||||||
|
📋 Unplanned Items
|
||||||
|
{unplannedItems.length > 0 && (
|
||||||
|
<span className="item-count">{unplannedItems.length}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="section-items">
|
||||||
|
{unplannedItems.length === 0 ? (
|
||||||
|
<p className="empty-message">No unplanned items</p>
|
||||||
|
) : (
|
||||||
|
unplannedItems.map(item => (
|
||||||
|
<PlannableItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onEdit={handleEditItem}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Slots Sections */}
|
||||||
|
{calendarSlots.map(slot => {
|
||||||
|
const items = plannedItemsBySlot[slot.id] || [];
|
||||||
|
const slotDate = new Date(slot.slot_date);
|
||||||
|
const dayName = slotDate.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const dateStr = slotDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={slot.id} className="plannables-section">
|
||||||
|
<h3 className="section-title">
|
||||||
|
📅 {slot.name}
|
||||||
|
<span className="section-date">{dayName}, {dateStr}</span>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="item-count">{items.length}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="section-items">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="empty-message">No items planned for this day</p>
|
||||||
|
) : (
|
||||||
|
items.map(item => (
|
||||||
|
<PlannableItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onEdit={handleEditItem}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<PlannableForm
|
||||||
|
item={editingItem}
|
||||||
|
tripId={tripId}
|
||||||
|
calendarSlots={calendarSlots}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={handleFormCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmDialog.isOpen}
|
||||||
|
title={confirmDialog.title}
|
||||||
|
message={confirmDialog.message}
|
||||||
|
confirmText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog({ isOpen: false, title: '', message: '', onConfirm: null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlannablesList;
|
||||||
35
frontend/src/hooks/useApiCall.js
Normal file
35
frontend/src/hooks/useApiCall.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAuthToken } from './useAuthToken';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create memoized API call function with authentication
|
||||||
|
* Centralizes API error handling and authentication headers
|
||||||
|
*/
|
||||||
|
export const useApiCall = () => {
|
||||||
|
const token = useAuthToken();
|
||||||
|
|
||||||
|
return useCallback(async (url, options = {}) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMessage = errorData.message || `API call failed: ${response.status} ${response.statusText}`;
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 204 No Content responses
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}, [token]);
|
||||||
|
};
|
||||||
9
frontend/src/hooks/useAuthToken.js
Normal file
9
frontend/src/hooks/useAuthToken.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get cached authentication token
|
||||||
|
* Avoids repeated localStorage access
|
||||||
|
*/
|
||||||
|
export const useAuthToken = () => {
|
||||||
|
return useMemo(() => localStorage.getItem('token'), []);
|
||||||
|
};
|
||||||
84
frontend/src/hooks/usePlannables.js
Normal file
84
frontend/src/hooks/usePlannables.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useApiCall } from './useApiCall';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for plannable items API operations
|
||||||
|
* Centralizes CRUD operations for plannable items
|
||||||
|
*/
|
||||||
|
export const usePlannables = () => {
|
||||||
|
const apiCall = useApiCall();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchPlannables = useCallback(async (tripId) => {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}/plannables`);
|
||||||
|
return response.data || [];
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const fetchCalendarSlots = useCallback(async (tripId) => {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}/calendar-slots`);
|
||||||
|
return response.data || [];
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const createPlannable = useCallback(async (tripId, data) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}/plannables`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const updatePlannable = useCallback(async (plannableId, data) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/plannables/${plannableId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const deletePlannable = useCallback(async (plannableId) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await apiCall(`${import.meta.env.VITE_API_URL}/api/plannables/${plannableId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const fetchBothData = useCallback(async (tripId) => {
|
||||||
|
const [plannablesResult, slotsResult] = await Promise.allSettled([
|
||||||
|
fetchPlannables(tripId),
|
||||||
|
fetchCalendarSlots(tripId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plannables: plannablesResult.status === 'fulfilled' ? plannablesResult.value : [],
|
||||||
|
calendarSlots: slotsResult.status === 'fulfilled' ? slotsResult.value : [],
|
||||||
|
errors: {
|
||||||
|
plannables: plannablesResult.status === 'rejected' ? plannablesResult.reason : null,
|
||||||
|
calendarSlots: slotsResult.status === 'rejected' ? slotsResult.reason : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchPlannables, fetchCalendarSlots]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchPlannables,
|
||||||
|
fetchCalendarSlots,
|
||||||
|
fetchBothData,
|
||||||
|
createPlannable,
|
||||||
|
updatePlannable,
|
||||||
|
deletePlannable,
|
||||||
|
loading
|
||||||
|
};
|
||||||
|
};
|
||||||
35
frontend/src/hooks/useTrip.js
Normal file
35
frontend/src/hooks/useTrip.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useApiCall } from './useApiCall';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for trip-related API calls
|
||||||
|
* Centralizes trip data fetching and error handling
|
||||||
|
*/
|
||||||
|
export const useTrip = () => {
|
||||||
|
const apiCall = useApiCall();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const fetchTrip = useCallback(async (tripId) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err.message.includes('404') ? 'Trip not found' : 'Failed to fetch trip';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchTrip,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
clearError: () => setError(null)
|
||||||
|
};
|
||||||
|
};
|
||||||
120
frontend/src/styles/variables.css
Normal file
120
frontend/src/styles/variables.css
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--color-primary: #4CAF50;
|
||||||
|
--color-primary-hover: #45a049;
|
||||||
|
--color-primary-light: #e8f5e8;
|
||||||
|
|
||||||
|
--color-secondary: #f5f5f5;
|
||||||
|
--color-secondary-hover: #e0e0e0;
|
||||||
|
|
||||||
|
--color-danger: #f44336;
|
||||||
|
--color-danger-hover: #d32f2f;
|
||||||
|
--color-danger-light: #ffebee;
|
||||||
|
|
||||||
|
--color-warning: #ff9800;
|
||||||
|
--color-warning-light: #fff3e0;
|
||||||
|
|
||||||
|
--color-info: #2196F3;
|
||||||
|
--color-info-light: #e3f2fd;
|
||||||
|
|
||||||
|
--color-success: #4CAF50;
|
||||||
|
--color-success-light: #e8f5e8;
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--color-text-primary: #333;
|
||||||
|
--color-text-secondary: #666;
|
||||||
|
--color-text-muted: #999;
|
||||||
|
--color-text-light: #ccc;
|
||||||
|
--color-text-white: #fff;
|
||||||
|
|
||||||
|
/* Background Colors */
|
||||||
|
--color-bg-primary: #fff;
|
||||||
|
--color-bg-secondary: #f5f5f5;
|
||||||
|
--color-bg-tertiary: #fafafa;
|
||||||
|
--color-bg-overlay: rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* Border Colors */
|
||||||
|
--color-border: #e0e0e0;
|
||||||
|
--color-border-light: #f0f0f0;
|
||||||
|
--color-border-dark: #ccc;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.25rem; /* 4px */
|
||||||
|
--spacing-sm: 0.5rem; /* 8px */
|
||||||
|
--spacing-md: 1rem; /* 16px */
|
||||||
|
--spacing-lg: 1.5rem; /* 24px */
|
||||||
|
--spacing-xl: 2rem; /* 32px */
|
||||||
|
--spacing-xxl: 3rem; /* 48px */
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--sidebar-width: 380px;
|
||||||
|
--sidebar-width-mobile: 320px;
|
||||||
|
--header-height: 140px;
|
||||||
|
--max-content-width: 1200px;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--border-radius-sm: 4px;
|
||||||
|
--border-radius-md: 6px;
|
||||||
|
--border-radius-lg: 8px;
|
||||||
|
--border-radius-xl: 12px;
|
||||||
|
--border-radius-round: 50%;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
--shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
|
|
||||||
|
/* Z-index */
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-modal: 1000;
|
||||||
|
--z-toast: 1001;
|
||||||
|
--z-tooltip: 1002;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-size-xs: 0.75rem; /* 12px */
|
||||||
|
--font-size-sm: 0.875rem; /* 14px */
|
||||||
|
--font-size-md: 0.95rem; /* 15px */
|
||||||
|
--font-size-lg: 1.125rem; /* 18px */
|
||||||
|
--font-size-xl: 1.25rem; /* 20px */
|
||||||
|
--font-size-xxl: 1.5rem; /* 24px */
|
||||||
|
|
||||||
|
--font-weight-normal: 400;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
|
--line-height-tight: 1.2;
|
||||||
|
--line-height-normal: 1.4;
|
||||||
|
--line-height-relaxed: 1.6;
|
||||||
|
|
||||||
|
/* Plannable Item Type Colors */
|
||||||
|
--color-type-hotel: #1976d2;
|
||||||
|
--color-type-restaurant: #f57c00;
|
||||||
|
--color-type-attraction: #388e3c;
|
||||||
|
--color-type-transport: #7b1fa2;
|
||||||
|
--color-type-activity: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile breakpoints */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--sidebar-width: var(--sidebar-width-mobile);
|
||||||
|
--spacing-lg: 1rem;
|
||||||
|
--spacing-xl: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--transition-fast: none;
|
||||||
|
--transition-normal: none;
|
||||||
|
--transition-slow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
frontend/src/utils/dateFormatter.js
Normal file
78
frontend/src/utils/dateFormatter.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* Shared date formatting utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const formatDate = (dateString, options = {}) => {
|
||||||
|
if (!dateString) return options.fallback || 'Not set';
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
...defaultOptions,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return options.fallback || 'Invalid date';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateShort = (dateString) => {
|
||||||
|
return formatDate(dateString, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateCompact = (dateString) => {
|
||||||
|
return formatDate(dateString, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateRange = (startDate, endDate, options = {}) => {
|
||||||
|
if (!startDate && !endDate) {
|
||||||
|
return options.fallback || 'Dates not set';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && !endDate) {
|
||||||
|
return `Starts: ${formatDate(startDate, options)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startDate && endDate) {
|
||||||
|
return `Ends: ${formatDate(endDate, options)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDate(startDate, options)} - ${formatDate(endDate, options)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDuration = (startDate, endDate) => {
|
||||||
|
if (!startDate || !endDate) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
const diffTime = Math.abs(end - start);
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
|
return diffDays === 1 ? '1 day' : `${diffDays} days`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calculating duration:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCreatedDate = (dateString) => {
|
||||||
|
return formatDate(dateString, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
296
tests/specs/integration/plannable-items.test.js
Normal file
296
tests/specs/integration/plannable-items.test.js
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
const { By, until } = require('selenium-webdriver');
|
||||||
|
const RegistrationPage = require('../../support/pages/RegistrationPage');
|
||||||
|
const LoginPage = require('../../support/pages/LoginPage');
|
||||||
|
const DashboardPage = require('../../support/pages/DashboardPage');
|
||||||
|
const TripPage = require('../../support/pages/TripPage');
|
||||||
|
|
||||||
|
describe('Plannable Items Feature Test', () => {
|
||||||
|
let driver;
|
||||||
|
let registrationPage;
|
||||||
|
let loginPage;
|
||||||
|
let dashboardPage;
|
||||||
|
let tripPage;
|
||||||
|
let testUser;
|
||||||
|
let testTrip;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
driver = await global.createDriver();
|
||||||
|
registrationPage = new RegistrationPage(driver);
|
||||||
|
loginPage = new LoginPage(driver);
|
||||||
|
dashboardPage = new DashboardPage(driver);
|
||||||
|
tripPage = new TripPage(driver);
|
||||||
|
|
||||||
|
// Create unique test data
|
||||||
|
const timestamp = Date.now();
|
||||||
|
testUser = {
|
||||||
|
name: `Plannable Test User ${timestamp}`,
|
||||||
|
email: `plannable.test.${timestamp}@example.com`,
|
||||||
|
password: 'PlanTest123!'
|
||||||
|
};
|
||||||
|
|
||||||
|
testTrip = {
|
||||||
|
name: `Test Trip with Plannables ${timestamp}`,
|
||||||
|
description: 'A trip to test plannable items feature',
|
||||||
|
startDate: '02/01/2025',
|
||||||
|
endDate: '02/05/2025'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await global.quitDriver(driver);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear storage and cookies
|
||||||
|
await Promise.all([
|
||||||
|
driver.manage().deleteAllCookies().catch(() => {}),
|
||||||
|
driver.executeScript('try { localStorage.clear(); sessionStorage.clear(); } catch(e) {}')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Navigate to base URL
|
||||||
|
await driver.get(process.env.APP_URL || 'http://localhost:5173');
|
||||||
|
await driver.wait(until.urlContains('/'), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Plannable Items Management', () => {
|
||||||
|
it('should create a trip with auto-generated calendar slots', async () => {
|
||||||
|
// Register new user
|
||||||
|
await driver.wait(until.elementLocated(By.css('[data-testid="register-link"]')), 10000);
|
||||||
|
await driver.findElement(By.css('[data-testid="register-link"]')).click();
|
||||||
|
|
||||||
|
await registrationPage.register(testUser);
|
||||||
|
await driver.wait(until.urlContains('/'), 10000);
|
||||||
|
|
||||||
|
// Create a new trip
|
||||||
|
await tripPage.openCreateModal();
|
||||||
|
await tripPage.fillTripForm(testTrip);
|
||||||
|
await tripPage.submitForm();
|
||||||
|
|
||||||
|
// Wait for trip to be created
|
||||||
|
await driver.wait(until.elementLocated(By.xpath(`//h3[contains(text(), "${testTrip.name}")]`)), 10000);
|
||||||
|
|
||||||
|
// Click on the trip card to navigate to detail page
|
||||||
|
const tripCard = await driver.findElement(By.xpath(`//h3[contains(text(), "${testTrip.name}")]/ancestor::div[contains(@class, 'trip-card')]`));
|
||||||
|
await tripCard.click();
|
||||||
|
|
||||||
|
// Wait for trip detail page to load
|
||||||
|
await driver.wait(until.urlContains('/trip/'), 10000);
|
||||||
|
|
||||||
|
// Verify trip detail page elements
|
||||||
|
await driver.wait(until.elementLocated(By.xpath(`//h1[contains(text(), "${testTrip.name}")]`)), 10000);
|
||||||
|
|
||||||
|
// Verify calendar slots were created (5 days: Feb 1-5)
|
||||||
|
const daySlots = await driver.findElements(By.xpath('//h3[contains(@class, "section-title") and contains(text(), "Day")]'));
|
||||||
|
expect(daySlots.length).toBeGreaterThanOrEqual(5); // Should have at least 5 day slots
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a plannable item', async () => {
|
||||||
|
// Login with existing user
|
||||||
|
await loginPage.login(testUser.email, testUser.password);
|
||||||
|
await driver.wait(until.urlContains('/'), 10000);
|
||||||
|
|
||||||
|
// Navigate to the trip detail page
|
||||||
|
const tripCard = await driver.findElement(By.xpath(`//h3[contains(text(), "${testTrip.name}")]/ancestor::div[contains(@class, 'trip-card')]`));
|
||||||
|
await tripCard.click();
|
||||||
|
await driver.wait(until.urlContains('/trip/'), 10000);
|
||||||
|
|
||||||
|
// Click Add Item button
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//button[contains(text(), "Add Item")]')), 10000);
|
||||||
|
const addItemButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]'));
|
||||||
|
await addItemButton.click();
|
||||||
|
|
||||||
|
// Wait for form modal to appear
|
||||||
|
await driver.wait(until.elementLocated(By.className('plannable-form-modal')), 5000);
|
||||||
|
|
||||||
|
// Fill in the plannable item form
|
||||||
|
const itemData = {
|
||||||
|
name: 'Eiffel Tower Visit',
|
||||||
|
type: 'attraction',
|
||||||
|
address: 'Champ de Mars, 5 Avenue Anatole France, 75007 Paris',
|
||||||
|
notes: 'Book tickets in advance for sunset visit'
|
||||||
|
};
|
||||||
|
|
||||||
|
await driver.findElement(By.name('name')).sendKeys(itemData.name);
|
||||||
|
|
||||||
|
// Select type
|
||||||
|
const typeSelect = await driver.findElement(By.name('type'));
|
||||||
|
await typeSelect.findElement(By.xpath(`//option[@value="${itemData.type}"]`)).click();
|
||||||
|
|
||||||
|
await driver.findElement(By.name('address')).sendKeys(itemData.address);
|
||||||
|
await driver.findElement(By.name('notes')).sendKeys(itemData.notes);
|
||||||
|
|
||||||
|
// Assign to Day 2
|
||||||
|
const slotSelect = await driver.findElement(By.name('calendar_slot_id'));
|
||||||
|
const day2Option = await slotSelect.findElement(By.xpath('//option[contains(text(), "Day 2")]'));
|
||||||
|
await day2Option.click();
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const submitButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]'));
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to close and item to appear
|
||||||
|
await driver.wait(until.stalenessOf(driver.findElement(By.className('plannable-form-overlay'))), 5000);
|
||||||
|
|
||||||
|
// Verify the item appears in Day 2 section
|
||||||
|
await driver.wait(until.elementLocated(By.xpath(`//h4[contains(text(), "${itemData.name}")]`)), 10000);
|
||||||
|
const itemElement = await driver.findElement(By.xpath(`//h4[contains(text(), "${itemData.name}")]`));
|
||||||
|
expect(await itemElement.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should edit a plannable item', async () => {
|
||||||
|
// Login with existing user
|
||||||
|
await loginPage.login(testUser.email, testUser.password);
|
||||||
|
await driver.wait(until.urlContains('/'), 10000);
|
||||||
|
|
||||||
|
// Navigate to the trip detail page
|
||||||
|
const tripCard = await driver.findElement(By.xpath(`//h3[contains(text(), "${testTrip.name}")]/ancestor::div[contains(@class, 'trip-card')]`));
|
||||||
|
await tripCard.click();
|
||||||
|
await driver.wait(until.urlContains('/trip/'), 10000);
|
||||||
|
|
||||||
|
// Find the item and hover to show actions
|
||||||
|
const itemElement = await driver.findElement(By.xpath('//h4[contains(text(), "Eiffel Tower Visit")]/ancestor::div[contains(@class, "plannable-item")]'));
|
||||||
|
await driver.actions().move({ origin: itemElement }).perform();
|
||||||
|
|
||||||
|
// Click edit button
|
||||||
|
await driver.wait(until.elementLocated(By.className('btn-edit')), 5000);
|
||||||
|
const editButton = await driver.findElement(By.className('btn-edit'));
|
||||||
|
await editButton.click();
|
||||||
|
|
||||||
|
// Wait for form modal to appear
|
||||||
|
await driver.wait(until.elementLocated(By.className('plannable-form-modal')), 5000);
|
||||||
|
|
||||||
|
// Update the item name
|
||||||
|
const nameInput = await driver.findElement(By.name('name'));
|
||||||
|
await nameInput.clear();
|
||||||
|
await nameInput.sendKeys('Eiffel Tower Evening Visit');
|
||||||
|
|
||||||
|
// Update notes
|
||||||
|
const notesInput = await driver.findElement(By.name('notes'));
|
||||||
|
await notesInput.clear();
|
||||||
|
await notesInput.sendKeys('Sunset visit confirmed for 7 PM');
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const updateButton = await driver.findElement(By.xpath('//button[contains(text(), "Update Item")]'));
|
||||||
|
await updateButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await driver.wait(until.stalenessOf(driver.findElement(By.className('plannable-form-overlay'))), 5000);
|
||||||
|
|
||||||
|
// Verify the item was updated
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//h4[contains(text(), "Eiffel Tower Evening Visit")]')), 10000);
|
||||||
|
const updatedItem = await driver.findElement(By.xpath('//h4[contains(text(), "Eiffel Tower Evening Visit")]'));
|
||||||
|
expect(await updatedItem.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a plannable item', async () => {
|
||||||
|
// Login with existing user
|
||||||
|
await loginPage.login(testUser.email, testUser.password);
|
||||||
|
await driver.wait(until.urlContains('/'), 10000);
|
||||||
|
|
||||||
|
// Navigate to the trip detail page
|
||||||
|
const tripCard = await driver.findElement(By.xpath(`//h3[contains(text(), "${testTrip.name}")]/ancestor::div[contains(@class, 'trip-card')]`));
|
||||||
|
await tripCard.click();
|
||||||
|
await driver.wait(until.urlContains('/trip/'), 10000);
|
||||||
|
|
||||||
|
// Find the item and hover to show actions
|
||||||
|
const itemElement = await driver.findElement(By.xpath('//h4[contains(text(), "Eiffel Tower Evening Visit")]/ancestor::div[contains(@class, "plannable-item")]'));
|
||||||
|
await driver.actions().move({ origin: itemElement }).perform();
|
||||||
|
|
||||||
|
// Click delete button
|
||||||
|
await driver.wait(until.elementLocated(By.className('btn-delete')), 5000);
|
||||||
|
const deleteButton = await driver.findElement(By.className('btn-delete'));
|
||||||
|
await deleteButton.click();
|
||||||
|
|
||||||
|
// Accept confirmation dialog
|
||||||
|
await driver.wait(until.alertIsPresent(), 5000);
|
||||||
|
const alert = await driver.switchTo().alert();
|
||||||
|
await alert.accept();
|
||||||
|
|
||||||
|
// Verify the item is removed
|
||||||
|
await driver.sleep(1000); // Give time for the item to be removed
|
||||||
|
const items = await driver.findElements(By.xpath('//h4[contains(text(), "Eiffel Tower Evening Visit")]'));
|
||||||
|
expect(items.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple plannable items of different types', async () => {
|
||||||
|
// Login with existing user
|
||||||
|
await loginPage.login(testUser.email, testUser.password);
|
||||||
|
await driver.wait(until.urlContains('/'), 10000);
|
||||||
|
|
||||||
|
// Navigate to the trip detail page
|
||||||
|
const tripCard = await driver.findElement(By.xpath(`//h3[contains(text(), "${testTrip.name}")]/ancestor::div[contains(@class, 'trip-card')]`));
|
||||||
|
await tripCard.click();
|
||||||
|
await driver.wait(until.urlContains('/trip/'), 10000);
|
||||||
|
|
||||||
|
// Test data for different item types
|
||||||
|
const itemsToAdd = [
|
||||||
|
{
|
||||||
|
name: 'Hotel Le Meurice',
|
||||||
|
type: 'hotel',
|
||||||
|
address: '228 Rue de Rivoli, 75001 Paris',
|
||||||
|
notes: 'Check-in at 3 PM'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Le Jules Verne Restaurant',
|
||||||
|
type: 'restaurant',
|
||||||
|
address: 'Eiffel Tower, Avenue Gustave Eiffel, 75007 Paris',
|
||||||
|
notes: 'Reservation at 8 PM'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Louvre Museum',
|
||||||
|
type: 'attraction',
|
||||||
|
address: 'Rue de Rivoli, 75001 Paris',
|
||||||
|
notes: 'Morning visit'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add each item
|
||||||
|
for (const item of itemsToAdd) {
|
||||||
|
// Click Add Item button
|
||||||
|
const addItemButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]'));
|
||||||
|
await addItemButton.click();
|
||||||
|
|
||||||
|
// Wait for form modal to appear
|
||||||
|
await driver.wait(until.elementLocated(By.className('plannable-form-modal')), 5000);
|
||||||
|
|
||||||
|
// Fill in the form
|
||||||
|
await driver.findElement(By.name('name')).sendKeys(item.name);
|
||||||
|
|
||||||
|
const typeSelect = await driver.findElement(By.name('type'));
|
||||||
|
await typeSelect.findElement(By.xpath(`//option[@value="${item.type}"]`)).click();
|
||||||
|
|
||||||
|
await driver.findElement(By.name('address')).sendKeys(item.address);
|
||||||
|
await driver.findElement(By.name('notes')).sendKeys(item.notes);
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const submitButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]'));
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await driver.wait(until.stalenessOf(driver.findElement(By.className('plannable-form-overlay'))), 5000);
|
||||||
|
|
||||||
|
// Verify the item appears
|
||||||
|
await driver.wait(until.elementLocated(By.xpath(`//h4[contains(text(), "${item.name}")]`)), 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all items are displayed
|
||||||
|
for (const item of itemsToAdd) {
|
||||||
|
const itemElement = await driver.findElement(By.xpath(`//h4[contains(text(), "${item.name}")]`));
|
||||||
|
expect(await itemElement.isDisplayed()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify items show correct type icons
|
||||||
|
const hotelItem = await driver.findElement(By.xpath('//h4[contains(text(), "Hotel Le Meurice")]/preceding-sibling::div[contains(@class, "item-icon")]'));
|
||||||
|
const hotelIcon = await hotelItem.getText();
|
||||||
|
expect(hotelIcon).toContain('🏨');
|
||||||
|
|
||||||
|
const restaurantItem = await driver.findElement(By.xpath('//h4[contains(text(), "Le Jules Verne")]/preceding-sibling::div[contains(@class, "item-icon")]'));
|
||||||
|
const restaurantIcon = await restaurantItem.getText();
|
||||||
|
expect(restaurantIcon).toContain('🍽️');
|
||||||
|
|
||||||
|
const attractionItem = await driver.findElement(By.xpath('//h4[contains(text(), "Louvre Museum")]/preceding-sibling::div[contains(@class, "item-icon")]'));
|
||||||
|
const attractionIcon = await attractionItem.getText();
|
||||||
|
expect(attractionIcon).toContain('🎯');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue