release/v0.1.0 #24

Open
myrmidex wants to merge 14 commits from release/v0.1.0 into main
132 changed files with 17397 additions and 127 deletions

4
.gitignore vendored
View file

@ -1 +1,5 @@
/.idea /.idea
/docker/data
/.claude
CLAUDE.md
/coverage

1
backend/.gitignore vendored
View file

@ -12,6 +12,7 @@
/.vscode /.vscode
/.zed /.zed
/auth.json /auth.json
/coverage
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot

View file

@ -0,0 +1,34 @@
<?php
namespace App\Domain\CalendarSlot\Policies;
use App\Models\User;
use App\Models\CalendarSlot;
use App\Models\Trip;
class CalendarSlotPolicy
{
/**
* Determine if the user can view calendar slots for the given trip
*/
public function viewAny(User $user, Trip $trip): bool
{
return $trip->created_by_user_id === $user->id;
}
/**
* Determine if the user can update the calendar slot
*/
public function update(User $user, CalendarSlot $calendarSlot): bool
{
return $calendarSlot->trip->created_by_user_id === $user->id;
}
/**
* Determine if the user can reorder items in the calendar slot
*/
public function reorder(User $user, CalendarSlot $calendarSlot): bool
{
return $calendarSlot->trip->created_by_user_id === $user->id;
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Domain\PlannableItem\Policies;
use App\Models\User;
use App\Models\PlannableItem;
use App\Models\Trip;
class PlannableItemPolicy
{
/**
* Determine if the user can create a plannable item for the given trip
*/
public function create(User $user, Trip $trip): bool
{
return $trip->created_by_user_id === $user->id;
}
/**
* Determine if the user can view the plannable item
*/
public function view(User $user, PlannableItem $plannableItem): bool
{
return $plannableItem->trip->created_by_user_id === $user->id;
}
/**
* Determine if the user can update the plannable item
*/
public function update(User $user, PlannableItem $plannableItem): bool
{
return $plannableItem->trip->created_by_user_id === $user->id;
}
/**
* Determine if the user can delete the plannable item
*/
public function delete(User $user, PlannableItem $plannableItem): bool
{
return $plannableItem->trip->created_by_user_id === $user->id;
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace App\Domain\PlannedItem\Actions;
use App\Domain\Trip\Services\CalendarSlotService;
use App\Models\PlannableItem;
use App\Models\CalendarSlot;
use App\Models\PlannedItem;
use Carbon\Carbon;
class CreatePlannedItemAction
{
public function __construct(
private CalendarSlotService $calendarSlotService
) {}
/**
* Create a new planned item from datetime parameters
*
* @param array $data ['plannable_item_id', 'trip_id', 'start_datetime', 'end_datetime', 'sort_order'?]
* @return CalendarSlot The calendar slot with loaded planned items
* @throws \Exception
*/
public function execute(array $data): CalendarSlot
{
$startDatetime = Carbon::parse($data['start_datetime']);
$endDatetime = Carbon::parse($data['end_datetime']);
$slotDate = $startDatetime->toDateString();
// Verify the plannable item belongs to the trip
$plannableItem = PlannableItem::findOrFail($data['plannable_item_id']);
if ($plannableItem->trip_id !== $data['trip_id']) {
throw new \Exception('Plannable item does not belong to this trip');
}
// Use database transaction to prevent race conditions
$calendarSlot = \DB::transaction(function () use ($data, $plannableItem, $startDatetime, $endDatetime, $slotDate) {
// Use firstOrCreate to prevent duplicate slots with same times
$calendarSlot = CalendarSlot::firstOrCreate(
[
'trip_id' => $data['trip_id'],
'datetime_start' => $startDatetime,
'datetime_end' => $endDatetime,
'slot_date' => $slotDate,
],
[
'name' => $plannableItem->name,
'slot_order' => 0, // Will be recalculated
]
);
// Recalculate slot orders to ensure consistency
$this->calendarSlotService->recalculateSlotOrdersForDate(
$data['trip_id'],
$slotDate
);
// Create PlannedItem link (use firstOrCreate to prevent race condition duplicates)
PlannedItem::firstOrCreate(
[
'plannable_item_id' => $data['plannable_item_id'],
'calendar_slot_id' => $calendarSlot->id,
],
[
'sort_order' => $data['sort_order'] ?? 0,
]
);
return $calendarSlot;
});
return $calendarSlot->load(['plannedItems.plannableItem']);
}
/**
* Create planned item from existing calendar slot (legacy flow)
*
* @param array $data ['plannable_item_id', 'calendar_slot_id', 'sort_order'?]
* @return PlannedItem
* @throws \Exception
*/
public function executeFromSlot(array $data): PlannedItem
{
// Validate that calendar slot and plannable item belong to the same trip
$plannableItem = PlannableItem::findOrFail($data['plannable_item_id']);
$calendarSlot = CalendarSlot::findOrFail($data['calendar_slot_id']);
if ($plannableItem->trip_id !== $calendarSlot->trip_id) {
throw new \Exception('Calendar slot and plannable item must belong to the same trip');
}
$plannedItem = PlannedItem::updateOrCreate(
[
'plannable_item_id' => $data['plannable_item_id'],
'calendar_slot_id' => $data['calendar_slot_id'],
],
[
'sort_order' => $data['sort_order'] ?? 0,
]
);
return $plannedItem->load(['plannableItem', 'calendarSlot']);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Domain\PlannedItem\Actions;
use App\Models\PlannedItem;
class DeletePlannedItemAction
{
/**
* Delete a planned item
*
* @param PlannedItem $plannedItem
* @return void
*/
public function execute(PlannedItem $plannedItem): void
{
$plannedItem->delete();
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Domain\PlannedItem\Actions;
use App\Models\PlannedItem;
class UpdatePlannedItemAction
{
/**
* Update a planned item's properties
*
* @param PlannedItem $plannedItem
* @param array $data ['calendar_slot_id'?, 'sort_order'?]
* @return PlannedItem
*/
public function execute(PlannedItem $plannedItem, array $data): PlannedItem
{
$plannedItem->update($data);
return $plannedItem->load(['plannableItem', 'calendarSlot']);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Domain\PlannedItem\Policies;
use App\Models\User;
use App\Models\PlannedItem;
use App\Models\Trip;
class PlannedItemPolicy
{
/**
* Determine if the user can create a planned item for the given trip
*/
public function create(User $user, int $tripId): bool
{
return \DB::table('trips')
->where('id', $tripId)
->where('created_by_user_id', $user->id)
->exists();
}
/**
* Determine if the user can update the planned item
*/
public function update(User $user, PlannedItem $plannedItem): bool
{
return \DB::table('planned_items')
->join('calendar_slots', 'planned_items.calendar_slot_id', '=', 'calendar_slots.id')
->join('trips', 'calendar_slots.trip_id', '=', 'trips.id')
->where('planned_items.id', $plannedItem->id)
->where('trips.created_by_user_id', $user->id)
->exists();
}
/**
* Determine if the user can delete the planned item
*/
public function delete(User $user, PlannedItem $plannedItem): bool
{
return $this->update($user, $plannedItem);
}
/**
* Determine if the user can move a planned item to a new calendar slot
*/
public function moveToSlot(User $user, int $calendarSlotId): bool
{
return \DB::table('calendar_slots')
->join('trips', 'calendar_slots.trip_id', '=', 'trips.id')
->where('calendar_slots.id', $calendarSlotId)
->where('trips.created_by_user_id', $user->id)
->exists();
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Domain\Trip\Observers;
use App\Models\Trip;
use App\Domain\Trip\Services\CalendarSlotService;
class TripObserver
{
protected CalendarSlotService $calendarSlotService;
public function __construct(CalendarSlotService $calendarSlotService)
{
$this->calendarSlotService = $calendarSlotService;
}
public function created(Trip $trip): void
{
if ($trip->start_date && $trip->end_date) {
$this->calendarSlotService->createOrUpdateSlotsForTrip($trip);
}
}
public function updated(Trip $trip): void
{
if ($trip->isDirty(['start_date', 'end_date'])) {
$this->calendarSlotService->createOrUpdateSlotsForTrip($trip);
}
}
public function deleted(Trip $trip): void
{
$this->calendarSlotService->deleteSlotsForTrip($trip);
}
public function restored(Trip $trip): void
{
$this->calendarSlotService->createOrUpdateSlotsForTrip($trip);
}
public function forceDeleted(Trip $trip): void
{
$this->calendarSlotService->deleteSlotsForTrip($trip);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Domain\Trip\Policies;
use App\Models\User;
use App\Models\Trip;
class TripPolicy
{
/**
* Determine if the user can view the trip
*/
public function view(User $user, Trip $trip): bool
{
return $trip->created_by_user_id === $user->id;
}
/**
* Determine if the user can update the trip
*/
public function update(User $user, Trip $trip): bool
{
return $trip->created_by_user_id === $user->id;
}
/**
* Determine if the user can delete the trip
*/
public function delete(User $user, Trip $trip): bool
{
return $trip->created_by_user_id === $user->id;
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Domain\Trip\Providers;
use App\Models\Trip;
use App\Domain\Trip\Observers\TripObserver;
use Illuminate\Support\ServiceProvider;
class TripServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Trip::observe(TripObserver::class);
}
}

View file

@ -0,0 +1,111 @@
<?php
namespace App\Domain\Trip\Services;
use App\Models\Trip;
use App\Models\CalendarSlot;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class CalendarSlotService
{
public function createOrUpdateSlotsForTrip(Trip $trip): Collection
{
if (!$trip->start_date || !$trip->end_date) {
return collect();
}
// Fresh load to avoid stale relationship data
$trip->refresh();
$existingSlots = $trip->calendarSlots;
$existingSlotsMap = $existingSlots->keyBy(function ($slot) {
return $slot->slot_date instanceof \Carbon\Carbon
? $slot->slot_date->toDateString()
: $slot->slot_date;
});
$startDate = Carbon::parse($trip->start_date);
$endDate = Carbon::parse($trip->end_date);
$newSlots = collect();
$currentDate = $startDate->copy();
$dayNumber = 1;
while ($currentDate->lte($endDate)) {
$slotDate = $currentDate->toDateString();
if (!$existingSlotsMap->has($slotDate)) {
$slot = CalendarSlot::create([
'trip_id' => $trip->id,
'name' => 'Day ' . $dayNumber,
'slot_date' => $slotDate,
'datetime_start' => $currentDate->copy()->startOfDay(),
'datetime_end' => $currentDate->copy()->endOfDay(),
'slot_order' => $dayNumber,
]);
$newSlots->push($slot);
} else {
$existingSlot = $existingSlotsMap->get($slotDate);
$existingSlot->update([
'slot_order' => $dayNumber,
'datetime_start' => $currentDate->copy()->startOfDay(),
'datetime_end' => $currentDate->copy()->endOfDay(),
]);
$newSlots->push($existingSlot);
}
$currentDate->addDay();
$dayNumber++;
}
$trip->calendarSlots()
->whereNotIn('slot_date', $newSlots->pluck('slot_date'))
->delete();
return $newSlots;
}
public function deleteSlotsForTrip(Trip $trip): void
{
$trip->calendarSlots()->delete();
}
/**
* Calculate the slot_order for a new slot based on datetime_start
* Orders chronologically by start time within the same day
*/
public function calculateSlotOrder(int $tripId, string $slotDate, Carbon $datetimeStart): int
{
$maxOrder = CalendarSlot::where('trip_id', $tripId)
->where('slot_date', $slotDate)
->where('datetime_start', '<', $datetimeStart)
->max('slot_order');
return ($maxOrder ?? -1) + 1;
}
/**
* Recalculate slot_order for all slots on a given date
* Orders by datetime_start ASC
* Uses database transaction with individual updates for safety
*/
public function recalculateSlotOrdersForDate(int $tripId, string $slotDate): void
{
$slots = CalendarSlot::where('trip_id', $tripId)
->where('slot_date', $slotDate)
->orderBy('datetime_start')
->get();
if ($slots->isEmpty()) {
return;
}
// Update each slot's order within a transaction
\DB::transaction(function () use ($slots) {
foreach ($slots as $index => $slot) {
$slot->slot_order = $index;
$slot->save();
}
});
}
}

View file

@ -1,8 +0,0 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\CalendarSlot;
use App\Domain\CalendarSlot\Policies\CalendarSlotPolicy;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\CalendarSlot;
use App\Models\Trip;
use App\Models\PlannedItem;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class CalendarSlotController extends Controller
{
public function __construct(
private CalendarSlotPolicy $policy
) {}
public function index(Trip $trip): JsonResponse
{
if (!$this->policy->viewAny(auth()->user(), $trip)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$calendarSlots = $trip->calendarSlots()
->with(['plannedItems.plannableItem'])
->orderBy('slot_date')
->orderBy('datetime_start')
->get();
return response()->json(['data' => $calendarSlots]);
}
public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse
{
if (!$this->policy->update(auth()->user(), $calendarSlot)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'name' => 'sometimes|required|string|max:255',
]);
$calendarSlot->update($validated);
return response()->json(['data' => $calendarSlot]);
}
public function reorder(Request $request, CalendarSlot $calendarSlot): JsonResponse
{
if (!$this->policy->reorder(auth()->user(), $calendarSlot)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'items' => 'required|array',
'items.*.plannable_item_id' => 'required|exists:plannable_items,id',
'items.*.sort_order' => 'required|integer',
]);
// Validate all plannable items belong to the same trip
$trip = $calendarSlot->trip;
$validPlannableItemIds = $trip->plannableItems()->pluck('id')->toArray();
foreach ($validated['items'] as $item) {
if (!in_array($item['plannable_item_id'], $validPlannableItemIds)) {
return response()->json(['message' => 'Invalid plannable item for this trip'], 422);
}
}
// Update sort orders in transaction
\DB::transaction(function () use ($validated, $calendarSlot) {
foreach ($validated['items'] as $item) {
PlannedItem::where('calendar_slot_id', $calendarSlot->id)
->where('plannable_item_id', $item['plannable_item_id'])
->update(['sort_order' => $item['sort_order']]);
}
});
return response()->json(['message' => 'Items reordered successfully']);
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\E2e;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class TestSetupController extends Controller
{
/**
* Create test user for E2E tests (development only)
*/
public function createTestUser(Request $request)
{
// Only allow in development/testing environments
if (app()->environment('production')) {
return response()->json(['error' => 'Not available in production'], 403);
}
$validated = $request->validate([
'email' => 'required|email',
'password' => 'required|min:8',
'name' => 'required|string'
]);
$user = User::firstOrCreate(
['email' => $validated['email']],
[
'name' => $validated['name'],
'password' => Hash::make($validated['password']),
'email_verified_at' => now(),
]
);
// Ensure email_verified_at is set even for existing users
if (!$user->email_verified_at) {
$user->email_verified_at = now();
$user->save();
}
return response()->json([
'success' => true,
'message' => $user->wasRecentlyCreated ? 'Test user created' : 'Test user already exists',
'data' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name
]
]);
}
/**
* Clean up test data
*/
public function cleanup(Request $request)
{
if (app()->environment('production')) {
return response()->json(['error' => 'Not available in production'], 403);
}
// Delete test users (those with specific test email patterns)
$deleted = User::where('email', 'LIKE', 'test%@example.com')
->orWhere('email', 'LIKE', 'test.user.%@example.com')
->delete();
return response()->json([
'success' => true,
'message' => "Deleted $deleted test users"
]);
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\PlannableItem;
use App\Domain\PlannableItem\Policies\PlannableItemPolicy;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\PlannableItem;
use App\Models\Trip;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class PlannableItemController extends Controller
{
public function __construct(
private PlannableItemPolicy $policy
) {}
public function index(Trip $trip): JsonResponse
{
if (!$this->policy->create(auth()->user(), $trip)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$plannableItems = $trip->plannableItems()
->with(['calendarSlots'])
->get();
return response()->json(['data' => $plannableItems]);
}
public function store(Request $request, Trip $trip): JsonResponse
{
if (!$this->policy->create(auth()->user(), $trip)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|in:hotel,restaurant,attraction,transport,activity',
'address' => 'nullable|string|max:255',
'notes' => 'nullable|string',
'metadata' => 'nullable|array',
]);
$plannableItem = $trip->plannableItems()->create($validated);
return response()->json(['data' => $plannableItem], 201);
}
public function show(PlannableItem $plannableItem): JsonResponse
{
if (!$this->policy->view(auth()->user(), $plannableItem)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$plannableItem->load(['calendarSlots', 'trip']);
return response()->json($plannableItem);
}
public function update(Request $request, PlannableItem $plannableItem): JsonResponse
{
if (!$this->policy->update(auth()->user(), $plannableItem)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'name' => 'sometimes|required|string|max:255',
'type' => 'sometimes|required|in:hotel,restaurant,attraction,transport,activity',
'address' => 'nullable|string|max:255',
'notes' => 'nullable|string',
'metadata' => 'nullable|array',
]);
$plannableItem->update($validated);
return response()->json(['data' => $plannableItem]);
}
public function destroy(PlannableItem $plannableItem): JsonResponse
{
if (!$this->policy->delete(auth()->user(), $plannableItem)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$plannableItem->delete();
return response()->json(null, 204);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\PlannedItem;
use App\Domain\PlannedItem\Actions\CreatePlannedItemAction;
use App\Domain\PlannedItem\Actions\UpdatePlannedItemAction;
use App\Domain\PlannedItem\Actions\DeletePlannedItemAction;
use App\Domain\PlannedItem\Policies\PlannedItemPolicy;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\PlannedItem;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
class PlannedItemController extends Controller
{
public function __construct(
private CreatePlannedItemAction $createAction,
private UpdatePlannedItemAction $updateAction,
private DeletePlannedItemAction $deleteAction,
private PlannedItemPolicy $policy
) {}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'plannable_item_id' => 'required|exists:plannable_items,id',
'trip_id' => 'required|exists:trips,id',
'start_datetime' => 'required|date',
'end_datetime' => 'required|date|after:start_datetime',
'calendar_slot_id' => 'sometimes|exists:calendar_slots,id',
'sort_order' => 'nullable|integer',
]);
// Check authorization using Policy
if (!$this->policy->create(auth()->user(), $validated['trip_id'])) {
return response()->json(['message' => 'Forbidden'], 403);
}
// If calendar_slot_id is provided, use existing flow (backward compatibility)
if (isset($validated['calendar_slot_id'])) {
$plannedItem = $this->createAction->executeFromSlot($validated);
return response()->json($plannedItem, 201);
}
// New flow: Create CalendarSlot from datetime using Action
try {
$calendarSlot = $this->createAction->execute($validated);
return response()->json($calendarSlot, 201);
} catch (\Exception $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function update(Request $request, PlannedItem $plannedItem): JsonResponse
{
// Check authorization using Policy
if (!$this->policy->update(auth()->user(), $plannedItem)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'calendar_slot_id' => 'sometimes|required|exists:calendar_slots,id',
'sort_order' => 'nullable|integer',
]);
// If changing calendar slot, verify user owns the new slot's trip
if (isset($validated['calendar_slot_id'])) {
if (!$this->policy->moveToSlot(auth()->user(), $validated['calendar_slot_id'])) {
return response()->json(['message' => 'Forbidden'], 403);
}
}
// Execute update using Action
$updatedItem = $this->updateAction->execute($plannedItem, $validated);
return response()->json($updatedItem);
}
public function destroy(PlannedItem $plannedItem): JsonResponse
{
// Check authorization using Policy
if (!$this->policy->delete(auth()->user(), $plannedItem)) {
return response()->json(['message' => 'Forbidden'], 403);
}
// Execute delete using Action
$this->deleteAction->execute($plannedItem);
return response()->json(null, 204);
}
}

View file

@ -0,0 +1,93 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\Trip;
use App\Domain\Trip\Policies\TripPolicy;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\Trip;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class TripController extends Controller
{
public function __construct(
private TripPolicy $policy
) {}
/**
* Display a listing of the resource.
*/
public function index(Request $request): JsonResponse
{
$trips = Trip::where('created_by_user_id', $request->user()->id)
->orderBy('created_at', 'desc')
->get();
return response()->json(['data' => $trips]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
$validated['created_by_user_id'] = $request->user()->id;
$trip = Trip::create($validated);
return response()->json(['data' => $trip], 201);
}
/**
* Display the specified resource.
*/
public function show(Request $request, Trip $trip): JsonResponse
{
if (!$this->policy->view($request->user(), $trip)) {
return response()->json(['message' => 'Forbidden'], 403);
}
return response()->json(['data' => $trip]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Trip $trip): JsonResponse
{
if (!$this->policy->update($request->user(), $trip)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
$trip->update($validated);
return response()->json(['data' => $trip]);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Request $request, Trip $trip): JsonResponse
{
if (!$this->policy->delete($request->user(), $trip)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$trip->delete();
return response()->json(['message' => 'Trip deleted successfully']);
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\User\Auth;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation errors',
'data' => $validator->errors()
], 422);
}
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'success' => true,
'message' => 'User registered successfully',
'data' => [
'user' => $user,
'access_token' => $token,
'token_type' => 'Bearer'
]
], 201);
}
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'password' => 'required',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation errors',
'data' => $validator->errors()
], 422);
}
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'success' => true,
'message' => 'Login successful',
'data' => [
'user' => $user,
'access_token' => $token,
'token_type' => 'Bearer'
]
]);
}
public function profile(Request $request)
{
return response()->json([
'success' => true,
'message' => 'Profile retrieved successfully',
'data' => $request->user()
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'success' => true,
'message' => 'Logout successful'
]);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Infrastructure\Http\Controllers;
abstract class Controller
{
//
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Infrastructure\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Cors
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$origin = $request->headers->get('Origin');
$allowedOrigins = config('cors.allowed_origins', ['http://localhost:5173']);
$allowedOrigin = $allowedOrigins[0] ?? 'http://localhost:5173';
// Only set CORS headers if the origin matches our frontend
if ($origin === $allowedOrigin) {
$response->headers->set('Access-Control-Allow-Origin', $origin);
$response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, X-XSRF-TOKEN');
$response->headers->set('Access-Control-Allow-Credentials', 'true');
}
// Handle preflight OPTIONS requests
if ($request->getMethod() === 'OPTIONS') {
$response->setStatusCode(200);
}
return $response;
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class CalendarSlot extends Model
{
use HasFactory;
protected $fillable = [
'trip_id',
'name',
'datetime_start',
'datetime_end',
'slot_date',
'slot_order',
];
protected $casts = [
'datetime_start' => 'datetime',
'datetime_end' => 'datetime',
'slot_date' => 'date',
];
public function trip(): BelongsTo
{
return $this->belongsTo(Trip::class);
}
public function plannableItems(): BelongsToMany
{
return $this->belongsToMany(PlannableItem::class, 'planned_items')
->withPivot('sort_order')
->withTimestamps()
->orderBy('planned_items.sort_order');
}
public function plannedItems()
{
return $this->hasMany(PlannedItem::class);
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class PlannableItem extends Model
{
use HasFactory;
protected $fillable = [
'trip_id',
'name',
'type',
'address',
'notes',
'metadata',
];
protected $casts = [
'metadata' => 'array',
];
public function trip(): BelongsTo
{
return $this->belongsTo(Trip::class);
}
public function calendarSlots(): BelongsToMany
{
return $this->belongsToMany(CalendarSlot::class, 'planned_items')
->withPivot('sort_order')
->withTimestamps()
->orderBy('planned_items.sort_order');
}
public function plannedItems()
{
return $this->hasMany(PlannedItem::class);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlannedItem extends Model
{
use HasFactory;
protected $fillable = [
'plannable_item_id',
'calendar_slot_id',
'sort_order',
];
public function plannableItem(): BelongsTo
{
return $this->belongsTo(PlannableItem::class);
}
public function calendarSlot(): BelongsTo
{
return $this->belongsTo(CalendarSlot::class);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Trip extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'start_date',
'end_date',
'created_by_user_id'
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function plannableItems(): HasMany
{
return $this->hasMany(PlannableItem::class);
}
public function calendarSlots(): HasMany
{
return $this->hasMany(CalendarSlot::class)->orderBy('slot_date')->orderBy('slot_order');
}
}

View file

@ -6,11 +6,12 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -45,4 +46,12 @@ protected function casts(): array
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
/**
* Get the trips created by this user.
*/
public function trips()
{
return $this->hasMany(Trip::class, 'created_by_user_id');
}
} }

View file

@ -2,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Trip;
use App\Domain\Trip\Observers\TripObserver;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -19,6 +21,6 @@ public function register(): void
*/ */
public function boot(): void public function boot(): void
{ {
// Trip::observe(TripObserver::class);
} }
} }

View file

@ -7,11 +7,14 @@
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->api(prepend: [
\App\Infrastructure\Http\Middleware\Cors::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View file

@ -2,4 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Domain\Trip\Providers\TripServiceProvider::class,
]; ];

View file

@ -11,15 +11,20 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1" "laravel/tinker": "^2.10.1"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^3.7",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
"laravel/sail": "^1.41", "laravel/sail": "^1.41",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.3" "phpunit/phpunit": "^11.5.3"
}, },
"autoload": { "autoload": {

349
backend/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c514d8f7b9fc5970bdd94287905ef584", "content-hash": "9656361ea974cbb5fad3b98127519a54",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@ -1332,6 +1332,70 @@
}, },
"time": "2025-09-19T13:47:56+00:00" "time": "2025-09-19T13:47:56+00:00"
}, },
{
"name": "laravel/sanctum",
"version": "v4.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
"reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0",
"illuminate/contracts": "^11.0|^12.0",
"illuminate/database": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0",
"php": "^8.2",
"symfony/console": "^7.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0|^10.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^11.3"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2025-07-09T19:45:24+00:00"
},
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.5", "version": "v2.0.5",
@ -6284,6 +6348,136 @@
}, },
"time": "2025-04-30T06:54:44+00:00" "time": "2025-04-30T06:54:44+00:00"
}, },
{
"name": "iamcal/sql-parser",
"version": "v0.6",
"source": {
"type": "git",
"url": "https://github.com/iamcal/SQLParser.git",
"reference": "947083e2dca211a6f12fb1beb67a01e387de9b62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62",
"reference": "947083e2dca211a6f12fb1beb67a01e387de9b62",
"shasum": ""
},
"require-dev": {
"php-coveralls/php-coveralls": "^1.0",
"phpunit/phpunit": "^5|^6|^7|^8|^9"
},
"type": "library",
"autoload": {
"psr-4": {
"iamcal\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cal Henderson",
"email": "cal@iamcal.com"
}
],
"description": "MySQL schema parser",
"support": {
"issues": "https://github.com/iamcal/SQLParser/issues",
"source": "https://github.com/iamcal/SQLParser/tree/v0.6"
},
"time": "2025-03-17T16:59:46+00:00"
},
{
"name": "larastan/larastan",
"version": "v3.7.2",
"source": {
"type": "git",
"url": "https://github.com/larastan/larastan.git",
"reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/a761859a7487bd7d0cb8b662a7538a234d5bb5ae",
"reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae",
"shasum": ""
},
"require": {
"ext-json": "*",
"iamcal/sql-parser": "^0.6.0",
"illuminate/console": "^11.44.2 || ^12.4.1",
"illuminate/container": "^11.44.2 || ^12.4.1",
"illuminate/contracts": "^11.44.2 || ^12.4.1",
"illuminate/database": "^11.44.2 || ^12.4.1",
"illuminate/http": "^11.44.2 || ^12.4.1",
"illuminate/pipeline": "^11.44.2 || ^12.4.1",
"illuminate/support": "^11.44.2 || ^12.4.1",
"php": "^8.2",
"phpstan/phpstan": "^2.1.28"
},
"require-dev": {
"doctrine/coding-standard": "^13",
"laravel/framework": "^11.44.2 || ^12.7.2",
"mockery/mockery": "^1.6.12",
"nikic/php-parser": "^5.4",
"orchestra/canvas": "^v9.2.2 || ^10.0.1",
"orchestra/testbench-core": "^9.12.0 || ^10.1",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpunit/phpunit": "^10.5.35 || ^11.5.15"
},
"suggest": {
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Larastan\\Larastan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Can Vural",
"email": "can9119@gmail.com"
}
],
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
"keywords": [
"PHPStan",
"code analyse",
"code analysis",
"larastan",
"laravel",
"package",
"php",
"static analysis"
],
"support": {
"issues": "https://github.com/larastan/larastan/issues",
"source": "https://github.com/larastan/larastan/tree/v3.7.2"
},
"funding": [
{
"url": "https://github.com/canvural",
"type": "github"
}
],
"time": "2025-09-19T09:03:05+00:00"
},
{ {
"name": "laravel/pail", "name": "laravel/pail",
"version": "v1.2.3", "version": "v1.2.3",
@ -6852,6 +7046,159 @@
}, },
"time": "2022-02-21T01:04:05+00:00" "time": "2022-02-21T01:04:05+00:00"
}, },
{
"name": "phpstan/phpstan",
"version": "2.1.29",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-phar-composer-source.git",
"reference": "git"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/d618573eed4a1b6b75e37b2e0b65ac65c885d88e",
"reference": "d618573eed4a1b6b75e37b2e0b65ac65c885d88e",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2025-09-25T06:58:18+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",
"version": "2.0.3",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-deprecation-rules.git",
"reference": "468e02c9176891cc901143da118f09dc9505fc2f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/468e02c9176891cc901143da118f09dc9505fc2f",
"reference": "468e02c9176891cc901143da118f09dc9505fc2f",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.15"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.6"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.",
"support": {
"issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues",
"source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.3"
},
"time": "2025-05-14T10:56:57+00:00"
},
{
"name": "phpstan/phpstan-strict-rules",
"version": "2.0.7",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-strict-rules.git",
"reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538",
"reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.29"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.6"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Extra strict and opinionated rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-strict-rules/issues",
"source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7"
},
"time": "2025-09-26T11:19:08+00:00"
},
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "11.0.11", "version": "11.0.11",

34
backend/config/cors.php Normal file
View file

@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];

View file

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

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,72 @@
<?php
namespace Database\Factories;
use App\Models\Trip;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Trip>
*/
class TripFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Trip::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$startDate = $this->faker->dateTimeBetween('now', '+1 year');
$endDate = $this->faker->dateTimeBetween($startDate, '+1 year');
return [
'name' => $this->faker->sentence(3),
'description' => $this->faker->optional()->paragraph(),
'start_date' => $this->faker->optional()->date('Y-m-d', $startDate),
'end_date' => $this->faker->optional()->date('Y-m-d', $endDate),
'created_by_user_id' => User::factory(),
];
}
/**
* Indicate that the trip has no dates.
*/
public function withoutDates(): static
{
return $this->state(fn (array $attributes) => [
'start_date' => null,
'end_date' => null,
]);
}
/**
* Indicate that the trip is upcoming.
*/
public function upcoming(): static
{
return $this->state(fn (array $attributes) => [
'start_date' => now()->addDays(30)->format('Y-m-d'),
'end_date' => now()->addDays(37)->format('Y-m-d'),
]);
}
/**
* Indicate that the trip is past.
*/
public function past(): static
{
return $this->state(fn (array $attributes) => [
'start_date' => now()->subDays(37)->format('Y-m-d'),
'end_date' => now()->subDays(30)->format('Y-m-d'),
]);
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('trips', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->date('start_date')->nullable();
$table->date('end_date')->nullable();
$table->foreignId('created_by_user_id')->constrained('users')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('trips');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('plannable_items', function (Blueprint $table) {
$table->id();
$table->foreignId('trip_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->enum('type', ['hotel', 'restaurant', 'attraction', 'transport', 'activity']);
$table->string('address')->nullable();
$table->text('notes')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('plannable_items');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('calendar_slots', function (Blueprint $table) {
$table->id();
$table->foreignId('trip_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->dateTime('datetime_start')->nullable();
$table->dateTime('datetime_end')->nullable();
$table->date('slot_date');
$table->integer('slot_order')->default(0);
$table->timestamps();
// Composite indexes for performance
$table->index(['trip_id', 'slot_date', 'datetime_start']);
$table->index(['trip_id', 'slot_date', 'slot_order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('calendar_slots');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('planned_items', function (Blueprint $table) {
$table->id();
$table->foreignId('plannable_item_id')->constrained()->cascadeOnDelete();
$table->foreignId('calendar_slot_id')->constrained()->cascadeOnDelete();
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->unique(['plannable_item_id', 'calendar_slot_id']);
$table->index(['calendar_slot_id', 'sort_order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('planned_items');
}
};

View file

@ -0,0 +1,36 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class TestUserSeeder extends Seeder
{
/**
* Run the database seeds for E2E testing.
*/
public function run(): void
{
// Create a standard test user for login tests
User::firstOrCreate(
['email' => 'test@example.com'],
[
'name' => 'Test User',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
]
);
// Create additional test users if needed
User::firstOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin User',
'password' => Hash::make('admin123'),
'email_verified_at' => now(),
]
);
}
}

24
backend/phpstan.neon Normal file
View file

@ -0,0 +1,24 @@
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 0
paths:
- app/
- config/
- database/
- routes/
- tests/
excludePaths:
- database/migrations/*
reportUnmatchedIgnoredErrors: false
ignoreErrors:
# Ignore errors for facades which are resolved at runtime
- '#Call to an undefined static method#'
# Laravel specific settings
checkModelProperties: true

View file

@ -16,7 +16,20 @@
<include> <include>
<directory>app</directory> <directory>app</directory>
</include> </include>
<exclude>
<directory>app/Console</directory>
<directory>app/Exceptions</directory>
<file>app/Http/Kernel.php</file>
<file>app/Providers/BroadcastServiceProvider.php</file>
</exclude>
</source> </source>
<coverage>
<report>
<html outputDirectory="coverage/html"/>
<text outputFile="coverage/coverage.txt"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>

54
backend/routes/api.php Normal file
View file

@ -0,0 +1,54 @@
<?php
use App\Infrastructure\Http\Controllers\API\User\Auth\AuthController;
use App\Infrastructure\Http\Controllers\API\Trip\TripController;
use App\Infrastructure\Http\Controllers\API\E2e\TestSetupController;
use App\Infrastructure\Http\Controllers\API\PlannableItem\PlannableItemController;
use App\Infrastructure\Http\Controllers\API\CalendarSlot\CalendarSlotController;
use App\Infrastructure\Http\Controllers\API\PlannedItem\PlannedItemController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// Handle preflight OPTIONS requests
Route::options('{any}', function() {
return response('', 200);
})->where('any', '.*');
// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// E2E test routes (development/testing only)
Route::prefix('e2e/test')->group(function () {
Route::post('/setup/user', [TestSetupController::class, 'createTestUser']);
Route::post('/cleanup', [TestSetupController::class, 'cleanup']);
});
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
Route::get('/profile', [AuthController::class, 'profile']);
Route::post('/logout', [AuthController::class, 'logout']);
// Trip routes
Route::apiResource('trips', TripController::class);
// Plannable items routes
Route::get('trips/{trip}/plannables', [PlannableItemController::class, 'index']);
Route::post('trips/{trip}/plannables', [PlannableItemController::class, 'store']);
Route::get('plannables/{plannableItem}', [PlannableItemController::class, 'show']);
Route::put('plannables/{plannableItem}', [PlannableItemController::class, 'update']);
Route::delete('plannables/{plannableItem}', [PlannableItemController::class, 'destroy']);
// Calendar slots routes
Route::get('trips/{trip}/calendar-slots', [CalendarSlotController::class, 'index']);
Route::put('calendar-slots/{calendarSlot}', [CalendarSlotController::class, 'update']);
Route::put('calendar-slots/{calendarSlot}/reorder', [CalendarSlotController::class, 'reorder']);
// Planned items routes
Route::post('planned-items', [PlannedItemController::class, 'store']);
Route::put('planned-items/{plannedItem}', [PlannedItemController::class, 'update']);
Route::delete('planned-items/{plannedItem}', [PlannedItemController::class, 'destroy']);
});

View file

@ -0,0 +1,457 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AuthTest extends TestCase
{
use RefreshDatabase, WithFaker;
/**
* Test user registration with valid data.
*/
public function test_user_can_register_with_valid_data()
{
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
];
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(201)
->assertJsonStructure([
'success',
'message',
'data' => [
'user' => [
'id',
'name',
'email',
'created_at',
'updated_at',
],
'access_token',
'token_type',
]
])
->assertJson([
'success' => true,
'message' => 'User registered successfully',
'data' => [
'user' => [
'name' => 'John Doe',
'email' => 'john@example.com',
],
'token_type' => 'Bearer',
]
]);
// Verify user was created in database
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
// Verify password was hashed
$user = User::where('email', 'john@example.com')->first();
$this->assertTrue(Hash::check('password123', $user->password));
}
/**
* Test registration validation errors.
*/
public function test_registration_validates_required_fields()
{
// Test without any data
$response = $this->postJson('/api/register', []);
$response->assertStatus(422)
->assertJson([
'success' => false,
'message' => 'Validation errors',
])
->assertJsonPath('data.name', fn($value) => !empty($value))
->assertJsonPath('data.email', fn($value) => !empty($value))
->assertJsonPath('data.password', fn($value) => !empty($value));
// Test with invalid email
$response = $this->postJson('/api/register', [
'name' => 'John Doe',
'email' => 'invalid-email',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(422)
->assertJsonPath('data.email', fn($value) => !empty($value));
// Test with password too short
$response = $this->postJson('/api/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => '123',
'password_confirmation' => '123',
]);
$response->assertStatus(422)
->assertJsonPath('data.password', fn($value) => !empty($value));
// Test with password confirmation mismatch
$response = $this->postJson('/api/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'different123',
]);
$response->assertStatus(422)
->assertJsonPath('data.password', fn($value) => !empty($value));
}
/**
* Test registration with duplicate email.
*/
public function test_registration_prevents_duplicate_email()
{
// Create an existing user
User::factory()->create([
'email' => 'existing@example.com',
]);
$userData = [
'name' => 'John Doe',
'email' => 'existing@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
];
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(422)
->assertJsonPath('data.email', fn($value) => !empty($value));
}
/**
* Test user login with valid credentials.
*/
public function test_user_can_login_with_valid_credentials()
{
$user = User::factory()->create([
'email' => 'john@example.com',
'password' => Hash::make('password123'),
]);
$loginData = [
'email' => 'john@example.com',
'password' => 'password123',
];
$response = $this->postJson('/api/login', $loginData);
$response->assertStatus(200)
->assertJsonStructure([
'success',
'message',
'data' => [
'user' => [
'id',
'name',
'email',
'created_at',
'updated_at',
],
'access_token',
'token_type',
]
])
->assertJson([
'success' => true,
'message' => 'Login successful',
'data' => [
'user' => [
'id' => $user->id,
'email' => 'john@example.com',
],
'token_type' => 'Bearer',
]
]);
// Verify token is valid
$this->assertNotEmpty($response->json('data.access_token'));
}
/**
* Test login with invalid credentials.
*/
public function test_login_fails_with_invalid_credentials()
{
$user = User::factory()->create([
'email' => 'john@example.com',
'password' => Hash::make('correct-password'),
]);
// Test with wrong password - This should return 422 with validation exception
$response = $this->postJson('/api/login', [
'email' => 'john@example.com',
'password' => 'wrong-password',
]);
$response->assertStatus(422);
// Test with non-existent email
$response = $this->postJson('/api/login', [
'email' => 'nonexistent@example.com',
'password' => 'password123',
]);
$response->assertStatus(422);
}
/**
* Test login validation errors.
*/
public function test_login_validates_required_fields()
{
// Test without any data
$response = $this->postJson('/api/login', []);
$response->assertStatus(422)
->assertJson([
'success' => false,
'message' => 'Validation errors',
])
->assertJsonPath('data.email', fn($value) => !empty($value))
->assertJsonPath('data.password', fn($value) => !empty($value));
// Test with invalid email format
$response = $this->postJson('/api/login', [
'email' => 'invalid-email',
'password' => 'password123',
]);
$response->assertStatus(422)
->assertJsonPath('data.email', fn($value) => !empty($value));
}
/**
* Test authenticated user can access profile.
*/
public function test_authenticated_user_can_access_profile()
{
$user = User::factory()->create([
'name' => 'John Doe',
'email' => 'john@example.com',
]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/profile');
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Profile retrieved successfully',
'data' => [
'id' => $user->id,
'name' => 'John Doe',
'email' => 'john@example.com',
]
]);
}
/**
* Test unauthenticated user cannot access profile.
*/
public function test_unauthenticated_user_cannot_access_profile()
{
$response = $this->getJson('/api/profile');
$response->assertStatus(401);
}
/**
* Test authenticated user can logout.
*/
public function test_authenticated_user_can_logout()
{
$user = User::factory()->create();
$token = $user->createToken('test-token');
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $token->plainTextToken,
])->postJson('/api/logout');
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Logout successful',
]);
// Verify token was deleted
$this->assertDatabaseMissing('personal_access_tokens', [
'id' => $token->accessToken->id,
]);
}
/**
* Test unauthenticated user cannot logout.
*/
public function test_unauthenticated_user_cannot_logout()
{
$response = $this->postJson('/api/logout');
$response->assertStatus(401);
}
/**
* Test token authentication works.
*/
public function test_token_authentication_works()
{
$user = User::factory()->create();
$token = $user->createToken('test-token');
// Test that the token can be used to access protected routes
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $token->plainTextToken,
])->getJson('/api/user');
$response->assertStatus(200)
->assertJson([
'id' => $user->id,
'email' => $user->email,
]);
}
/**
* Test invalid token is rejected.
*/
public function test_invalid_token_is_rejected()
{
$response = $this->withHeaders([
'Authorization' => 'Bearer invalid-token',
])->getJson('/api/user');
$response->assertStatus(401);
}
/**
* Test registration creates valid tokens.
*/
public function test_registration_creates_valid_token()
{
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
];
$response = $this->postJson('/api/register', $userData);
$token = $response->json('data.access_token');
// Use the token to access a protected route
$profileResponse = $this->withHeaders([
'Authorization' => 'Bearer ' . $token,
])->getJson('/api/profile');
$profileResponse->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'email' => 'john@example.com',
]
]);
}
/**
* Test login creates valid tokens.
*/
public function test_login_creates_valid_token()
{
$user = User::factory()->create([
'email' => 'john@example.com',
'password' => Hash::make('password123'),
]);
$loginResponse = $this->postJson('/api/login', [
'email' => 'john@example.com',
'password' => 'password123',
]);
$token = $loginResponse->json('data.access_token');
// Use the token to access a protected route
$profileResponse = $this->withHeaders([
'Authorization' => 'Bearer ' . $token,
])->getJson('/api/profile');
$profileResponse->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'id' => $user->id,
]
]);
}
/**
* Test registration returns user without password.
*/
public function test_registration_does_not_return_password()
{
$userData = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
];
$response = $this->postJson('/api/register', $userData);
$response->assertStatus(201)
->assertJsonMissing(['data.user.password']);
}
/**
* Test login returns user without password.
*/
public function test_login_does_not_return_password()
{
$user = User::factory()->create([
'email' => 'john@example.com',
'password' => Hash::make('password123'),
]);
$response = $this->postJson('/api/login', [
'email' => 'john@example.com',
'password' => 'password123',
]);
$response->assertStatus(200)
->assertJsonMissing(['data.user.password']);
}
/**
* Test profile returns user without password.
*/
public function test_profile_does_not_return_password()
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/profile');
$response->assertStatus(200)
->assertJsonMissing(['data.password']);
}
}

View file

@ -0,0 +1,336 @@
<?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 for this trip (Jan 1, 2, 3)
$this->assertEquals(3, CalendarSlot::where('trip_id', $tripId)->count());
$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);
}
public function test_calendar_slots_include_planned_items_with_plannable_item()
{
$trip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
'start_date' => '2024-01-01',
'end_date' => '2024-01-01'
]);
// Create a plannable item and schedule it
$plannableItem = \App\Models\PlannableItem::factory()->create([
'trip_id' => $trip->id,
'name' => 'Eiffel Tower'
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json'
])->postJson('/api/planned-items', [
'plannable_item_id' => $plannableItem->id,
'trip_id' => $trip->id,
'start_datetime' => '2024-01-01 14:00:00',
'end_datetime' => '2024-01-01 16:00:00',
]);
$response->assertStatus(201);
// Fetch calendar slots and verify relationships are loaded
$slotsResponse = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json'
])->getJson("/api/trips/{$trip->id}/calendar-slots");
$slotsResponse->assertStatus(200);
// Find the slot we just created
$slots = $slotsResponse->json('data');
$scheduledSlot = collect($slots)->firstWhere('name', 'Eiffel Tower');
$this->assertNotNull($scheduledSlot);
$this->assertArrayHasKey('planned_items', $scheduledSlot);
$this->assertCount(1, $scheduledSlot['planned_items']);
$this->assertArrayHasKey('plannable_item', $scheduledSlot['planned_items'][0]);
$this->assertEquals('Eiffel Tower', $scheduledSlot['planned_items'][0]['plannable_item']['name']);
}
public function test_calendar_slots_ordered_by_date_and_time()
{
$trip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
'start_date' => '2024-01-01',
'end_date' => '2024-01-02'
]);
// Create plannable items
$item1 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast']);
$item2 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Lunch']);
$item3 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Dinner Day 1']);
$item4 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast Day 2']);
// Schedule in non-chronological order
$this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $item3->id,
'trip_id' => $trip->id,
'start_datetime' => '2024-01-01 19:00:00',
'end_datetime' => '2024-01-01 21:00:00',
]);
$this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $item1->id,
'trip_id' => $trip->id,
'start_datetime' => '2024-01-01 08:00:00',
'end_datetime' => '2024-01-01 09:00:00',
]);
$this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $item4->id,
'trip_id' => $trip->id,
'start_datetime' => '2024-01-02 08:00:00',
'end_datetime' => '2024-01-02 09:00:00',
]);
$this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $item2->id,
'trip_id' => $trip->id,
'start_datetime' => '2024-01-01 12:00:00',
'end_datetime' => '2024-01-01 13:00:00',
]);
// Fetch slots and verify they're ordered correctly
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Accept' => 'application/json'
])->getJson("/api/trips/{$trip->id}/calendar-slots");
$response->assertStatus(200);
$slots = $response->json('data');
$scheduledSlots = collect($slots)->filter(fn($slot) => $slot['datetime_start'] !== null)->values();
// Should be ordered: Breakfast (Day 1, 8am), Lunch (Day 1, 12pm), Dinner (Day 1, 7pm), Breakfast (Day 2, 8am)
$this->assertEquals('Breakfast', $scheduledSlots[0]['name']);
$this->assertEquals('Lunch', $scheduledSlots[1]['name']);
$this->assertEquals('Dinner Day 1', $scheduledSlots[2]['name']);
$this->assertEquals('Breakfast Day 2', $scheduledSlots[3]['name']);
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

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

@ -0,0 +1,321 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Trip;
use App\Models\PlannableItem;
use App\Models\CalendarSlot;
use App\Models\PlannedItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PlannedItemTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Trip $trip;
private PlannableItem $plannableItem;
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' => '2025-01-15',
'end_date' => '2025-01-17',
]);
$this->plannableItem = PlannableItem::factory()->create([
'trip_id' => $this->trip->id,
'name' => 'Eiffel Tower',
]);
}
/** @test */
public function it_creates_calendar_slot_when_scheduling_item_with_datetime()
{
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $this->plannableItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 14:00:00',
'end_datetime' => '2025-01-15 16:00:00',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('calendar_slots', [
'trip_id' => $this->trip->id,
'name' => 'Eiffel Tower',
'slot_date' => '2025-01-15',
]);
$this->assertDatabaseHas('planned_items', [
'plannable_item_id' => $this->plannableItem->id,
]);
}
/** @test */
public function it_calculates_slot_order_correctly()
{
// Create first slot at 10:00
$this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $this->plannableItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 10:00:00',
'end_datetime' => '2025-01-15 11:00:00',
]);
// Create another plannable item
$secondItem = PlannableItem::factory()->create([
'trip_id' => $this->trip->id,
'name' => 'Louvre',
]);
// Create second slot at 14:00
$this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $secondItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 14:00:00',
'end_datetime' => '2025-01-15 16:00:00',
]);
// Create third slot at 08:00 (should have slot_order 0 after recalculation)
$thirdItem = PlannableItem::factory()->create([
'trip_id' => $this->trip->id,
'name' => 'Breakfast',
]);
$this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $thirdItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 08:00:00',
'end_datetime' => '2025-01-15 09:00:00',
]);
$slots = CalendarSlot::where('trip_id', $this->trip->id)
->where('slot_date', '2025-01-15')
->orderBy('slot_order')
->get();
// After recalculation, order should be by datetime_start
$this->assertEquals('Breakfast', $slots[0]->name);
$this->assertEquals(0, $slots[0]->slot_order);
$this->assertEquals('Eiffel Tower', $slots[1]->name);
$this->assertEquals(1, $slots[1]->slot_order);
$this->assertEquals('Louvre', $slots[2]->name);
$this->assertEquals(2, $slots[2]->slot_order);
}
/** @test */
public function it_validates_end_time_is_after_start_time()
{
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $this->plannableItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 16:00:00',
'end_datetime' => '2025-01-15 14:00:00',
]);
$response->assertStatus(422);
}
/** @test */
public function it_requires_plannable_item_to_belong_to_trip()
{
$otherTrip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
]);
$otherItem = PlannableItem::factory()->create([
'trip_id' => $otherTrip->id,
]);
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $otherItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 14:00:00',
'end_datetime' => '2025-01-15 16:00:00',
]);
$response->assertStatus(422);
$response->assertJson([
'error' => 'Plannable item does not belong to this trip'
]);
}
/** @test */
public function it_supports_backward_compatible_calendar_slot_id_flow()
{
$calendarSlot = CalendarSlot::factory()->create([
'trip_id' => $this->trip->id,
]);
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $this->plannableItem->id,
'calendar_slot_id' => $calendarSlot->id,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('planned_items', [
'plannable_item_id' => $this->plannableItem->id,
'calendar_slot_id' => $calendarSlot->id,
]);
}
/** @test */
public function it_requires_authentication_to_create_planned_item()
{
$response = $this->postJson('/api/planned-items', [
'plannable_item_id' => $this->plannableItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 14:00:00',
'end_datetime' => '2025-01-15 16:00:00',
]);
$response->assertStatus(401);
}
/** @test */
public function it_validates_required_fields()
{
$response = $this->actingAs($this->user)->postJson('/api/planned-items', []);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['plannable_item_id', 'trip_id', 'start_datetime', 'end_datetime']);
}
/** @test */
public function it_validates_plannable_item_exists()
{
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => 99999,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 14:00:00',
'end_datetime' => '2025-01-15 16:00:00',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['plannable_item_id']);
}
/** @test */
public function it_validates_trip_exists()
{
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $this->plannableItem->id,
'trip_id' => 99999,
'start_datetime' => '2025-01-15 14:00:00',
'end_datetime' => '2025-01-15 16:00:00',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['trip_id']);
}
/** @test */
public function it_returns_calendar_slot_with_relationships()
{
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
'plannable_item_id' => $this->plannableItem->id,
'trip_id' => $this->trip->id,
'start_datetime' => '2025-01-15 14:00:00',
'end_datetime' => '2025-01-15 16:00:00',
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'id',
'trip_id',
'name',
'datetime_start',
'datetime_end',
'slot_date',
'slot_order',
'planned_items' => [
'*' => [
'id',
'plannable_item_id',
'calendar_slot_id',
'plannable_item' => [
'id',
'name',
'trip_id',
]
]
]
]);
}
/** @test */
public function it_can_update_planned_item_slot()
{
$calendarSlot1 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
$calendarSlot2 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
$plannedItem = PlannedItem::create([
'plannable_item_id' => $this->plannableItem->id,
'calendar_slot_id' => $calendarSlot1->id,
'sort_order' => 0,
]);
$response = $this->actingAs($this->user)->putJson("/api/planned-items/{$plannedItem->id}", [
'calendar_slot_id' => $calendarSlot2->id,
'sort_order' => 5,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('planned_items', [
'id' => $plannedItem->id,
'calendar_slot_id' => $calendarSlot2->id,
'sort_order' => 5,
]);
}
/** @test */
public function it_can_delete_planned_item()
{
$calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
$plannedItem = PlannedItem::create([
'plannable_item_id' => $this->plannableItem->id,
'calendar_slot_id' => $calendarSlot->id,
'sort_order' => 0,
]);
$response = $this->actingAs($this->user)->deleteJson("/api/planned-items/{$plannedItem->id}");
$response->assertStatus(204);
$this->assertDatabaseMissing('planned_items', [
'id' => $plannedItem->id,
]);
}
/** @test */
public function it_requires_authentication_to_update_planned_item()
{
$calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
$plannedItem = PlannedItem::create([
'plannable_item_id' => $this->plannableItem->id,
'calendar_slot_id' => $calendarSlot->id,
]);
$response = $this->putJson("/api/planned-items/{$plannedItem->id}", [
'sort_order' => 1,
]);
$response->assertStatus(401);
}
/** @test */
public function it_requires_authentication_to_delete_planned_item()
{
$calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
$plannedItem = PlannedItem::create([
'plannable_item_id' => $this->plannableItem->id,
'calendar_slot_id' => $calendarSlot->id,
]);
$response = $this->deleteJson("/api/planned-items/{$plannedItem->id}");
$response->assertStatus(401);
}
}

View file

@ -0,0 +1,267 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TestSetupControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Test creating a new test user.
*/
public function test_can_create_new_test_user()
{
$userData = [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
];
$response = $this->postJson('/api/e2e/test/setup/user', $userData);
$response->assertStatus(200);
$response->assertJson([
'success' => true,
'message' => 'Test user created',
'data' => [
'email' => 'test@example.com',
'name' => 'Test User'
]
]);
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
'name' => 'Test User',
]);
$user = User::where('email', 'test@example.com')->first();
$this->assertNotNull($user->email_verified_at);
$this->assertTrue(\Hash::check('password123', $user->password));
}
/**
* Test returning existing test user if already exists.
*/
public function test_returns_existing_test_user_if_already_exists()
{
// Create a user first
$existingUser = User::factory()->create([
'email' => 'existing@example.com',
'name' => 'Existing User',
]);
$userData = [
'name' => 'Different Name',
'email' => 'existing@example.com',
'password' => 'password123',
];
$response = $this->postJson('/api/e2e/test/setup/user', $userData);
$response->assertStatus(200);
$response->assertJson([
'success' => true,
'message' => 'Test user already exists',
'data' => [
'id' => $existingUser->id,
'email' => 'existing@example.com',
'name' => 'Existing User' // Should keep original name
]
]);
// Should not create a new user
$this->assertCount(1, User::where('email', 'existing@example.com')->get());
}
/**
* Test validation for required fields.
*/
public function test_validates_required_fields_for_create_user()
{
$response = $this->postJson('/api/e2e/test/setup/user', []);
$response->assertStatus(422);
$response->assertJsonPath('errors.name', ['The name field is required.']);
$response->assertJsonPath('errors.email', ['The email field is required.']);
$response->assertJsonPath('errors.password', ['The password field is required.']);
}
/**
* Test email validation.
*/
public function test_validates_email_format()
{
$userData = [
'name' => 'Test User',
'email' => 'invalid-email',
'password' => 'password123',
];
$response = $this->postJson('/api/e2e/test/setup/user', $userData);
$response->assertStatus(422);
$response->assertJsonPath('errors.email', ['The email field must be a valid email address.']);
}
/**
* Test password length validation.
*/
public function test_validates_password_length()
{
$userData = [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'short',
];
$response = $this->postJson('/api/e2e/test/setup/user', $userData);
$response->assertStatus(422);
$response->assertJsonPath('errors.password', ['The password field must be at least 8 characters.']);
}
/**
* Test cleanup removes test users with test email patterns.
*/
public function test_cleanup_removes_test_users()
{
// Create test users with test email patterns
User::factory()->create(['email' => 'test.user.1@example.com']);
User::factory()->create(['email' => 'test.user.2@example.com']);
User::factory()->create(['email' => 'test123@example.com']);
// Create a regular user that should not be deleted
User::factory()->create(['email' => 'regular@example.com']);
$response = $this->postJson('/api/e2e/test/cleanup');
$response->assertStatus(200);
$response->assertJson([
'success' => true,
'message' => 'Deleted 3 test users'
]);
// Test users should be deleted
$this->assertDatabaseMissing('users', ['email' => 'test.user.1@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'test.user.2@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'test123@example.com']);
// Regular user should still exist
$this->assertDatabaseHas('users', ['email' => 'regular@example.com']);
}
/**
* Test cleanup when no test users exist.
*/
public function test_cleanup_when_no_test_users_exist()
{
// Create only regular users
User::factory()->create(['email' => 'regular1@example.com']);
User::factory()->create(['email' => 'regular2@example.com']);
$response = $this->postJson('/api/e2e/test/cleanup');
$response->assertStatus(200);
$response->assertJson([
'success' => true,
'message' => 'Deleted 0 test users'
]);
// Regular users should still exist
$this->assertDatabaseHas('users', ['email' => 'regular1@example.com']);
$this->assertDatabaseHas('users', ['email' => 'regular2@example.com']);
}
/**
* Test cleanup removes only users matching test patterns.
*/
public function test_cleanup_only_removes_matching_patterns()
{
// Create users with various email patterns
// Patterns that should be deleted: 'test%@example.com' OR 'test.user.%@example.com'
User::factory()->create(['email' => 'test@example.com']); // Should be deleted (matches test%@example.com)
User::factory()->create(['email' => 'test.user.123@example.com']); // Should be deleted (matches test.user.%@example.com)
User::factory()->create(['email' => 'testABC@example.com']); // Should be deleted (matches test%@example.com)
User::factory()->create(['email' => 'test.user.xyz@example.com']); // Should be deleted (matches test.user.%@example.com)
User::factory()->create(['email' => 'test999@example.com']); // Should be deleted (matches test%@example.com)
User::factory()->create(['email' => 'mytesting@example.com']); // Should NOT be deleted
User::factory()->create(['email' => 'test@gmail.com']); // Should NOT be deleted
User::factory()->create(['email' => 'mytest@example.com']); // Should NOT be deleted
$response = $this->postJson('/api/e2e/test/cleanup');
$response->assertStatus(200);
$response->assertJsonPath('success', true);
// Just verify the message contains "Deleted" and "test users"
$this->assertStringContainsString('Deleted', $response->json('message'));
$this->assertStringContainsString('test users', $response->json('message'));
// Only specific patterns should be deleted
$this->assertDatabaseMissing('users', ['email' => 'test@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'test.user.123@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'testABC@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'test.user.xyz@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'test999@example.com']);
// Others should remain
$this->assertDatabaseHas('users', ['email' => 'mytesting@example.com']);
$this->assertDatabaseHas('users', ['email' => 'test@gmail.com']);
$this->assertDatabaseHas('users', ['email' => 'mytest@example.com']);
}
/**
* Test create user endpoint works in non-production environment.
*/
public function test_endpoints_work_in_non_production_environment()
{
// Ensure we're not in production environment
$this->assertNotEquals('production', app()->environment());
$response = $this->postJson('/api/e2e/test/setup/user', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
}
/**
* Test user data is properly formatted in response.
*/
public function test_user_data_properly_formatted_in_response()
{
$userData = [
'name' => 'John Doe',
'email' => 'john.doe@example.com',
'password' => 'securepassword123',
];
$response = $this->postJson('/api/e2e/test/setup/user', $userData);
$response->assertStatus(200);
$response->assertJsonStructure([
'success',
'message',
'data' => [
'id',
'email',
'name'
]
]);
// Ensure password is not included in response
$response->assertJsonMissing(['password']);
$data = $response->json('data');
$this->assertIsInt($data['id']);
$this->assertEquals('john.doe@example.com', $data['email']);
$this->assertEquals('John Doe', $data['name']);
}
}

View file

@ -0,0 +1,420 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Trip;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class TripTest extends TestCase
{
use RefreshDatabase, WithFaker;
protected $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
/**
* Test that unauthenticated users cannot access trip endpoints.
*/
public function test_unauthenticated_user_cannot_access_trips()
{
$response = $this->getJson('/api/trips');
$response->assertStatus(401);
$response = $this->postJson('/api/trips', []);
$response->assertStatus(401);
$response = $this->putJson('/api/trips/1', []);
$response->assertStatus(401);
$response = $this->deleteJson('/api/trips/1');
$response->assertStatus(401);
}
/**
* Test user can create a trip.
*/
public function test_user_can_create_trip()
{
Sanctum::actingAs($this->user);
$tripData = [
'name' => 'Summer Vacation 2025',
'description' => 'A wonderful trip to Europe',
'start_date' => '2025-06-01',
'end_date' => '2025-06-15',
];
$response = $this->postJson('/api/trips', $tripData);
$response->assertStatus(201)
->assertJsonStructure([
'data' => [
'id',
'name',
'description',
'start_date',
'end_date',
'created_by_user_id',
'created_at',
'updated_at',
]
])
->assertJsonPath('data.name', 'Summer Vacation 2025')
->assertJsonPath('data.description', 'A wonderful trip to Europe')
->assertJsonPath('data.created_by_user_id', $this->user->id);
$this->assertDatabaseHas('trips', [
'name' => 'Summer Vacation 2025',
'created_by_user_id' => $this->user->id,
]);
}
/**
* Test trip creation validation.
*/
public function test_trip_creation_validates_required_fields()
{
Sanctum::actingAs($this->user);
// Test without name
$response = $this->postJson('/api/trips', [
'description' => 'A trip without a name',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name']);
// Test with empty name
$response = $this->postJson('/api/trips', [
'name' => '',
'description' => 'A trip with empty name',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name']);
// Test with invalid dates
$response = $this->postJson('/api/trips', [
'name' => 'Trip with invalid dates',
'start_date' => 'not-a-date',
'end_date' => '2025-13-45',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['start_date', 'end_date']);
// Test with end date before start date
$response = $this->postJson('/api/trips', [
'name' => 'Trip with reversed dates',
'start_date' => '2025-06-15',
'end_date' => '2025-06-01',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['end_date']);
}
/**
* Test user can list their trips.
*/
public function test_user_can_list_their_own_trips()
{
Sanctum::actingAs($this->user);
// Create some trips for this user
$trips = Trip::factory()->count(3)->create([
'created_by_user_id' => $this->user->id,
]);
// Create trips for another user (should not be visible)
$otherUser = User::factory()->create();
Trip::factory()->count(2)->create([
'created_by_user_id' => $otherUser->id,
]);
$response = $this->getJson('/api/trips');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => [
'id',
'name',
'description',
'start_date',
'end_date',
'created_by_user_id',
'created_at',
'updated_at',
]
]
]);
// Verify all returned trips belong to the authenticated user
foreach ($response->json('data') as $trip) {
$this->assertEquals($this->user->id, $trip['created_by_user_id']);
}
}
/**
* Test user can view a specific trip.
*/
public function test_user_can_view_their_own_trip()
{
Sanctum::actingAs($this->user);
$trip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
'name' => 'My Special Trip',
]);
$response = $this->getJson("/api/trips/{$trip->id}");
$response->assertStatus(200)
->assertJsonPath('data.id', $trip->id)
->assertJsonPath('data.name', 'My Special Trip')
->assertJsonPath('data.created_by_user_id', $this->user->id);
}
/**
* Test user cannot view another user's trip.
*/
public function test_user_cannot_view_another_users_trip()
{
Sanctum::actingAs($this->user);
$otherUser = User::factory()->create();
$otherTrip = Trip::factory()->create([
'created_by_user_id' => $otherUser->id,
]);
$response = $this->getJson("/api/trips/{$otherTrip->id}");
// Controller returns 404 when trip doesn't belong to user
$response->assertStatus(404);
}
/**
* Test user can update their trip.
*/
public function test_user_can_update_their_own_trip()
{
Sanctum::actingAs($this->user);
$trip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
'name' => 'Original Name',
'description' => 'Original Description',
]);
$updateData = [
'name' => 'Updated Trip Name',
'description' => 'Updated Description',
'start_date' => '2025-07-01',
'end_date' => '2025-07-15',
];
$response = $this->putJson("/api/trips/{$trip->id}", $updateData);
$response->assertStatus(200)
->assertJsonPath('data.id', $trip->id)
->assertJsonPath('data.name', 'Updated Trip Name')
->assertJsonPath('data.description', 'Updated Description');
$this->assertDatabaseHas('trips', [
'id' => $trip->id,
'name' => 'Updated Trip Name',
'description' => 'Updated Description',
]);
}
/**
* Test user cannot update another user's trip.
*/
public function test_user_cannot_update_another_users_trip()
{
Sanctum::actingAs($this->user);
$otherUser = User::factory()->create();
$otherTrip = Trip::factory()->create([
'created_by_user_id' => $otherUser->id,
'name' => 'Other User Trip',
]);
$response = $this->putJson("/api/trips/{$otherTrip->id}", [
'name' => 'Trying to Update',
]);
// Controller returns 404 when trip doesn't belong to user
$response->assertStatus(404);
// Verify the trip wasn't updated
$this->assertDatabaseHas('trips', [
'id' => $otherTrip->id,
'name' => 'Other User Trip',
]);
}
/**
* Test user can delete their trip.
*/
public function test_user_can_delete_their_own_trip()
{
Sanctum::actingAs($this->user);
$trip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
]);
$response = $this->deleteJson("/api/trips/{$trip->id}");
// Controller returns 200 with a message
$response->assertStatus(200)
->assertJson([
'message' => 'Trip deleted successfully'
]);
$this->assertDatabaseMissing('trips', [
'id' => $trip->id,
]);
}
/**
* Test user cannot delete another user's trip.
*/
public function test_user_cannot_delete_another_users_trip()
{
Sanctum::actingAs($this->user);
$otherUser = User::factory()->create();
$otherTrip = Trip::factory()->create([
'created_by_user_id' => $otherUser->id,
]);
$response = $this->deleteJson("/api/trips/{$otherTrip->id}");
// Controller returns 404 when trip doesn't belong to user
$response->assertStatus(404);
// Verify the trip still exists
$this->assertDatabaseHas('trips', [
'id' => $otherTrip->id,
]);
}
/**
* Test handling non-existent trip.
*/
public function test_returns_404_for_non_existent_trip()
{
Sanctum::actingAs($this->user);
$response = $this->getJson('/api/trips/99999');
$response->assertStatus(404);
$response = $this->putJson('/api/trips/99999', ['name' => 'Updated']);
$response->assertStatus(404);
$response = $this->deleteJson('/api/trips/99999');
$response->assertStatus(404);
}
/**
* Test trip creation with minimal data.
*/
public function test_user_can_create_trip_with_minimal_data()
{
Sanctum::actingAs($this->user);
$tripData = [
'name' => 'Minimal Trip',
];
$response = $this->postJson('/api/trips', $tripData);
$response->assertStatus(201)
->assertJsonPath('data.name', 'Minimal Trip')
->assertJsonPath('data.created_by_user_id', $this->user->id)
->assertJsonStructure([
'data' => [
'id',
'name',
'created_by_user_id',
'created_at',
'updated_at',
]
]);
$this->assertDatabaseHas('trips', [
'name' => 'Minimal Trip',
'created_by_user_id' => $this->user->id,
'description' => null,
'start_date' => null,
'end_date' => null,
]);
}
/**
* Test trip name length validation.
*/
public function test_trip_name_length_validation()
{
Sanctum::actingAs($this->user);
// Test with too long name (assuming max is 255)
$response = $this->postJson('/api/trips', [
'name' => str_repeat('a', 256),
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name']);
}
/**
* Test trips are returned in correct order.
*/
public function test_trips_are_returned_in_descending_order()
{
Sanctum::actingAs($this->user);
// Create trips with specific timestamps
$oldTrip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
'name' => 'Old Trip',
'created_at' => now()->subDays(2),
]);
$newTrip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
'name' => 'New Trip',
'created_at' => now(),
]);
$middleTrip = Trip::factory()->create([
'created_by_user_id' => $this->user->id,
'name' => 'Middle Trip',
'created_at' => now()->subDay(),
]);
$response = $this->getJson('/api/trips');
$response->assertStatus(200);
$trips = $response->json('data');
// Verify trips are in descending order (newest first)
$this->assertEquals('New Trip', $trips[0]['name']);
$this->assertEquals('Middle Trip', $trips[1]['name']);
$this->assertEquals('Old Trip', $trips[2]['name']);
}
}

View file

@ -0,0 +1,235 @@
<?php
namespace Tests\Unit;
use App\Domain\Trip\Services\CalendarSlotService;
use App\Models\Trip;
use App\Models\CalendarSlot;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Carbon\Carbon;
class CalendarSlotServiceTest extends TestCase
{
use RefreshDatabase;
private CalendarSlotService $service;
private Trip $trip;
protected function setUp(): void
{
parent::setUp();
$this->service = new CalendarSlotService();
$this->trip = Trip::factory()->create([
'start_date' => '2024-01-01',
'end_date' => '2024-01-03',
]);
}
/** @test */
public function it_calculates_slot_order_for_first_slot_on_date()
{
$order = $this->service->calculateSlotOrder(
$this->trip->id,
'2024-01-01',
Carbon::parse('2024-01-01 10:00:00')
);
$this->assertEquals(0, $order);
}
/** @test */
public function it_calculates_slot_order_based_on_chronological_position()
{
// Create existing slots
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Morning',
'datetime_start' => '2024-01-01 08:00:00',
'datetime_end' => '2024-01-01 09:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 0,
]);
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Afternoon',
'datetime_start' => '2024-01-01 14:00:00',
'datetime_end' => '2024-01-01 15:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 1,
]);
// Calculate order for noon slot (should be between morning and afternoon)
$order = $this->service->calculateSlotOrder(
$this->trip->id,
'2024-01-01',
Carbon::parse('2024-01-01 12:00:00')
);
$this->assertEquals(1, $order);
}
/** @test */
public function it_calculates_slot_order_for_earliest_time()
{
// Create existing slot
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Late Morning',
'datetime_start' => '2024-01-01 10:00:00',
'datetime_end' => '2024-01-01 11:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 0,
]);
// Calculate order for earlier time
$order = $this->service->calculateSlotOrder(
$this->trip->id,
'2024-01-01',
Carbon::parse('2024-01-01 08:00:00')
);
$this->assertEquals(0, $order);
}
/** @test */
public function it_calculates_slot_order_for_latest_time()
{
// Create existing slots
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Morning',
'datetime_start' => '2024-01-01 08:00:00',
'datetime_end' => '2024-01-01 09:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 0,
]);
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Noon',
'datetime_start' => '2024-01-01 12:00:00',
'datetime_end' => '2024-01-01 13:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 1,
]);
// Calculate order for later time
$order = $this->service->calculateSlotOrder(
$this->trip->id,
'2024-01-01',
Carbon::parse('2024-01-01 20:00:00')
);
$this->assertEquals(2, $order);
}
/** @test */
public function it_only_considers_slots_from_same_date()
{
// Create slot on different date
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Other Day',
'datetime_start' => '2024-01-02 08:00:00',
'datetime_end' => '2024-01-02 09:00:00',
'slot_date' => '2024-01-02',
'slot_order' => 0,
]);
// Calculate order for Jan 1 (should start at 0, not affected by Jan 2)
$order = $this->service->calculateSlotOrder(
$this->trip->id,
'2024-01-01',
Carbon::parse('2024-01-01 10:00:00')
);
$this->assertEquals(0, $order);
}
/** @test */
public function it_recalculates_slot_orders_for_date()
{
// Create slots in non-chronological slot_order
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Evening',
'datetime_start' => '2024-01-01 20:00:00',
'datetime_end' => '2024-01-01 21:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 5, // Wrong order
]);
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Morning',
'datetime_start' => '2024-01-01 08:00:00',
'datetime_end' => '2024-01-01 09:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 3, // Wrong order
]);
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Afternoon',
'datetime_start' => '2024-01-01 14:00:00',
'datetime_end' => '2024-01-01 15:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 1, // Wrong order
]);
// Recalculate
$this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01');
// Verify correct chronological order
$slots = CalendarSlot::where('trip_id', $this->trip->id)
->where('slot_date', '2024-01-01')
->orderBy('slot_order')
->get();
$this->assertEquals('Morning', $slots[0]->name);
$this->assertEquals(0, $slots[0]->slot_order);
$this->assertEquals('Afternoon', $slots[1]->name);
$this->assertEquals(1, $slots[1]->slot_order);
$this->assertEquals('Evening', $slots[2]->name);
$this->assertEquals(2, $slots[2]->slot_order);
}
/** @test */
public function it_only_recalculates_slots_for_specified_date()
{
// Create slots on Jan 1
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Jan 1 Slot',
'datetime_start' => '2024-01-01 10:00:00',
'datetime_end' => '2024-01-01 11:00:00',
'slot_date' => '2024-01-01',
'slot_order' => 5,
]);
// Create slots on Jan 2
CalendarSlot::create([
'trip_id' => $this->trip->id,
'name' => 'Jan 2 Slot',
'datetime_start' => '2024-01-02 10:00:00',
'datetime_end' => '2024-01-02 11:00:00',
'slot_date' => '2024-01-02',
'slot_order' => 7,
]);
// Recalculate only Jan 1
$this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01');
// Jan 1 should be recalculated to 0
$jan1Slot = CalendarSlot::where('slot_date', '2024-01-01')->first();
$this->assertEquals(0, $jan1Slot->slot_order);
// Jan 2 should remain unchanged
$jan2Slot = CalendarSlot::where('slot_date', '2024-01-02')->first();
$this->assertEquals(7, $jan2Slot->slot_order);
}
}

View file

@ -1,16 +0,0 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}

View file

@ -0,0 +1,248 @@
<?php
namespace Tests\Unit;
use App\Models\Trip;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TripTest extends TestCase
{
use RefreshDatabase;
/**
* Test trip creation with factory.
*/
public function test_trip_can_be_created()
{
$user = User::factory()->create();
$trip = Trip::factory()->create([
'name' => 'Paris Adventure',
'description' => 'A wonderful trip to Paris',
'created_by_user_id' => $user->id,
]);
$this->assertInstanceOf(Trip::class, $trip);
$this->assertEquals('Paris Adventure', $trip->name);
$this->assertEquals('A wonderful trip to Paris', $trip->description);
$this->assertEquals($user->id, $trip->created_by_user_id);
$this->assertNotNull($trip->id);
$this->assertNotNull($trip->created_at);
$this->assertNotNull($trip->updated_at);
}
/**
* Test trip fillable attributes.
*/
public function test_trip_fillable_attributes()
{
$user = User::factory()->create();
$tripData = [
'name' => 'Tokyo Trip',
'description' => 'Exploring Japan',
'start_date' => '2025-06-01',
'end_date' => '2025-06-15',
'created_by_user_id' => $user->id,
];
$trip = Trip::create($tripData);
$this->assertEquals('Tokyo Trip', $trip->name);
$this->assertEquals('Exploring Japan', $trip->description);
$this->assertEquals('2025-06-01', $trip->start_date->format('Y-m-d'));
$this->assertEquals('2025-06-15', $trip->end_date->format('Y-m-d'));
$this->assertEquals($user->id, $trip->created_by_user_id);
}
/**
* Test trip date casting.
*/
public function test_trip_date_casting()
{
$trip = Trip::factory()->create([
'start_date' => '2025-07-01',
'end_date' => '2025-07-10',
]);
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $trip->start_date);
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $trip->end_date);
$this->assertEquals('2025-07-01', $trip->start_date->format('Y-m-d'));
$this->assertEquals('2025-07-10', $trip->end_date->format('Y-m-d'));
}
/**
* Test trip can have null dates.
*/
public function test_trip_can_have_null_dates()
{
$trip = Trip::factory()->withoutDates()->create([
'name' => 'Flexible Trip',
]);
$this->assertNull($trip->start_date);
$this->assertNull($trip->end_date);
$this->assertEquals('Flexible Trip', $trip->name);
}
/**
* Test trip belongs to user relationship.
*/
public function test_trip_belongs_to_user()
{
$user = User::factory()->create(['name' => 'John Doe']);
$trip = Trip::factory()->create([
'created_by_user_id' => $user->id,
]);
$this->assertInstanceOf(User::class, $trip->user);
$this->assertEquals($user->id, $trip->user->id);
$this->assertEquals('John Doe', $trip->user->name);
}
/**
* Test trip factory creates valid trips.
*/
public function test_trip_factory_creates_valid_trips()
{
$trip = Trip::factory()->create();
$this->assertNotEmpty($trip->name);
$this->assertNotNull($trip->created_by_user_id);
$this->assertInstanceOf(User::class, $trip->user);
}
/**
* Test trip factory upcoming state.
*/
public function test_trip_factory_upcoming_state()
{
$trip = Trip::factory()->upcoming()->create();
$this->assertNotNull($trip->start_date);
$this->assertNotNull($trip->end_date);
$this->assertTrue($trip->start_date->isFuture());
$this->assertTrue($trip->end_date->isAfter($trip->start_date));
}
/**
* Test trip factory past state.
*/
public function test_trip_factory_past_state()
{
$trip = Trip::factory()->past()->create();
$this->assertNotNull($trip->start_date);
$this->assertNotNull($trip->end_date);
$this->assertTrue($trip->start_date->isPast());
$this->assertTrue($trip->end_date->isPast());
$this->assertTrue($trip->end_date->isAfter($trip->start_date));
}
/**
* Test trip model uses correct table.
*/
public function test_trip_uses_correct_table()
{
$trip = new Trip();
$this->assertEquals('trips', $trip->getTable());
}
/**
* Test trip model has correct primary key.
*/
public function test_trip_has_correct_primary_key()
{
$trip = new Trip();
$this->assertEquals('id', $trip->getKeyName());
$this->assertTrue($trip->getIncrementing());
}
/**
* Test trip model uses timestamps.
*/
public function test_trip_uses_timestamps()
{
$trip = new Trip();
$this->assertTrue($trip->usesTimestamps());
}
/**
* Test trip can be created with minimal data.
*/
public function test_trip_can_be_created_with_minimal_data()
{
$user = User::factory()->create();
$trip = Trip::create([
'name' => 'Minimal Trip',
'created_by_user_id' => $user->id,
]);
$this->assertEquals('Minimal Trip', $trip->name);
$this->assertNull($trip->description);
$this->assertNull($trip->start_date);
$this->assertNull($trip->end_date);
$this->assertEquals($user->id, $trip->created_by_user_id);
}
/**
* Test trip name is required.
*/
public function test_trip_name_is_required()
{
$user = User::factory()->create();
$this->expectException(\Illuminate\Database\QueryException::class);
Trip::create([
'description' => 'A trip without a name',
'created_by_user_id' => $user->id,
]);
}
/**
* Test trip requires a user.
*/
public function test_trip_requires_user()
{
$this->expectException(\Illuminate\Database\QueryException::class);
Trip::create([
'name' => 'Orphaned Trip',
'description' => 'A trip without a user',
]);
}
/**
* Test trip dates can be formatted.
*/
public function test_trip_dates_can_be_formatted()
{
$trip = Trip::factory()->create([
'start_date' => '2025-12-25',
'end_date' => '2025-12-31',
]);
$this->assertEquals('2025-12-25', $trip->start_date->format('Y-m-d'));
$this->assertEquals('2025-12-31', $trip->end_date->format('Y-m-d'));
$this->assertEquals('December 25, 2025', $trip->start_date->format('F j, Y'));
$this->assertEquals('December 31, 2025', $trip->end_date->format('F j, Y'));
}
/**
* Test trip can calculate duration.
*/
public function test_trip_can_calculate_duration()
{
$trip = Trip::factory()->create([
'start_date' => '2025-06-01',
'end_date' => '2025-06-07',
]);
$duration = $trip->start_date->diffInDays($trip->end_date) + 1; // Include both start and end day
$this->assertEquals(7, $duration);
}
}

View file

@ -0,0 +1,224 @@
<?php
namespace Tests\Unit;
use App\Models\User;
use App\Models\Trip;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\PersonalAccessToken;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase;
/**
* Test user creation with factory.
*/
public function test_user_can_be_created()
{
$user = User::factory()->create([
'name' => 'John Doe',
'email' => 'john@example.com',
]);
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('John Doe', $user->name);
$this->assertEquals('john@example.com', $user->email);
$this->assertNotNull($user->id);
$this->assertNotNull($user->created_at);
$this->assertNotNull($user->updated_at);
}
/**
* Test user fillable attributes.
*/
public function test_user_fillable_attributes()
{
$userData = [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => 'password123',
];
$user = User::create($userData);
$this->assertEquals('Jane Doe', $user->name);
$this->assertEquals('jane@example.com', $user->email);
$this->assertTrue(Hash::check('password123', $user->password));
}
/**
* Test user hidden attributes.
*/
public function test_user_hidden_attributes()
{
$user = User::factory()->create([
'password' => Hash::make('secret123'),
]);
$userArray = $user->toArray();
$this->assertArrayNotHasKey('password', $userArray);
$this->assertArrayNotHasKey('remember_token', $userArray);
}
/**
* Test user casts.
*/
public function test_user_casts()
{
$user = User::factory()->create([
'email_verified_at' => now(),
]);
$this->assertInstanceOf(\Illuminate\Support\Carbon::class, $user->email_verified_at);
}
/**
* Test password is automatically hashed.
*/
public function test_password_is_automatically_hashed()
{
$user = User::factory()->create([
'password' => 'plaintext-password',
]);
$this->assertNotEquals('plaintext-password', $user->password);
$this->assertTrue(Hash::check('plaintext-password', $user->password));
}
/**
* Test user has API tokens trait.
*/
public function test_user_can_create_api_tokens()
{
$user = User::factory()->create();
$token = $user->createToken('test-token');
$this->assertInstanceOf(PersonalAccessToken::class, $token->accessToken);
$this->assertIsString($token->plainTextToken);
$this->assertEquals('test-token', $token->accessToken->name);
}
/**
* Test user can have multiple tokens.
*/
public function test_user_can_have_multiple_tokens()
{
$user = User::factory()->create();
$token1 = $user->createToken('token-1');
$token2 = $user->createToken('token-2');
$this->assertCount(2, $user->tokens);
$this->assertNotEquals($token1->plainTextToken, $token2->plainTextToken);
}
/**
* Test user can delete tokens.
*/
public function test_user_can_delete_tokens()
{
$user = User::factory()->create();
$token = $user->createToken('test-token');
$this->assertCount(1, $user->tokens);
$token->accessToken->delete();
$user->refresh();
$this->assertCount(0, $user->tokens);
}
/**
* Test user has trips relationship.
*/
public function test_user_has_trips_relationship()
{
$user = User::factory()->create();
// Create some trips for this user
Trip::factory()->count(3)->create([
'created_by_user_id' => $user->id,
]);
// Create a trip for another user
$otherUser = User::factory()->create();
Trip::factory()->create([
'created_by_user_id' => $otherUser->id,
]);
$this->assertCount(3, $user->trips);
$this->assertInstanceOf(Trip::class, $user->trips->first());
}
/**
* Test user factory creates valid users.
*/
public function test_user_factory_creates_valid_users()
{
$user = User::factory()->create();
$this->assertNotEmpty($user->name);
$this->assertNotEmpty($user->email);
$this->assertNotEmpty($user->password);
$this->assertNotNull($user->email_verified_at);
$this->assertTrue(filter_var($user->email, FILTER_VALIDATE_EMAIL) !== false);
}
/**
* Test user factory can create unverified users.
*/
public function test_user_factory_can_create_unverified_users()
{
$user = User::factory()->unverified()->create();
$this->assertNull($user->email_verified_at);
}
/**
* Test user email must be unique.
*/
public function test_user_email_must_be_unique()
{
User::factory()->create(['email' => 'test@example.com']);
$this->expectException(\Illuminate\Database\QueryException::class);
User::factory()->create(['email' => 'test@example.com']);
}
/**
* Test user model uses correct table.
*/
public function test_user_uses_correct_table()
{
$user = new User();
$this->assertEquals('users', $user->getTable());
}
/**
* Test user model has correct primary key.
*/
public function test_user_has_correct_primary_key()
{
$user = new User();
$this->assertEquals('id', $user->getKeyName());
$this->assertTrue($user->getIncrementing());
}
/**
* Test user model uses timestamps.
*/
public function test_user_uses_timestamps()
{
$user = new User();
$this->assertTrue($user->usesTimestamps());
}
}

51
bin/phpstan Executable file
View file

@ -0,0 +1,51 @@
#!/bin/bash
# PHPStan runner script for Docker environment
# Usage: ./bin/phpstan [options]
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Get the directory of this script
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
# Change to project root
cd "$PROJECT_ROOT"
# Check if docker compose is running
if ! docker compose -f docker-compose.dev.yml ps --services --filter "status=running" | grep -q "backend"; then
echo -e "${RED}Error: Backend container is not running${NC}"
echo -e "${YELLOW}Starting containers...${NC}"
docker compose -f docker-compose.dev.yml up -d
# Wait a moment for containers to be ready
sleep 3
fi
# Run PHPStan with default memory limit if not specified
echo -e "${GREEN}Running PHPStan analysis...${NC}"
# Pass all arguments to phpstan, with default memory limit
if [[ "$*" == *"--memory-limit"* ]]; then
# User specified memory limit, use their arguments
docker compose -f docker-compose.dev.yml exec backend vendor/bin/phpstan analyse "$@"
else
# Add default memory limit
docker compose -f docker-compose.dev.yml exec backend vendor/bin/phpstan analyse --memory-limit=256M "$@"
fi
# Capture exit code
EXIT_CODE=$?
# Display result
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}✓ PHPStan analysis completed successfully${NC}"
else
echo -e "${RED}✗ PHPStan found issues${NC}"
fi
exit $EXIT_CODE

55
bin/phpunit Executable file
View file

@ -0,0 +1,55 @@
#!/bin/bash
# PHPUnit test runner script for Docker environment
# Usage: ./bin/phpunit [options]
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Get the directory of this script
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
# Change to project root
cd "$PROJECT_ROOT"
# Check if backend container is running
if ! podman-compose -f docker-compose.dev.yml ps | grep "trip-planner-backend-dev" | grep -q "Up"; then
echo -e "${RED}Error: Backend container is not running${NC}"
echo -e "${YELLOW}Please start the containers first with: podman-compose -f docker-compose.dev.yml up -d${NC}"
exit 1
fi
# Run PHPUnit tests
echo -e "${GREEN}Running PHPUnit tests...${NC}"
# If no arguments provided, run all tests
if [ $# -eq 0 ]; then
echo -e "${BLUE}Running all tests...${NC}"
podman-compose -f docker-compose.dev.yml exec backend php -d memory_limit=512M artisan test
else
# Pass all arguments to phpunit
echo -e "${BLUE}Running tests with options: $*${NC}"
podman-compose -f docker-compose.dev.yml exec backend php -d memory_limit=512M artisan test "$@"
fi
# Capture exit code
EXIT_CODE=$?
# Display result
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}✓ All tests passed successfully${NC}"
else
echo -e "${RED}✗ Some tests failed${NC}"
fi
echo -e "${YELLOW}Tip: You can also run specific tests:${NC}"
echo -e " ${BLUE}./bin/phpunit --filter=PlannableItemTest${NC}"
echo -e " ${BLUE}./bin/phpunit tests/Feature/PlannableItemTest.php${NC}"
echo -e " ${BLUE}./bin/phpunit --coverage-html=coverage${NC}"
exit $EXIT_CODE

View file

@ -13,6 +13,7 @@ services:
- node_modules:/app/node_modules - node_modules:/app/node_modules
environment: environment:
- NODE_ENV=development - NODE_ENV=development
privileged: true
networks: networks:
- trip-planner-network - trip-planner-network
@ -31,6 +32,7 @@ services:
depends_on: depends_on:
- database - database
- redis - redis
privileged: true
networks: networks:
- trip-planner-network - trip-planner-network
@ -45,7 +47,8 @@ services:
MYSQL_USER: ${DB_USERNAME:-trip_user} MYSQL_USER: ${DB_USERNAME:-trip_user}
MYSQL_PASSWORD: ${DB_PASSWORD:-secret} MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
volumes: volumes:
- db-data:/var/lib/mysql - ./docker/data/mysql-data:/var/lib/mysql:Z
privileged: true
networks: networks:
- trip-planner-network - trip-planner-network
@ -56,6 +59,7 @@ services:
- "6379:6379" - "6379:6379"
volumes: volumes:
- redis-data:/data - redis-data:/data
privileged: true
networks: networks:
- trip-planner-network - trip-planner-network
@ -68,6 +72,7 @@ services:
networks: networks:
- trip-planner-network - trip-planner-network
networks: networks:
trip-planner-network: trip-planner-network:
driver: bridge driver: bridge

View file

@ -11,33 +11,34 @@ RUN apk add --no-cache \
unzip \ unzip \
nodejs \ nodejs \
npm \ npm \
shadow shadow \
linux-headers \
$PHPIZE_DEPS
# Install PHP extensions # Install PHP extensions
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Install Xdebug for code coverage
RUN pecl install xdebug && \
docker-php-ext-enable xdebug
# Configure Xdebug for coverage (not debugging)
RUN echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.start_with_request=no" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
# Install Composer # Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html WORKDIR /var/www/html
# Create developer user with UID 1000 (same as host user) # Create storage and bootstrap/cache directories with proper permissions
RUN adduser -u 1000 -s /bin/sh -D developer RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache && \
chmod -R 777 storage bootstrap/cache
# Create storage and bootstrap/cache directories # Run as root to avoid permission issues with volume mounts
RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache
# Change ownership to developer user
RUN chown -R developer:developer /var/www/html
# Set proper permissions for Laravel directories
RUN chmod -R 775 storage bootstrap/cache
# Switch to developer user
USER developer
# Expose port 8000 for artisan serve # Expose port 8000 for artisan serve
EXPOSE 8000 EXPOSE 8000
# Start Laravel development server with composer install # Start Laravel development server with composer install
CMD sh -c "composer install && php artisan key:generate --force && php artisan serve --host=0.0.0.0 --port=8000" CMD sh -c "composer install && php artisan key:generate --force && php artisan migrate --force && php artisan serve --host=0.0.0.0 --port=8000"

View file

@ -5,11 +5,7 @@ RUN npm install -g vite
WORKDIR /app WORKDIR /app
# Change ownership of /app to node user (UID 1000) # Run as root to avoid permission issues with volume mounts
RUN chown -R node:node /app
# Switch to node user (UID 1000, same as host user)
USER node
# Expose Vite dev server port # Expose Vite dev server port
EXPOSE 5173 EXPOSE 5173

2
frontend/.env Normal file
View file

@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8000
VITE_API_BASE_URL=http://localhost:8000/api

View file

@ -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>

View file

@ -8,8 +8,11 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"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",
@ -901,6 +904,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1481,6 +1493,23 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1543,6 +1572,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -1611,6 +1653,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -1625,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",
@ -1672,6 +1735,29 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.224", "version": "1.5.224",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz",
@ -1679,6 +1765,51 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.10", "version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
@ -2012,6 +2143,42 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2027,6 +2194,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -2037,6 +2213,43 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -2063,6 +2276,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2073,6 +2298,45 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -2264,6 +2528,36 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -2459,6 +2753,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -2500,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",
@ -2568,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",

View file

@ -10,8 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"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",

View file

@ -1,42 +1,958 @@
#root { @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');
max-width: 1280px; @import './styles/variables.css';
:root {
/* Color Palette from Coolors.co */
--color-navy: #03071e;
--color-dark-red: #370617;
--color-burgundy: #6a040f;
--color-crimson: #9d0208;
--color-red: #d00000;
--color-red-orange: #dc2f02;
--color-orange: #e85d04;
--color-orange-yellow: #f48c06;
--color-yellow-orange: #faa307;
--color-yellow: #ffba08;
/* Semantic colors */
--primary-color: var(--color-orange);
--primary-hover: var(--color-red-orange);
--secondary-color: var(--color-burgundy);
--accent-color: var(--color-yellow-orange);
--danger-color: var(--color-crimson);
--danger-hover: var(--color-red);
--text-primary: var(--color-navy);
--text-secondary: var(--color-dark-red);
--text-muted: #666;
/* Background colors */
--bg-primary: #faf8f5;
--bg-secondary: #f5f1eb;
--bg-light: #fdf9f4;
--bg-card: #fbf7f2;
--bg-gradient: linear-gradient(135deg, #faf8f5 0%, #f5f1eb 100%);
/* Typography */
--font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-secondary: 'Playfair Display', Georgia, 'Times New Roman', serif;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background: var(--bg-gradient);
min-height: 100vh;
font-family: var(--font-primary);
line-height: 1.6;
}
.App {
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 0;
width: 100vw;
box-sizing: border-box;
}
.App-header {
text-align: center;
margin-bottom: 2rem;
}
.registration-form,
.login-form {
background: #f8f9fa;
padding: 2rem;
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.registration-form h2,
.login-form h2 {
text-align: center;
margin-bottom: 1.5rem;
color: #333;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--color-dark-red);
background: rgba(255, 244, 230, 0.3);
padding: 0.25rem 0.5rem;
border-radius: var(--border-radius-sm);
display: inline-block;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--primary-color);
border-radius: var(--border-radius-md);
font-size: 1rem;
box-sizing: border-box;
background: #fff4e6;
color: #000;
}
.form-group input:focus {
outline: none;
border-color: var(--color-red-orange);
box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25);
}
.form-group input.error {
border-color: #dc3545;
}
.error-message {
display: block;
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.alert {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius-sm);
}
.alert-success {
color: #155724;
background-color: #d4edda;
border: 1px solid #c3e6cb;
}
.alert-error {
color: #721c24;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
}
button[type="submit"] {
width: 100%;
padding: 0.75rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius-sm);
font-size: 1rem;
cursor: pointer;
transition: background-color var(--transition-normal);
}
button[type="submit"]:hover:not(:disabled) {
background-color: var(--primary-hover);
}
button[type="submit"]:disabled {
background-color: var(--text-muted);
cursor: not-allowed;
}
/* Auth Guard Styles */
.auth-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.auth-content {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
}
.auth-toggle {
display: flex;
margin-bottom: 2rem;
border-radius: var(--border-radius-md);
overflow: hidden;
border: 1px solid #ddd;
}
.auth-toggle button {
flex: 1;
padding: 0.75rem;
background: #f8f9fa;
border: none;
cursor: pointer;
transition: all var(--transition-normal);
}
.auth-toggle button.active {
background: var(--primary-color);
color: white;
}
.auth-toggle button:hover:not(.active) {
background: #e9ecef;
}
.auth-switch {
text-align: center;
margin-top: 1rem;
}
.link-button {
background: none;
border: none;
color: var(--primary-color);
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: inherit;
}
.link-button:hover {
color: var(--primary-hover);
}
/* Dashboard Styles */
.dashboard {
width: 1200px;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
position: relative;
}
.dashboard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 200px;
background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 50%, var(--color-red-orange) 100%);
opacity: 0.03;
border-radius: 0 0 50px 50px;
z-index: -1;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.dashboard-title {
display: flex;
align-items: center;
}
.dashboard-header h1 {
margin: 0;
color: var(--text-primary);
background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 25%, var(--color-red-orange) 50%, var(--color-crimson) 75%, var(--color-dark-red) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 700;
font-size: 2rem;
font-family: var(--font-secondary);
}
/* User menu styles */
.user-dropdown {
position: relative;
}
.user-menu-trigger {
background: var(--bg-card);
border: 1px solid var(--primary-color);
font-size: 0.875rem;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 20px;
transition: all var(--transition-normal);
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-primary);
font-weight: 500;
}
.user-menu-trigger:hover {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%);
color: white;
border-color: var(--primary-color);
}
.user-menu-trigger:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.dropdown-arrow {
font-size: 0.7rem;
transition: transform 0.2s;
}
.user-dropdown[data-open="true"] .dropdown-arrow {
transform: rotate(180deg);
}
.user-dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: var(--bg-card);
border: 1px solid rgba(228, 93, 4, 0.2);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-lg);
z-index: 1000;
min-width: 120px;
margin-top: 0.5rem;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
/* Trips section header */
.trips-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0;
}
.create-trip-btn-small {
padding: 0.5rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-normal);
margin-top: 0.2rem;
}
.create-trip-btn-small:hover {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%);
transform: translateY(-1px);
}
.welcome-section {
text-align: center;
margin-bottom: 3rem;
}
.welcome-section h2 {
margin-bottom: 0.5rem;
color: #333;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.feature-card {
background: #f8f9fa;
padding: 2rem;
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-md);
text-align: center; text-align: center;
} }
.logo { .feature-card h3 {
height: 6em; margin-bottom: 1rem;
padding: 1.5em; color: #007bff;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
} }
@keyframes logo-spin { .feature-card p {
color: #666;
line-height: 1.6;
}
/* Loading Styles */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.loading-spinner {
font-size: 1.2rem;
color: #666;
}
.unauthorized-container {
text-align: center;
padding: 3rem;
background: #f8f9fa;
border-radius: var(--border-radius-md);
margin: 2rem 0;
}
.unauthorized-container h2 {
color: #dc3545;
margin-bottom: 1rem;
}
.unauthorized-container p {
color: #666;
}
/* Trip Styles */
.trips-section {
margin-top: 4rem;
position: relative;
}
.trips-section::before {
content: '';
position: absolute;
top: -3.5rem;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent 0%, var(--color-yellow) 20%, var(--color-orange) 40%, var(--color-red-orange) 60%, var(--color-crimson) 80%, transparent 100%);
opacity: 0.6;
}
.trips-section-title {
background: linear-gradient(135deg, #6a040f 0%, #370617 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1.5rem;
font-size: 2rem;
font-weight: 700;
font-family: var(--font-secondary);
}
.trips-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-top: 0;
}
.trip-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
position: relative;
border: 1px solid rgba(228, 93, 4, 0.1);
overflow: hidden;
}
.trip-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-yellow-orange) 50%, var(--color-yellow) 100%);
}
.trip-card:hover {
box-shadow: 0 8px 30px rgba(228, 93, 4, 0.15);
transform: translateY(-4px) scale(1.02);
}
.add-trip-card {
background: var(--bg-card);
border: 4px dashed var(--primary-color);
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
opacity: 0.8;
}
.add-trip-card:hover {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%);
border-color: var(--primary-hover);
transform: translateY(-2px);
opacity: 1;
}
.add-trip-card:hover .add-trip-content {
color: white;
}
.add-trip-card:hover .add-trip-icon {
color: white;
}
.add-trip-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--primary-color);
transition: color 0.3s ease;
}
.add-trip-icon {
width: 4rem;
height: 4rem;
color: var(--primary-color);
}
.add-trip-text {
font-size: 1.3rem;
font-weight: 600;
}
.trip-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.trip-card-title {
margin: 0;
color: var(--primary-color);
font-size: 1.25rem;
line-height: 1.3;
flex: 1;
margin-right: 1rem;
position: relative;
}
.trip-card-title::before {
content: '🗺️';
font-size: 0.9rem;
margin-right: 0.5rem;
opacity: 0.7;
}
.trip-card-menu {
position: relative;
}
.trip-menu-trigger {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
color: #666;
border-radius: var(--border-radius-sm);
transition: background-color var(--transition-normal);
}
.trip-menu-trigger:hover {
background: #e9ecef;
}
.trip-dropdown {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-lg);
z-index: 1000;
min-width: 120px;
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.5rem 1rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
transition: background-color var(--transition-normal);
color: #333;
}
.dropdown-item:hover {
background: #f8f9fa;
}
.dropdown-item-danger {
color: var(--danger-color);
}
.dropdown-item-danger:hover {
background: #f8d7da;
color: var(--danger-hover);
}
.trip-card-description {
color: #666;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.trip-card-dates {
margin-bottom: 1rem;
}
.trip-date-range {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.trip-dates {
font-weight: 500;
color: var(--text-primary);
}
.trip-duration {
font-size: 0.875rem;
color: #666;
}
.trip-dates-placeholder {
color: #999;
font-style: italic;
}
.trip-card-footer {
border-top: 1px solid #e9ecef;
padding-top: 0.75rem;
margin-top: 1rem;
}
.trip-created {
font-size: 0.875rem;
color: #999;
}
.empty-state {
text-align: center;
padding: 3rem 2rem;
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-light) 100%);
border-radius: 16px;
margin-top: 2rem;
border: 2px dashed rgba(228, 93, 4, 0.2);
position: relative;
}
.empty-state::before {
content: '✈️';
font-size: 3rem;
display: block;
margin-bottom: 1rem;
opacity: 0.6;
}
.empty-state h3 {
color: #666;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #999;
margin: 0;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(3, 7, 30, 0.8);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
animation: modalOverlayFadeIn 0.2s ease-out;
}
@keyframes modalOverlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalSlideIn {
from { from {
transform: rotate(0deg); opacity: 0;
transform: translateY(-20px) scale(0.95);
} }
to { to {
transform: rotate(360deg); opacity: 1;
transform: translateY(0) scale(1);
} }
} }
@media (prefers-reduced-motion: no-preference) { .modal-content {
a:nth-of-type(2) .logo { background: var(--bg-card);
animation: logo-spin infinite 20s linear; border-radius: var(--border-radius-md);
} width: 100%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
box-shadow:
0 25px 80px rgba(3, 7, 30, 0.4),
0 0 0 1px rgba(228, 93, 4, 0.1);
animation: modalSlideIn 0.3s ease-out;
position: relative;
} }
.card { .modal-content::before {
padding: 2em; content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-red-orange) 50%, var(--color-crimson) 100%);
} }
.read-the-docs { .modal-header {
color: #888; display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 2rem 1rem;
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-light) 100%);
}
.modal-header h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.modal-close {
background: var(--bg-secondary);
border: 1px solid rgba(228, 93, 4, 0.2);
font-size: 1.25rem;
cursor: pointer;
color: var(--primary-color);
padding: 0;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
font-weight: 300;
}
.modal-close:hover {
background: var(--primary-color);
color: white;
transform: scale(1.1);
}
.modal-body {
padding: 0 2rem;
max-height: calc(90vh - 200px);
overflow-y: auto;
}
.trip-form {
padding: 1.5rem 0;
}
.trip-form .form-group {
margin-bottom: 1.5rem;
}
.trip-form textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--primary-color);
border-radius: var(--border-radius-md);
font-size: 1rem;
font-family: inherit;
resize: vertical;
min-height: 80px;
box-sizing: border-box;
background: #fff4e6;
color: #000;
}
.trip-form textarea:focus {
outline: none;
border-color: var(--color-red-orange);
box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25);
}
.date-format-hint {
font-size: 0.875rem;
color: #666;
font-weight: normal;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid rgba(228, 93, 4, 0.1);
background: var(--bg-secondary);
border-radius: 0 0 16px 16px;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: background-color var(--transition-normal);
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%);
}
.btn-primary:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: transparent;
color: var(--secondary-color);
border: 2px solid var(--secondary-color);
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: all var(--transition-normal);
}
.btn-secondary:hover:not(:disabled) {
background: linear-gradient(135deg, var(--secondary-color) 0%, var(--color-burgundy) 100%);
border-color: var(--color-burgundy);
color: white;
transform: translateY(-1px);
}
.btn-secondary:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.btn-danger {
padding: 0.75rem 1.5rem;
background: var(--danger-color);
color: white;
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: background-color var(--transition-normal);
}
.btn-danger:hover:not(:disabled) {
background: var(--danger-hover);
}
.btn-danger:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.confirm-dialog {
max-width: 400px;
}
.confirm-dialog-body {
padding: 1.5rem;
}
.confirm-dialog-body p {
margin: 0;
color: var(--text-primary);
line-height: 1.5;
}
/* Responsive Design */
@media (max-width: 768px) {
.dashboard {
padding: 1rem;
}
.dashboard-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.dashboard-header-actions {
justify-content: space-between;
}
.trips-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.modal-content {
margin: 1rem;
max-width: none;
}
.modal-actions {
flex-direction: column;
}
} }

View file

@ -1,34 +1,28 @@
import { useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import reactLogo from './assets/react.svg' import { AuthProvider } from './contexts/AuthContext'
import viteLogo from '/vite.svg' 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' import './App.css'
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<> <AuthProvider>
<div> <ToastProvider>
<a href="https://vite.dev" target="_blank"> <BrowserRouter>
<img src={viteLogo} className="logo" alt="Vite logo" /> <div className="App">
</a> <AuthGuard>
<a href="https://react.dev" target="_blank"> <Routes>
<img src={reactLogo} className="logo react" alt="React logo" /> <Route path="/" element={<Dashboard />} />
</a> <Route path="/trip/:id" element={<TripDetail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthGuard>
</div> </div>
<h1>Vite + React</h1> </BrowserRouter>
<div className="card"> </ToastProvider>
<button onClick={() => setCount((count) => count + 1)}> </AuthProvider>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
) )
} }

View file

@ -0,0 +1,48 @@
const BaseModal = ({
isOpen,
onClose,
title,
children,
actions,
maxWidth = "500px",
className = ""
}) => {
if (!isOpen) return null;
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div
className={`modal-content ${className}`}
style={{ maxWidth }}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="modal-header">
<h2>{title}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close modal">
×
</button>
</div>
)}
<div className="modal-body">
{children}
</div>
{actions && (
<div className="modal-actions">
{actions}
</div>
)}
</div>
</div>
);
};
export default BaseModal;

View file

@ -0,0 +1,60 @@
import BaseModal from './BaseModal';
const ConfirmDialog = ({
isOpen,
onClose,
onConfirm,
title = "Confirm Action",
message = "Are you sure you want to proceed?",
confirmText = "Confirm",
cancelText = "Cancel",
isLoading = false,
variant = "danger" // "danger" or "primary"
}) => {
const handleConfirm = async () => {
try {
await onConfirm();
onClose();
} catch (error) {
console.error('Confirm action failed:', error);
}
};
const actions = (
<>
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn-secondary"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={isLoading}
className={variant === "danger" ? "btn-danger" : "btn-primary"}
>
{isLoading ? 'Processing...' : confirmText}
</button>
</>
);
return (
<BaseModal
isOpen={isOpen}
onClose={onClose}
title={title}
actions={actions}
maxWidth="400px"
className="confirm-dialog"
>
<div className="confirm-dialog-body">
<p>{message}</p>
</div>
</BaseModal>
);
};
export default ConfirmDialog;

View file

@ -0,0 +1,166 @@
import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext';
import api from '../utils/api';
import TripList from './TripList';
import TripModal from './TripModal';
import ConfirmDialog from './ConfirmDialog';
const Dashboard = () => {
const { user, logout } = useAuth();
const [trips, setTrips] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [showTripModal, setShowTripModal] = useState(false);
const [selectedTrip, setSelectedTrip] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [tripToDelete, setTripToDelete] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showUserDropdown, setShowUserDropdown] = useState(false);
const userDropdownRef = useRef(null);
useEffect(() => {
fetchTrips();
}, []);
useEffect(() => {
const handleClickOutside = (event) => {
if (userDropdownRef.current && !userDropdownRef.current.contains(event.target)) {
setShowUserDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const fetchTrips = async () => {
try {
setIsLoading(true);
const response = await api.get('/trips');
setTrips(response.data.data);
} catch (error) {
console.error('Error fetching trips:', error);
} finally {
setIsLoading(false);
}
};
const handleLogout = async () => {
await logout();
};
const handleCreateTrip = () => {
setSelectedTrip(null);
setShowTripModal(true);
};
const handleEditTrip = (trip) => {
setSelectedTrip(trip);
setShowTripModal(true);
};
const handleDeleteTrip = (trip) => {
setTripToDelete(trip);
setShowDeleteConfirm(true);
};
const handleTripSubmit = async (tripData) => {
setIsSubmitting(true);
try {
if (selectedTrip) {
const response = await api.put(`/trips/${selectedTrip.id}`, tripData);
setTrips(trips.map(trip =>
trip.id === selectedTrip.id ? response.data.data : trip
));
} else {
const response = await api.post('/trips', tripData);
setTrips([response.data.data, ...trips]);
}
setShowTripModal(false);
setSelectedTrip(null);
} catch (error) {
console.error('Error saving trip:', error);
throw error;
} finally {
setIsSubmitting(false);
}
};
const confirmDeleteTrip = async () => {
try {
await api.delete(`/trips/${tripToDelete.id}`);
setTrips(trips.filter(trip => trip.id !== tripToDelete.id));
setShowDeleteConfirm(false);
setTripToDelete(null);
} catch (error) {
console.error('Error deleting trip:', error);
throw error;
}
};
return (
<div className="dashboard">
<header className="dashboard-header">
<div className="dashboard-title">
<h1>TripPlanner</h1>
</div>
<div className="user-info">
<span>Welcome back!</span>
<div className="user-dropdown" ref={userDropdownRef}>
<button
className="user-menu-trigger"
onClick={() => setShowUserDropdown(!showUserDropdown)}
>
{user?.name}
<span className="dropdown-arrow"></span>
</button>
{showUserDropdown && (
<div className="user-dropdown-menu">
<button onClick={handleLogout} className="dropdown-item">
🚪 Logout
</button>
</div>
)}
</div>
</div>
</header>
<main className="dashboard-content">
<TripList
trips={trips}
isLoading={isLoading}
onEdit={handleEditTrip}
onDelete={handleDeleteTrip}
onCreateTrip={handleCreateTrip}
/>
</main>
<TripModal
isOpen={showTripModal}
onClose={() => {
setShowTripModal(false);
setSelectedTrip(null);
}}
onSubmit={handleTripSubmit}
trip={selectedTrip}
isLoading={isSubmitting}
/>
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => {
setShowDeleteConfirm(false);
setTripToDelete(null);
}}
onConfirm={confirmDeleteTrip}
title="Delete Trip"
message={`Are you sure you want to delete "${tripToDelete?.name}"? This action cannot be undone.`}
confirmText="Delete"
variant="danger"
/>
</div>
);
};
export default Dashboard;

View file

@ -0,0 +1,120 @@
import { useState } from 'react';
import api from '../utils/api';
const LoginForm = ({ onLoginSuccess }) => {
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prevState => ({
...prevState,
[name]: value
}));
if (errors[name]) {
setErrors(prevErrors => ({
...prevErrors,
[name]: ''
}));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setErrors({});
try {
const response = await api.post('/login', formData);
if (response.data.success) {
localStorage.setItem('token', response.data.data.access_token);
localStorage.setItem('user', JSON.stringify(response.data.data.user));
if (onLoginSuccess) {
onLoginSuccess(response.data.data);
}
}
} catch (error) {
if (error.response && error.response.status === 422) {
// Validation errors - check both .data and .errors structure
const validationErrors = error.response.data.errors || error.response.data.data || {};
// If it's credential error, show as general message
if (validationErrors.email && validationErrors.email[0] === 'The provided credentials are incorrect.') {
setErrors({ general: 'Invalid email or password. Please try again.' });
} else {
setErrors(validationErrors);
}
} else if (error.response && error.response.status === 401) {
// Unauthorized - wrong credentials
setErrors({ general: 'Invalid email or password. Please try again.' });
} else if (error.response && error.response.data.message) {
// Other server errors
setErrors({ general: error.response.data.message });
} else if (error.request) {
// Network error
setErrors({ general: 'Unable to connect to server. Please check your connection.' });
} else {
// Unknown error
setErrors({ general: 'Unknown error occurred. Please try again.' });
}
} finally {
setIsLoading(false);
}
};
return (
<div className="login-form">
<h2>Login</h2>
{errors.general && (
<div className="alert alert-error">
{errors.general}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error-message">{errors.email[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
className={errors.password ? 'error' : ''}
/>
{errors.password && <span className="error-message">{errors.password[0]}</span>}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
);
};
export default LoginForm;

View file

@ -0,0 +1,148 @@
import { useState } from 'react';
import api from '../utils/api';
const RegistrationForm = ({ onRegistrationSuccess }) => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
password_confirmation: ''
});
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prevState => ({
...prevState,
[name]: value
}));
// Clear specific field error when user starts typing
if (errors[name]) {
setErrors(prevErrors => ({
...prevErrors,
[name]: ''
}));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setErrors({});
setSuccessMessage('');
try {
const response = await api.post('/register', formData);
if (response.data.success) {
setSuccessMessage('Registration successful! You are now logged in.');
setFormData({
name: '',
email: '',
password: '',
password_confirmation: ''
});
if (onRegistrationSuccess) {
onRegistrationSuccess(response.data.data);
}
}
} catch (error) {
if (error.response && error.response.status === 422) {
setErrors(error.response.data.data || {});
} else {
setErrors({ general: 'Registration failed. Please try again.' });
}
} finally {
setIsLoading(false);
}
};
return (
<div className="registration-form">
<h2>Register</h2>
{successMessage && (
<div className="alert alert-success">
{successMessage}
</div>
)}
{errors.general && (
<div className="alert alert-error">
{errors.general}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className={errors.name ? 'error' : ''}
/>
{errors.name && <span className="error-message">{errors.name[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error-message">{errors.email[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
minLength="8"
className={errors.password ? 'error' : ''}
/>
{errors.password && <span className="error-message">{errors.password[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="password_confirmation">Confirm Password:</label>
<input
type="password"
id="password_confirmation"
name="password_confirmation"
value={formData.password_confirmation}
onChange={handleChange}
required
minLength="8"
className={errors.password_confirmation ? 'error' : ''}
/>
{errors.password_confirmation && <span className="error-message">{errors.password_confirmation[0]}</span>}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Registering...' : 'Register'}
</button>
</form>
</div>
);
};
export default RegistrationForm;

View file

@ -0,0 +1,108 @@
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);
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Removed - using shared utility
const handleEdit = () => {
setShowDropdown(false);
onEdit(trip);
};
const handleDelete = () => {
setShowDropdown(false);
onDelete(trip);
};
// Removed - using shared utility
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" 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}>
<button
className="trip-menu-trigger"
onClick={() => setShowDropdown(!showDropdown)}
aria-label="Trip options"
>
</button>
{showDropdown && (
<div className="trip-dropdown">
<button onClick={handleEdit} className="dropdown-item">
Edit
</button>
<button onClick={handleDelete} className="dropdown-item dropdown-item-danger">
Delete
</button>
</div>
)}
</div>
</div>
{trip.description && (
<p className="trip-card-description">{trip.description}</p>
)}
<div className="trip-card-dates">
{trip.start_date && trip.end_date ? (
<div className="trip-date-range">
<span className="trip-dates">
{formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)}
</span>
<span className="trip-duration">
{getDuration(trip.start_date, trip.end_date)}
</span>
</div>
) : trip.start_date ? (
<span className="trip-dates">
Starts: {formatDateShort(trip.start_date)}
</span>
) : trip.end_date ? (
<span className="trip-dates">
Ends: {formatDateShort(trip.end_date)}
</span>
) : (
<span className="trip-dates trip-dates-placeholder">
Dates not set
</span>
)}
</div>
<div className="trip-card-footer">
<span className="trip-created">
Created {formatDateShort(trip.created_at)}
</span>
</div>
</div>
);
};
export default TripCard;

View file

@ -0,0 +1,198 @@
.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: grid;
grid-template-columns: 1fr var(--sidebar-width);
gap: 0;
height: calc(100vh - var(--header-height));
min-height: 0;
}
.trip-detail-main {
padding: var(--spacing-xl);
overflow-y: auto;
background: var(--color-bg-secondary);
order: 1;
}
.trip-detail-sidebar {
width: var(--sidebar-width);
background: var(--color-bg-primary);
border-left: 1px solid var(--color-border);
overflow-y: auto;
order: 2;
}
/* 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 {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: auto;
}
.trip-detail-main {
order: 2;
padding: var(--spacing-md);
}
.trip-detail-sidebar {
order: 1;
width: 100%;
border-left: none;
border-bottom: 1px solid var(--color-border);
min-height: 300px;
max-height: 50vh;
}
.trip-detail-header {
padding: var(--spacing-md);
}
.header-content h1 {
font-size: 1.5rem;
}
}

View file

@ -0,0 +1,121 @@
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 TripTimeline from './timeline/TripTimeline';
import axios from 'axios';
import './TripDetail.css';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const TripDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [trip, setTrip] = useState(null);
const [plannableItems, setPlannableItems] = useState([]);
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]);
useEffect(() => {
const loadPlannableItems = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(`${API_URL}/api/trips/${id}/plannables`, {
headers: { Authorization: `Bearer ${token}` }
});
setPlannableItems(response.data.data || []);
} catch (err) {
console.error('Error loading plannable items:', err);
}
};
if (id) {
loadPlannableItems();
}
}, [id]);
// 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} onItemsChange={(items) => setPlannableItems(items)} />
</div>
<div className="trip-detail-main">
<TripTimeline
trip={trip}
plannableItems={plannableItems}
onScheduleSuccess={() => {
// Optional: refresh plannable items if needed
}}
/>
</div>
</div>
</div>
);
};
export default TripDetail;

View file

@ -0,0 +1,54 @@
import TripCard from './TripCard';
import { GlobeEuropeAfricaIcon } from '@heroicons/react/24/outline';
const TripList = ({ trips, isLoading, onEdit, onDelete, onCreateTrip }) => {
if (isLoading) {
return (
<div className="trips-section">
<div className="loading-container">
<span className="loading-spinner">Loading trips...</span>
</div>
</div>
);
}
if (!trips || trips.length === 0) {
return (
<div className="trips-section">
<h2 className="trips-section-title">Your Trips</h2>
<div className="trips-grid">
<div className="add-trip-card" onClick={onCreateTrip}>
<div className="add-trip-content">
<GlobeEuropeAfricaIcon className="add-trip-icon" />
<span className="add-trip-text">Create New Trip</span>
</div>
</div>
</div>
</div>
);
}
return (
<div className="trips-section">
<h2 className="trips-section-title">Your Trips</h2>
<div className="trips-grid">
{trips.map((trip) => (
<TripCard
key={trip.id}
trip={trip}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
<div className="add-trip-card" onClick={onCreateTrip}>
<div className="add-trip-content">
<GlobeEuropeAfricaIcon className="add-trip-icon" />
<span className="add-trip-text">Create New Trip</span>
</div>
</div>
</div>
</div>
);
};
export default TripList;

View file

@ -0,0 +1,175 @@
import { useState, useEffect } from 'react';
import BaseModal from './BaseModal';
const TripModal = ({ isOpen, onClose, onSubmit, trip = null, isLoading = false }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
start_date: '',
end_date: ''
});
const [errors, setErrors] = useState({});
useEffect(() => {
if (trip) {
setFormData({
name: trip.name || '',
description: trip.description || '',
start_date: trip.start_date ? trip.start_date.split('T')[0] : '',
end_date: trip.end_date ? trip.end_date.split('T')[0] : ''
});
} else {
setFormData({
name: '',
description: '',
start_date: '',
end_date: ''
});
}
setErrors({});
}, [trip, isOpen]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Trip name is required';
}
if (formData.start_date && formData.end_date && formData.start_date > formData.end_date) {
newErrors.end_date = 'End date must be after start date';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
await onSubmit(formData);
onClose();
} catch (error) {
if (error.response?.data?.errors) {
setErrors(error.response.data.errors);
}
}
};
const handleClose = () => {
setFormData({
name: '',
description: '',
start_date: '',
end_date: ''
});
setErrors({});
onClose();
};
const actions = (
<>
<button type="button" onClick={handleClose} disabled={isLoading} className="btn-secondary">
Cancel
</button>
<button type="submit" form="trip-form" disabled={isLoading} className="btn-primary">
{isLoading ? 'Saving...' : (trip ? 'Update Trip' : 'Create Trip')}
</button>
</>
);
return (
<BaseModal
isOpen={isOpen}
onClose={handleClose}
title={trip ? 'Edit Trip' : 'Create New Trip'}
actions={actions}
className="trip-modal"
>
<form id="trip-form" onSubmit={handleSubmit} className="trip-form">
<div className="form-group">
<label htmlFor="name">Trip Name *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className={errors.name ? 'error' : ''}
disabled={isLoading}
required
/>
{errors.name && <span className="error-message">{errors.name}</span>}
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows="3"
className={errors.description ? 'error' : ''}
disabled={isLoading}
/>
{errors.description && <span className="error-message">{errors.description}</span>}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="start_date">Start Date</label>
<input
type="date"
id="start_date"
name="start_date"
value={formData.start_date}
onChange={handleChange}
className={errors.start_date ? 'error' : ''}
disabled={isLoading}
lang="en-GB"
/>
{errors.start_date && <span className="error-message">{errors.start_date}</span>}
</div>
<div className="form-group">
<label htmlFor="end_date">End Date</label>
<input
type="date"
id="end_date"
name="end_date"
value={formData.end_date}
onChange={handleChange}
className={errors.end_date ? 'error' : ''}
disabled={isLoading}
lang="en-GB"
/>
{errors.end_date && <span className="error-message">{errors.end_date}</span>}
</div>
</div>
</form>
</BaseModal>
);
};
export default TripModal;

View file

@ -0,0 +1,74 @@
import { useAuth } from '../../contexts/AuthContext';
import LoginForm from '../LoginForm';
import RegistrationForm from '../RegistrationForm';
import { useState } from 'react';
const AuthGuard = ({ children }) => {
const { isAuthenticated, isLoading, login, register } = useAuth();
const [showLogin, setShowLogin] = useState(true);
if (isLoading) {
return (
<div className="loading-container">
<div className="loading-spinner">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="auth-container">
<div className="auth-content">
<div className="auth-toggle">
<button
className={showLogin ? 'active' : ''}
onClick={() => setShowLogin(true)}
>
Login
</button>
<button
className={!showLogin ? 'active' : ''}
onClick={() => setShowLogin(false)}
>
Register
</button>
</div>
{showLogin ? (
<LoginForm onLoginSuccess={login} />
) : (
<RegistrationForm onRegistrationSuccess={register} />
)}
<div className="auth-switch">
{showLogin ? (
<p>
Don't have an account?{' '}
<button
className="link-button"
onClick={() => setShowLogin(false)}
>
Sign up here
</button>
</p>
) : (
<p>
Already have an account?{' '}
<button
className="link-button"
onClick={() => setShowLogin(true)}
>
Login here
</button>
</p>
)}
</div>
</div>
</div>
);
}
return children;
};
export default AuthGuard;

View file

@ -0,0 +1,26 @@
import { useAuth } from '../../contexts/AuthContext';
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="loading-container">
<div className="loading-spinner">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="unauthorized-container">
<h2>Access Denied</h2>
<p>Please log in to access this page.</p>
</div>
);
}
return children;
};
export default ProtectedRoute;

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,240 @@
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, onItemsChange }) => {
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);
console.log('PlannablesList: Received data:', {
plannablesCount: plannables.length,
calendarSlotsCount: calendarSlots.length,
firstFewSlots: calendarSlots.slice(0, 3).map(s => ({ id: s.id, name: s.name, date: s.slot_date }))
});
setPlannables(plannables);
// Safeguard: limit calendar slots to prevent performance issues
const limitedCalendarSlots = calendarSlots.slice(0, 365); // Max 1 year of slots
setCalendarSlots(limitedCalendarSlots);
// Notify parent component of items change
if (onItemsChange) {
onItemsChange(plannables);
}
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);
const updatedPlannables = plannables.filter(item => item.id !== itemId);
setPlannables(updatedPlannables);
if (onItemsChange) {
onItemsChange(updatedPlannables);
}
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;
let updatedPlannables;
if (isEditing) {
savedItem = await updatePlannable(editingItem.id, formData);
updatedPlannables = plannables.map(item =>
item.id === editingItem.id ? savedItem : item
);
setPlannables(updatedPlannables);
showSuccess('Item updated successfully');
} else {
savedItem = await createPlannable(tripId, formData);
updatedPlannables = [...plannables, savedItem];
setPlannables(updatedPlannables);
showSuccess('Item added successfully');
}
if (onItemsChange) {
onItemsChange(updatedPlannables);
}
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>
</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,32 @@
.day-section {
background: var(--bg-card);
border: 1px solid var(--primary-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.day-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid rgba(228, 93, 4, 0.2);
}
.day-header h4 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
}
.day-header p {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0;
}
.hours-grid {
display: flex;
flex-direction: column;
gap: 0.25rem;
}

View file

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import HourRow from './HourRow';
import './DaySection.css';
const START_HOUR = 6; // 6 AM
const END_HOUR = 23; // 11 PM
function DaySection({ date, dayNumber, slots, plannableItems, onScheduleItem }) {
const dayName = date.toLocaleDateString('en-US', { weekday: 'long' });
const dateString = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
// Memoize slot-to-hour mapping to avoid recalculating on every render
const slotsByHour = useMemo(() => {
const mapping = {};
for (let hour = START_HOUR; hour <= END_HOUR; hour++) {
// Pre-compute hour boundaries once per hour (not per slot)
// Create hour boundaries explicitly to avoid DST issues
const hourStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 0, 0, 0);
const hourEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 59, 59, 999);
mapping[hour] = slots.filter(slot => {
if (!slot.datetime_start) return false;
const slotStart = new Date(slot.datetime_start);
const slotEnd = new Date(slot.datetime_end);
// Check if slot overlaps with this hour (strict comparison)
return slotStart < hourEnd && slotEnd > hourStart;
});
}
return mapping;
}, [slots, date.getTime()]);
return (
<div className="day-section">
<div className="day-header">
<h4>
Day {dayNumber}: {dayName}
</h4>
<p>{dateString}</p>
</div>
<div className="hours-grid">
{Object.keys(slotsByHour).map(hour => (
<HourRow
key={hour}
hour={parseInt(hour)}
date={date}
slots={slotsByHour[hour]}
plannableItems={plannableItems}
onScheduleItem={onScheduleItem}
/>
))}
</div>
</div>
);
}
export default DaySection;

View file

@ -0,0 +1,98 @@
.hour-row {
display: flex;
align-items: stretch;
border-left: 2px solid var(--primary-color);
min-height: 60px;
transition: background-color 0.2s ease;
position: relative;
}
.hour-row:hover {
background-color: rgba(228, 93, 4, 0.03);
}
.hour-label {
width: 100px;
flex-shrink: 0;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.hour-content {
flex: 1;
padding: 0.75rem 1rem;
position: relative;
}
.scheduled-slot {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: var(--radius-md);
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.scheduled-slot-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.scheduled-slot-content {
flex: 1;
}
.scheduled-slot-title {
font-weight: 600;
color: #1e40af;
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
}
.scheduled-slot-time {
font-size: 0.85rem;
color: #1d4ed8;
margin-bottom: 0.25rem;
}
.scheduled-slot-items {
margin-top: 0.5rem;
font-size: 0.8rem;
color: #2563eb;
}
.add-button {
position: absolute;
right: 0.5rem;
top: 0.5rem;
padding: 0.375rem;
background-color: #22c55e;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.3);
transition: all 0.2s ease;
opacity: 0;
width: 28px;
height: 28px;
}
.hour-row:hover .add-button {
opacity: 1;
}
.add-button:hover {
background-color: #16a34a;
transform: scale(1.1);
}
.add-button svg {
width: 16px;
height: 16px;
}

View file

@ -0,0 +1,100 @@
import { useState } from 'react';
import { PlusIcon } from '@heroicons/react/24/outline';
import ScheduleItemModal from './ScheduleItemModal';
import './HourRow.css';
function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) {
const [showModal, setShowModal] = useState(false);
const formatHour = (h) => {
return `${h.toString().padStart(2, '0')}:00`;
};
const handleAddClick = () => {
setShowModal(true);
};
const handleSchedule = async (plannableItemId, startDatetime, endDatetime) => {
try {
await onScheduleItem(plannableItemId, startDatetime, endDatetime);
setShowModal(false);
} catch (error) {
// Error is already logged in parent, just rethrow to let modal handle it
throw error;
}
};
// Check if there's a slot that starts at this hour
const slotStartsHere = slots.find(slot => {
// Parse the datetime string and get the local hours
const slotStart = new Date(slot.datetime_start);
const startHour = slotStart.getHours();
return startHour === hour;
});
return (
<>
<div className="hour-row">
{/* Hour label */}
<div className="hour-label">
{formatHour(hour)}
</div>
{/* Content area */}
<div className="hour-content">
{/* Scheduled items that start at this hour */}
{slotStartsHere && (
<div className="scheduled-slot">
<div className="scheduled-slot-header">
<div className="scheduled-slot-content">
<div className="scheduled-slot-title">
{slotStartsHere.name}
</div>
<div className="scheduled-slot-time">
{new Date(slotStartsHere.datetime_start).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})}
{' - '}
{new Date(slotStartsHere.datetime_end).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})}
</div>
{slotStartsHere.planned_items && slotStartsHere.planned_items.length > 0 && (
<div className="scheduled-slot-items">
{slotStartsHere.planned_items.map(pi => pi.plannable_item?.name).join(', ')}
</div>
)}
</div>
</div>
</div>
)}
{/* Add button - CSS-only hover */}
<button
onClick={handleAddClick}
className="add-button"
title="Schedule an item"
>
<PlusIcon />
</button>
</div>
</div>
{showModal && (
<ScheduleItemModal
date={date}
hour={hour}
plannableItems={plannableItems}
onSchedule={handleSchedule}
onClose={() => setShowModal(false)}
/>
)}
</>
);
}
export default HourRow;

View file

@ -0,0 +1,98 @@
.schedule-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.schedule-form-error {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 0.75rem 1rem;
border-radius: var(--border-radius-md);
font-size: 0.9rem;
}
.schedule-form-group {
display: flex;
flex-direction: column;
}
.schedule-form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.schedule-form-select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: 1rem;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: border-color var(--transition-normal);
}
.schedule-form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(228, 93, 4, 0.1);
}
.schedule-time-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.schedule-form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-light);
}
.schedule-btn-cancel {
padding: 0.75rem 1.5rem;
color: var(--color-text-primary);
background-color: var(--color-bg-secondary);
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: background-color var(--transition-normal);
}
.schedule-btn-cancel:hover:not(:disabled) {
background-color: var(--color-border);
}
.schedule-btn-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.schedule-btn-submit {
padding: 0.75rem 1.5rem;
color: white;
background-color: var(--primary-color);
border: none;
border-radius: var(--border-radius-md);
cursor: pointer;
font-weight: 500;
transition: background-color var(--transition-normal);
}
.schedule-btn-submit:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.schedule-btn-submit:disabled {
background-color: var(--color-border);
cursor: not-allowed;
}

View file

@ -0,0 +1,159 @@
import { useState, useEffect, useMemo } from 'react';
import BaseModal from '../BaseModal';
import './ScheduleItemModal.css';
function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) {
const [selectedItemId, setSelectedItemId] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
// Set default times based on the clicked hour
const startHour = hour.toString().padStart(2, '0');
const endHour = (hour + 1).toString().padStart(2, '0');
setStartTime(`${startHour}:00`);
setEndTime(`${endHour}:00`);
}, [hour]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!selectedItemId) {
setError('Please select an item to schedule');
return;
}
if (!startTime || !endTime) {
setError('Please select start and end times');
return;
}
// Construct full datetime strings using local date
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const dateString = `${year}-${month}-${day}`;
const startDatetime = `${dateString} ${startTime}:00`;
const endDatetime = `${dateString} ${endTime}:00`;
// Validate end time is after start time
if (endTime <= startTime) {
setError('End time must be after start time');
return;
}
try {
setSubmitting(true);
await onSchedule(selectedItemId, startDatetime, endDatetime);
// Modal will be closed by parent (HourRow) on success
} catch (err) {
console.error('Error in modal submit:', err);
setError(err.response?.data?.message || err.message || 'Failed to schedule item');
setSubmitting(false);
}
};
// Generate time options (every 30 minutes) - memoized to avoid recreation
const timeOptions = useMemo(() => {
const options = [];
for (let h = 0; h < 24; h++) {
for (let m = 0; m < 60; m += 30) {
const timeStr = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
options.push(timeStr);
}
}
return options;
}, []);
return (
<BaseModal isOpen={true} onClose={onClose} title="Schedule Item">
<form onSubmit={handleSubmit} className="schedule-form">
{error && (
<div className="schedule-form-error">
{error}
</div>
)}
<div className="schedule-form-group">
<label className="schedule-form-label">
Select Item
</label>
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)}
className="schedule-form-select"
required
>
<option value="">-- Choose an item --</option>
{plannableItems.map(item => (
<option key={item.id} value={item.id}>
{item.name} {item.type ? `(${item.type})` : ''}
</option>
))}
</select>
</div>
<div className="schedule-time-group">
<div className="schedule-form-group">
<label className="schedule-form-label">
Start Time
</label>
<select
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="schedule-form-select"
required
>
{timeOptions.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
<div className="schedule-form-group">
<label className="schedule-form-label">
End Time
</label>
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="schedule-form-select"
required
>
{timeOptions.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
</div>
<div className="schedule-form-actions">
<button
type="button"
onClick={onClose}
className="schedule-btn-cancel"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="schedule-btn-submit"
disabled={submitting}
>
{submitting ? 'Scheduling...' : 'Schedule'}
</button>
</div>
</form>
</BaseModal>
);
}
export default ScheduleItemModal;

View file

@ -0,0 +1,24 @@
.trip-timeline {
width: 100%;
}
.trip-timeline h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--text-primary);
font-family: var(--font-secondary);
}
.timeline-days-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.timeline-loading {
padding: 2rem;
color: var(--text-muted);
text-align: center;
font-size: 1.1rem;
}

View file

@ -0,0 +1,113 @@
import { useState, useEffect } from 'react';
import DaySection from './DaySection';
import axios from 'axios';
import './TripTimeline.css';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
function TripTimeline({ trip, plannableItems, onScheduleSuccess }) {
const [calendarSlots, setCalendarSlots] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCalendarSlots();
}, [trip.id]);
const fetchCalendarSlots = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get(`${API_URL}/api/trips/${trip.id}/calendar-slots`, {
headers: { Authorization: `Bearer ${token}` }
});
const slots = response.data.data || [];
setCalendarSlots(slots);
} catch (error) {
console.error('Error fetching calendar slots:', error);
} finally {
setLoading(false);
}
};
const handleScheduleItem = async (plannableItemId, startDatetime, endDatetime) => {
try {
const token = localStorage.getItem('token');
const payload = {
plannable_item_id: plannableItemId,
trip_id: trip.id,
start_datetime: startDatetime,
end_datetime: endDatetime,
};
const response = await axios.post(
`${API_URL}/api/planned-items`,
payload,
{
headers: { Authorization: `Bearer ${token}` }
}
);
await fetchCalendarSlots();
if (onScheduleSuccess) {
onScheduleSuccess();
}
} catch (error) {
console.error('Error scheduling item:', error);
console.error('Error response:', error.response?.data);
throw error;
}
};
const generateDays = () => {
const days = [];
const start = new Date(trip.start_date);
const end = new Date(trip.end_date);
for (let d = new Date(start); d <= end; ) {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return days;
};
const getSlotsForDay = (date) => {
// Format date as YYYY-MM-DD in local timezone, not UTC
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const dateString = `${year}-${month}-${day}`;
const slots = calendarSlots.filter(slot => {
// slot_date comes as "2025-12-12T00:00:00.000000Z", extract just the date part
const slotDateString = slot.slot_date.split('T')[0];
return slotDateString === dateString;
});
return slots;
};
if (loading) {
return <div className="timeline-loading">Loading timeline...</div>;
}
const days = generateDays();
return (
<div className="trip-timeline">
<h3>Trip Timeline</h3>
<div className="timeline-days-container">
{days.map((day, index) => (
<DaySection
key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`}
date={day}
dayNumber={index + 1}
slots={getSlotsForDay(day)}
plannableItems={plannableItems}
onScheduleItem={handleScheduleItem}
/>
))}
</div>
</div>
);
}
export default TripTimeline;

Some files were not shown because too many files have changed in this diff Show more