Optimize frontend with performance improvements and design consistency

This commit is contained in:
myrmidex 2025-09-28 18:48:44 +02:00
parent 24b59c5755
commit 59d785c248
32 changed files with 3063 additions and 64 deletions

View 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 [];
}
}
}

View 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);
}
}

View 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']);
}
}

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>TripPlanner</title>
</head>
<body>
<div id="root"></div>

View file

@ -11,7 +11,8 @@
"@heroicons/react": "^2.2.0",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.3"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@ -1678,6 +1679,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2790,6 +2800,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -2858,6 +2906,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -13,7 +13,8 @@
"@heroicons/react": "^2.2.0",
"axios": "^1.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.3"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View file

@ -1,4 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap');
@import './styles/variables.css';
:root {
/* Color Palette from Coolors.co */
@ -74,8 +75,8 @@ body {
.login-form {
background: #f8f9fa;
padding: 2rem;
border-radius: var(--radius-md);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 400px;
margin: 0 auto;
@ -99,7 +100,7 @@ body {
color: var(--color-dark-red);
background: rgba(255, 244, 230, 0.3);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
border-radius: var(--border-radius-sm);
display: inline-block;
}
@ -107,7 +108,7 @@ body {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
font-size: 1rem;
box-sizing: border-box;
background: #fff4e6;
@ -134,7 +135,7 @@ body {
.alert {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: var(--radius-sm);
border-radius: var(--border-radius-sm);
}
.alert-success {
@ -155,10 +156,10 @@ button[type="submit"] {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-sm);
border-radius: var(--border-radius-sm);
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
transition: background-color var(--transition-normal);
}
button[type="submit"]:hover:not(:disabled) {
@ -190,7 +191,7 @@ button[type="submit"]:disabled {
.auth-toggle {
display: flex;
margin-bottom: 2rem;
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
overflow: hidden;
border: 1px solid #ddd;
}
@ -201,7 +202,7 @@ button[type="submit"]:disabled {
background: #f8f9fa;
border: none;
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-normal);
}
.auth-toggle button.active {
@ -292,7 +293,7 @@ button[type="submit"]:disabled {
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 20px;
transition: all 0.2s;
transition: all var(--transition-normal);
display: flex;
align-items: center;
gap: 0.5rem;
@ -326,8 +327,8 @@ button[type="submit"]:disabled {
right: 0;
background: var(--bg-card);
border: 1px solid rgba(228, 93, 4, 0.2);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-lg);
z-index: 1000;
min-width: 120px;
margin-top: 0.5rem;
@ -356,7 +357,7 @@ button[type="submit"]:disabled {
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
transition: all 0.2s;
transition: all var(--transition-normal);
margin-top: 0.2rem;
}
@ -385,8 +386,8 @@ button[type="submit"]:disabled {
.feature-card {
background: #f8f9fa;
padding: 2rem;
border-radius: var(--radius-md);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-md);
text-align: center;
}
@ -417,7 +418,7 @@ button[type="submit"]:disabled {
text-align: center;
padding: 3rem;
background: #f8f9fa;
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
margin: 2rem 0;
}
@ -575,8 +576,8 @@ button[type="submit"]:disabled {
cursor: pointer;
padding: 0.25rem;
color: #666;
border-radius: var(--radius-sm);
transition: background-color 0.2s;
border-radius: var(--border-radius-sm);
transition: background-color var(--transition-normal);
}
.trip-menu-trigger:hover {
@ -589,8 +590,8 @@ button[type="submit"]:disabled {
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-lg);
z-index: 1000;
min-width: 120px;
}
@ -603,7 +604,7 @@ button[type="submit"]:disabled {
background: none;
text-align: left;
cursor: pointer;
transition: background-color 0.2s;
transition: background-color var(--transition-normal);
color: #333;
}
@ -725,7 +726,7 @@ button[type="submit"]:disabled {
.modal-content {
background: var(--bg-card);
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
width: 100%;
max-width: 500px;
max-height: 90vh;
@ -807,7 +808,7 @@ button[type="submit"]:disabled {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
font-size: 1rem;
font-family: inherit;
resize: vertical;
@ -850,10 +851,10 @@ button[type="submit"]:disabled {
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
transition: background-color var(--transition-normal);
}
.btn-primary:hover:not(:disabled) {
@ -870,10 +871,10 @@ button[type="submit"]:disabled {
background: transparent;
color: var(--secondary-color);
border: 2px solid var(--secondary-color);
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
transition: all var(--transition-normal);
}
.btn-secondary:hover:not(:disabled) {
@ -893,10 +894,10 @@ button[type="submit"]:disabled {
background: var(--danger-color);
color: white;
border: none;
border-radius: var(--radius-md);
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
transition: background-color var(--transition-normal);
}
.btn-danger:hover:not(:disabled) {

View file

@ -1,16 +1,27 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { ToastProvider } from './components/common/ToastContainer'
import AuthGuard from './components/auth/AuthGuard'
import Dashboard from './components/Dashboard'
import TripDetail from './components/TripDetail'
import './App.css'
function App() {
return (
<AuthProvider>
<div className="App">
<AuthGuard>
<Dashboard />
</AuthGuard>
</div>
<ToastProvider>
<BrowserRouter>
<div className="App">
<AuthGuard>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/trip/:id" element={<TripDetail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthGuard>
</div>
</BrowserRouter>
</ToastProvider>
</AuthProvider>
)
}

View file

@ -1,6 +1,9 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatDateShort, getDuration } from '../utils/dateFormatter';
const TripCard = ({ trip, onEdit, onDelete }) => {
const navigate = useNavigate();
const [showDropdown, setShowDropdown] = useState(false);
const dropdownRef = useRef(null);
@ -17,15 +20,7 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
};
}, []);
const formatDate = (dateString) => {
if (!dateString) return null;
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
// Removed - using shared utility
const handleEdit = () => {
setShowDropdown(false);
@ -37,19 +32,18 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
onDelete(trip);
};
const getDuration = () => {
if (!trip.start_date || !trip.end_date) return null;
// Removed - using shared utility
const start = new Date(trip.start_date);
const end = new Date(trip.end_date);
const diffTime = Math.abs(end - start);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays === 1 ? '1 day' : `${diffDays} days`;
const handleCardClick = (e) => {
// Don't navigate if clicking on menu buttons
if (e.target.closest('.trip-card-menu')) {
return;
}
navigate(`/trip/${trip.id}`);
};
return (
<div className="trip-card">
<div className="trip-card" onClick={handleCardClick} style={{ cursor: 'pointer' }}>
<div className="trip-card-header">
<h3 className="trip-card-title">{trip.name}</h3>
<div className="trip-card-menu" ref={dropdownRef}>
@ -81,19 +75,19 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
{trip.start_date && trip.end_date ? (
<div className="trip-date-range">
<span className="trip-dates">
{formatDate(trip.start_date)} - {formatDate(trip.end_date)}
{formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)}
</span>
<span className="trip-duration">
{getDuration()}
{getDuration(trip.start_date, trip.end_date)}
</span>
</div>
) : trip.start_date ? (
<span className="trip-dates">
Starts: {formatDate(trip.start_date)}
Starts: {formatDateShort(trip.start_date)}
</span>
) : trip.end_date ? (
<span className="trip-dates">
Ends: {formatDate(trip.end_date)}
Ends: {formatDateShort(trip.end_date)}
</span>
) : (
<span className="trip-dates trip-dates-placeholder">
@ -104,11 +98,7 @@ const TripCard = ({ trip, onEdit, onDelete }) => {
<div className="trip-card-footer">
<span className="trip-created">
Created {new Date(trip.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
Created {formatDateShort(trip.created_at)}
</span>
</div>
</div>

View 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;
}
}

View 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;

View 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);
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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>
);
};

View 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);
}
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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]);
};

View 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'), []);
};

View 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
};
};

View 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)
};
};

View 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;
}
}

View 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'
});
};

View 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('🎯');
});
});
});